diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 7fc9b5925..b121ce45f 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -27,8 +27,8 @@ permissions: jobs: windows-arm64: - # Immutable supporting workflow revision until the new install contract lands upstream. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main with: app-name: vectorcraft app-dir: VectorCraft diff --git a/AGENTS.md b/AGENTS.md index 61f141079..0d33612d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. - - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. @@ -46,7 +46,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). ## Running and looking at the app @@ -59,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - 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 and progress docs -The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +The progress docs: - [`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. @@ -72,17 +72,19 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit ## Contributor credits (About window) - About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` - (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for themselves). See `docs/contributors.md`. - **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file, or run - `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + (copy the template at the top of the file) and include it in their PR, committed as them. If no, change nothing: they are credited as `@username` anyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or GitHub profiles). Never hand-edit `contributors.json`. -- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also - re-verifies who wrote each `people.toml` entry; `--check` only verifies). + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/ASSETS.md b/ASSETS.md index 26d463091..5bebefae6 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -6,7 +6,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). -**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay. +**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. **Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them. diff --git a/CLAUDE.md b/CLAUDE.md index 0460b6192..17783d9a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - Creative Commons with redistribution allowed. - **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`. - `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset. - - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. + - **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts. - Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft. - Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it. - Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets. @@ -47,7 +47,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). -- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. +- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). ## Running and looking at the app @@ -60,7 +60,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - 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 and progress docs -The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): +The progress docs: - [`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. @@ -69,3 +69,8 @@ The progress docs follow craftrules' [`standards/progress-docs.md`](https://gith 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. + +## CI workflows +- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. +- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. +- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. diff --git a/Cargo.lock b/Cargo.lock index 78306baae..62f084afc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "slab", "windows-sys 0.61.2", ] @@ -334,7 +334,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -360,7 +360,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.5", + "rustix 1.1.6", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -665,7 +665,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", - "rustix 1.1.5", + "rustix 1.1.6", "tracing", ] @@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.5", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-client", ] @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -1017,7 +1017,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13af dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.9.1", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1047,13 +1047,14 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" dependencies = [ "chrono", "dunce", "egui", "flate2", "getrandom 0.3.4", + "image", "log", "plist", "semver", @@ -1887,7 +1888,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "windows-link", ] @@ -3899,7 +3900,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -4445,9 +4446,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -4488,9 +4489,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -5096,9 +5097,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -5141,7 +5142,7 @@ dependencies = [ "libc", "log", "memmap2", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "wayland-backend", "wayland-client", @@ -5416,7 +5417,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "windows-sys 0.61.2", ] @@ -5992,7 +5993,7 @@ dependencies = [ "kurbo 0.13.1", "log", "pico-args", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "simplecss", "siphasher", "skrifa 0.44.0", @@ -6036,7 +6037,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.18.0" +version = "0.19.0" dependencies = [ "arboard", "block2 0.6.2", @@ -6061,6 +6062,7 @@ dependencies = [ "raw-window-handle", "rfd", "serde_json", + "smithay-clipboard", "socket2", "vectorcraft-engine", "vectorcraft-text", @@ -6079,7 +6081,7 @@ dependencies = [ [[package]] name = "vectorcraft-affinity" -version = "0.18.0" +version = "0.19.0" dependencies = [ "crc32fast", "flate2", @@ -6090,7 +6092,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -6102,7 +6104,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6118,7 +6120,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.18.0" +version = "0.19.0" dependencies = [ "log", "objc2-core-foundation", @@ -6140,7 +6142,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -6152,7 +6154,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.18.0" +version = "0.19.0" dependencies = [ "chrono", "hayro-ccitt", @@ -6171,7 +6173,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6187,7 +6189,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.18.0" +version = "0.19.0" dependencies = [ "craft-docpath", "crc32fast", @@ -6196,8 +6198,8 @@ dependencies = [ "kurbo 0.13.1", "log", "proptest", - "roxmltree 0.21.1", - "rustix 1.1.5", + "roxmltree 0.21.2", + "rustix 1.1.6", "ruzstd", "serde", "serde_json", @@ -6226,7 +6228,7 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.18.0" +version = "0.19.0" dependencies = [ "fax", "flate2", @@ -6250,11 +6252,11 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.18.0" +version = "0.19.0" dependencies = [ "flate2", "proptest", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "thiserror 2.0.21", @@ -6265,7 +6267,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -6277,7 +6279,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "log", @@ -6296,7 +6298,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6310,7 +6312,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.18.0" +version = "0.19.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -6322,7 +6324,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.18.0" +version = "0.19.0" dependencies = [ "aes", "chrono", @@ -6335,7 +6337,7 @@ dependencies = [ "kurbo 0.13.1", "log", "md-5", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "serde", "serde_json", "sha2", @@ -6354,7 +6356,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.18.0" +version = "0.19.0" dependencies = [ "serde", "serde_json", @@ -6368,7 +6370,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.18.0" +version = "0.19.0" dependencies = [ "flate2", "gif", @@ -6397,14 +6399,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.18.0" +version = "0.19.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.18.0" +version = "0.19.0" dependencies = [ "craft-docpath", "flate2", @@ -6429,7 +6431,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.18.0" +version = "0.19.0" dependencies = [ "fax", "image", @@ -6447,7 +6449,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.18.0" +version = "0.19.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -6468,7 +6470,7 @@ dependencies = [ [[package]] name = "vectorcraft-three-d" -version = "0.18.0" +version = "0.19.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -6476,7 +6478,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6492,7 +6494,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.18.0" +version = "0.19.0" dependencies = [ "image", "kurbo 0.13.1", @@ -6504,7 +6506,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.18.0" +version = "0.19.0" dependencies = [ "bytemuck", "craft-keymap", @@ -6515,7 +6517,7 @@ dependencies = [ "egui_kittest", "image", "log", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_json", "toml", @@ -6542,7 +6544,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.18.0" +version = "0.19.0" dependencies = [ "eframe", "egui", @@ -6808,7 +6810,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.5", + "rustix 1.1.6", "scoped-tls", "smallvec", "wayland-sys", @@ -6821,7 +6823,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.5", + "rustix 1.1.6", "wayland-backend", "wayland-scanner", ] @@ -6843,7 +6845,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.5", + "rustix 1.1.6", "wayland-client", "xcursor", ] @@ -7518,7 +7520,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.5", + "rustix 1.1.6", "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", @@ -7577,7 +7579,7 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.5", + "rustix 1.1.6", "x11rb-protocol", ] @@ -7619,7 +7621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.5", + "rustix 1.1.6", ] [[package]] @@ -7673,7 +7675,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.18.0" +version = "0.19.0" dependencies = [ "serde_json", ] @@ -7723,7 +7725,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.5", + "rustix 1.1.6", "serde", "serde_repr", "tracing", @@ -7907,9 +7909,9 @@ dependencies = [ [[package]] name = "zip" -version = "9.1.0" +version = "9.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb" dependencies = [ "crc32fast", "flate2", diff --git a/Cargo.toml b/Cargo.toml index c134a3c19..b5b4312ea 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.18.0" +version = "0.19.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -19,7 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 8a4d00db9..e7d8f0bba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -110,6 +110,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta | Japanese | `ja` | full | 100% | | German | `de` | full | 98% | | Korean | `ko` | full | 100% | +| Bosnian | `bs` | full (AI-assisted, native review pending) | 100% | | Vietnamese | `vi` | full | 100% | Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Catalan (full; AI-drafted, native review welcome), Czech (catalog complete; render-site gaps reduced; native review pending). @@ -128,6 +129,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-10-30: Gradient panel presets: a Presets menu applies one of the built-in gradient libraries' presets (grouped by theme) or a saved one to the gradient behind the active proxy, keeping its placement, as one undo step; the panel menu saves the current gradient as a preset, kept with the preferences, and deletes saved ones (`gradient.presets.list/apply/save/delete`). - 2026-10-23: Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add a thin overprinting strip of the lighter colour inside the darker one where two solid colours meet, so misregistered plates leave no gap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)), with Thickness, Height/Width, Tint Reduction, Traps with Process Color and Reverse Traps. Illustrator effects 49/50 (SVG Filters remains); Live vector effects hours 6–10 → 4–8; headline numbers unchanged. - 2026-10-23: OpenType SVG colour fonts draw as vector art: each glyph's SVG document is read by the SVG importer, in the text's colours where it asks for them, and draws, exports and outlines as COLR glyphs do; all colour font formats are now drawn. - 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph. @@ -144,6 +146,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-29 | Linux: the Flatpak on GNOME copies and pastes text with other apps through the compositor's core Wayland protocol (smithay-clipboard on the window's connection), where arboard has neither data-control nor X11. Scores and estimates unchanged. | | 2026-10-29 | Linux: a clipboard that can't be opened (the Flatpak on GNOME, where arboard has neither data-control nor X11) is retried after 1 s, doubling to a minute, and logged once, where the background Paste check used to reconnect and log a warning every 250 ms (267k warnings in 18 h). Scores and estimates unchanged. | | 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. | | 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. | @@ -183,7 +186,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-12 | Graphs: Object › Graph › Design… and Marker… (designs with a preview, New Design, Rename, Delete Design, Paste Design, Select Unused, and markers drawn with a design or None) (#1092); Graph Data opens as a worksheet with Import Data…, column width and decimals (#1138), and takes Transpose, Switch x/y, `|` line breaks in labels and the pie Legend, Position and Sort options (#1115); Graph Type adds Add Drop Shadow and Add Legend Across Top (#1119), widths past 100%, First Row and First Column in Front, and Draw Filled Lines (#1136); graphs keep their label formatting when drawn again (#1137). Align: a Horizontal & Vertical Align Center button (#1073); Align to Key Object makes the topmost selected object the key (#1221), and the palette's key entry reads Cancel Key Object (#1224). Pen: the nearest end wins over snapping, Alt on the last anchor edits it, and Enter, Escape or a tool switch finish a handle preview as one undo step (#821). Direct Selection: a click on an anchor of an already selected path selects that anchor alone (#1237). Make Compound Path keeps the backmost object's paint, and a selected member reads and changes the compound's fill rule (#1225). Shape Builder and Live Paint no longer turn a line's loose end past a crossing into a piece that runs out and back (#1230). Art moved or duplicated with an artboard follows Transform Pattern Tiles (#1219). A click with the Rectangular Grid tool opens its options dialog (#1169). Commands that change nothing (Lock, Hide, Transform Each, Rearrange Artboards and others) add no undo step (#1233). Saving with missing placed files keeps their relink data, and .ai export honours the chosen artboards (#718). SVG export keeps Unicode data-attribute names, and SVG Options refuses an invalid artboard range (#989). CMYK colours given in percent with a component under 1 keep their scale (#1249). Colour Image Trace keeps close dark tones apart (#1247). macOS: the Save sheet has a Format menu (#1235). The Hand and Zoom tools show drawn cursors on every platform (#1238). Czech: the remaining strings are translated, and Pathfinder's Add reads "Sjednotit" (#1226). MCP: a lost reply reports that the edit may have happened instead of replaying it (#1246); `select.key` checks its id and agents can read the key object (#1222). Tests: the command sweeps and import fuzz tests fail on a panic caught by the engine's guard (#1218), and the envelope export test ignores PDF dates and file IDs (#1220). Effect › Artistic › Film Grain (Grain, Highlight Area, Intensity; Photoshop-style filters 29/57) (#1282), and Craquelure, Mosaic Tiles and Stained Glass redraw 25–30% faster with identical pixels (#1265). Direct Selection: clicking a segment selects only that segment, and Delete removes just it (#1300); Shift-click deselects one anchor of a path selected whole (#1263). Pen + Alt converts anchors of paths inside a selected group or compound path (#1306). The Curvature tool shows circular point markers without direction handles (#1284). Snap to Point: pressing near an anchor drags by that anchor, with Smart Guides on or off (#1313). Transform Again repeats the options of the transform it repeats (#1303). Aligning anchors that are already aligned adds no undo step (#1276), and type commands that change nothing record none either (#1316). Simplify keeps the path selected with its new anchors, and its dialog previews and shows the anchor count (#1305). Adding a stroke colour to an unpainted zero-width stroke gives it a visible 1 pt width (#1256). The Eyedropper samples locked objects, template layers and art outside isolation mode (#1260), and shows a pipette cursor (#1288). Fonts named by their full name resolve to the right face (#1267), and Proportional Metrics brings the matching kerning (`apkn`, `vkrn`, `vapk`) (#1291). Artboards: Escape leaves Artboard editing (#1296), Document Setup's Edit Artboards switches to the Artboard tool (#1294), and the Artboard tool starts on the active artboard (#1309). New and imported documents use the Gridline every and Subdivisions preferences (#1314). Send to Current Layer refuses a hidden or locked current layer (#1301), and layer row commands check explicit ids (#1255). Links: Find in Folder… finds missing linked files in a folder and its subfolders and can copy them into the document's folder (#1287). Image Trace traces images over 64 megapixels scaled down instead of refusing them (#1315). DXF export marks closed curves as closed SPLINE entities (#1320). EPS import: closepath respects the path size limit (#1311). The interface is available in Hungarian (#1302), and more Czech labels are translated (#1259). Linux RPMs declare shared-library capabilities (#1290). CI pins every third-party action to a verified commit (#1321). Select menu commands skip hidden and locked art and stay inside the isolated group in isolation mode (#1325). Fill, stroke, gradient and Appearance panel commands that change nothing add no undo step (#1331). EPS import no longer hangs on shading functions fanned out through shared arrays (#1330), looped huge arcs, fine dashes and rectangle arrays (#1329), or very long dash patterns (#1332). Plug-in whole-number fields keep values past 2^53 exactly as typed (#1327). Large documents use far less memory: decoded images are shared and capped, undo history is bounded by memory, native files open in the background and save as a stream, and Data Recovery writes images once (#1245). Opacity mask commands (#1336), Simplify and Add Anchor Points (#1337) add no undo step when they change nothing. EPS files with thousands of tiling patterns, spot inks or clipped shadings open several times faster with the same result (#1341). A `.ai` or PDF saved from a CMYK document with transparency no longer opens blank in applications that read soft masks by luminosity (#1359, part of #1286). Gradient-filled shapes in a PDF or `.ai` open as one path with the gradient instead of a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on their outlines (#1351). A `.ai` saved with a bleed opens on its artboard with the bleed round it as the document's bleed, from its layers, and Crop To › Trim does the same for a PDF (#1349); a PDF saved with its editing data opens from it, with its layers, groups and art past the page, as a `.ai` does (#1345). Art moved into a hidden or locked layer (row drag, Merge Selected, Flatten Artwork, Collect in New Layer) or hidden by a data set is deselected (#1355), and hiding or locking a layer with a large selection deselects its objects in linear time (200,000 objects: 3.9 s to 24 ms) (#1353). Objects moved whole together with direct-selected anchors follow Preferences › Transform Pattern Tiles (#1354). Preferences › Appearance of Black › On Screen works: Display All Blacks as Rich Black shows 100K black as RGB black on the canvas (#1350). DXF: a closed control-point SPLINE opens as a closed path (#1318). Numeric fields: dragging over part of a value in an unfocused field selects that part instead of the whole value (#1339). Pathfinder Divide: an open line or curve across a filled shape cuts it into separate pieces (#1335); Unite, Minus Front, Minus Back, Intersect and Exclude results keep the source object's name, so exported SVG keeps its id (#1308). Object › Align follows the Align To choice (one object aligns to the artboard), and the choice is kept across restarts (#1347). Artboard tool: a click on an object creates an artboard fitted to it (#1252). Rulers: drag the ruler corner to move the zero point, double-click it to reset; the X/Y fields measure from it (#1250). Live Corners: with Direct Selection, a click on a corner widget selects that corner, so the next drag rounds it alone (#1239). A click with the Polar Grid tool opens a dialog for its size and divider counts, as the Rectangular Grid tool does (#1192). A `.ai` whose editing data holds an image on hidden lines is no longer misread (#1364, part of #1264). PDF export leaves out art placed beyond 1,000,000,000 pt instead of aborting debug and test builds (#1360). Affinity import: rectangles keep per-corner radii and straight (cut) corners (#1369, part of #666). Tools panel: the tool buttons are centred inside the panel border, in one and two columns (#1363). Spanish: 371 more interface strings translated (presets, shortcut editor, print and PDF options, Save for Web and more) (#1371). The Properties panel no longer widens and cuts off its labels when an Image Trace object is selected (#1374, part of #1358). Art drawn flush with an artboard (a background a rounding error outside it) moves, copies and scales with the artboard again (#1375, from #1295). A character no installed font had is looked for again after the fonts are rescanned, so installing a font fixes it without a restart (#1376, from #992). A PDF saved with its editing data and a bleed opens on its artboard with the bleed round it, as a `.ai` does (#1379, follow-up to #1349 and #1345). Dialogs opened from a menu focus their first field every time they open, not only the first time, so typing, Tab and Enter work (#1382, fixes #1367); a dialog whose first field is greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the keyboard focus to that field once it is enabled (#1395). In a `.ai` opened from its editing data, a gradient with stops in the middle draws across its whole shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392) (follow-ups to #1264). `--control` without a valid port (or a bad `VECTORCRAFT_CONTROL_PORT`) stops the desktop app with an error instead of starting without a control server, and `--control=` works too (#1383). Documents with many symbol instances or inline graphics redraw faster and use less memory: the art placed for each instance is kept between frames (#1386). PDF and `.ai` import and placed PDFs keep overprint (`/OP`, `/op`), so overprinting spot colours still overprint after export (#1413, fixes #1388). A `.ai` whose PDF part draws only text opens from that part when its editing data can't be read, instead of being refused (#1414, fixes #1387). In a `.ai` opened from its editing data, a compound path takes the paint of its last path, so its gradient no longer comes in black, and paths grouped inside a compound path stay its paths (#1426, fixes #1410). With anchors selected with Direct Selection, the Scale, Rotate, Reflect and Shear tools, their dialogs and Move transform only those anchors (#1427, fixes #1408). The Move, Rotate, Scale, Reflect and Shear dialogs have a live Preview, and the arrow keys step their angles and percentages by 1 (Shift: 10) (#1428, fixes #1412). A stroke wider than its closed shape (a small ellipse or rectangle with a heavy stroke) paints solid on the canvas, as in exports, instead of showing a hole (#1409, fixes #1396). The bounding box hides while a corner radius is dragged with the Selection tool (#1402, fixes #1229). Pasting a picture works when the clipboard also holds a blank text item beside it (#1404). `document.inspect` reports a compound path's fill, stroke and weight and every node's blend mode when it isn't Normal (#1397, fixes #1389); `object.isolate` with an id that doesn't exist says there is no such object (#1415); the MCP guide covers Spacing Guides and four more preferences, all resources, and the symbol, brush, variable and Image Trace commands (#1418). PDF and `.ai` import keep a stroke opaque and unmasked when the fill under it was faded or masked by its group, and a `.ai` read as its PDF part keeps its RGB numbers (#1434). SVG export writes knockout groups of opaque art without masks (smaller files, no edge seams), and `vectorcraft-cli convert` takes `--decimals` (#1435). Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate and the copying transforms of thousands of objects take a fraction of a second instead of seconds (#1430, part of #1429), and an Alt-drag with the selection and transform tools copies the selection once per drag instead of on every move (#1464, part of #1429). An Affinity text frame too short for its first line (a 64 pt title in a 54 pt frame) is made taller on import, with a note, instead of showing nothing (#1449, fixes #1423). Fit Headline no longer pushes the tracking to its ceiling and loses glyphs when the frame holds one line (#1445, fixes #1444). Text frames, wrap shapes and type paths with coordinates beyond a billion points (from damaged files) hold no text instead of hanging the layout (#1451). Czech: 128 more strings translated (#1461). Plug-ins: a numeric parameter's default stays inside its declared range (#1463). Split Into Grid has the documented options: number, height or width, gutter and total for rows and columns, Add Guides and Preview; several selected paths become one grid painted as the topmost one (#1488, fixes #1462). Grayscale TIFFs with an alpha channel or other extra channels can be placed, relinked and opened, the alpha as the image's transparency (#1489, part of #1466). Selected text in numeric and dialog fields is drawn in white or black instead of blue on the blue highlight (#1490, part of #1465). The Pathfinder panel works on a group selected alone (its members are combined), and a greyed-out button says what it needs (#1493, for #1450). Transform Objects joins Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and in Transform Each (off: only the pattern tiles transform), and Reflect takes an angle for its axis (#1494, fixes #1448 and #1460). A window larger than 8192 px (several monitors, a display-scale change) keeps its surface instead of closing the app (#1495, part of #1459). Commands run through MCP, the control channel and the CLI refuse a parameter key they don't take, naming it and the nearest accepted key (#1496, fixes #1442). A focused number field follows its value when a slider or preview changes it, so the next key no longer puts the old value back (#1497, fixes #1457). Effect › 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (curves are sampled by a tolerance; Rotate takes up to 65,536 outline points; #1481), Extrude & Bevel's Twist runs 0° to 360°, as the public panel reference gives it (#1524), and a twisted extrusion keeps its walls (from about 45° they were dropped; #1525). Text commands and the MCP `add_text` tool say when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). A tab stop in the Tabs panel drags from where the press landed, including one just added (#1487, fixes #1458). `graph.setData`'s CSV keeps each value exactly instead of rounding it to two decimals, so applying the returned CSV changes nothing (#1485). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible (#1505, fixes #1486). Sliders in Save for Web, the raster export options, Preferences and the opacity popup show their rail, filled up to the handle (#1506, fixes #1483). Direct Selection picks the anchor of the selected object where anchors of overlapping objects coincide, also under another object (#1509, fixes #1508). Print-size images (past 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image whose side is past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows, `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as on macOS and Linux (#1502). French: 423 missing strings added and 188 entries reworded to the feature names French users know (#1511). Hungarian: one message uses the informal address like the rest of the interface (#1474). | | 2026-10-12 | Georgian interface (`ka`, ქართული): the whole catalog, plurals and messages, tested complete; Georgian letters come from an installed font (SF Georgian, Sylfaen, Noto Sans Georgian, DejaVu Sans) from the first frame; Type › Change Case › Title Case and Sentence case leave Georgian lower case instead of turning it into Mtavruli. | | 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). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). Shape Builder merges regions that crossing lines cut along the lines' own edges, so a strip merged across a grid of lines is one outline with no seams left inside (#1081). Proportional Metrics also works in vertical type: upright glyphs take the font's proportional heights (`vpal`), and Line-end Punctuation Half Width takes nothing more off the punctuation it re-spaced (#1082). Plug-in whole-number parameters keep their exact value however large, and stay in the range the manifest gives (#1091). PDF and `.ai` import keeps type spaced glyph by glyph (character tightening, optical kerning) with each glyph in its place, giving each character its own tracking when the file's font is installed, and CJK punctuation closed up by its proportional width continues its line (#1101). Direct Selection: pressing an anchor of a path selected whole keeps all its anchors selected, so the drag moves them all; Alt-dragging by an anchor copies the whole path, held by that anchor, which snaps onto other anchors and onto the original the copy leaves behind; Alt pressed or released during the drag turns the copy on and off, as with the Selection tool, whose copies snap to their original too, and the undo step is named Copy or Move for what the drag did (#1104). The toolbar's Fill/Stroke proxy and Color, Gradient and None buttons grow in two columns, where the three drawing modes sit side by side (#1129). An image copied in a browser that puts its `` markup on the clipboard as text pastes as the picture, not the markup (#1089). `file.place` of an SVG with `crop: "crop"` frames and clips it by its viewBox, so art drawn on a shared canvas keeps its place and scale (#788). While a star is dragged, Ctrl/Cmd keeps its inner radius as the outer one grows and Alt keeps its shoulders straight (#812). Edit Toolbar opens the All Tools drawer beside the toolbar, dims the tools already in it, and adds, removes and moves tools by drag and drop while it is open (#1018). The Selection tool's arrow is black and Direct Selection's white in every theme, as users know them: in the dark themes they looked swapped. The font menu keeps its highlight on the same font when the list changes under it (a family's styles listed above it, fonts that finish loading, a favourite unstarred), and ends the live preview when that font leaves the list (#1116). The Windows installer has a Shortcuts page with a ticked "Create a desktop shortcut" box, and a silent install adds the shortcut unless `INSTALLDESKTOPSHORTCUT=""` is passed (#1128). Image Trace's Noise merges specks until none under its size is left beside another colour, so a higher Noise never gives more paths (#1130). `cargo clippy -D warnings` passes for the wasm32 web build (#1132). Gradient stops with opacity blend colour and opacity apart on the canvas, as Illustrator and the PDF and SVG exports do: a transparent stop's colour still tints the fade (#1142). An SVG mask whose art is all hidden imports as an empty opacity mask, so its object stays hidden as browsers draw it (#1143). Shift-click with the Artboard tool, or in the Artboards panel, selects several artboards, which then move, duplicate (Alt-drag) and delete together (#1139). A shape far beyond any page (from a malformed file) is left undrawn instead of making the renderer abort the app. Live Corners round corners where curved segments meet too, trimming each curve to what the cut leaves of it (#1144). Effect › Distort & Transform › Zig Zag's dialog has sliders for Size and Ridges per segment, and Relative / Absolute and Smooth / Corner as radio buttons (#1133). Object › Path › Offset Path opens on the offset, joins and miter limit it was last applied with (#1152). A marquee selects only the objects whose paths it touches: one drawn over a background, in Preview or Outline, no longer selects the background too (#1103). On Linux, Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland on a Wayland session, where files dropped from a file manager arrive (#1151). A selected gradient mesh shows its mesh lines and points with the Selection and Direct Selection tools too, not only with the Mesh tool (#1111). A blend's spine bends: the Anchor Point tool drags any of its segments into a curve, straight ones too, and Direct Selection reshapes its curved segments (`object.blend.spine.reshapeSegment`, #1111). Alt-click on a hidden layer's eye shows that layer as it hides the others, and on a locked layer's lock unlocks it as it locks the others: the layer clicked is the one left in view (#1158). Object › Compound Path › Release splits a path of several subpaths (an SVG `d` with several `M`s, a path drawn in one go over MCP) into one path per subpath, with its paint and stacking order, as it does a compound path (#1163). MCP `render_preview` takes an `artboard` index, so an agent can look at every page of a multi-artboard document (#1163). The MCP drawing tools say that omitted paint follows the new-art appearance (a white fill and 1 pt black stroke at first), and `export` says which formats take `outlineText` (#1148). The desktop app refuses a malformed `--automation-read-root` / `--automation-write-root` (no folder, an empty one, another option or the flag twice) before it starts, instead of dropping the root its environment variable set and leaving the control channel unconfined (#1157). PDF export warns, once per object, when the 64 million pixel cap renders its raster effects below the document's raster effects resolution, with the requested and the achieved ppi (#1147). MCP `command_batch` with `atomic: true` makes the whole batch one undo step, named by `label` (`edit.undoGroup.begin`/`end`, #1163). `file.list {path}` lists a folder's files and folders for agents, held to the automation read root (#1163). Thai typed in a font without Thai glyphs is set in an installed font made for Thai (Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai…) instead of whichever face with Thai glyphs loaded first, and Lao, Khmer and Myanmar likewise. With the Type tool, the Left and Right arrows and Delete step over whole clusters (a Thai consonant with its vowel and tone marks, a letter with a combining accent, an emoji sequence), so the caret never lands inside one; Backspace still takes back one character at a time, a mistyped tone mark say. Thai, Lao, Khmer and Myanmar area type breaks its lines between words, which those scripts don't separate with spaces, instead of running on to the next space; the words come from ICU4X's line segmenter (its dictionaries on the desktop, its smaller LSTM models on the web). The font menu's script filter has Thai beside Japanese and Latin: the families whose character map has Thai. With craft-fonts' Noto Sans Thai, Thai is set in it where no Thai font is installed, and the web build fetches it before the app starts, so Thai no longer shows as empty boxes there. With the Type tool, Ctrl/Alt+arrows and double-click take Thai, Lao, Khmer and Myanmar text word by word (ภาษา, ไทย…) instead of a whole run without spaces, and a word's vowel and tone marks (and combining accents) stay part of it. Releases embed the current craft-fonts (Noto Sans Thai, Noto Sans Arabic and Noto Sans CJK SC beside the Japanese fonts), as the other craft apps' releases do. Object › Compound Path › Make cuts holes where the paths in front overlap the backmost one, also when they were drawn running the same way (#1197). On Windows, Direct3D 12 shaders compile with FXC, part of Windows, so a dxcompiler.dll another program left on the search path without its dxil.dll no longer loses the graphics device at start-up and drops the app to OpenGL (#1194). With anchor points selected by Direct Selection, the Align buttons align those points (to their own bounds or the artboard) instead of moving the whole path (#1180). With Smart Guides, an object grabbed by an anchor and dropped near another object's anchor lands exactly on it, instead of its bounds lining up a fraction of a point beside it (#955). .ai import: a copied area type or type-on-a-path object now sits where its own art puts it, not on top of the original (#1208). EPS/.ai import: a font is found by its exact PostScript name before the name is split, so families with hyphens keep their name (#1207). EPS/.ai import: type from the text document keeps its line spacing across paragraphs instead of applying the paragraph spacing twice (#1206). Brazilian Portuguese translates the remaining UI labels and engine messages (#1205). Czech joins the complete languages: panels, dialogs, plurals and status and error messages (#1189). Type: Digits (Character panel menu, with the Middle Eastern options) draws every digit in the Default, Arabic, Hindi or Farsi numeral system (#1201). Linux: under Wayland, Automatic draws on the GPU the compositor says it draws with, so a hybrid laptop whose compositor runs on the discrete GPU no longer opens an empty window (#1200). Large documents: editing a big selection takes time linear in the document (a 25,000-path nudge 3.5 s → 48 ms), live geometry effects are kept across frames, Pathfinder Trim, Merge, Crop and Outline are 9–20× faster with identical output, and undo history holds 41% less (#1173). | -| 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-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured (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 | @@ -193,12 +196,13 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | 2026-10-01 | Renamed from DrawCraft to VectorCraft | | 2026-09-30 | Repository history begins | -The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#progress-log-before-the-progress-docs-standard). +The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap.md#earlier-progress-log). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Progress log row for the Linux Flatpak clipboard's core Wayland protocol text fallback; headline numbers unchanged. | | 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | | 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. | @@ -219,7 +223,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. | | 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. | -| 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 | Method changed, 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 | diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 8fe3d23b7..ac3a9cf3a 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -116,7 +116,7 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> { let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() }; let err = |e: vectorcraft_engine::EngineError| e.to_string(); let mut drag = |down: Point| -> Result { - let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 }; + let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0 }; s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?; s.select_tool("directSelection", view).map_err(err)?; let undo = s.doc().map_err(err)?.history.undo.len(); @@ -215,7 +215,7 @@ fn deep(n: usize) -> Result<(), String> { let first = s.doc().map_err(err)?.selection.objects.first().copied(); let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center()); if let Some(down) = at { - let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 }; + let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 }; s.select_tool("selection", view).map_err(err)?; s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?; let mut dx = 0.0; diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 3173d3bc9..0657c93c8 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -92,6 +92,10 @@ x11rb = { version = "0.13", default-features = false, features = ["xinput"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. wayland-client = "0.31" +# Copy and Paste of text through the compositor's core data-device protocol on the window's +# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak +# on a Wayland session). MIT, already in the build through egui-winit. +smithay-clipboard = "0.7" wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build diff --git a/apps/vectorcraft/src/app.rs b/apps/vectorcraft/src/app.rs index 2847004b3..05c756a0c 100644 --- a/apps/vectorcraft/src/app.rs +++ b/apps/vectorcraft/src/app.rs @@ -1,6 +1,6 @@ #[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))] use super::gpu; -use super::prefs::save_prefs; +use super::prefs::{save_prefs, save_prefs_soon}; #[cfg(all(windows, not(target_vendor = "win7")))] use super::windows_stylus; use super::{App, VectorcraftApp, file_access, window}; @@ -71,6 +71,9 @@ impl eframe::App for App { // (the system shutting down, the process ended or crashing) used to lose them (#1747). if self.app.take_prefs_changed() { file_access::unconfined(|| save_prefs(&self.app)); + } else { + // The workspace, panels and other UI settings change without the dialog (#4625). + file_access::unconfined(|| save_prefs_soon(&self.app)); } // Not while a file dialog's sheet has the keyboard: its name field's composition would go. #[cfg(target_os = "macos")] diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index fcaf3857d..08bb6aa8a 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -9,8 +9,10 @@ //! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on //! Linux arboard reads no other formats). On Wayland, arboard's data-control //! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps -//! copied), falling back to X11 where the compositor lacks the protocol. A clipboard that can't -//! be opened is tried again after a wait that grows to a minute, not each time Paste asks. +//! copied), falling back to X11 where the compositor lacks the protocol, and where there is no +//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection, +//! text only. A clipboard that can't be opened is tried again after a wait that grows to a +//! minute, not each time Paste asks. //! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS, //! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). @@ -20,12 +22,16 @@ use std::path::PathBuf; use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour}; use vectorcraft_ui_egui::SystemClipboard; -/// This platform's system clipboard. -pub fn system_clipboard() -> Box { +/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where +/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11). +pub fn system_clipboard(window: Option>) -> Box { #[cfg(windows)] - let cb = Box::::default(); + let cb = { + let _ = window; + Box::::default() + }; #[cfg(not(windows))] - let cb = Box::::default(); + let cb = Box::new(portable::Clipboard::new(window)); cb } @@ -307,10 +313,12 @@ mod win { } } -/// arboard: one format at a time. Built everywhere so it is checked everywhere. +/// arboard, or on a Wayland compositor without its data-control protocol the core protocol's text +/// clipboard: one format at a time. Built everywhere so it is checked everywhere. #[cfg_attr(windows, allow(dead_code))] mod portable { use std::borrow::Cow; + use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -322,6 +330,109 @@ mod portable { Image(usize, usize), } + /// An open clipboard connection. + enum Backend { + Arboard(arboard::Clipboard), + /// The compositor's core data-device protocol on the window's connection (text only). + #[cfg(target_os = "linux")] + Wayland(super::wayland::TextClipboard), + } + + /// What a write of a picture says where the clipboard carries text only. + #[cfg(target_os = "linux")] + const TEXT_ONLY: &str = "this clipboard carries text only"; + + impl Backend { + /// arboard (Wayland data-control, else X11), else the core protocol on `window`'s + /// Wayland connection. Err: why each failed. + fn open(window: Option<&Arc>) -> Result { + first_open( + || arboard::Clipboard::new().map(Self::Arboard).map_err(|e| e.to_string()), + || { + #[cfg(target_os = "linux")] + return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window"); + #[cfg(not(target_os = "linux"))] + { + let _ = window; + Err("not on Linux") + } + }, + ) + } + + fn name(&self) -> &'static str { + match self { + Self::Arboard(_) => "arboard", + #[cfg(target_os = "linux")] + Self::Wayland(_) => "the Wayland core protocol (text only)", + } + } + + fn get_text(&mut self) -> Result { + match self { + Self::Arboard(cb) => cb.get_text().map_err(|e| e.to_string()), + #[cfg(target_os = "linux")] + Self::Wayland(cb) => cb.get_text(), + } + } + + fn set_text(&mut self, text: &str) -> Result<(), String> { + match self { + Self::Arboard(cb) => cb.set_text(text).map_err(|e| e.to_string()), + #[cfg(target_os = "linux")] + Self::Wayland(cb) => { + cb.set_text(text); + Ok(()) + } + } + } + + fn get_image(&mut self) -> Result, String> { + match self { + Self::Arboard(cb) => cb.get_image().map_err(|e| e.to_string()), + #[cfg(target_os = "linux")] + Self::Wayland(_) => Err(TEXT_ONLY.into()), + } + } + + fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> { + match self { + Self::Arboard(cb) => cb.set_image(img).map_err(|e| e.to_string()), + #[cfg(target_os = "linux")] + Self::Wayland(_) => Err(TEXT_ONLY.into()), + } + } + + fn clear(&mut self) -> Result<(), String> { + match self { + Self::Arboard(cb) => cb.clear().map_err(|e| e.to_string()), + #[cfg(target_os = "linux")] + Self::Wayland(cb) => { + cb.set_text(""); + Ok(()) + } + } + } + + /// Files copied in a file manager (none where the backend can't read a file list). + fn file_list(&mut self) -> Vec { + match self { + Self::Arboard(cb) => cb.get().file_list().unwrap_or_default(), + #[cfg(target_os = "linux")] + Self::Wayland(_) => vec![], + } + } + } + + /// The first of `arboard` and `wayland` that opens, tried in that order. Err: why each failed. + fn first_open(arboard: impl FnOnce() -> Result, wayland: impl FnOnce() -> Result) -> Result { + let arboard = match arboard() { + Ok(cb) => return Ok(cb), + Err(e) => e, + }; + wayland().map_err(|wayland| format!("arboard: {arboard}; Wayland core protocol: {wayland}")) + } + /// Who asks for the clipboard: the background paste check waits out the whole wait after a /// failure, a Copy or Paste of the user's [`USER_RETRY`] at most. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -348,7 +459,7 @@ mod portable { } /// A handle opened on first use and kept, or after a failure opened again only once its wait is - /// over: where the clipboard can't be opened, asking a few times a second must not reconnect each time. + /// over: where neither backend opens, asking a few times a second must not reconnect each time. pub(super) struct Reopen { handle: Option, failed: Option, @@ -415,20 +526,20 @@ mod portable { pub struct Clipboard { /// Kept open: on Linux the copied data lives as long as the handle does. - cb: Reopen, + cb: Reopen, + /// The app window, for the Wayland core protocol (on its connection). + window: Option>, ours: Option, } - impl Default for Clipboard { - fn default() -> Self { - Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), ours: None } - } - } - impl Clipboard { - fn cb(&mut self, asker: Asker) -> Result<&mut arboard::Clipboard, String> { - let open = || arboard::Clipboard::new().map_err(|e| e.to_string()); - self.cb.get(asker, Instant::now(), open, |_| "arboard").ok_or_else(|| "no system clipboard".to_string()) + pub fn new(window: Option>) -> Self { + Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), window, ours: None } + } + + fn cb(&mut self, asker: Asker) -> Result<&mut Backend, String> { + let window = self.window.as_ref(); + self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string()) } } @@ -442,16 +553,16 @@ mod portable { let cb = self.cb(Asker::User)?; let ours = match (text, image) { (Some(t), _) => { - cb.set_text(t.as_str()).map_err(|e| e.to_string())?; + cb.set_text(t.as_str())?; Some(Ours::Text(t)) } (None, Some(img)) => { let (width, height) = (img.width() as usize, img.height() as usize); - cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?; + cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) })?; Some(Ours::Image(width, height)) } (None, None) => { - cb.clear().map_err(|e| e.to_string())?; + cb.clear()?; None } }; @@ -470,7 +581,7 @@ mod portable { fn read(&mut self, mimes: &[&'static str]) -> Option { let cb = self.cb(Asker::User).ok()?; - if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) { + if let Some(f) = copied_file(cb.file_list(), mimes) { return Some(f); } mimes.iter().find_map(|m| { @@ -608,6 +719,26 @@ mod portable { assert_eq!(cb.opened().copied(), Some(7)); } + #[test] + fn arboard_is_tried_before_the_wayland_core_protocol() { + let wayland_tried = Cell::new(false); + let wayland = |ok| { + wayland_tried.set(true); + if ok { Ok("wayland") } else { Err("not a Wayland window") } + }; + // Where arboard opens (data-control, X11) the core protocol isn't tried. + assert_eq!(first_open(|| Ok("arboard"), || wayland(true)), Ok("arboard")); + assert!(!wayland_tried.get()); + // The Flatpak on GNOME: no data-control, no X11. + assert_eq!(first_open(|| Err("no X11".into()), || wayland(true)), Ok("wayland")); + assert!(wayland_tried.get()); + // Neither: every reason is kept for the log. + assert_eq!( + first_open(|| Err("no X11".into()), || wayland(false)), + Err::<&str, _>("arboard: no X11; Wayland core protocol: not a Wayland window".into()) + ); + } + #[test] fn one_outage_is_logged_once_across_handles() { static LOGGED: AtomicBool = AtomicBool::new(false); @@ -623,6 +754,169 @@ mod portable { } } +/// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the +/// window's own Wayland connection: what Copy and Paste use where the compositor lacks the +/// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on +/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the +/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's +/// data with no limit, so reads run on a worker of their own. +#[cfg(target_os = "linux")] +mod wayland { + use std::sync::Arc; + use std::sync::mpsc; + use std::time::Duration; + + /// The longest a read waits for the selection's owner to send its text. + const LOAD_WAIT: Duration = Duration::from_secs(2); + /// What a read says once its worker has stopped. + const GONE: &str = "the clipboard's reader stopped"; + + pub struct TextClipboard { + /// Writes: a store only hands the text to smithay-clipboard's thread. + cb: smithay_clipboard::Clipboard, + /// Reads, on a worker with a second clipboard on the same connection. + loads: Loads, + /// Held as long as the clipboards: their connection is the window's. + _window: Arc, + } + + /// The read worker's clipboard and the window whose connection it uses, dropped in that order. + struct Loader { + cb: smithay_clipboard::Clipboard, + _window: Arc, + } + + impl Loader { + fn load(&self) -> Result { + self.cb.load().map_err(|e| e.to_string()) + } + } + + impl TextClipboard { + /// None when `window` isn't a Wayland window or the read worker can't start. + #[allow(unsafe_code)] + pub fn new(window: Arc) -> Option { + use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle}; + let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None }; + let display = d.display.as_ptr(); + // SAFETY: the display is winit's open `wl_display`, which outlives `window`; each + // clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard + // makes its own event queue on the display and doesn't close it. + let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) }; + let loader = Loader { cb: reader, _window: window.clone() }; + // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names, + // and the worker must hold the window as long as its clipboard. + let loads = Loads::spawn(move || loader.load())?; + Some(Self { cb, loads, _window: window }) + } + + pub fn get_text(&mut self) -> Result { + self.loads.get(LOAD_WAIT) + } + + pub fn set_text(&self, text: &str) { + self.cb.store(text); + } + } + + /// Reads made on a worker thread and waited for a bounded time: a selection owner that never + /// sends its data holds the worker, not the caller. + struct Loads { + requests: mpsc::Sender<()>, + answers: mpsc::Receiver>, + /// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it. + pending: bool, + } + + impl Loads { + fn spawn(mut load: impl FnMut() -> Result + Send + 'static) -> Option { + let (requests, asked) = mpsc::channel::<()>(); + let (answer, answers) = mpsc::channel(); + let work = move || { + while asked.recv().is_ok() { + if answer.send(load()).is_err() { + break; + } + } + }; + std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?; + Some(Self { requests, answers, pending: false }) + } + + /// The text, or why there is none, after `wait` at most. + fn get(&mut self, wait: Duration) -> Result { + if self.pending { + match self.answers.try_recv() { + // The late answer of the read that timed out: stale by now. + Ok(_) => self.pending = false, + Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()), + Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()), + } + } + self.requests.send(()).map_err(|_| GONE.to_string())?; + match self.answers.recv_timeout(wait) { + Ok(text) => text, + Err(mpsc::RecvTimeoutError::Timeout) => { + self.pending = true; + Err(format!("the clipboard's owner didn't send its text within {wait:?}")) + } + Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()), + } + } + } + + #[cfg(test)] + mod tests { + use std::time::Instant; + + use super::*; + + #[test] + fn a_read_that_answers_in_time_gives_the_text() { + let mut loads = Loads::spawn(|| Ok("".into())).unwrap(); + assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); + assert_eq!(loads.get(Duration::from_secs(5)), Ok("".to_string())); + } + + #[test] + fn an_owner_that_never_answers_holds_the_worker_not_the_caller() { + let (release, gate) = mpsc::channel::<()>(); + let mut calls = 0; + let mut loads = Loads::spawn(move || { + calls += 1; + if calls == 1 { + // The first owner sends nothing until released. + let _ = gate.recv(); + return Ok("stale".into()); + } + Ok("fresh".into()) + }) + .unwrap(); + let start = Instant::now(); + assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out"); + // While it is unanswered, a read doesn't queue behind it or wait at all. + assert!(loads.get(Duration::from_secs(60)).is_err()); + assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound"); + release.send(()).unwrap(); + // Once the late answer is in, it is dropped and a new read is made. + let text = (0..200).find_map(|_| { + std::thread::sleep(Duration::from_millis(10)); + loads.get(Duration::from_secs(5)).ok() + }); + assert_eq!(text.as_deref(), Some("fresh")); + } + + #[test] + fn a_read_after_the_worker_stops_says_so() { + let mut loads = Loads::spawn(|| Ok(String::new())).unwrap(); + // The worker ends when its answers can't be sent: drop them by hand. + let (_, dead) = mpsc::channel(); + drop(std::mem::replace(&mut loads.answers, dead)); + assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string())); + } + } +} + /// The macOS pasteboard's vector art and bitmaps as they are (arboard reads text, and of the /// pictures TIFF only, decoded). #[cfg(target_os = "macos")] diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 4254d1591..e25332b61 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -255,6 +255,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { .as_ref() .and_then(|w| w.window_handle().ok()) .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); + let (clip, clip_probe) = (parent.clone(), parent.clone()); let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); #[cfg(target_os = "macos")] let sampled_ui = ui.clone(); @@ -286,11 +287,12 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) })), // Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event). - system_clipboard: Some(clipboard::system_clipboard()), + system_clipboard: Some(clipboard::system_clipboard(clip)), // Linux checks whether Paste has something to take on a background thread: an X11 clipboard // owner that never answers holds a read for up to 4 s. Windows only asks which formats the // clipboard holds and macOS asks the pasteboard server, so they check in line. - clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory), + // The probe's handle is made on its own thread, from the window (`Send`), not its raw display. + clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory), // Help → Discord / website / GitHub, the Discord button, About and Home links. open_url: Some(Box::new(|url: &str| { let _ = webbrowser::open(url); diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index f02cbda18..e90977726 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -128,7 +128,7 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli: fn main() -> std::process::ExitCode { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] - if let Some(result) = craft_update::self_update::finish(&craft_update::suite::VECTORCRAFT, &std::env::args_os().skip(1).collect::>()) { + if let Some(result) = craft_update::self_update::finish(&vectorcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { std::process::exit(i32::from(result.is_err())); } // Before any thread starts: the pointer must find a cursor theme on Wayland (#2450). diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs index 56ea30f21..728fe9ba8 100644 --- a/apps/vectorcraft/src/prefs.rs +++ b/apps/vectorcraft/src/prefs.rs @@ -84,24 +84,200 @@ pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option(&b).ok()); + let merged = match (base.as_ref(), on_disk) { + (Some(base), Some(on_disk)) => merge_changes(base, &mine, on_disk), + _ => mine.clone(), + }; + let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); + let Ok(bytes) = serde_json::to_vec_pretty(&merged) else { return }; + // Preferences are best effort: a failed write keeps the previous file, and the changes are + // tried again on the next save. + if fileio::write_atomic(&p, &bytes).is_ok() { + set_saved_base(Some(mine)); } } +/// How often [`save_prefs_soon`] looks for changes to save. +const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(2); + +/// Save the preferences at most every [`SAVE_EVERY`] while the app runs, when something changed: +/// the workspace, panels, toolbars and the other UI settings kept only in the UI state were saved +/// only on quitting, so a session ended any other way (the window closed by the system, the +/// process ended or crashing) started the next one from the settings before it (#4625). +pub(crate) fn save_prefs_soon(app: &VectorcraftApp) { + static LAST: std::sync::Mutex> = std::sync::Mutex::new(None); + let Ok(mut last) = LAST.lock() else { return }; + if last.is_some_and(|t| t.elapsed() < SAVE_EVERY) { + return; + } + *last = Some(std::time::Instant::now()); + drop(last); + save_prefs(app); +} + +/// The preferences as this window last read or saved them (`None`: not read yet). +static SAVED_BASE: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn saved_base() -> Option { + SAVED_BASE.lock().ok().and_then(|b| b.clone()) +} + +fn set_saved_base(value: Option) { + if let Ok(mut b) = SAVED_BASE.lock() { + *b = value; + } +} + +/// Fields of the UI state that a start resets ([`vectorcraft_ui_egui::UiState::sanitized`]): a +/// change to them alone isn't saved, and they are never merged over another window's. +const PASSING: &[&str] = &["status", "dialog", "flyout", "palette_open", "palette_query", "about", "open_panel"]; + +/// Do `a` and `b` hold the same preferences, leaving out what a start resets ([`PASSING`])? +fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool { + match (a.as_object(), b.as_object()) { + (Some(a), Some(b)) => { + let keys: std::collections::BTreeSet<&String> = a.keys().chain(b.keys()).filter(|k| !PASSING.contains(&k.as_str())).collect(); + keys.into_iter().all(|k| a.get(k) == b.get(k)) + } + _ => a == b, + } +} + +/// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI +/// state, and each engine preference (`engine_prefs`) on its own, so two windows that change +/// different settings keep both. A field both changed takes this window's value (it saves last). +fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else { + return mine.clone(); + }; + let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect(); + for key in keys { + if PASSING.contains(&key.as_str()) { + if let Some(v) = mine.get(key) { + out.entry(key.clone()).or_insert_with(|| v.clone()); + } + continue; + } + let (was, now) = (base.get(key), mine.get(key)); + if was == now { + continue; + } + if key == "engine_prefs" + && let (Some(Value::Object(was)), Some(Value::Object(now)), Some(Value::Object(theirs))) = (was, now, out.get_mut(key)) + { + let names: std::collections::BTreeSet<&String> = was.keys().chain(now.keys()).collect(); + for name in names { + match (was.get(name), now.get(name)) { + (a, b) if a == b => {} + (_, Some(v)) => { + theirs.insert(name.clone(), v.clone()); + } + (_, None) => { + theirs.remove(name); + } + } + } + continue; + } + match now { + Some(v) => { + out.insert(key.clone(), v.clone()); + } + None => { + out.remove(key); + } + } + } + Value::Object(out) +} + /// The preferences file's contents: the UI state with the engine preferences. +#[cfg(test)] pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> { + serde_json::to_vec_pretty(&prefs_value(app)?) +} + +/// [`prefs_bytes`] as JSON. +fn prefs_value(app: &VectorcraftApp) -> serde_json::Result { let mut ui = app.ui.clone(); ui.engine_prefs = app.session.prefs.to_json(); - serde_json::to_vec_pretty(&ui) + serde_json::to_value(&ui) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{merge_changes, same_lasting}; + + /// Two windows open at once (#4625): the one that saves last keeps the other's changes and + /// adds its own, instead of putting back the preferences it started from. + #[test] + fn a_window_saves_only_what_it_changed_over_another_windows_save() { + let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}}); + // The other window switched the workspace and the accent, and saved. + let on_disk = json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.0}}); + // This one changed only the UI scaling. + let mine = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.25}}); + let merged = merge_changes(&base, &mine, on_disk); + assert_eq!(merged, json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.25}})); + } + + #[test] + fn a_setting_both_windows_changed_takes_the_last_saved() { + let base = json!({"workspace": "Essentials", "engine_prefs": {"uiAccent": "blue"}}); + let on_disk = json!({"workspace": "Painting", "engine_prefs": {"uiAccent": "violet"}}); + let mine = json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}})); + } + + #[test] + fn fields_added_or_removed_by_this_window_are_saved() { + let base = json!({"docking": {"root": 1}, "engine_prefs": {"old": 1}}); + let on_disk = json!({"docking": {"root": 1}, "recent_files": ["a.svg"], "engine_prefs": {"old": 1}}); + let mine = json!({"dock_width": 300.0, "engine_prefs": {"new": 2}}); + assert_eq!(merge_changes(&base, &mine, on_disk), json!({"dock_width": 300.0, "recent_files": ["a.svg"], "engine_prefs": {"new": 2}})); + } + + /// The status line and open dialogs reset at start: they don't make a save, and they don't + /// replace another window's. + #[test] + fn what_a_start_resets_is_left_out() { + let base = json!({"status": "", "dialog": null, "workspace": "Essentials"}); + let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials"}); + assert!(same_lasting(&base, &mine)); + let on_disk = json!({"status": "Opened", "dialog": null, "workspace": "Painting"}); + let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials", "toolbar": false}); + let merged = merge_changes(&base, &mine, on_disk); + assert_eq!(merged, json!({"status": "Opened", "dialog": null, "workspace": "Painting", "toolbar": false})); + assert!(!same_lasting(&base, &mine)); + } + + #[test] + fn a_file_that_is_not_an_object_is_replaced() { + let base = json!({"workspace": "Essentials"}); + let mine = json!({"workspace": "Painting"}); + assert_eq!(merge_changes(&base, &mine, json!([1, 2])), mine); + } } diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 0b8fce921..5f57c94a6 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -16,9 +16,7 @@ head-and-shoulders portrait on a full-bleed field, no frame or roundel, with the **Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112`, clipped. The macOS files add Apple's transparent margin (tile = 824/1024 of the canvas); Windows and Linux use the tile edge to edge. -**Provenance:** the owner's original ArtCraft drawing (2880 px, keyed to the palette), vectorised with -craftrules `assets/logo-options/_tools/vectorize_tile.py`. The drawing is kept in craftrules at -`craftrules/assets/app-icons/vectorcraft/source.png`, not here. `vectorcraft.svg` is the canonical +**Provenance:** the owner's original ArtCraft drawing, vectorised. `vectorcraft.svg` is the canonical artwork. Licence: see `LICENSE.txt`. ## Files diff --git a/contributors/people.toml b/contributors/people.toml index 4883c236a..0134ded6c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -1,13 +1,10 @@ -# Contributor names for the About window (craftrules standards/contributors.md). +# Contributor names for the About window. # # Everyone who contributes is credited by GitHub username automatically. Anything more is up to # you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a # maintainer may add a person only when that person asked, recording where in consent_source. # -# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ -# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] -# -# Or copy this block, replacing your-github-username (every field is optional): +# Copy this block, replacing your-github-username (every field is optional): # # [people.your-github-username] # real_name = "Your Name" # shown when the viewer picks "Real name" @@ -180,3 +177,6 @@ sync_github_name = true [people.ashokram-si] real_name = "Ashok Ram" + +[people.dylanmtaylor] +sync_github_name = true diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 69ad12781..ced9472a7 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -196,7 +196,11 @@ pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { /// so dashes open out of a solid line; patterns of different lengths repeat to a common one. fn lerp_dash(x: Option<&Dash>, y: Option<&Dash>, t: f64) -> Option { let solid_like = |d: &Dash| Dash { - pattern: d.pattern.chunks(2).flat_map(|c| [c.iter().sum::(), 0.0]).take(d.pattern.len().max(2)).collect(), + // An odd pattern repeats twice per period, so pair up the doubled one to keep every gap closed. + pattern: if d.pattern.len() % 2 == 1 { d.pattern.repeat(2) } else { d.pattern.clone() } + .chunks(2) + .flat_map(|c| [c.iter().sum::(), 0.0]) + .collect(), offset: d.offset, align_corners: d.align_corners, }; @@ -1423,6 +1427,12 @@ mod tests { assert_eq!(p.at(0.5), (1.0, 1.0)); } + #[test] + fn odd_dash_blends_to_solid_with_closed_gaps() { + let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); + assert!(d.pattern.iter().skip(1).step_by(2).all(|g| *g == 0.0), "{:?}", d.pattern); + } + #[test] fn copied_steps_get_new_members() { let rect = |x: f64| Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default())); diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index cdbdeec27..fcf2c9429 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -683,8 +683,8 @@ impl Node { impl Node { /// Whether this object shows any transparency, itself or anything inside it: object, fill or - /// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient - /// stops. Hidden objects show none. + /// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient + /// stops or a colorized image. Hidden objects show none. pub fn shows_transparency(&self) -> bool { use vectorcraft_color::Paint; let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0)); @@ -693,6 +693,7 @@ impl Node { && (self.opacity < 1.0 || self.blend != BlendMode::Normal || self.extras().mask.as_ref().is_some_and(|m| !m.disabled) + || (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some()) || effects(&self.appearance.effects) || self .appearance diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs index a5a878710..0efa82296 100644 --- a/crates/doc/src/recolor.rs +++ b/crates/doc/src/recolor.rs @@ -18,8 +18,8 @@ pub trait ColorVisitor { fn image(&mut self, key: &str) -> Option; } -/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of -/// embedded images. +/// What a recolouring pass reaches: fills (including colorized image fills and gradient-mesh +/// points), strokes and the pixels of embedded images without a colorize paint. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Reach { pub fill: bool, @@ -33,11 +33,18 @@ impl Reach { } /// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of -/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay -/// as they are). Members that change are replaced by copies; the others stay shared. Returns the -/// number of paints, meshes and images that changed. +/// its text runs, its gradient-mesh points and an embedded image's pixels (linked and colorized +/// images keep their pixels). Members that change are replaced by copies; the others stay shared. +/// Returns the number of paints, meshes and images that changed. pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize { let mut changed = 0; + let colorized = matches!(&n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some(); + if colorized + && reach.fill + && let Some(paint) = n.extras_mut().colorize.as_deref_mut() + { + changed += v.paint(paint) as usize; + } for it in &mut n.appearance.items { match it { AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize, @@ -67,7 +74,7 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi } // An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its // colours, as the glyph does in type. - NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => { + NodeKind::Image(im) if reach.images && !colorized && im.link.is_none() && im.pixels.is_none() => { if let Some(k) = v.image(&im.key) { im.key = k; changed += 1; diff --git a/crates/effects/src/addons.rs b/crates/effects/src/addons.rs new file mode 100644 index 000000000..10c5977bf --- /dev/null +++ b/crates/effects/src/addons.rs @@ -0,0 +1,15 @@ +//! Effects of VectorCraft's built-in add-ons, one module per add-on. +//! +//! - [`color`]: Color Effects' live Exposure, Desaturate and Invert Colors, colour adjustments +//! evaluated with the others ([`crate::adjust`]). +//! - [`duotone`]: Color Effects' live Duotone, a colour adjustment that prints each colour's tone +//! with one to four inks. +//! - [`halftone`]: Color Effects' live Vector Halftone, and the screens Object › Vector Halftone +//! builds its dots with. +//! - [`path`]: Path Precision Tools' live Path Effects, geometry effects built on the add-on's path +//! kernels. + +pub mod color; +pub mod duotone; +pub mod halftone; +pub mod path; diff --git a/crates/effects/src/addons/color.rs b/crates/effects/src/addons/color.rs new file mode 100644 index 000000000..07627dd8d --- /dev/null +++ b/crates/effects/src/addons/color.rs @@ -0,0 +1,138 @@ +//! Color Effects (a built-in add-on): live Exposure, Desaturate, Duotone and Invert Colors, in +//! Effect › Color Effects. They are colour adjustments ([`crate::is_adjustment`]): they recolour what +//! an object paints as the others do, in stack order with them. + +use serde_json::{Value, json}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::util::num; + +/// Exposure: brighten or darken in stops of light, with an offset and a gamma. +pub const EXPOSURE: &str = "addon.color.exposure"; +/// Desaturate: every colour to the grey of its lightness. +pub const DESATURATE: &str = "addon.color.desaturate"; +/// Invert Colors: every colour to its RGB inverse. +pub const INVERT: &str = "addon.color.invert"; +pub use super::duotone::DUOTONE; + +/// The add-on's effects, in menu order. +pub const EFFECTS: [&str; 4] = [DESATURATE, DUOTONE, EXPOSURE, INVERT]; + +/// Is `id` one of the add-on's effects? +pub fn is_color_effect(id: &str) -> bool { + EFFECTS.contains(&id) +} + +/// The add-on's catalogue entries. +pub(crate) fn catalog() -> Vec { + const MENU: &[&str] = &["Effect", "Color Effects"]; + let e = |id, label, params, defaults| EffectInfo { id, label, menu: MENU, params, defaults, raster: false, lengths: Lengths::default() }; + vec![ + e(DESATURATE, "Desaturate", "{} turns every colour the object paints to the grey of its lightness (each colour keeps its model)", json!({})), + e(DUOTONE, "Duotone…", super::duotone::PARAMS, super::duotone::defaults()), + e( + EXPOSURE, + "Exposure…", + "{exposure: stops -20..20 (0; mostly the highlights: each stop doubles or halves the light), offset: -0.5..0.5 (0; lifts or sinks the shadows and midtones), gamma: 0.1..10 (1; above 1 lightens the midtones)} recolours as Brightness/Contrast does, in linear light", + json!({"exposure": 0.0, "offset": 0.0, "gamma": 1.0}), + ), + e(INVERT, "Invert Colors", "{} turns every colour the object paints to its RGB inverse (each colour keeps its model)", json!({})), + ] +} + +/// One of the add-on's adjustments, its parameters normalised. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Adjust { + /// Linear light times `gain`, plus `offset`, to the power 1 / `gamma`. + Exposure { + gain: f32, + offset: f32, + gamma: f32, + }, + Desaturate, + Invert, + /// Duotone ([`super::duotone`]). + Duotone(super::duotone::Duotone), +} + +impl Adjust { + pub(crate) fn parse(id: &str, p: &Value) -> Option { + Some(match id { + EXPOSURE => Adjust::Exposure { + gain: 2f64.powf(num(p, "exposure", 0.0).clamp(-20.0, 20.0)) as f32, + offset: num(p, "offset", 0.0).clamp(-0.5, 0.5) as f32, + gamma: num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32, + }, + DESATURATE => Adjust::Desaturate, + INVERT => Adjust::Invert, + DUOTONE => Adjust::Duotone(super::duotone::Duotone::parse(p)?), + _ => return None, + }) + } + + pub(crate) fn apply(&self, rgb: [f32; 3]) -> [f32; 3] { + match self { + Adjust::Exposure { gain, offset, gamma } => rgb.map(|v| to_srgb((to_linear(v) * gain + offset).max(0.0).powf(1.0 / gamma))), + Adjust::Desaturate => { + let [r, g, b] = rgb; + [(r.max(g).max(b) + r.min(g).min(b)) / 2.0; 3] + } + Adjust::Invert => rgb.map(|v| 1.0 - v), + Adjust::Duotone(d) => d.apply_rgb(rgb), + } + } +} + +fn to_linear(v: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } +} + +fn to_srgb(v: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color_map; + use vectorcraft_doc::Effect; + + fn map(id: &str, params: Value) -> crate::ColorMap { + color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap() + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01) + } + + #[test] + fn the_effects_are_colour_adjustments_in_their_own_menu() { + for id in EFFECTS { + assert!(crate::is_adjustment(id), "{id}"); + assert_eq!(crate::effect_info(id).unwrap().menu, ["Effect", "Color Effects"]); + } + } + + #[test] + fn exposure_works_in_stops_of_linear_light() { + let grey = [0.5; 3]; + assert!(close(map(EXPOSURE, json!({})).apply_rgb(grey), grey), "neutral by default"); + // One stop doubles the light: sRGB 0.5 (linear 0.214) becomes linear 0.428, sRGB 0.686. + assert!(close(map(EXPOSURE, json!({"exposure": 1})).apply_rgb(grey), [0.686; 3])); + assert!(close(map(EXPOSURE, json!({"exposure": -20})).apply_rgb([1.0; 3]), [0.0; 3])); + assert!(map(EXPOSURE, json!({"offset": 0.1})).apply_rgb([0.0; 3])[0] > 0.3, "the offset lifts the shadows"); + assert!(map(EXPOSURE, json!({"gamma": 2})).apply_rgb(grey)[0] > 0.6); + assert!(close(map(EXPOSURE, json!({"exposure": 1e300, "gamma": 0})).apply_rgb(grey), [1.0; 3]), "out of range values are clamped"); + } + + #[test] + fn desaturate_and_invert() { + assert!(close(map(DESATURATE, json!({})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3])); + assert!(close(map(DESATURATE, json!({})).apply_rgb([1.0, 1.0, 0.0]), [0.5; 3]), "lightness is kept, not luminance"); + assert!(close(map(INVERT, json!({})).apply_rgb([1.0, 0.25, 0.0]), [0.0, 0.75, 1.0])); + let both = map(INVERT, json!({})).then(&map(INVERT, json!({}))); + assert!(close(both.apply_rgb([0.2, 0.4, 0.6]), [0.2, 0.4, 0.6])); + } +} diff --git a/crates/effects/src/addons/duotone.rs b/crates/effects/src/addons/duotone.rs new file mode 100644 index 000000000..c4febd865 --- /dev/null +++ b/crates/effects/src/addons/duotone.rs @@ -0,0 +1,178 @@ +//! Color Effects' Duotone (Effect › Color Effects › Duotone…), a colour adjustment: the +//! grey-equivalent tone of each colour an object paints is printed with one to four inks (a +//! monotone, duotone, tritone or quadtone). Each ink, a tone, is a colour, typically a spot swatch, +//! with its own curve from the tone's tint to the ink's. The inks overprint, so their tints +//! multiply. +//! +//! With one tone of a swatch, a colour becomes a tint of that swatch, linked to it, so a spot tone +//! separates onto its plate. With several tones it becomes the CMYK mix of their tints: a colour +//! links to one swatch at one tint, and a mix of several spot inks has no swatch to link to. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_doc::Document; +use vectorcraft_doc::color::{Color, cms::Model}; + +use crate::adjust::{LUT, curve_at, curve_points_on, lookup, sample}; +use crate::raster::color_param; +use crate::util::flag; + +/// Duotone (Effect › Color Effects › Duotone…). +pub const DUOTONE: &str = "addon.color.duotone"; + +/// Most tones Duotone reads (a quadtone): any past them are left out. +pub const MAX_TONES: usize = 4; + +/// Most points a tone's curve reads: any past them are left out. +pub const MAX_CURVE_POINTS: usize = 64; + +/// The parameters' description (`effect.apply`). +pub(crate) const PARAMS: &str = "{tones: [{color: \"#rrggbb\" or a colour object, swatch?: a global or spot swatch's name (with the swatch's colour as `color`), curve?: \"tint,ink tint,ink …\" or [[tint, ink], …] (0..100, at most 64 points; \"0,0 100,100\"), active?: true}] (up to four inks, overprinting; any past four are left out), skipSpot?: false (colours linked to a spot swatch keep their colour)} prints the grey-equivalent tone of each colour the object paints with the inks, each at the tint its curve gives for the tone's: with one tone of a swatch, as a tint of that swatch (a spot tone prints on its own plate); with several, as the CMYK mix of their tints"; + +/// The dialog's defaults: one black tone, its curve linear. +pub(crate) fn defaults() -> Value { + json!({"tones": [{"color": "#000000", "curve": "0,0 100,100", "active": true}], "skipSpot": false}) +} + +/// Whether swatch `name` of the document is a spot colour: `Some(false)` for a global process +/// swatch, `None` when the document has no global swatch of that name. +pub type SwatchKinds<'a> = dyn Fn(&str) -> Option + 'a; + +/// [`SwatchKinds`] of `d`'s swatch `name`. +pub fn swatch_kind(d: &Document, name: &str) -> Option { + d.swatches_iter().find(|w| w.global && w.name == name).map(|w| w.spot) +} + +/// [`swatch_kind`] of each of `d`'s global swatches. +pub fn swatch_kinds(d: &Document) -> HashMap { + d.swatches_iter().filter(|w| w.global).map(|w| (w.name.clone(), w.spot)).collect() +} + +/// A tone's curve points (0..1 each) from its `curve` (0..100). +pub fn tone_curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { + curve_points_on(v, 100.0, MAX_CURVE_POINTS) +} + +/// One ink. +#[derive(Clone, Debug, PartialEq)] +struct Tone { + color: Color, + /// `color` as CMYK inks, which several tones mix in. + cmyk: [f32; 4], + /// The swatch the colour is of, if any. + swatch: Option, + /// Its curve, sampled: the tone's tint → the ink's. + curve: Arc<[f32]>, +} + +/// Duotone's tones and options, its parameters read. +#[derive(Clone, Debug, PartialEq)] +pub struct Duotone { + tones: Vec, + skip_spot: bool, + /// The display colour at each sampled tint, for pixels. + rgb: Arc<[[f32; 3]]>, +} + +impl Duotone { + /// `None` without an active tone (the effect then leaves the colours as they are). + pub(crate) fn parse(p: &Value) -> Option { + let tones: Vec = p + .get("tones") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(MAX_TONES) + .filter(|t| t.is_object() && flag(t, "active", true)) + .map(|t| { + let color = finite(color_param(t, Color::BLACK)); + let cmyk = match color.in_model(Model::Cmyk) { + Color::Cmyk { c, m, y, k } => [c, m, y, k].map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }), + _ => [0.0, 0.0, 0.0, 1.0], + }; + let pts = tone_curve_points(t.get("curve")); + let swatch = t.get("swatch").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string); + Tone { color, cmyk, swatch, curve: sample(|x| curve_at(&pts, x)) } + }) + .collect(); + if tones.is_empty() { + return None; + } + let mut d = Duotone { tones, skip_spot: flag(p, "skipSpot", false), rgb: Arc::new([]) }; + d.rgb = (0..LUT).map(|i| d.mix(i as f32 / (LUT - 1) as f32).to_rgb()).collect(); + Some(d) + } + + /// The inks' colour for tone `t` (0 = white, 1 = black): one tone's colour at its tint, or + /// several tones' CMYK tints overprinted (each ink lets through what the others leave). + fn mix(&self, t: f32) -> Color { + if let [one] = self.tones.as_slice() { + return one.color.tinted(lookup(&one.curve, t)); + } + let mut paper = [1.0f32; 4]; + for tone in &self.tones { + let k = lookup(&tone.curve, t); + for (p, ink) in paper.iter_mut().zip(tone.cmyk) { + *p *= 1.0 - ink * k; + } + } + let [c, m, y, k] = paper.map(|p| 1.0 - p); + Color::cmyk(c, m, y, k) + } + + /// Display components 0..1 through the effect. + pub(crate) fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] { + let x = tone(rgb) * (LUT - 1) as f32; + let i = (x.floor() as usize).min(LUT - 2); + let f = x - i as f32; + match (self.rgb.get(i), self.rgb.get(i + 1)) { + (Some(a), Some(b)) => std::array::from_fn(|k| a[k] + (b[k] - a[k]) * f), + _ => rgb, + } + } + + /// A colour with its swatch link and tint through the effect ([`crate::ColorMap::apply_link`]): + /// true when it changed. With `skipSpot`, a colour linked to a spot swatch stays. + pub(crate) fn map_link(&self, c: &mut Color, link: &mut Option, tint: &mut f32, swatches: &SwatchKinds) -> bool { + if self.skip_spot && link.as_deref().and_then(swatches) == Some(true) { + return false; + } + let t = tone(c.to_rgb()); + let new = self.mix(t); + let (new_link, new_tint) = match self.tones.as_slice() { + [one] => match one.swatch.as_ref().filter(|n| swatches(n).is_some()) { + Some(name) => (Some(name.clone()), lookup(&one.curve, t)), + None => (None, 1.0), + }, + _ => (None, 1.0), + }; + if new == *c && new_link == *link && new_tint == *tint { + return false; + } + (*c, *link, *tint) = (new, new_link, new_tint); + true + } +} + +/// The grey-equivalent tone of display colour `rgb`: 0 for white, 1 for black (its luma, as +/// Convert to Grayscale reads it, so a grey's tone is its ink). +fn tone([r, g, b]: [f32; 3]) -> f32 { + let t = 1.0 - (0.299 * r + 0.587 * g + 0.114 * b); + if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 } +} + +/// `c` with its components finite and in range. +fn finite(c: Color) -> Color { + let v = |x: f32, lo: f32, hi: f32| if x.is_finite() { x.clamp(lo, hi) } else { lo }; + match c { + Color::Rgb { r, g, b } => Color::rgb(v(r, 0.0, 1.0), v(g, 0.0, 1.0), v(b, 0.0, 1.0)), + Color::Cmyk { c, m, y, k } => Color::cmyk(v(c, 0.0, 1.0), v(m, 0.0, 1.0), v(y, 0.0, 1.0), v(k, 0.0, 1.0)), + Color::Gray { k } => Color::gray(v(k, 0.0, 1.0)), + Color::Lab { l, a, b } => Color::lab(v(l, 0.0, 100.0), v(a, -128.0, 127.0), v(b, -128.0, 127.0)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/addons/duotone/tests.rs b/crates/effects/src/addons/duotone/tests.rs new file mode 100644 index 000000000..8a3255b05 --- /dev/null +++ b/crates/effects/src/addons/duotone/tests.rs @@ -0,0 +1,128 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::Effect; +use vectorcraft_doc::color::Color; + +use super::*; +use crate::{ColorMap, color_map}; + +fn map(params: Value) -> ColorMap { + color_map(&[Effect { id: DUOTONE.into(), params, visible: true }]).unwrap() +} + +/// `c` with link `link` through `m`, the document having spot swatch "Ink" and process swatch +/// "Blue": (colour, link, tint, changed). +fn linked(m: &ColorMap, c: Color, link: Option<&str>) -> (Color, Option, f32, bool) { + let (mut c, mut link, mut tint) = (c, link.map(str::to_string), 1.0); + let kinds = |n: &str| match n { + "Ink" => Some(true), + "Blue" => Some(false), + _ => None, + }; + let changed = m.apply_link(&mut c, &mut link, &mut tint, &kinds); + (c, link, tint, changed) +} + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 +} + +fn looks(c: Color, rgb: [f32; 3]) -> bool { + c.to_rgb().iter().zip(rgb).all(|(a, b)| close(*a, b)) +} + +const INK: Color = Color::Cmyk { c: 0.0, m: 0.5, y: 1.0, k: 0.0 }; + +fn ink_tone(curve: &str) -> Value { + json!({"tones": [{"color": INK, "swatch": "Ink", "curve": curve}]}) +} + +#[test] +fn a_spot_tone_turns_greys_into_tints_of_its_swatch() { + let m = map(ink_tone("0,0 100,100")); + for k in [0.0, 0.25, 0.6, 1.0] { + let (c, link, tint, _) = linked(&m, Color::gray(k), None); + assert_eq!(link.as_deref(), Some("Ink"), "linked at {k}"); + assert!(close(tint, k), "the tint is the grey's ink: {k} → {tint}"); + assert_eq!(c, INK.tinted(tint), "its colour is the swatch's at that tint"); + } + // A colour reads as its grey equivalent: white is no ink. + let (_, _, tint, _) = linked(&m, Color::rgb(1.0, 1.0, 1.0), None); + assert!(close(tint, 0.0)); + // A swatch the document hasn't: an unlinked tint of the tone's colour. + let (c, link, ..) = linked(&map(json!({"tones": [{"color": INK, "swatch": "Gone"}]})), Color::gray(0.5), None); + assert!(link.is_none() && looks(c, INK.tinted(0.5).to_rgb())); + // Without the links, colours (mesh points) take the tone's colour too. + assert!(looks(m.apply(Color::gray(0.5)), INK.tinted(0.5).to_rgb())); +} + +#[test] +fn the_curve_changes_the_mapping() { + // Inverse: dark greys get little ink. + let (_, _, tint, _) = linked(&map(ink_tone("0,100 100,0")), Color::gray(0.8), None); + assert!(close(tint, 0.2), "{tint}"); + // Through its points exactly, flat past its ends. + let lifted = map(ink_tone("0,0 50,80 100,100")); + assert!(close(linked(&lifted, Color::gray(0.5), None).2, 0.8)); + assert!(close(linked(&map(ink_tone("20,0 80,100")), Color::gray(0.1), None).2, 0.0)); + // Unreadable or missing curves are linear; points past the cap are left out. + for curve in [json!("junk"), json!(null), json!([[0, 0]])] { + let p = json!({"tones": [{"color": INK, "swatch": "Ink", "curve": curve}]}); + assert!(close(linked(&map(p), Color::gray(0.3), None).2, 0.3), "{curve}"); + } + let many: Vec<[f64; 2]> = (0..200).map(|i| [f64::from(i) / 2.0, 100.0 - f64::from(i) / 2.0]).collect(); + assert_eq!(tone_curve_points(Some(&json!(many))).len(), MAX_CURVE_POINTS); +} + +#[test] +fn several_tones_multiply_as_cmyk_inks() { + let cyan = json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}, "swatch": "Ink"}); + let half_black = json!({"color": {"model": "cmyk", "c": 0.6, "m": 0.0, "y": 0.0, "k": 1.0}, "curve": "0,0 100,50"}); + let m = map(json!({"tones": [cyan, half_black]})); + let (c, link, tint, changed) = linked(&m, Color::gray(1.0), Some("Ink")); + assert!(changed && link.is_none() && tint == 1.0, "a process mix, unlinked"); + // Cyan 100% over the second ink at 50% (60% cyan, 100% black at half): cyan 1-(1-1)(1-0.3). + let Color::Cmyk { c, m: mg, y, k } = c else { panic!("CMYK: {c:?}") }; + assert!(close(c, 1.0) && close(mg, 0.0) && close(y, 0.0) && close(k, 0.5), "{c} {mg} {y} {k}"); + let (mid, ..) = linked(&m, Color::gray(0.5), None); + let Color::Cmyk { c, k, .. } = mid else { panic!("CMYK") }; + // Cyan 50% over the second ink at 25% (15% cyan, 25% black): 1 - 0.5 * 0.85 = 0.575. + assert!(close(c, 0.575) && close(k, 0.25), "{c} {k}"); + // Inactive tones and tones past the fourth don't count; none active leaves the colours. + let one = json!({"color": "#000000"}); + let off = json!({"color": "#ff0000", "active": false}); + let only_black = map(json!({"tones": [one, off, off, off, {"color": "#ff0000"}]})); + assert!(looks(only_black.apply(Color::gray(0.4)), [0.6; 3])); + assert!(color_map(&[Effect { id: DUOTONE.into(), params: json!({"tones": [off]}), visible: true }]).is_none()); + assert!(color_map(&[Effect { id: DUOTONE.into(), params: json!({"tones": "x"}), visible: true }]).is_none()); +} + +#[test] +fn skip_spot_keeps_spot_colours() { + let mut p = json!({"tones": [{"color": "#000000"}], "skipSpot": true}); + let m = map(p.clone()); + let spot = Color::cmyk(0.0, 1.0, 0.0, 0.0); + assert_eq!(linked(&m, spot, Some("Ink")), (spot, Some("Ink".to_string()), 1.0, false)); + // A process swatch's colour, and an unlinked one, still change. + assert!(linked(&m, spot, Some("Blue")).3); + assert!(linked(&m, spot, None).3); + p["skipSpot"] = json!(false); + assert!(linked(&map(p), spot, Some("Ink")).3); +} + +#[test] +fn pixels_and_other_adjustments_compose() { + let m = map(json!({"tones": [{"color": "#ff0000"}]})); + // A grey pixel half way: half way to red from white. + let px = m.apply_rgb([0.5, 0.5, 0.5]); + assert!(close(px[0], 1.0) && close(px[1], 0.5) && close(px[2], 0.5), "{px:?}"); + // Invert, then Duotone: white becomes full red. + let both = color_map(&[ + Effect { id: crate::addons::color::INVERT.into(), params: json!({}), visible: true }, + Effect { id: DUOTONE.into(), params: json!({"tones": [{"color": "#ff0000"}]}), visible: true }, + ]) + .unwrap(); + assert!(looks(both.apply(Color::WHITE), [1.0, 0.0, 0.0])); + // Out of range colours are brought into range. + let wild = map(json!({"tones": [{"color": [1e30, -5, 0.5]}]})); + assert!(looks(wild.apply(Color::gray(1.0)), [1.0, 0.0, 0.5])); +} diff --git a/crates/effects/src/addons/halftone.rs b/crates/effects/src/addons/halftone.rs new file mode 100644 index 000000000..9b5771e7b --- /dev/null +++ b/crates/effects/src/addons/halftone.rs @@ -0,0 +1,489 @@ +//! Vector Halftone (Color Effects): the art as vector dots, lines or shapes sized by its tone, as a +//! live effect (Effect › Color Effects › Vector Halftone…, [`HALFTONE`]) and for Object › Vector +//! Halftone, which builds its paths with the same screens ([`Halftone::layers`]). +//! +//! The art is sampled from pixels ([`Samples`], drawn by the renderer) once per halftone cell on a +//! grid turned by the screen angle; each cell gets a shape whose area follows the tone there. Mono +//! screens follow lightness in one colour (or, with `fromArt`, each dot takes the art's colour under +//! it); CMYK screens follow the art's ink amounts (as CMYK output separates it), one screen per ink at +//! the classic angles, multiplied over each other. The dots can be clipped to the art's outline as +//! real geometry. +//! +//! The renderer, the exporters ([`crate::bake_document`]) and Expand Appearance draw an object with +//! the effect as [`halftone_art`]: the renderer samples the art, so it registers how ([`set_art`]). + +use std::collections::BTreeMap; +use std::sync::{Arc, OnceLock}; + +use serde_json::{Value, json}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; +use vectorcraft_pathops::{self as po, BoolOp}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::util::{flag, num, text}; + +/// Vector Halftone, the live effect. +pub const HALFTONE: &str = "addon.color.halftone"; + +/// Most halftone cells per screen. +pub const MAX_CELLS: f64 = 100_000.0; +/// Most pixels the tone is sampled from. +pub const MAX_SAMPLE_PIXELS: f64 = 16.0e6; +/// Cells lighter than this get no dot. +const MIN_TONE: f64 = 0.01; +/// Levels per channel the art's colours are rounded to with `fromArt` (one path per colour). +const COLOR_LEVELS: f64 = 15.0; + +/// The effect's catalogue entry. +pub(crate) fn catalog() -> Vec { + vec![EffectInfo { + id: HALFTONE, + label: "Vector Halftone…", + menu: &["Effect", "Color Effects"], + params: "{shape: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency: lines per inch 1..300 (20), angle: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode: \"mono\"|\"cmyk\" (mono), color: mono dot colour (\"#000000\"), fromArt: bool (false; mono: each dot takes the art's colour under it), invert: bool (false; dots for light tones), clip: bool (true; the dots clipped to the art's outline)} draws the object as vector halftone dots sized by its tone, as object.vectorHalftone makes them (CMYK screens multiply); the object is sampled as drawn with its other effects; at most 100000 cells per screen, past that the object draws as it is", + defaults: json!({"shape": "circle", "frequency": 20.0, "angle": 45.0, "mode": "mono", "color": "#000000", "fromArt": false, "invert": false, "clip": true}), + raster: false, + lengths: Lengths::default(), + }] +} + +/// Does `n` carry a visible Vector Halftone effect? +pub fn has_halftone(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible && e.id == HALFTONE) +} + +/// The shape of a halftone dot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DotShape { + Circle, + Ellipse, + Square, + Diamond, + Line, +} + +impl DotShape { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "circle" | "round" | "dot" => DotShape::Circle, + "ellipse" => DotShape::Ellipse, + "square" => DotShape::Square, + "diamond" => DotShape::Diamond, + "line" | "lines" => DotShape::Line, + _ => return None, + }) + } + + /// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen + /// running along x. + fn path(self, t: f64, s: f64) -> PathData { + let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side))); + match self { + DotShape::Circle => { + let r = s * (t / std::f64::consts::PI).sqrt(); + shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r))) + } + DotShape::Ellipse => { + let r = s * (t / std::f64::consts::PI).sqrt(); + shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3))) + } + DotShape::Square => square(s * t.sqrt()), + DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)), + // Cells along a row join into a line as thick as the tone. + DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))), + } + } +} + +/// One screen: its ink and angle, and how to read its tone from a sampled pixel. +struct Screen { + paint: Color, + angle: f64, + /// Index into the sampled pixel (unused for mono, CMYK inks for cmyk). + channel: usize, +} + +/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt. +pub struct Samples { + px: Vec, + w: usize, + h: usize, + region: Rect, + scale: f64, + /// Mono: tones are 1 − lightness of RGBA pixels drawn on white; CMYK: ink amounts. + mono: bool, + invert: bool, +} + +impl Samples { + /// Pixels `px` (`w`×`h`, 4 bytes each) over `region` at `scale` px/pt; `None` when there are + /// fewer bytes than that. + pub fn new(px: Vec, (w, h): (usize, usize), region: Rect, scale: f64, mono: bool, invert: bool) -> Option { + (px.len() >= w.checked_mul(h)?.checked_mul(4)?).then_some(Self { px, w, h, region, scale, mono, invert }) + } + + /// The index of the pixel at document point `p`, `None` outside the samples. + fn index(&self, p: Point) -> Option { + let x = ((p.x - self.region.x0) * self.scale).floor(); + let y = ((p.y - self.region.y0) * self.scale).floor(); + (x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h).then(|| (y as usize * self.w + x as usize) * 4) + } + + fn byte(&self, i: usize, k: usize) -> f64 { + self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0 + } + + /// The tone (0..1) of `channel` at document point `p`. + fn tone(&self, p: Point, channel: usize) -> f64 { + let Some(i) = self.index(p) else { return if self.invert { 1.0 } else { 0.0 } }; + let t = + if self.mono { 1.0 - (0.2126 * self.byte(i, 0) + 0.7152 * self.byte(i, 1) + 0.0722 * self.byte(i, 2)) } else { self.byte(i, channel) }; + if self.invert { 1.0 - t } else { t } + } + + /// The 3 × 3 sample points of a cell of side `s` at `c` turned by `rot`. + fn cell_points(c: Point, s: f64, rot: Affine) -> impl Iterator { + (0..9).map(move |k| c + (rot * Point::new((f64::from(k / 3) - 1.0) * s / 3.0, (f64::from(k % 3) - 1.0) * s / 3.0)).to_vec2()) + } + + /// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples). + fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 { + Self::cell_points(c, s, rot).map(|p| self.tone(p, channel)).sum::() / 9.0 + } + + /// The mean colour over a cell (as [`Self::cell_tone`]; white outside the samples), each channel + /// rounded to [`COLOR_LEVELS`] steps. + fn cell_color(&self, c: Point, s: f64, rot: Affine) -> [u8; 3] { + let mut sum = [0.0; 3]; + for p in Self::cell_points(c, s, rot) { + for (k, v) in sum.iter_mut().enumerate() { + *v += self.index(p).map_or(1.0, |i| self.byte(i, k)); + } + } + sum.map(|v| (v / 9.0 * COLOR_LEVELS).round().clamp(0.0, COLOR_LEVELS) as u8) + } +} + +/// A halftone's settings. +#[derive(Clone, Debug, PartialEq)] +pub struct Halftone { + pub shape: DotShape, + /// Lines per inch. + pub frequency: f64, + /// Degrees. + pub angle: f64, + /// One screen in `color` (else four CMYK screens). + pub mono: bool, + pub color: Color, + /// Mono: each dot in the art's colour under it instead of `color`. + pub from_art: bool, + /// Dots for light tones. + pub invert: bool, + /// The dots clipped to the art's outline. + pub clip: bool, +} + +impl Halftone { + /// The settings of `n`'s last visible Vector Halftone effect, its values brought into range. + pub fn of(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.visible && e.id == HALFTONE)?; + Some(Self::from_params(&e.params)) + } + + fn from_params(p: &Value) -> Self { + Self { + shape: DotShape::parse(text(p, "shape", "circle")).unwrap_or(DotShape::Circle), + frequency: num(p, "frequency", 20.0).clamp(1.0, 300.0), + angle: num(p, "angle", 45.0), + mono: !text(p, "mode", "mono").eq_ignore_ascii_case("cmyk"), + color: crate::raster::color_param(p, Color::BLACK), + from_art: flag(p, "fromArt", false), + invert: flag(p, "invert", false), + clip: flag(p, "clip", true), + } + } + + /// The side of a cell in points. + pub fn cell(&self) -> f64 { + 72.0 / self.frequency + } + + /// Where the art over `bounds` is sampled and at how many pixels per point: about four pixels per + /// cell, at most [`MAX_SAMPLE_PIXELS`]. `None` when `bounds` have no area or the grid turned by + /// the screen angle would have more than [`MAX_CELLS`] cells. + pub fn plan(&self, bounds: Rect) -> Option<(Rect, f64)> { + let cell = self.cell(); + if !(bounds.width() > 0.0 && bounds.height() > 0.0 && bounds.area().is_finite()) { + return None; + } + // The grid turned by the screen angle covers at most (w + h)² / 2. + if (bounds.width() + bounds.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS { + return None; + } + let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / bounds.area()).sqrt()).clamp(0.01, 16.0); + Some((bounds.inflate(cell, cell), scale)) + } + + /// The screens, bottom to top. + fn screens(&self) -> Vec { + let angle = self.angle; + if self.mono { + return vec![Screen { paint: self.color, angle, channel: 0 }]; + } + // Bottom to top: yellow, magenta, cyan, black. + vec![ + Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 }, + Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 }, + Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 }, + Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 }, + ] + } + + /// The dots of the art `samples` show over `bounds`, one path per ink (per colour with + /// `fromArt`) in paint order, each clipped to `outline` when given, and how many dots there are. + pub fn layers(&self, samples: &Samples, bounds: Rect, outline: Option<&(BezPath, FillRule)>) -> (Vec<(PathData, Color)>, usize) { + let cell = self.cell(); + let mut layers = vec![]; + let mut count = 0; + for sc in self.screens() { + let dots = screen_dots(samples, bounds, cell, &sc, self.shape); + count += dots.len(); + if self.mono && self.from_art { + let rot = Affine::rotate(sc.angle.to_radians()); + let mut by_color: BTreeMap<[u8; 3], Vec> = BTreeMap::new(); + for (dot, c) in dots { + by_color.entry(samples.cell_color(c, cell, rot)).or_default().push(dot); + } + for (rgb, dots) in by_color { + let [r, g, b] = rgb.map(|v| (f64::from(v) / COLOR_LEVELS) as f32); + layers.push((merged(dots, outline), Color::rgb(r, g, b))); + } + } else { + layers.push((merged(dots.into_iter().map(|d| d.0).collect(), outline), sc.paint)); + } + } + layers.retain(|(path, _)| !path.is_empty()); + (layers, count) + } +} + +/// The dots of one screen over `bounds`, each with its cell's centre. +fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape) -> Vec<(PathData, Point)> { + let rot = Affine::rotate(sc.angle.to_radians()); + let inv = rot.inverse(); + // The grid covers the bounds in screen space. + let corners = + [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q); + let (mut lo, mut hi) = (corners[0], corners[0]); + for q in &corners[1..] { + lo = Point::new(lo.x.min(q.x), lo.y.min(q.y)); + hi = Point::new(hi.x.max(q.x), hi.y.max(q.y)); + } + let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1); + let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1); + let near = bounds.inflate(cell, cell); + let mut dots = vec![]; + for j in j0..=j1 { + for i in i0..=i1 { + let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell); + if !near.contains(c) { + continue; + } + let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0); + if t < MIN_TONE { + continue; + } + let place = Affine::translate(c.to_vec2()) * rot; + dots.push((shape.path(t, cell).transformed(place), c)); + } + } + dots +} + +/// The outline Clip to Art clips the dots of `nodes` to: their clip outline taken together. +pub fn art_outline(nodes: &[Node]) -> Option<(BezPath, FillRule)> { + crate::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())) +} + +/// `dots` as one path, clipped to `outline` when given (dots wholly inside it need no clipping). +fn merged(dots: Vec, outline: Option<&(BezPath, FillRule)>) -> PathData { + let (mut inside, mut edge) = (vec![], vec![]); + for dot in dots { + let whole = match (outline, dot.control_bounds()) { + (Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)] + .iter() + .all(|q| fill_contains(bp, *rule, *q)), + _ => true, + }; + if whole { inside.push(dot) } else { edge.push(dot) } + } + let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect(); + if let Some((bp, rule)) = outline + && !edge.is_empty() + { + let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect()); + let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect); + subs.extend(clipped.subpaths); + } else { + subs.extend(edge.into_iter().flat_map(|p| p.subpaths)); + } + PathData::new(subs) +} + +/// The paths of `layers` ([`Halftone::layers`]), each filled with its ink and ids from `id`; CMYK +/// screens (`mono` false) multiply over each other. +pub fn dot_nodes(layers: Vec<(PathData, Color)>, mono: bool, id: &mut dyn FnMut() -> NodeId) -> Vec> { + layers + .into_iter() + .map(|(path, ink)| { + let mut n = Node::path(id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0)); + if !mono { + n.blend = BlendMode::Multiply; + } + Arc::new(n) + }) + .collect() +} + +/// `n` split for its halftone: the object the effect samples (`n` visible, without its Vector +/// Halftone effects, transparency, mask and link) and an empty group that takes over its identity, +/// transparency, mask and link, for the dots. +pub fn split(n: &Node) -> (Node, Node) { + let mut object = n.clone(); + object.appearance.effects.retain(|e| e.id != HALFTONE); + object.visible = true; + let mut g = Node::group(n.id, vec![]); + (g.visible, g.locked) = (n.visible, n.locked); + g.name = object.name.take(); + g.opacity = std::mem::replace(&mut object.opacity, 1.0); + g.blend = std::mem::take(&mut object.blend); + g.isolate = std::mem::take(&mut object.isolate); + g.extras_mut().mask = object.extras_mut().mask.take(); + g.extras_mut().attrs = object.extras_mut().attrs.take(); + (object, g) +} + +/// How far the dots of `n`'s halftone reach past the bounds of the object it samples: nothing when +/// they are clipped to its outline, else up to two cells (a dot's centre lies within a cell of the +/// bounds, and the dot within another). +pub fn reach(n: &Node) -> f64 { + Halftone::of(n).filter(|h| !h.clip).map_or(0.0, |h| 2.0 * h.cell()) +} + +/// Makes the art of an object with a visible Vector Halftone effect ([`halftone_art`]). +pub type ArtHook = fn(&Document, &Node) -> Option; + +static ART: OnceLock = OnceLock::new(); + +/// Register how the art of an object with Vector Halftone is made (the renderer samples it, above +/// this crate; the first registration stays). +pub fn set_art(f: ArtHook) { + // Already set: the same renderer, registered by another session. + let _ = ART.set(f); +} + +/// The art of `n` with a visible Vector Halftone effect in `d`: the group [`split`] makes, holding +/// the dot paths ([`dot_nodes`], ids `n`'s). `None` without the effect, while nothing is registered +/// ([`set_art`]), and when the art has no area or would take too many cells ([`Halftone::plan`]). +pub fn halftone_art(d: &Document, n: &Node) -> Option { + if !has_halftone(n) { + return None; + } + ART.get()?(d, n) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::Effect; + + /// A black square's samples: `side` points of black from (10, 10), white around it. + fn black_square(side: f64, h: &Halftone) -> (Samples, Rect) { + let b = Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side); + let (region, scale) = h.plan(b).unwrap(); + let (w, hh) = ((region.width() * scale).round() as usize, (region.height() * scale).round() as usize); + let mut px = vec![255u8; w * hh * 4]; + for y in 0..hh { + for x in 0..w { + let p = Point::new(region.x0 + (x as f64 + 0.5) / scale, region.y0 + (y as f64 + 0.5) / scale); + if b.contains(p) { + px[(y * w + x) * 4..][..3].copy_from_slice(&[200, 0, 0]); + } + } + } + (Samples::new(px, (w, hh), region, scale, true, h.invert).unwrap(), b) + } + + fn effect(params: Value) -> Node { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + n.appearance.effects.push(Effect { id: HALFTONE.into(), params, visible: true }); + n + } + + #[test] + fn the_effect_is_in_color_effects_with_its_defaults() { + let info = crate::effect_info(HALFTONE).unwrap(); + assert_eq!(info.menu, ["Effect", "Color Effects"]); + assert!(!crate::is_geometry(HALFTONE) && !crate::is_raster(HALFTONE) && !crate::is_adjustment(HALFTONE)); + let h = Halftone::of(&effect(info.defaults)).unwrap(); + assert_eq!((h.shape, h.frequency, h.angle, h.mono, h.from_art, h.invert, h.clip), (DotShape::Circle, 20.0, 45.0, true, false, false, true)); + assert_eq!(h.color, Color::BLACK); + } + + #[test] + fn values_out_of_range_are_brought_in() { + let h = Halftone::of(&effect(json!({"frequency": 1e308, "angle": "NaN", "shape": 7, "mode": "CMYK", "color": "nope"}))).unwrap(); + assert_eq!((h.frequency, h.angle, h.shape, h.mono, h.color), (300.0, 45.0, DotShape::Circle, false, Color::BLACK)); + assert_eq!(Halftone::of(&effect(json!({"frequency": -5}))).unwrap().frequency, 1.0); + let mut hidden = effect(json!({})); + hidden.appearance.effects[0].visible = false; + assert!(Halftone::of(&hidden).is_none() && !has_halftone(&hidden)); + } + + #[test] + fn dots_follow_the_tone_and_the_settings() { + let h = Halftone::of(&effect(json!({"frequency": 18}))).unwrap(); + let (s, b) = black_square(60.0, &h); + let (layers, dots) = h.layers(&s, b, None); + assert_eq!(layers.len(), 1); + assert!((150..=320).contains(&dots), "{dots}"); + // Inverted, the dark square gets small dots and the white around it full ones. + let inv = Halftone { invert: true, ..h.clone() }; + let (s2, _) = black_square(60.0, &inv); + assert!(inv.layers(&s2, b, None).1 > dots); + // The colour of the art under each dot. + let art = Halftone { from_art: true, ..h.clone() }; + let (layers, _) = art.layers(&s, b, None); + assert!(layers.iter().any(|(_, c)| c.to_hex() == "#cc0000"), "{:?}", layers.iter().map(|l| l.1.to_hex()).collect::>()); + // CMYK screens: one layer per ink with dots (red: magenta and yellow). + let cmyk = Halftone { mono: false, ..h }; + let one = Samples::new([0u8, 255, 255, 0].repeat(400), (20, 20), Rect::new(0.0, 0.0, 20.0, 20.0), 1.0, false, false).unwrap(); + assert_eq!(cmyk.layers(&one, Rect::new(2.0, 2.0, 18.0, 18.0), None).0.len(), 2); + } + + #[test] + fn the_caps_refuse_too_many_cells() { + let h = Halftone::of(&effect(json!({"frequency": 300}))).unwrap(); + assert!(h.plan(Rect::new(0.0, 0.0, 10_000.0, 10_000.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, f64::INFINITY, 10.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, 0.0, 10.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, 40.0, 40.0)).is_some()); + assert!(Samples::new(vec![0; 3], (1, 1), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none()); + assert!(Samples::new(vec![], (usize::MAX, 2), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none()); + } + + #[test] + fn the_group_takes_the_objects_identity() { + let mut n = effect(json!({"clip": false, "frequency": 36})); + n.opacity = 0.5; + n.name = Some("art".into()); + let (object, g) = split(&n); + assert!(!has_halftone(&object)); + assert_eq!((object.opacity, g.opacity, g.id, g.name.as_deref()), (1.0, 0.5, n.id, Some("art"))); + assert_eq!(reach(&n), 4.0); + assert_eq!(reach(&effect(json!({}))), 0.0); + } +} diff --git a/crates/effects/src/addons/path.rs b/crates/effects/src/addons/path.rs new file mode 100644 index 000000000..556122f63 --- /dev/null +++ b/crates/effects/src/addons/path.rs @@ -0,0 +1,243 @@ +//! Path Effects (Path Precision Tools): live geometry effects that rewrite an object's path with the +//! add-on's path kernels (Effect › Path Effects). Each is evaluated with the other geometry effects +//! ([`crate::apply_geometry`]), so it draws, measures, exports and expands as they do; the geometry +//! is written only by Expand Appearance. +//! +//! Parameters come from documents and scripts, so each is clamped to its documented range, and a +//! path with a coordinate past [`MAX_COORDINATE`] or not finite is left as it is. + +use serde_json::{Value, json}; +use vectorcraft_doc::Dash; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_pathops::addons::pathtools::{self as tools, AnchorSpacing, CloseMode, ExtendMode, SMOOTH_RATIO, SegmentKind}; +use vectorcraft_pathops::addons::{PT_PER_TOLERANCE, retract_tiny_handles, simplify_keep_shape}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::stroke; +use crate::util::{flag, num, text}; + +/// What every Path Effect's id starts with. +pub const PREFIX: &str = "addon.path."; +pub const DASHES: &str = "addon.path.dashes"; +pub const ADD_ANCHORS: &str = "addon.path.addAnchors"; +pub const EXTEND: &str = "addon.path.extend"; +pub const CLOSE: &str = "addon.path.close"; +pub const SMOOTH: &str = "addon.path.smooth"; +pub const RETRACT: &str = "addon.path.retract"; +pub const REVERSE: &str = "addon.path.reverse"; +pub const SIMPLIFY: &str = "addon.path.simplify"; +pub const SPLIT: &str = "addon.path.split"; + +const MENU: &[&str] = &["Effect", "Path Effects"]; + +/// The dash and gap parameters of [`DASHES`], pair by pair. +const DASH_PAIRS: [(&str, &str); 5] = [("dash1", "gap1"), ("dash2", "gap2"), ("dash3", "gap3"), ("dash4", "gap4"), ("dash5", "gap5")]; +/// Longest dash or gap, and the farthest offset (pt). +const MAX_DASH: f64 = 10_000.0; +/// Longest stretch [`EXTEND`] adds or takes off an end, in points and in percent of the path. +const MAX_EXTEND: f64 = 100_000.0; +const MAX_EXTEND_PERCENT: f64 = 1000.0; +/// Most anchors [`ADD_ANCHORS`] adds to one segment. +const MAX_PER_SEGMENT: f64 = 1000.0; +/// Closest and farthest apart [`ADD_ANCHORS`]' anchors are (pt). +const DISTANCE_RANGE: (f64, f64) = (0.01, 100_000.0); +/// The range of [`SMOOTH`]'s ratio. +const RATIO_RANGE: (f64, f64) = (0.01, 0.5); +/// Longest handle length [`RETRACT`] takes (pt). +const MAX_HANDLE: f64 = 100_000.0; +/// Highest tolerance of [`SIMPLIFY`]. +const MAX_TOLERANCE: f64 = 200.0; +// shortcut: simplifying measures every anchor on every draw, so a larger path draws as it is; lift +// this if the kernel gets an incremental pass. +/// Most anchors [`SIMPLIFY`] takes in one path. +const MAX_SIMPLIFY_ANCHORS: usize = 50_000; +/// A path with a coordinate past this, or one that isn't finite, is left as it is: lengths measured +/// on it would overflow. +const MAX_COORDINATE: f64 = 1e12; + +/// The effects' catalogue entries. +pub(crate) fn catalog() -> Vec { + let entry = |id, label, params, defaults, always| EffectInfo { + id, + label, + menu: MENU, + params, + defaults, + raster: false, + lengths: Lengths { always, absolute: &[] }, + }; + vec![ + entry( + DASHES, + "Dashes to Geometry…", + "{dash1: pt 0..10000 (12), gap1: pt 0..10000 (6), dash2: pt 0..10000 (0), gap2: pt 0..10000 (0), dash3: pt 0..10000 (0), gap3: pt 0..10000 (0), dash4: pt 0..10000 (0), gap4: pt 0..10000 (0), dash5: pt 0..10000 (0), gap5: pt 0..10000 (0), offset: pt -10000..10000 (0; how far into the pattern the path starts; not with alignCorners), alignCorners: bool (false; every run between corners and path ends holds whole periods, stretched or squeezed to fit, with a dash centred on each corner and end)} cuts the path into its dashes, open subpaths along it that the stroke paints as it paints any path. A dash of 0 is a point its stroke's round or projecting cap paints; a pair of two zeros is left out, and the pattern repeats. A pattern of nothing, or of more than 200000 dashes, leaves the path as it is; a dash longer than a closed subpath keeps it closed", + json!({"dash1": 12.0, "gap1": 6.0, "dash2": 0.0, "gap2": 0.0, "dash3": 0.0, "gap3": 0.0, "dash4": 0.0, "gap4": 0.0, "dash5": 0.0, "gap5": 0.0, "offset": 0.0, "alignCorners": false}), + &["dash1", "gap1", "dash2", "gap2", "dash3", "gap3", "dash4", "gap4", "dash5", "gap5", "offset"], + ), + entry( + ADD_ANCHORS, + "Add Anchors…", + "{target: \"all\"|\"curved\"|\"straight\" (all; the segments that get anchors), spacing: \"count\"|\"distance\" (count), count: 1..1000 (1; anchors on each segment, evenly spaced by length), distance: pt 0.01..100000 (20; an anchor every so far along each segment from its start), maxCount: 1..1000 (100; most anchors added to one segment)} adds anchors without changing the shape; past 2 million anchors in all the path stays as it is", + json!({"target": "all", "spacing": "count", "count": 1, "distance": 20.0, "maxCount": 100}), + &["distance"], + ), + entry( + EXTEND, + "Extend Path…", + "{startLength: pt -100000..100000 (10), startPercent: % of the path's length -1000..1000 (0), startShape: \"straight\"|\"arc\" (straight), endLength: pt -100000..100000 (10), endPercent: % of the path's length -1000..1000 (0), endShape: \"straight\"|\"arc\" (straight)} lengthens each end of an open subpath by its length and percent together, or shortens it back along itself when they come to less than 0. Straight runs on along the end's direction; arc follows the circle of the end's curvature, at most half way round. Closed subpaths stay as they are, and so does an end that would be shortened to nothing", + json!({"startLength": 10.0, "startPercent": 0.0, "startShape": "straight", "endLength": 10.0, "endPercent": 0.0, "endShape": "straight"}), + &["startLength", "endLength"], + ), + entry( + CLOSE, + "Close Path…", + "{mode: \"straight\"|\"smooth\" (straight; smooth: the closing segment leaves both ends smoothly)} closes every open subpath of two anchors or more", + json!({"mode": "straight"}), + &[], + ), + entry( + SMOOTH, + "Smooth Anchors…", + "{ratio: 0.01..0.5 (0.3905; a handle's length as a share of the distance to the nearer neighbour; 0.3905 makes a square's corners a circle)} makes every anchor smooth: a lone handle is mirrored, two are brought into line keeping their lengths, and an anchor without handles gets them at right angles to the bisector of its neighbours", + json!({"ratio": SMOOTH_RATIO}), + &[], + ), + entry( + RETRACT, + "Retract Handles…", + "{handles: \"all\"|\"shorter\" (all; shorter: only those shorter than length), length: pt 0..100000 (5)} takes the handles back to their anchors; an anchor that loses one becomes a corner", + json!({"handles": "all", "length": 5.0}), + &["length"], + ), + entry( + REVERSE, + "Reverse Direction", + "{} runs every subpath the other way: the shape stays, and arrowheads, dashes and the effects after it swap ends", + json!({}), + &[], + ), + entry( + SIMPLIFY, + "Simplify Anchors…", + "{tolerance: 0..200 (10; higher removes more anchors and lets the outline move further, up to tolerance × 0.02 pt), protectCorners: bool (true; anchors where the path turns by more than 30° stay)} removes the anchors a path can do without, refitting the handles beside each; open ends stay; a path of more than 50000 anchors stays as it is", + json!({"tolerance": 10.0, "protectCorners": true}), + &[], + ), + entry( + SPLIT, + "Split at Anchors…", + "{anchors: \"corner\"|\"smooth\"|\"both\" (both; smooth: where the handles stand in line, corner: the others)} cuts subpaths into open pieces at those anchors; the shape stays, and an open path's ends aren't cuts", + json!({"anchors": "both"}), + &[], + ), + ] +} + +/// Path Effect `id` with parameters `p` on `path`, which keeps to `max` anchors where anchors are +/// added one by one. `path` as it is when `id` is no such effect or the path can't be measured. +pub(crate) fn apply(id: &str, p: &Value, path: &PathData, max: usize) -> PathData { + if !measurable(path) { + return path.clone(); + } + let edit = |change: &dyn Fn(&mut SubPath)| { + let mut out = path.clone(); + out.subpaths.iter_mut().for_each(change); + out + }; + match id { + DASHES => dashes(path, p), + ADD_ANCHORS => add_anchors(path, p, max).unwrap_or_else(|| path.clone()), + EXTEND => PathData::new(path.subpaths.iter().map(|sp| extend(sp, p)).collect()), + CLOSE => { + let mode = if text(p, "mode", "straight") == "smooth" { CloseMode::Smooth } else { CloseMode::Straight }; + edit(&|sp| { + tools::close(sp, mode, SMOOTH_RATIO); + }) + } + SMOOTH => { + let ratio = num(p, "ratio", SMOOTH_RATIO).clamp(RATIO_RANGE.0, RATIO_RANGE.1); + edit(&|sp| tools::smooth_anchors(sp, ratio)) + } + RETRACT if text(p, "handles", "all") == "shorter" => { + let length = num(p, "length", 5.0).clamp(0.0, MAX_HANDLE); + edit(&|sp| { + retract_tiny_handles(sp, length); + }) + } + RETRACT => edit(&|sp| sp.anchors.iter_mut().for_each(|a| a.retract())), + REVERSE => edit(&SubPath::reverse), + SIMPLIFY if path.anchor_count() <= MAX_SIMPLIFY_ANCHORS => { + let tolerance = num(p, "tolerance", 10.0).clamp(0.0, MAX_TOLERANCE) * PT_PER_TOLERANCE; + simplify_keep_shape(path, tolerance, flag(p, "protectCorners", true)) + } + SPLIT => { + let at = text(p, "anchors", "both"); + let (corners, smooth) = (at != "smooth", at != "corner"); + PathData::new(path.subpaths.iter().flat_map(|sp| tools::split_at_anchors(sp, corners, smooth)).collect()) + } + _ => path.clone(), + } +} + +/// Is every coordinate of `path` within [`MAX_COORDINATE`]? +fn measurable(path: &PathData) -> bool { + let near = |q: Point| q.x.abs() <= MAX_COORDINATE && q.y.abs() <= MAX_COORDINATE; + path.anchors().all(|(_, _, a)| near(a.p) && near(a.h_in) && near(a.h_out)) +} + +/// [`DASHES`]: the dashes of `path` as open subpaths, those of no length as subpaths that stay on +/// their point; `path` as it is when the pattern draws no dashes. +fn dashes(path: &PathData, p: &Value) -> PathData { + let length = |key| num(p, key, 0.0).clamp(0.0, MAX_DASH); + let pairs = DASH_PAIRS.iter().map(|(dash, gap)| (length(dash), length(gap))); + let pattern = pairs.filter(|(d, g)| *d > 0.0 || *g > 0.0).flat_map(|(d, g)| [d, g]).collect(); + let dash = Dash { pattern, offset: num(p, "offset", 0.0).clamp(-MAX_DASH, MAX_DASH), align_corners: flag(p, "alignCorners", false) }; + let Some(dashed) = stroke::dash(&path.to_bezpath(), &dash) else { return path.clone() }; + let mut out = PathData::from_bezpath(&dashed.path); + out.subpaths.extend(dashed.dots.iter().map(|d| SubPath::polyline(&[d.at, d.at], false))); + out +} + +/// [`ADD_ANCHORS`] within `max` anchors in all; `None` past them. +fn add_anchors(path: &PathData, p: &Value, max: usize) -> Option { + let which = match text(p, "target", "all") { + "curved" => SegmentKind::Curved, + "straight" => SegmentKind::Straight, + _ => SegmentKind::All, + }; + let spacing = match text(p, "spacing", "count") { + "distance" => AnchorSpacing::Distance(num(p, "distance", 20.0).clamp(DISTANCE_RANGE.0, DISTANCE_RANGE.1)), + _ => AnchorSpacing::Count(num(p, "count", 1.0).clamp(1.0, MAX_PER_SEGMENT) as usize), + }; + let cap = num(p, "maxCount", 100.0).clamp(1.0, MAX_PER_SEGMENT) as usize; + let mut room = max.saturating_sub(path.anchor_count()); + let mut out = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let added = tools::add_anchors(sp, which, spacing, cap, room)?; + room = room.saturating_sub(added.anchors.len().saturating_sub(sp.anchors.len())); + out.push(added); + } + Some(PathData::new(out)) +} + +/// [`EXTEND`] on one subpath, the start then the end; what can't be done stays as it is. +fn extend(sp: &SubPath, p: &Value) -> SubPath { + let total = sp.anchor_arc_positions().last().copied().unwrap_or(0.0); + let by = |length, percent| { + num(p, length, 0.0).clamp(-MAX_EXTEND, MAX_EXTEND) + num(p, percent, 0.0).clamp(-MAX_EXTEND_PERCENT, MAX_EXTEND_PERCENT) / 100.0 * total + }; + let shape = |key| if text(p, key, "straight") == "arc" { ExtendMode::Arc } else { ExtendMode::Straight }; + let ends = [(true, by("startLength", "startPercent"), shape("startShape")), (false, by("endLength", "endPercent"), shape("endShape"))]; + let mut out = sp.clone(); + for (at_start, by, shape) in ends { + if by != 0.0 + && let Some(changed) = tools::extend_open(&out, at_start, by, shape) + { + out = changed; + } + } + out +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/addons/path/tests.rs b/crates/effects/src/addons/path/tests.rs new file mode 100644 index 000000000..ef99706cc --- /dev/null +++ b/crates/effects/src/addons/path/tests.rs @@ -0,0 +1,278 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::Effect; +use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath}; + +use super::*; +use crate::{apply_geometry, effect_info, is_geometry, is_length, is_raster, length_range}; + +fn pt(x: f64, y: f64) -> Point { + Point::new(x, y) +} + +fn line(pts: &[(f64, f64)], closed: bool) -> PathData { + PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| pt(x, y)).collect::>(), closed)) +} + +fn square() -> PathData { + line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true) +} + +/// A curve: a smooth anchor between two ends. +fn curve() -> PathData { + let mid = Anchor::smooth(pt(50.0, -30.0), pt(70.0, -30.0)); + PathData::single(SubPath::new( + vec![ + Anchor { h_out: pt(10.0, -20.0), ..Anchor::corner(pt(0.0, 0.0)) }, + mid, + Anchor { h_in: pt(90.0, -20.0), ..Anchor::corner(pt(100.0, 0.0)) }, + ], + false, + )) +} + +fn run(id: &str, params: Value, path: &PathData) -> PathData { + let e = Effect { id: id.into(), params, visible: true }; + apply_geometry(&[e], path, path.bounds().unwrap_or(Rect::ZERO)) +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-3 +} + +const ALL: [&str; 9] = [DASHES, ADD_ANCHORS, EXTEND, CLOSE, SMOOTH, RETRACT, REVERSE, SIMPLIFY, SPLIT]; + +#[test] +fn the_effects_are_geometry_effects_of_the_path_effects_menu() { + for id in ALL { + let info = effect_info(id).unwrap(); + assert!(is_geometry(id) && !is_raster(id), "{id}"); + assert_eq!(info.menu, ["Effect", "Path Effects"], "{id}"); + assert!(id.starts_with(PREFIX)); + } + assert!(is_length(DASHES, "gap5", false) && is_length(EXTEND, "endLength", false) && !is_length(EXTEND, "endPercent", false)); + assert_eq!(length_range(DASHES, "offset"), Some(-10_000.0..=10_000.0)); +} + +#[test] +fn dashes_become_open_subpaths_along_the_path() { + let out = run(DASHES, json!({"dash1": 10, "gap1": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert_eq!(out.subpaths.len(), 7, "periods of 15 on 100"); + assert!(out.subpaths.iter().all(|s| !s.closed)); + assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 25.0)); + // Two pairs, and a pair of zeros between them left out. + let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "dash3": 20, "gap3": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 35.0)); + assert!(near(out.subpaths[2].anchors[0].p.x, 40.0)); + // The offset starts part way into the pattern. + let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "offset": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false)); + assert!(near(out.subpaths[0].anchors[1].p.x, 5.0)); +} + +#[test] +fn aligned_dashes_centre_on_corners_and_ends() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let out = run(DASHES, json!({"dash1": 10, "gap1": 10, "alignCorners": true}), &open); + let on = |p: Point| out.subpaths.iter().any(|s| (0..s.segment_count()).any(|i| s.anchors[i].p == p || s.anchors[i + 1].p == p)); + assert!(on(pt(0.0, 0.0)) && on(pt(100.0, 100.0)), "a dash on each end"); + let corner = out.subpaths.iter().find(|s| s.anchors.iter().any(|a| a.p == pt(100.0, 0.0))).expect("a dash over the corner"); + assert!(near(corner.anchors[0].p.distance(pt(100.0, 0.0)), corner.anchors[2].p.distance(pt(100.0, 0.0))), "centred: {corner:?}"); +} + +#[test] +fn zero_dashes_are_points_and_patterns_of_nothing_leave_the_path() { + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + let dots = run(DASHES, json!({"dash1": 0, "gap1": 25}), &path); + assert_eq!(dots.subpaths.len(), 5); + assert!(dots.subpaths.iter().all(|s| s.anchors.len() == 2 && s.anchors[0].p == s.anchors[1].p)); + for params in [ + json!({"dash1": 0, "gap1": 0}), + json!({"dash1": -5, "gap1": -5}), + json!({"dash1": 0.0001, "gap1": 0.0001}), + json!({"dash1": "x", "gap1": null}), + ] { + assert_eq!(run(DASHES, params.clone(), &path), path, "{params}"); + } +} + +#[test] +fn add_anchors_spaces_them_and_keeps_to_the_caps() { + let square = square(); + assert_eq!(run(ADD_ANCHORS, json!({}), &square).anchor_count(), 8, "one on each segment"); + assert_eq!(run(ADD_ANCHORS, json!({"count": 3}), &square).anchor_count(), 16); + assert_eq!(run(ADD_ANCHORS, json!({"count": 3, "maxCount": 2}), &square).anchor_count(), 12); + let out = run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 30}), &square); + assert_eq!(out.anchor_count(), 16, "at 30, 60 and 90 on each side"); + assert!(near(out.subpaths[0].anchors[2].p.x, 60.0)); + assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 25}), &square).anchor_count(), 16, "none on the segment's end"); + assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 1e-9, "maxCount": 2}), &square).anchor_count(), 12); + // Only the segments asked for. + let mixed = PathData::new(vec![curve().subpaths[0].clone(), square.subpaths[0].clone()]); + assert_eq!(run(ADD_ANCHORS, json!({"target": "curved"}), &mixed).anchor_count(), 7 + 2); + assert_eq!(run(ADD_ANCHORS, json!({"target": "straight"}), &mixed).anchor_count(), 7 + 4); + // The shape stays. + let out = run(ADD_ANCHORS, json!({"count": 4}), &curve()); + assert_eq!(out.anchor_count(), 3 + 8); + assert!(near(out.length(), curve().length())); + assert_eq!(out.bounds().map(|b| (b.y0 * 100.0).round()), curve().bounds().map(|b| (b.y0 * 100.0).round())); +} + +#[test] +fn add_anchors_past_the_budget_leaves_the_path() { + let path = square(); + let most = json!({"count": 1000, "maxCount": 1000}); + assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4003), path); + assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4004).anchor_count(), 4004); + // Many segments, each with the most anchors: refused before they are built. + let long = line(&(0..4000).map(|i| (i as f64, 0.0)).collect::>(), false); + assert_eq!(run(ADD_ANCHORS, json!({"count": 1000, "maxCount": 1000}), &long).anchor_count(), 4000, "4 million would pass the budget"); +} + +#[test] +fn extend_lengthens_and_shortens_each_end() { + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + let out = run(EXTEND, json!({"startLength": 20, "endLength": 10}), &path); + let xs: Vec = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, [-20.0, 0.0, 100.0, 110.0]); + // Percent of the path's length, adding to the length; negative shortens. + let out = run(EXTEND, json!({"startLength": 0, "endLength": 5, "endPercent": 10}), &path); + assert!(out.subpaths[0].anchors.last().is_some_and(|a| near(a.p.x, 115.0))); + let out = run(EXTEND, json!({"startLength": -10, "endLength": 0, "endPercent": -50}), &path); + let xs: Vec = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert!(near(xs[0], 10.0) && near(xs[1], 50.0), "{xs:?}"); + // An arc carries on round the end's curve. + let arc = run(EXTEND, json!({"startLength": 0, "endLength": 40, "endShape": "arc"}), &curve()); + let straight = run(EXTEND, json!({"startLength": 0, "endLength": 40}), &curve()); + assert!(arc.anchor_count() >= straight.anchor_count()); + assert!((arc.length() - straight.length()).abs() < 0.1); + assert_ne!(arc.subpaths[0].anchors.last().map(|a| a.p), straight.subpaths[0].anchors.last().map(|a| a.p)); +} + +#[test] +fn extend_leaves_what_it_cannot_change() { + let closed = square(); + assert_eq!(run(EXTEND, json!({"endLength": 50}), &closed), closed); + let path = line(&[(0.0, 0.0), (100.0, 0.0)], false); + assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": -500}), &path), path, "nothing would be left"); + assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": 0, "endPercent": -100}), &path), path); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false)); + assert_eq!(run(EXTEND, json!({}), &one), one); +} + +#[test] +fn close_joins_open_subpaths_straight_or_smooth() { + let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let out = run(CLOSE, json!({}), &open); + assert!(out.subpaths[0].closed && out.subpaths[0].segment_is_line(2)); + let out = run(CLOSE, json!({"mode": "smooth"}), &open); + assert!(out.subpaths[0].closed && !out.subpaths[0].segment_is_line(2)); + assert_eq!(run(CLOSE, json!({}), &square()), square()); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false)); + assert_eq!(run(CLOSE, json!({}), &one), one); +} + +#[test] +fn smooth_makes_a_square_round_and_clamps_its_ratio() { + let out = run(SMOOTH, json!({}), &square()); + assert!(out.subpaths[0].anchors.iter().all(|a| near(a.h_out.distance(a.p), 39.05) && a.has_in())); + let at = |ratio: f64| run(SMOOTH, json!({ "ratio": ratio }), &square()).subpaths[0].anchors[0]; + assert!(near(at(9.0).h_out.distance(pt(0.0, 0.0)), 50.0)); + assert!(near(at(-9.0).h_out.distance(pt(0.0, 0.0)), 1.0)); +} + +#[test] +fn retract_takes_all_handles_or_the_short_ones() { + let out = run(RETRACT, json!({}), &curve()); + assert!(out.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out())); + // The ends' handles are about 22 pt long and the middle anchor's 20. + let out = run(RETRACT, json!({"handles": "shorter", "length": 21}), &curve()); + let a = &out.subpaths[0].anchors; + assert!(!a[1].has_in() && !a[1].has_out() && a[0].has_out() && a[2].has_in()); + assert_eq!(run(RETRACT, json!({"handles": "shorter", "length": 1}), &curve()), curve()); +} + +#[test] +fn reverse_runs_the_other_way_and_twice_is_as_it_was() { + let out = run(REVERSE, json!({}), &curve()); + assert_eq!(out.subpaths[0].anchors[0].p, pt(100.0, 0.0)); + assert_eq!(run(REVERSE, json!({}), &out), curve()); +} + +#[test] +fn simplify_takes_anchors_within_the_tolerance_and_stops_at_its_cap() { + let pts: Vec<(f64, f64)> = (0..=40).map(|i| (i as f64, (i as f64 / 40.0 * std::f64::consts::PI).sin() * 3.0)).collect(); + let path = line(&pts, false); + let out = run(SIMPLIFY, json!({"tolerance": 200}), &path); + assert!(out.anchor_count() < 20, "{}", out.anchor_count()); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 0}), &path), path); + // Corners stay unless told otherwise. + let corner = line(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], false); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 200}), &corner), corner); + assert_eq!(run(SIMPLIFY, json!({"tolerance": 200, "protectCorners": false}), &corner).anchor_count(), 2); + let long = line(&(0..=MAX_SIMPLIFY_ANCHORS).map(|i| (i as f64, 0.0)).collect::>(), false); + assert_eq!(run(SIMPLIFY, json!({}), &long).anchor_count(), long.anchor_count()); +} + +#[test] +fn split_cuts_at_the_anchors_asked_for() { + let mixed = PathData::single(SubPath::new( + vec![ + Anchor::corner(pt(0.0, 0.0)), + Anchor::smooth(pt(50.0, 0.0), pt(60.0, 0.0)), + Anchor::corner(pt(100.0, 0.0)), + Anchor::corner(pt(100.0, 50.0)), + ], + false, + )); + assert_eq!(run(SPLIT, json!({}), &mixed).subpaths.len(), 3); + assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &mixed).subpaths.len(), 2); + assert_eq!(run(SPLIT, json!({"anchors": "corner"}), &mixed).subpaths.len(), 2); + let out = run(SPLIT, json!({}), &square()); + assert_eq!(out.subpaths.len(), 4); + assert!(out.subpaths.iter().all(|s| !s.closed && s.anchors.len() == 2)); + assert!(near(out.length(), 400.0)); + assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &square()), square(), "no smooth anchors"); +} + +/// Whatever a file holds: every effect takes wrong types, huge numbers and paths of nothing, of one +/// anchor or past measuring, and keeps to the budget. +#[test] +fn junk_params_and_paths_are_harmless() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!("x"), + json!({"dash1": 1e308, "gap1": 1e308, "dash2": -1e308, "gap2": "x", "offset": 1e308, "alignCorners": "yes"}), + json!({"dash1": 1e-300, "gap1": 1e-300, "offset": -1e308, "alignCorners": true}), + json!({"target": 3, "spacing": ["distance"], "count": 1e308, "distance": 1e-308, "maxCount": -1e308}), + json!({"spacing": "distance", "distance": -1e308, "maxCount": 1e308}), + json!({"startLength": 1e308, "startPercent": 1e308, "startShape": 7, "endLength": -1e308, "endPercent": -1e308, "endShape": "arc"}), + json!({"mode": {}, "ratio": 1e308, "handles": "shorter", "length": 1e308, "tolerance": 1e308, "protectCorners": "no", "anchors": 9}), + json!({"ratio": -1e308, "length": -1e308, "tolerance": -1e308, "anchors": "none"}), + ]; + let far = line(&[(1e308, -1e308), (-1e308, 1e308), (1e300, 0.0)], false); + let nan = line(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)], false); + let tiny = line(&[(0.0, 0.0), (1e-300, 0.0), (1e-300, 1e-300)], true); + let same = line(&[(5.0, 5.0), (5.0, 5.0), (5.0, 5.0)], true); + let empty = PathData::single(SubPath::default()); + let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], true)); + let paths = [square(), curve(), far.clone(), nan.clone(), tiny, same, empty, one, PathData::default()]; + for id in ALL { + for params in &junk { + for path in &paths { + let out = super::apply(id, &crate::merged_params(id, params), path, 100_000); + assert!(out.anchor_count() <= 100_000.max(path.anchor_count()) + 8 * path.subpaths.len(), "{id} {params}"); + } + } + // Compared as text: NaN never equals itself. + assert_eq!(format!("{:?}", super::apply(id, &json!({}), &nan, 100)), format!("{nan:?}"), "{id}"); + assert_eq!(super::apply(id, &json!({}), &far, 100), far, "{id}"); + } +} + +/// The dash generator's limit holds: a pattern too fine for the path leaves it solid. +#[test] +fn too_many_dashes_leave_the_path() { + let long = line(&[(0.0, 0.0), (1e9, 0.0)], false); + assert_eq!(run(DASHES, json!({"dash1": 1, "gap1": 1}), &long), long); +} diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index faa7a5a7c..93601680f 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -14,9 +14,10 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint, keep_model}; -use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node}; +use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; use vectorcraft_doc::{Document, Effect, Node, NodeKind}; +use crate::addons::duotone::{Duotone, SwatchKinds}; use crate::merged_params; use crate::raster::color_param; use crate::util::{flag, num, text}; @@ -27,7 +28,7 @@ pub const ADJUSTMENTS: [&str; 6] = /// Is `id` a colour adjustment effect? pub fn is_adjustment(id: &str) -> bool { - ADJUSTMENTS.contains(&id) + ADJUSTMENTS.contains(&id) || crate::addons::color::is_color_effect(id) } fn visible_adjustment(e: &Effect) -> bool { @@ -72,7 +73,7 @@ pub fn is_channel(s: &str) -> bool { } /// Entries of a sampled tone curve. -const LUT: usize = 256; +pub(crate) const LUT: usize = 256; /// One adjustment, its parameters normalised. #[derive(Clone, Debug, PartialEq)] @@ -88,6 +89,18 @@ enum Adjust { ShiftToColor { target: [f32; 3], amount: f32, preserve: bool }, /// Warm/cool and magenta/green shifts, -1..1. TemperatureTint { temperature: f32, tint: f32 }, + /// One of the Color Effects add-on's. + Addon(crate::addons::color::Adjust), +} + +impl Adjust { + /// Duotone, which maps a colour with its swatch link ([`ColorMap::apply_link`]). + fn duotone(&self) -> Option<&Duotone> { + match self { + Adjust::Addon(crate::addons::color::Adjust::Duotone(d)) => Some(d), + _ => None, + } + } } /// The colour adjustments of an effect list, composed in order. @@ -102,17 +115,44 @@ impl ColorMap { /// Adjust display (sRGB) components 0..1. pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] { - self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })) + apply_rgb(&self.0, rgb) } /// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is). + /// Duotone gives its inks' colour instead ([`Duotone`]). pub fn apply(&self, c: Color) -> Color { - let rgb = c.to_rgb(); - let out = self.apply_rgb(rgb); - if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) { - return c; + let (mut c, mut link, mut tint) = (c, None, 1.0); + self.apply_link(&mut c, &mut link, &mut tint, &|_| None); + c + } + + /// Adjust a colour with its swatch link and tint (`link`, `tint`), as a paint holds them; + /// true when it changed. A colour that changes loses its link, except that Duotone with one + /// tone of a swatch the document has (`swatches`, [`SwatchKinds`]) links it to that swatch at + /// the tone's tint. The adjustments between Duotones work in display RGB, each run keeping + /// the colour's model. + pub fn apply_link(&self, c: &mut Color, link: &mut Option, tint: &mut f32, swatches: &SwatchKinds) -> bool { + let mut changed = false; + let mut rest: &[Adjust] = &self.0; + while !rest.is_empty() { + let (run, after) = rest.split_at(rest.iter().position(|a| a.duotone().is_some()).unwrap_or(rest.len())); + if !run.is_empty() { + let rgb = c.to_rgb(); + let out = apply_rgb(run, rgb); + if !out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) { + *c = keep_model(*c, Color::rgb(out[0], out[1], out[2])); + *link = None; + *tint = 1.0; + changed = true; + } + } + let Some((duotone, tail)) = after.split_first() else { break }; + if let Some(d) = duotone.duotone() { + changed |= d.map_link(c, link, tint, swatches); + } + rest = tail; } - keep_model(c, Color::rgb(out[0], out[1], out[2])) + changed } /// Adjust an 8-bit sRGB pixel. @@ -122,6 +162,11 @@ impl ColorMap { } } +/// Display (sRGB) components 0..1 through `adjustments`, in order. +fn apply_rgb(adjustments: &[Adjust], rgb: [f32; 3]) -> [f32; 3] { + adjustments.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 })) +} + /// The visible colour adjustments of `effects` as one map, `None` when there are none. pub fn color_map(effects: &[Effect]) -> Option { let v: Vec = @@ -175,7 +220,7 @@ impl Adjust { preserve: flag(p, "preserveLightness", true), }, "adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") }, - _ => return None, + _ => Adjust::Addon(crate::addons::color::Adjust::parse(id, p)?), }) } @@ -225,17 +270,18 @@ impl Adjust { let (t, m) = (temperature * 0.3, tint * 0.3); [r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)] } + Adjust::Addon(a) => a.apply([r, g, b]), } } } /// `f` sampled at [`LUT`] inputs 0..1. -fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> { +pub(crate) fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> { (0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect() } /// The tone curve `lut` at `v` (linear between samples). -fn lookup(lut: &[f32], v: f32) -> f32 { +pub(crate) fn lookup(lut: &[f32], v: f32) -> f32 { let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32; let i = (x.floor() as usize).min(LUT - 2); let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v)); @@ -245,6 +291,12 @@ fn lookup(lut: &[f32], v: f32) -> f32 { /// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter: /// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line. pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { + // At most a point per level is meaningful. + curve_points_on(v, 255.0, 256) +} + +/// [`curve_points`] on a scale of 0..`max`, reading at most `cap` points. +pub fn curve_points_on(v: Option<&Value>, max: f64, cap: usize) -> Vec<(f32, f32)> { let mut pts: Vec<(f64, f64)> = match v { Some(Value::String(s)) => s .split(|c: char| c.is_whitespace() || c == ';') @@ -257,10 +309,9 @@ pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> { _ => vec![], }; pts.retain(|(x, y)| x.is_finite() && y.is_finite()); - // At most a point per level is meaningful. - pts.truncate(256); - let mut pts: Vec<(f32, f32)> = - pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect(); + pts.truncate(cap); + let unit = |v: f64| (v.clamp(0.0, max) / max) as f32; + let mut pts: Vec<(f32, f32)> = pts.into_iter().map(|(x, y)| (unit(x), unit(y))).collect(); pts.sort_by(|a, b| a.0.total_cmp(&b.0)); pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6); if pts.len() < 2 { @@ -354,15 +405,22 @@ fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] { /// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is). pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option + 'a; -/// The live visitor: every colour through `map`, embedded images through the hook. +/// The live visitor: every colour through `map` (paints with their swatch links, the document's +/// swatches being `swatches`), embedded images through the hook. struct Live<'a, 'b> { map: &'a ColorMap, + swatches: &'a SwatchKinds<'b>, images: &'a mut ImageHook<'b>, } +/// `p`'s colours through `map` with their swatch links ([`ColorMap::apply_link`]). +fn map_paint(p: &mut Paint, map: &ColorMap, swatches: &SwatchKinds) -> bool { + map_links(p, &|c, link, tint| map.apply_link(c, link, tint, swatches)) +} + impl ColorVisitor for Live<'_, '_> { fn paint(&mut self, p: &mut Paint) -> bool { - map_paint(p, &|c| self.map.apply(c)) + map_paint(p, self.map, self.swatches) } fn color(&mut self, c: Color) -> Color { self.map.apply(c) @@ -375,12 +433,13 @@ impl ColorVisitor for Live<'_, '_> { /// `n` with the colour adjustments in it applied, `None` when it has none: its members' first, /// then those of its fills and strokes on their paints, then its own followed by `outer` (the /// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects -/// are gone from the result; embedded images take the keys `images` gives. -pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option { +/// are gone from the result; embedded images take the keys `images` gives. `swatches` tells the +/// document's global and spot swatches apart ([`crate::addons::duotone::swatch_kind`]). +pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images: &mut ImageHook<'_>) -> Option { let mut m: Option = None; if let Some(ch) = n.children() { for (i, c) in ch.iter().enumerate() { - if let Some(new) = adjust(c, None, images) + if let Some(new) = adjust(c, None, swatches, images) && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) { *slot = Arc::new(new); @@ -391,7 +450,7 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> if let Some(map) = color_map(item.effects()) && let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i) { - map_paint(it.paint_mut(), &|c| map.apply(c)); + map_paint(it.paint_mut(), &map, swatches); it.effects_mut().retain(|e| !is_adjustment(&e.id)); } } @@ -403,7 +462,7 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> if let Some(map) = map { let mm = m.get_or_insert_with(|| n.clone()); mm.appearance.effects.retain(|e| !is_adjustment(&e.id)); - recolor_node(mm, Reach::ALL, &mut Live { map: &map, images }); + recolor_node(mm, Reach::ALL, &mut Live { map: &map, swatches, images }); } m } @@ -420,13 +479,14 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { } _ => n, }; + let kinds = crate::addons::duotone::swatch_kinds(d); let mut hook = |key: &str, map: &ColorMap| -> Option { let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?; let k = blob.content_key(); d.images.entry(k.clone()).or_insert(blob); Some(k) }; - adjust(src, None, &mut hook) + adjust(src, None, &|name| kinds.get(name).copied(), &mut hook) } #[cfg(test)] @@ -489,17 +549,17 @@ mod tests { let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]); g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true }); assert!(has_adjustment(&g)); - let out = adjust(&g, None, &mut |_, _| None).unwrap(); + let out = adjust(&g, None, &|_| None, &mut |_, _| None).unwrap(); assert!(out.appearance.effects.is_empty()); for c in out.children().unwrap() { assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00"); } // A hidden effect does nothing; a stroke's own effect recolours just that stroke. g.appearance.effects[0].visible = false; - assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none()); + assert!(!has_adjustment(&g) && adjust(&g, None, &|_| None, &mut |_, _| None).is_none()); let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true }); - let out = adjust(&p, None, &mut |_, _| None).unwrap(); + let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap(); assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff"); assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint()); assert!(out.appearance.items[1].effects().is_empty()); @@ -514,7 +574,7 @@ mod tests { let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0); let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill); p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true }); - let out = adjust(&p, None, &mut |_, _| None).unwrap(); + let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap(); let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") }; // The points are what a freeform gradient draws from, not its stops. let hexes: Vec = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect(); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index c92947ac3..e713e0ffa 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -52,6 +52,7 @@ pub fn needs_bake(n: &Node) -> bool { || crate::has_solid(n) || has_adjustment(n) || has_crop_marks(n) + || crate::addons::halftone::has_halftone(n) || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) @@ -104,6 +105,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } + // Vector Halftone: the object as its dots. + if let Some(m) = crate::addons::halftone::halftone_art(d, n) { + return Some(bake_pieces(d, m)); + } if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index e000b930f..bab9ee6ed 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -42,6 +42,20 @@ pub fn is_length(id: &str, key: &str, relative: bool) -> bool { catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key)) } +/// The range in points that distance parameter `key` of effect `id` is kept within, as its +/// parameter documentation gives it (`{maxRadius: pt 4..127 (8), …}`); `None` when it gives none. +pub fn length_range(id: &str, key: &str) -> Option> { + let doc = catalog_index().get(id)?.params; + let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' '); + let rest = doc.match_indices(key).map(|(at, _)| at).filter(|at| starts(*at)).find_map(|at| { + let rest = doc.get(at + key.len()..)?; + rest.strip_prefix(": pt ").or_else(|| rest.strip_prefix("?: pt ")) + })?; + let (lo, hi) = rest.split_whitespace().next()?.split_once("..")?; + let (lo, hi) = (lo.parse::().ok()?, hi.trim_end_matches(',').parse::().ok()?); + (lo.is_finite() && hi.is_finite() && lo <= hi).then_some(lo..=hi) +} + /// The distance parameters of effect `id` (none for unknown effects). fn lengths_of(id: &str) -> Lengths { let always = |always| Lengths { always, absolute: &[] }; @@ -777,6 +791,9 @@ pub fn effect_catalog() -> Vec { v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); } } + v.extend(crate::addons::color::catalog()); + v.extend(crate::addons::halftone::catalog()); + v.extend(crate::addons::path::catalog()); v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); v.push(g( CROP_MARKS, diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index 542098201..f6f007b5c 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -265,6 +265,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p), "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), "stylize.scribble" => stylize::scribble(path, b, p), + _ if id.starts_with(crate::addons::path::PREFIX) => crate::addons::path::apply(id, p, path, max), _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { Some(style) => warp::warp(path, b, style, p, max), None => path.clone(), diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b6db2f4d9..b15a642c3 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -34,6 +34,7 @@ //! anchored at the object's centre. #![forbid(unsafe_code)] +pub mod addons; mod adjust; mod bake; mod clip; @@ -94,7 +95,7 @@ mod catalog; mod geometry; mod params; -pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, plugin_effects}; +pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects}; pub(crate) use geometry::{Budget, apply_one}; pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; #[cfg(test)] diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index d64ac7b78..c8cea8259 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -118,14 +118,16 @@ pub fn is_raster(id: &str) -> bool { || PIXEL_EFFECTS.contains(&id) } -/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour -/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in +/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead, +/// [`crop_marks_art`] and [`crate::addons::halftone::halftone_art`]; colour adjustments recolour, +/// [`adjust`].) Plug-in effects do, installed or not (a missing plug-in /// leaves the geometry as it is). pub fn is_geometry(id: &str) -> bool { !is_raster(id) && !is_pathfinder(id) && !is_adjustment(id) && id != CROP_MARKS + && id != crate::addons::halftone::HALFTONE && !is_three_d(id) && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index a68b7f579..1165340bc 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -114,10 +114,34 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { NodeKind::Compound { children, rule } => { Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) } + // Type extrudes as its glyph outlines and stays type: editing it reshapes the solid (#4715). + NodeKind::Text(_) => { + let mut subpaths = vec![]; + crate::outline_text(n)?.walk(&mut |c| subpaths.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().cloned()))); + (!subpaths.is_empty()).then(|| (PathData::new(subpaths), FillRule::NonZero)) + } _ => None, } } +/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the +/// fill and stroke of its first painted run (its characters' colours). +fn solid_paints(n: &Node) -> Vec { + let own: Vec = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); + if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) { + return own; + } + let mut runs = vec![]; + if let Some(outlined) = crate::outline_text(n) { + outlined.walk(&mut |c| { + if runs.is_empty() && c.path_data().is_some() { + runs = c.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect(); + } + }); + } + runs +} + fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { let view = view_options(p); match id { @@ -320,7 +344,8 @@ pub fn solid_art(n: &Node) -> Option { } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); - let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect(); + let paints = solid_paints(n); + let painted: Vec<&AppearanceItem> = paints.iter().collect(); let mut children = vec![]; let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 0241fa8b9..198da329e 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1207,3 +1207,17 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { let small = Node::new(NodeId(0), NodeKind::Repeat(RepeatSpec::mirror(vec![instance("Small")], 90.0, 5.0))); assert_eq!(expand_live(Some(&d), &small).len(), 2); } + +#[test] +fn a_distance_parameters_range_comes_from_its_documentation() { + assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0)); + // The effect applies the range's floor to a radius below it. + assert_eq!( + crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 1})), + crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 4})) + ); + // A distance documented without a range, and unknown effects and parameters, have none. + assert_eq!(length_range("path.offsetPath", "offset"), None); + assert_eq!(length_range("pixelate.colorHalftone", "channel1"), None); + assert_eq!(length_range("no.such", "maxRadius"), None); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 061d293b9..dc047d9d6 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -1,5 +1,6 @@ //! The command registry. Ids follow Illustrator's menu structure. +mod addons; pub(crate) mod appearance; mod assets; mod attributes; @@ -32,6 +33,7 @@ mod fonts; pub(crate) mod freeform; pub mod globaledit; pub(crate) mod gradient; +pub mod gradpresets; pub(crate) mod graph; mod halftone; pub mod help; @@ -243,6 +245,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(style::specs()); v.extend(swatch::specs()); v.extend(gradient::specs()); + v.extend(gradpresets::specs()); v.extend(opacitymask::specs()); v.extend(layer::specs()); v.extend(layerpanel::specs()); @@ -322,6 +325,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(perspgrid::specs()); v.extend(shortcutset::specs()); v.extend(importmatches::specs()); + v.extend(addons::specs()); v }) } @@ -611,6 +615,9 @@ pub use flatten::FlattenOptions; /// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]). pub use flatten::FlattenerPreset; +/// A saved Gradient panel preset ([`crate::Prefs::gradient_presets`]) and a row of the Presets list. +pub use gradpresets::{GradientPreset, GradientPresetEntry}; + /// A saved Image Trace preset ([`crate::Prefs::trace_presets`]), and a change to the saved presets. pub use buildcmds::{TracePreset, TracePresetChange}; diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs new file mode 100644 index 000000000..ca6625540 --- /dev/null +++ b/crates/engine/src/cmd/addons.rs @@ -0,0 +1,18 @@ +//! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own +//! with commands under `addon..*` (see `docs/mcp.md`). + +mod cleanup; +mod color; +mod pathtools; +mod pen; + +use super::CommandSpec; + +/// The commands of every built-in add-on. +pub fn specs() -> Vec { + let mut v = cleanup::specs(); + v.extend(color::specs()); + v.extend(pathtools::specs()); + v.extend(pen::specs()); + v +} diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs new file mode 100644 index 000000000..4f96c2634 --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -0,0 +1,365 @@ +//! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the +//! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path +//! Pieces, and its text tools ([`text`]). Its panel and its submenu, Object › Plug-ins › Artwork +//! Cleanup, are the UI's. + +mod checks; +#[cfg(test)] +mod tests; +mod text; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{PathData, SubPath}; +use vectorcraft_pathops::addons as po; + +use self::checks::{Kind, MAX_OBJECTS, Outcome, Scope, set_path}; +use super::super::edit::selected_roots; +use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Simplify Anchors' tolerance by default. +const TOLERANCE: f64 = 20.0; +/// Rejoin Path Pieces' gap by default, and the widest (pt). +const GAP: f64 = 0.1; +const MAX_GAP: f64 = 6.0; +/// Most anchors Simplify Anchors takes at once. +const MAX_ANCHORS: usize = 2_000_000; +/// Most rounds of fixes `addon.cleanup.fix` runs. +const FIX_ROUNDS: usize = 3; +/// Most ids a finding lists (its count counts them all). +const MAX_LISTED: usize = 1000; + +pub fn specs() -> Vec { + let mut v = vec![ + cmd!( + "addon.cleanup.simplify", + "Simplify Anchors (Keep Shape)…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay → {ids, before, after} anchor counts", + has_selection, + simplify + ), + cmd!( + query "addon.cleanup.check", + "Health Check", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found", + has_doc, + check + ), + cmd!( + query "addon.cleanup.show", + "Show Issue", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{kind: kind (see addon.cleanup.check), scope?: \"selection\"|\"document\"} select the objects with that issue → {kind, count, ids, artboards}", + has_doc, + show + ), + cmd!( + "addon.cleanup.fix", + "Fix All Issues", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{scope?: \"selection\"|\"document\", kinds?: [kind…] (default: all; see addon.cleanup.check)} fix the issues addon.cleanup.check finds, in up to three rounds while a round still finds some (a fix can leave issues for another kind), as one undo step: what is empty, unpainted, invisible, duplicated or off the artboards goes, anchors merge, snap or go, tiny handles retract, paths close and needless groups, compound paths and clipping groups give way to their contents → {scope, total, fixed: {kind: count}}", + has_doc, + fix + ), + cmd!( + "addon.cleanup.deepUngroup", + "Deep Ungroup…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in → {ids (the objects left, selected), ungrouped (groups)}", + has_selection, + deep_ungroup + ), + cmd!( + "addon.cleanup.rejoin", + "Rejoin Path Pieces…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}", + has_selection, + rejoin + ), + ]; + v.extend(text::specs()); + v +} + +/// The paths (not guides) under `roots`, compound path members included. +fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { + let mut out = vec![]; + for n in roots.iter().filter_map(|id| d.node(*id)) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { guide: false, .. }) { + out.push(c.id); + } + }); + } + out +} + +fn simplify(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.simplify"; + let tolerance = number_or(p, "tolerance", TOLERANCE, C)?; + if !(1.0..=100.0).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 1 to 100")); + } + let protect = switch_or(p, "protectCorners", false, C)?; + let roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + let ids = paths_under(d, &roots); + let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); + let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum(); + if before > MAX_ANCHORS { + return Err(EngineError::Other("Simplify Anchors: the paths have too many anchors to simplify at once".into())); + } + let changed: Vec<(NodeId, PathData)> = paths + .iter() + .filter_map(|(id, path)| { + let np = po::simplify_keep_shape(path, tolerance * po::PT_PER_TOLERANCE, protect); + (np != **path).then_some((*id, np)) + }) + .collect(); + let removed: usize = changed.iter().filter_map(|(id, np)| Some(d.node(*id)?.path_data()?.anchor_count().saturating_sub(np.anchor_count()))).sum(); + let after = before.saturating_sub(removed); + // When no anchor can go, the document and its undo history stay as they are. + if !changed.is_empty() { + s.edit("Simplify Anchors (Keep Shape)", |d, sel| { + for (id, np) in changed { + // The anchors left are new ones: the path is selected whole. + sel.anchors.remove(&id); + sel.segments.remove(&id); + set_path(d, id, np); + } + Ok(()) + })?; + } + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after })) +} + +/// The scope a check, show or fix request names (by default the selection, or the document while +/// nothing is selected) and its name. +fn scope_of(s: &Session, p: &Value, cmd: &str) -> Result<(Scope, &'static str)> { + let roots = selected_roots(s)?; + match text_param(p, "scope", cmd)? { + Some("document") => Ok((Scope::Document, "document")), + None if roots.is_empty() => Ok((Scope::Document, "document")), + Some("selection") if roots.is_empty() => Err(bad(cmd, "nothing selected")), + Some("selection") | None => Ok((Scope::Objects(roots), "selection")), + Some(other) => Err(bad(cmd, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))), + } +} + +/// The kinds a request names (all by default), in the order Fix runs them. +fn kinds_of(p: &Value, cmd: &str) -> Result> { + let given: Vec = match p.get("kinds") { + None | Some(Value::Null) => return Ok(Kind::ALL.to_vec()), + Some(Value::Array(a)) => { + a.iter().map(|v| v.as_str().and_then(Kind::parse).ok_or_else(|| bad(cmd, format!("unknown issue kind {v}")))).collect::>()? + } + Some(_) => return Err(bad(cmd, "`kinds` must be a list of issue kinds")), + }; + Ok(Kind::ALL.into_iter().filter(|k| given.contains(k)).collect()) +} + +/// Kind `kind`'s fix run on a copy of the active document: what Fix would change. +fn dry_run(s: &Session, kind: Kind, scope: &Scope) -> Result { + let st = s.doc()?; + let (mut d, mut sel, mut scope) = ((*st.doc).clone(), st.selection.clone(), scope.clone()); + kind.fix(&mut d, &mut sel, &mut scope) +} + +fn finding(kind: Kind, o: &Outcome) -> Value { + let ids: Vec = o.ids.iter().take(MAX_LISTED).map(|i| i.0).collect(); + json!({ "kind": kind.id(), "label": kind.label(), "count": o.count, "ids": ids, "artboards": o.artboards }) +} + +fn check(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.check"; + let (scope, name) = scope_of(s, p, C)?; + let mut findings = vec![]; + let mut total = 0; + for kind in kinds_of(p, C)? { + let o = dry_run(s, kind, &scope)?; + if o.count > 0 { + total += o.count; + findings.push(finding(kind, &o)); + } + } + Ok(json!({ "scope": name, "total": total, "findings": findings })) +} + +fn show(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.show"; + let id = text_param(p, "kind", C)?.ok_or_else(|| bad(C, "missing `kind`"))?; + let kind = Kind::parse(id).ok_or_else(|| bad(C, format!("unknown issue kind {id:?}")))?; + let (scope, _) = scope_of(s, p, C)?; + let o = dry_run(s, kind, &scope)?; + if o.count == 0 { + return Err(EngineError::Other("None of these issues are left: check again".into())); + } + // Artboards are no objects to select: the selection stays as it is for them. + let ids = o.ids.clone(); + if !ids.is_empty() { + s.select(|_, sel| sel.set(ids))?; + } + Ok(finding(kind, &o)) +} + +fn fix(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.fix"; + let (mut scope, name) = scope_of(s, p, C)?; + let kinds = kinds_of(p, C)?; + // Fixed on a copy, kind after kind (what one leaves, the next cleans up), then kept as one step. + let st = s.doc()?; + let (mut d, mut sel) = ((*st.doc).clone(), st.selection.clone()); + let mut fixed = Map::new(); + let mut total = 0; + // A fix can leave issues for a kind that ran before it (groups unwrapped side by side may hold + // duplicates): the kinds run again, a few rounds at most, until a round finds nothing. + for _ in 0..FIX_ROUNDS { + let before = total; + for &kind in &kinds { + let o = kind.fix(&mut d, &mut sel, &mut scope)?; + if o.count > 0 { + total += o.count; + let n = fixed.get(kind.id()).and_then(Value::as_u64).unwrap_or(0); + fixed.insert(kind.id().into(), json!(n + o.count as u64)); + } + } + if total == before { + break; + } + } + // With nothing to fix, the document and its undo history stay as they are. + if total > 0 { + sel.prune(&d); + s.edit("Fix Issues", |doc, selection| { + *doc = d; + *selection = sel; + Ok(()) + })?; + } + Ok(json!({ "scope": name, "total": total, "fixed": fixed })) +} + +/// Is `n` a group Deep Ungroup takes apart (a clipping group only with `clips`)? +fn ungroupable(n: &Node, clips: bool) -> bool { + matches!(n.kind, NodeKind::Group { clip, .. } if clips || !clip) +} + +/// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts +/// the groups. Members take on the opacity of the groups they were in, and a clipping path +/// becomes a plain path. +fn flatten(n: Node, clips: bool, out: &mut Vec, count: &mut usize) { + if !ungroupable(&n, clips) { + out.push(n); + return; + } + let NodeKind::Group { children, clip } = &n.kind else { return }; + *count += 1; + for (k, c) in children.iter().enumerate() { + let mut c = (**c).clone(); + if *clip + && k == 0 + && let NodeKind::Path { clipping, .. } = &mut c.kind + { + *clipping = false; + } + c.opacity *= n.opacity; + flatten(c, clips, out, count); + } +} + +fn deep_ungroup(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.deepUngroup"; + let clips = switch_or(p, "includeClipGroups", false, C)?; + let roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + let (mut items, mut selected, mut count) = (vec![], vec![], 0); + for id in &roots { + match d.node(*id).filter(|n| ungroupable(n, clips)) { + Some(n) => { + let mut out = vec![]; + flatten(n.clone(), clips, &mut out, &mut count); + selected.extend(out.iter().map(|n| n.id)); + items.push((*id, out)); + } + None => selected.push(*id), + } + } + // With no group to take apart, the document and its undo history stay as they are. + if !items.is_empty() { + let ids = selected.clone(); + s.edit("Deep Ungroup", |d, sel| { + d.insert_beside(items, true)?; + sel.set(ids); + Ok(()) + })?; + } + Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "ungrouped": count })) +} + +/// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order +/// (compound path members, clipping paths and guides stay out). +fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> { + fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) { + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => { + if let [sp] = path.subpaths.as_slice() + && !sp.closed + { + out.push((n.id, sp.clone())); + } + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + _ => {} + } + } + let mut out = vec![]; + roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); + let editable = d.editable(); + out.retain(|(id, _)| editable(*id)); + out +} + +fn rejoin(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.rejoin"; + let gap = number_or(p, "gap", GAP, C)?; + if !(0.0..=MAX_GAP).contains(&gap) { + return Err(bad(C, "`gap` must be from 0 to 6 pt")); + } + let roots = checked_ids_or_selection(s, p, C)?; + let found = pieces(&s.doc()?.doc, &roots); + if found.len() > MAX_OBJECTS { + return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into())); + } + let subpaths: Vec = found.iter().map(|(_, sp)| sp.clone()).collect(); + // Each chain becomes its topmost piece (the last in paint order); the others go. + let (mut kept, mut gone) = (vec![], vec![]); + for j in po::rejoin(&subpaths, gap) { + let ids: Vec = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect(); + let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue }; + gone.extend(ids.into_iter().filter(|id| *id != top)); + kept.push((top, j.path)); + } + if !kept.is_empty() { + let ids: Vec = kept.iter().map(|k| k.0).collect(); + s.edit("Rejoin Path Pieces", |d, sel| { + for (id, sp) in &kept { + set_path(d, *id, PathData::single(sp.clone())); + } + d.remove_many(&gone)?; + sel.set(ids); + Ok(()) + })?; + } + let ids: Vec = kept.iter().map(|k| k.0.0).collect(); + Ok(json!({ "ids": ids, "joined": kept.len(), "removed": gone.len() })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs new file mode 100644 index 000000000..46c81293d --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -0,0 +1,454 @@ +//! Document Health Check: the kinds of issue Artwork Cleanup looks for, and how each is fixed. A +//! check runs a kind's fix on a copy of the document, so what a check finds is what Fix changes. + +use std::collections::{HashMap, HashSet}; + +use vectorcraft_color::BlendMode; +use vectorcraft_doc::appearance::AppearanceItem; +use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeExtras, NodeId, NodeKind, Selection, SymbolBoxes}; +use vectorcraft_geom::{PathData, Point, Rect}; +use vectorcraft_pathops::addons as po; + +use super::super::super::edit::outermost; +use super::super::super::layer::empty_artboards; +use crate::Result; + +/// Sine of the turn below which an anchor on a straight run is collinear. +const COLLINEAR: f64 = 0.005; +/// Distance (pt) within which anchors coincide, handles are tiny and path ends meet. +const NEAR: f64 = 0.01; +/// Most objects a check looks at; the rest of a larger document is left as it is. +pub(super) const MAX_OBJECTS: usize = 250_000; +/// Most anchors Near Anchors compares. +const MAX_ANCHORS: usize = 1_000_000; +/// Most rounds of unwrapping nested groups. +const MAX_PASSES: usize = 64; + +/// One kind of issue. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Kind { + EmptyText, + StrayPoints, + Unpainted, + ZeroOpacity, + DuplicateObjects, + OffArtboard, + DuplicateAnchors, + TinyHandles, + CollinearAnchors, + NearPoints, + NearlyClosed, + FilledOpenPaths, + NeedlessClipGroups, + NeedlessCompounds, + NeedlessGroups, + EmptyArtboards, +} + +impl Kind { + /// Every kind, in the order Fix runs them (removing art first can leave groups to unwrap). + pub(super) const ALL: [Kind; 16] = [ + Kind::EmptyText, + Kind::StrayPoints, + Kind::Unpainted, + Kind::ZeroOpacity, + Kind::DuplicateObjects, + Kind::OffArtboard, + Kind::DuplicateAnchors, + Kind::TinyHandles, + Kind::CollinearAnchors, + Kind::NearPoints, + Kind::NearlyClosed, + Kind::FilledOpenPaths, + Kind::NeedlessClipGroups, + Kind::NeedlessCompounds, + Kind::NeedlessGroups, + Kind::EmptyArtboards, + ]; + + /// (id, label) of the kind. + fn names(self) -> (&'static str, &'static str) { + match self { + Kind::EmptyText => ("emptyText", "Empty Text"), + Kind::StrayPoints => ("strayPoints", "Stray Points"), + Kind::Unpainted => ("unpainted", "Unpainted Objects"), + Kind::ZeroOpacity => ("zeroOpacity", "Zero-Opacity Objects"), + Kind::DuplicateObjects => ("duplicateObjects", "Duplicate Objects"), + Kind::OffArtboard => ("offArtboard", "Off-Artboard Art"), + Kind::DuplicateAnchors => ("duplicateAnchors", "Duplicate Anchors"), + Kind::TinyHandles => ("tinyHandles", "Tiny Handles"), + Kind::CollinearAnchors => ("collinearAnchors", "Collinear Anchors"), + Kind::NearPoints => ("nearPoints", "Nearly Coincident Anchors"), + Kind::NearlyClosed => ("nearlyClosed", "Nearly Closed Paths"), + Kind::FilledOpenPaths => ("filledOpenPaths", "Filled Open Paths"), + Kind::NeedlessClipGroups => ("needlessClipGroups", "Needless Clipping Groups"), + Kind::NeedlessCompounds => ("needlessCompounds", "Needless Compound Paths"), + Kind::NeedlessGroups => ("needlessGroups", "Needless Groups"), + Kind::EmptyArtboards => ("emptyArtboards", "Empty Artboards"), + } + } + + pub(super) fn id(self) -> &'static str { + self.names().0 + } + + pub(super) fn label(self) -> &'static str { + self.names().1 + } + + pub(super) fn parse(id: &str) -> Option { + Kind::ALL.into_iter().find(|k| k.id() == id) + } + + /// Fix this kind of issue in `scope` of `d` → what changed. + pub(super) fn fix(self, d: &mut Document, sel: &mut Selection, scope: &mut Scope) -> Result { + match self { + Kind::DuplicateAnchors => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::merge_duplicate_anchors(sp, NEAR)).sum()), + Kind::TinyHandles => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::retract_tiny_handles(sp, NEAR)).sum()), + Kind::CollinearAnchors => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::remove_collinear_anchors(sp, COLLINEAR)).sum()), + Kind::NearlyClosed => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| usize::from(po::close_nearly_closed(sp, NEAR))).sum()), + Kind::FilledOpenPaths => reshape(d, scope, close_filled), + Kind::NearPoints => near_points(d, scope), + Kind::EmptyArtboards => artboards(d, sel, scope), + _ => restructure(d, scope, self), + } + } + + /// What replaces object `n` (at `place`) when it is this kind of issue: nothing to remove it, + /// its members to unwrap it. `None` when it isn't one. + fn replacement(self, cx: &Context, n: &Node, place: Place) -> Option> { + let removable = !place.clips && !is_guide_or_clip(n); + let gone = |is: bool| is.then(Vec::new); + match self { + Kind::EmptyText => gone(matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())), + Kind::StrayPoints => gone(removable && matches!(&n.kind, NodeKind::Path { path, .. } if path.anchor_count() <= 1)), + Kind::Unpainted => gone(removable && !place.in_compound && is_shape(n) && !is_painted(&n.appearance)), + Kind::ZeroOpacity => gone(removable && !place.in_compound && n.opacity <= 0.0), + Kind::DuplicateObjects => gone(removable && duplicates_the_one_below(cx.d, n)), + Kind::OffArtboard => gone(place.top && !is_guide_or_clip(n) && off_artboards(cx, n)), + Kind::NeedlessGroups => needless_group(n), + Kind::NeedlessCompounds => needless_compound(n), + Kind::NeedlessClipGroups => needless_clip_group(cx, n), + _ => None, + } + } +} + +/// What a fix changed (or a check found): how many issues, the objects they are on and the +/// artboards (by index) they are. +#[derive(Clone, Debug, Default)] +pub(super) struct Outcome { + pub count: usize, + pub ids: Vec, + pub artboards: Vec, +} + +/// What a check looks at: the whole document, or the objects given and what is inside them. +#[derive(Clone, Debug)] +pub(super) enum Scope { + Document, + Objects(Vec), +} + +/// Where an object sits. +#[derive(Clone, Copy, Default)] +struct Place { + /// A member of a compound path. + in_compound: bool, + /// The clipping path of its group or layer. + clips: bool, + /// A top-level object of the scope (on a layer, or given). + top: bool, +} + +/// The document a pass looks at, with its symbols measured once. +struct Context<'a> { + d: &'a Document, + symbols: SymbolBoxes<'a>, +} + +/// The visible, unlocked objects in `scope` with where they sit, in paint order: groups, layers, +/// clip groups and compound paths are looked into; live and generated art (blends, envelopes, +/// graphs, symbols…) is taken as it is. +fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> { + let mut out = vec![]; + let top = Place { top: true, ..Place::default() }; + match scope { + Scope::Document => d.layers.iter().for_each(|l| visit(l, top, &mut out)), + Scope::Objects(ids) => ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, top, &mut out)), + } + let editable = d.editable(); + out.retain(|(n, _)| editable(n.id)); + out +} + +fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) { + if !n.visible || n.locked || out.len() >= MAX_OBJECTS { + return; + } + let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false }; + match &n.kind { + // A layer's objects are top-level ones. + NodeKind::Layer { children, clip, .. } => children.iter().enumerate().for_each(|(i, c)| visit(c, inner(*clip, i, true), out)), + NodeKind::Group { children, clip } if !is_generated(n) => { + out.push((n, place)); + children.iter().enumerate().for_each(|(i, c)| visit(c, inner(*clip, i, false), out)); + } + NodeKind::Compound { children, .. } => { + out.push((n, place)); + children.iter().for_each(|c| visit(c, Place { in_compound: true, ..Place::default() }, out)); + } + _ => out.push((n, place)), + } +} + +/// A group that stands for live or generated art: its members aren't loose art to clean. +fn is_generated(n: &Node) -> bool { + let e = n.extras(); + e.graph.is_some() || e.shaper.is_some() || e.dimension.is_some() || e.objects_on_path.is_some() || e.live_paint_gaps.is_some() || e.compound_shape +} + +fn is_guide_or_clip(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) +} + +fn is_shape(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) +} + +fn is_painted(a: &Appearance) -> bool { + a.items.iter().any(|i| match i { + AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0, + }) +} + +/// A container with nothing of its own to show: full opacity, normal blending, no fill, stroke, +/// effect, mask, knockout or anything else attached. +fn is_plain(n: &Node) -> bool { + n.opacity >= 1.0 + && n.blend == BlendMode::Normal + && n.appearance.items.is_empty() + && n.appearance.effects.is_empty() + && !n.isolate + && n.knockout == Knockout::Neutral + && *n.extras() == NodeExtras::default() +} + +/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it? +fn duplicates_the_one_below(d: &Document, n: &Node) -> bool { + if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal { + return false; + } + let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned()); + below.is_some_and(|b| { + b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras() + }) +} + +fn inside(a: Rect, b: Rect) -> bool { + a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1 +} + +fn meets(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Does `n` lie wholly off every artboard (a document without artboards has none off them)? +fn off_artboards(cx: &Context, n: &Node) -> bool { + let Some(b) = n.visual_bounds_with(&cx.symbols) else { return false }; + // As Delete Empty Artboards measures: a millionth of a point covers rounding at the edges. + !cx.d.artboards.is_empty() && !cx.d.artboards.iter().any(|a| meets(a.rect.inflate(1e-6, 1e-6), b)) +} + +/// A plain group of one member or none: its member, or nothing. +fn needless_group(n: &Node) -> Option> { + let NodeKind::Group { children, clip: false } = &n.kind else { return None }; + (children.len() <= 1 && is_plain(n)).then(|| children.iter().map(|c| (**c).clone()).collect()) +} + +/// A compound path of one path or none: that path, painted as the compound was, or nothing. +fn needless_compound(n: &Node) -> Option> { + let NodeKind::Compound { children, rule } = &n.kind else { return None }; + let mut path = match children.as_slice() { + [] => return Some(vec![]), + [one] => (**one).clone(), + _ => return None, + }; + let NodeKind::Path { rule: r, .. } = &mut path.kind else { return None }; + *r = *rule; + path.appearance = n.appearance.clone(); + path.opacity = n.opacity; + path.blend = n.blend; + path.extras = n.extras.clone(); + Some(vec![path]) +} + +/// A plain clip group whose unpainted clipping path changes nothing: it clips nothing or lies wholly +/// away from its art (nothing shows: nothing is left), or it is a rectangle around all its art (its +/// art is left). +fn needless_clip_group(cx: &Context, n: &Node) -> Option> { + let NodeKind::Group { children, clip: true } = &n.kind else { return None }; + if !is_plain(n) { + return None; + } + let Some((clip, art)) = children.split_first() else { return Some(vec![]) }; + let paint = clip.clip_paint(); + if paint.fill.is_some() || paint.stroke.is_some() { + return None; + } + let clip_box = clip.geometric_bounds()?; + let art_box = art.iter().filter_map(|a| a.visual_bounds_with(&cx.symbols)).reduce(|a, b| a.union(b)); + match art_box { + None => Some(vec![]), + Some(b) if !meets(clip_box, b) => Some(vec![]), + Some(b) if is_rectangle(clip) && inside(b, clip_box.inflate(1e-6, 1e-6)) => Some(art.iter().map(|a| (**a).clone()).collect()), + _ => None, + } +} + +/// Is `n` a path drawing a square-to-the-page rectangle: four corners on its bounds, no curves? +fn is_rectangle(n: &Node) -> bool { + let NodeKind::Path { path, .. } = &n.kind else { return false }; + let (Some(b), [sp]) = (path.bounds(), path.subpaths.as_slice()) else { return false }; + let corner = |p: Point| ((p.x - b.x0).abs() < 1e-6 || (p.x - b.x1).abs() < 1e-6) && ((p.y - b.y0).abs() < 1e-6 || (p.y - b.y1).abs() < 1e-6); + sp.closed && sp.anchors.len() == 4 && sp.anchors.iter().all(|a| !a.has_in() && !a.has_out() && corner(a.p)) +} + +/// Replace the objects with this kind of issue (the outermost ones each round, until none is +/// left) by what [`Kind::replacement`] gives. +fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result { + let mut out = Outcome::default(); + for _ in 0..MAX_PASSES { + let found: Vec<(NodeId, Vec)> = { + let d: &Document = d; + let cx = Context { d, symbols: d.symbol_boxes() }; + let all: Vec<(NodeId, Vec)> = + objects(d, scope).into_iter().filter_map(|(n, pl)| Some((n.id, kind.replacement(&cx, n, pl)?))).collect(); + let keep: HashSet = outermost(d, all.iter().map(|(id, _)| *id).collect()).into_iter().collect(); + all.into_iter().filter(|(id, _)| keep.contains(id)).collect() + }; + if found.is_empty() { + break; + } + if let Scope::Objects(roots) = scope { + for (id, nodes) in &found { + if let Some(at) = roots.iter().position(|r| r == id) { + roots.splice(at..=at, nodes.iter().map(|n| n.id)); + } + } + } + out.ids.extend(found.iter().map(|(id, _)| *id)); + d.insert_beside(found, true)?; + } + out.count = out.ids.len(); + Ok(out) +} + +/// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f` +/// returns how many issues it fixed. +fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result { + let changed: Vec<(NodeId, PathData, usize)> = objects(d, scope) + .into_iter() + .filter_map(|(n, _)| { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None }; + let mut p = path.clone(); + let k = f(n, &mut p); + (k > 0).then_some((n.id, p, k)) + }) + .collect(); + let mut out = Outcome::default(); + for (id, p, k) in changed { + set_path(d, id, p); + out.count += k; + out.ids.push(id); + } + Ok(out) +} + +/// Give path `id` the geometry `p` (it stops being a live shape). +pub(super) fn set_path(d: &mut Document, id: NodeId, p: PathData) { + if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + *path = p; + *live = None; + } +} + +/// Filled Open Paths: close the open subpaths of a path with a fill and no stroke. +fn close_filled(n: &Node, p: &mut PathData) -> usize { + let items = &n.appearance.items; + let filled = items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none())); + let stroked = items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0)); + if !filled || stroked { + return 0; + } + let mut count = 0; + for sp in p.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() >= 2) { + sp.closed = true; + count += 1; + } + count +} + +/// Nearly Coincident Anchors: anchors of the paths in scope within [`NEAR`] of one another (but +/// not neighbours on one path, which Duplicate Anchors merges) move onto the first of them. +fn near_points(d: &mut Document, scope: &Scope) -> Result { + // Each anchor: (path, subpath, anchor, its subpath's length and closedness). + let mut refs: Vec<(NodeId, usize, usize, usize, bool)> = vec![]; + let mut points: Vec = vec![]; + for (n, _) in objects(d, scope) { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { continue }; + for (si, sp) in path.subpaths.iter().enumerate() { + for (ai, a) in sp.anchors.iter().enumerate().take(MAX_ANCHORS.saturating_sub(points.len())) { + refs.push((n.id, si, ai, sp.anchors.len(), sp.closed)); + points.push(a.p); + } + } + } + let neighbours = |i: usize, j: usize| { + let (Some(a), Some(b)) = (refs.get(i), refs.get(j)) else { return false }; + let gap = a.2.abs_diff(b.2); + a.0 == b.0 && a.1 == b.1 && (gap == 1 || (a.4 && gap + 1 == a.3)) + }; + let mut moves: HashMap> = HashMap::new(); + let mut count = 0; + for group in po::near_point_groups(&points, NEAR, neighbours) { + let Some(&to) = group.first().and_then(|&i| points.get(i)) else { continue }; + for &i in &group { + if let (Some(&(id, si, ai, ..)), Some(&p)) = (refs.get(i), points.get(i)) + && p != to + { + moves.entry(id).or_default().push((si, ai, to)); + count += 1; + } + } + } + let mut ids: Vec = moves.keys().copied().collect(); + ids.sort_unstable(); + for id in &ids { + let Some(mut p) = d.node(*id).and_then(Node::path_data).cloned() else { continue }; + for &(si, ai, to) in moves.get(id).into_iter().flatten() { + if let Some(a) = p.anchor_mut(si, ai) { + a.translate(to - a.p); + } + } + set_path(d, *id, p); + } + Ok(Outcome { count, ids, artboards: vec![] }) +} + +/// Empty Artboards: the artboards with no art on them (as Delete Empty Artboards finds them), +/// deleted with their guides. Only a check of the whole document looks at artboards. +fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result { + let Scope::Document = scope else { return Ok(Outcome::default()) }; + let empty = empty_artboards(d); + // The last first, so the others keep their places. + for &i in empty.iter().rev() { + if i < d.artboards.len() { + let id = d.artboards.remove(i).id; + d.retire_artboard_id(id); + d.retain_guides(sel, |_, g| g.artboard != Some(id)); + } + } + Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty }) +} diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs new file mode 100644 index 000000000..3351dd755 --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -0,0 +1,336 @@ +//! Artwork Cleanup's commands: Simplify Anchors (Keep Shape), the health check's kinds found and +//! fixed one by one, Fix as one undo step, scopes, Show, Deep Ungroup and Rejoin Path Pieces. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind}; +use vectorcraft_geom::{FillRule, Rect}; + +use crate::Session; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()) +} + +/// A path through `pts` (anchors as `{x, y, out?}` objects). +fn path(s: &mut Session, anchors: Value, closed: bool) -> NodeId { + id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()) +} + +fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId { + path(s, pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(), false) +} + +fn node(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).cloned() +} + +fn change(s: &mut Session, id: NodeId, f: impl FnOnce(&mut Node)) { + s.edit("t", |d, _| { + f(d.node_mut(id).unwrap()); + Ok(()) + }) + .unwrap(); +} + +/// Put `id` inside a new container that `kind` makes of its members. +fn wrap(s: &mut Session, id: NodeId, kind: fn(Vec>) -> NodeKind) -> NodeId { + s.edit("t", |d, _| { + let w = d.alloc_id(); + Ok(d.wrap(&[id], |c| Node::new(w, kind(c)))?) + }) + .unwrap() +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn anchors(s: &Session, id: NodeId) -> usize { + node(s, id).unwrap().path_data().unwrap().anchor_count() +} + +/// How many issues of `kind` a check of `scope` finds. +fn found(s: &mut Session, kind: &str, scope: &str) -> usize { + let r = s.execute("addon.cleanup.check", &json!({"kinds": [kind], "scope": scope})).unwrap(); + r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == kind).map_or(0, |f| f["count"].as_u64().unwrap() as usize) +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() { + let mut s = session(); + let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + assert_eq!(anchors(&s, e), 16); + let bounds = node(&s, e).unwrap().geometric_bounds().unwrap(); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(16), Some(anchors(&s, e) as u64))); + assert!(anchors(&s, e) <= 8, "{r}"); + let now = node(&s, e).unwrap().geometric_bounds().unwrap(); + for (a, b) in [(bounds.x0, now.x0), (bounds.y0, now.y0), (bounds.x1, now.x1), (bounds.y1, now.y1)] { + assert!((a - b).abs() < 0.4, "{bounds:?} → {now:?}"); + } + assert_eq!(undo_steps(&s), steps + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(anchors(&s, e), 16); + // A rectangle's corners stay; the anchors on its sides go. + let r = rect(&mut s, 200.0, 50.0, 100.0, 60.0); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + assert_eq!(anchors(&s, r), 8); + s.execute("addon.cleanup.simplify", &json!({"protectCorners": true})).unwrap(); + assert_eq!(anchors(&s, r), 4); + // Nothing left to remove: no undo step. + let steps = undo_steps(&s); + s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps); + for bad in [json!({"tolerance": 0}), json!({"tolerance": 101}), json!({"tolerance": "20"}), json!({"protectCorners": 1})] { + assert!(s.execute("addon.cleanup.simplify", &bad).is_err(), "{bad}"); + } +} + +/// Each kind: what makes the issue in a fresh document (beside a clean rectangle). +fn make_issue(s: &mut Session, kind: &str) { + match kind { + "emptyText" => { + s.execute("text.create", &json!({"x": 20, "y": 20, "text": " "})).unwrap(); + } + "strayPoints" => { + path(s, json!([{"x": 30, "y": 30}]), false); + } + "unpainted" => { + let id = rect(s, 20.0, 150.0, 30.0, 30.0); + change(s, id, |n| n.appearance = Appearance::default().into()); + } + "zeroOpacity" => { + let id = rect(s, 20.0, 150.0, 30.0, 30.0); + change(s, id, |n| n.opacity = 0.0); + } + "duplicateObjects" => { + let id = rect(s, 20.0, 150.0, 30.0, 30.0); + s.edit("t", |d, _| { + let mut copy = d.node(id).unwrap().clone(); + copy.id = d.alloc_id(); + let (parent, i, _) = d.position(id).unwrap(); + Ok(d.insert(parent, i + 1, copy)?) + }) + .unwrap(); + } + "offArtboard" => { + rect(s, -500.0, -500.0, 30.0, 30.0); + } + "duplicateAnchors" => { + line(s, &[(10.0, 150.0), (50.0, 150.0), (50.005, 150.0), (50.0, 190.0)]); + } + "tinyHandles" => { + path(s, json!([{"x": 10, "y": 150, "out": [10.004, 150]}, {"x": 60, "y": 190}]), false); + } + "collinearAnchors" => { + line(s, &[(10.0, 150.0), (30.0, 150.0), (50.0, 150.0), (50.0, 190.0)]); + } + "nearPoints" => { + line(s, &[(10.0, 150.0), (50.0, 150.0)]); + line(s, &[(50.005, 150.0), (90.0, 190.0)]); + } + "nearlyClosed" => { + line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0), (10.005, 150.0)]); + } + "filledOpenPaths" => { + let id = line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0)]); + change(s, id, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0).into()); + } + "needlessClipGroups" => { + let inner = rect(s, 30.0, 160.0, 20.0, 20.0); + let clip = rect(s, 20.0, 150.0, 50.0, 50.0); + select(s, &[inner, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + } + "needlessCompounds" => { + // A compound path holds the paint of its paths. + let id = rect(s, 20.0, 150.0, 30.0, 30.0); + let look = node(s, id).unwrap().appearance; + let compound = wrap(s, id, |children| NodeKind::Compound { children, rule: FillRule::NonZero }); + change(s, compound, |n| n.appearance = look); + } + "needlessGroups" => { + let id = rect(s, 20.0, 150.0, 30.0, 30.0); + wrap(s, id, |children| NodeKind::Group { children, clip: false }); + } + "emptyArtboards" => { + s.edit("t", |d, _| { + let mut a = d.artboards[0].clone(); + a.id = d.next_artboard_id(); + a.rect = Rect::new(1000.0, 1000.0, 1100.0, 1100.0); + d.artboards.push(a); + Ok(()) + }) + .unwrap(); + } + _ => panic!("no setup for {kind}"), + } +} + +const KINDS: [&str; 16] = [ + "emptyText", + "strayPoints", + "unpainted", + "zeroOpacity", + "duplicateObjects", + "offArtboard", + "duplicateAnchors", + "tinyHandles", + "collinearAnchors", + "nearPoints", + "nearlyClosed", + "filledOpenPaths", + "needlessClipGroups", + "needlessCompounds", + "needlessGroups", + "emptyArtboards", +]; + +#[test] +fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() { + let mut clean = session(); + let r = rect(&mut clean, 200.0, 20.0, 80.0, 60.0); + let before = node(&clean, r).unwrap(); + assert_eq!(clean.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "clean art has no issues"); + assert_eq!(clean.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0); + assert_eq!(node(&clean, r).unwrap(), before); + for kind in KINDS { + let mut s = session(); + let r = rect(&mut s, 200.0, 20.0, 80.0, 60.0); + make_issue(&mut s, kind); + select(&mut s, &[]); + assert!(found(&mut s, kind, "document") > 0, "{kind} not found"); + let fixed = s.execute("addon.cleanup.fix", &json!({"kinds": [kind], "scope": "document"})).unwrap(); + assert!(fixed["fixed"][kind].as_u64().unwrap() > 0, "{kind}: {fixed}"); + assert_eq!(found(&mut s, kind, "document"), 0, "{kind} still found after Fix"); + assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "{kind} left other issues"); + assert_eq!(node(&s, r).unwrap(), before, "{kind} changed the clean rectangle"); + } +} + +#[test] +fn fix_all_is_one_undo_step() { + let mut s = session(); + for kind in KINDS { + make_issue(&mut s, kind); + } + select(&mut s, &[]); + let total = s.execute("addon.cleanup.check", &json!({})).unwrap()["total"].as_u64().unwrap(); + assert!(total >= 16, "{total}"); + let (steps, doc) = (undo_steps(&s), s.doc().unwrap().doc.clone()); + let r = s.execute("addon.cleanup.fix", &json!({})).unwrap(); + assert_eq!(r["scope"], "document"); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, doc.layers); + assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len()); +} + +#[test] +fn the_scope_is_the_selection_or_the_document() { + let mut s = session(); + let a = path(&mut s, json!([{"x": 30, "y": 30}]), false); + let b = path(&mut s, json!([{"x": 60, "y": 30}]), false); + select(&mut s, &[a]); + let r = s.execute("addon.cleanup.check", &json!({"kinds": ["strayPoints"]})).unwrap(); + assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("selection"), Some(1))); + assert_eq!(found(&mut s, "strayPoints", "document"), 2); + s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"]})).unwrap(); + assert!(node(&s, a).is_none() && node(&s, b).is_some(), "only the selection is fixed"); + // Nothing selected: the document. + let r = s.execute("addon.cleanup.check", &json!({})).unwrap(); + assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("document"), Some(1))); + assert!(s.execute("addon.cleanup.check", &json!({"scope": "selection"})).is_err(), "nothing selected"); + for bad in [json!({"scope": "page"}), json!({"kinds": ["nope"]}), json!({"kinds": "strayPoints"}), json!({"kinds": [1]})] { + assert!(s.execute("addon.cleanup.check", &bad).is_err(), "{bad}"); + assert!(s.execute("addon.cleanup.fix", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn show_selects_the_objects_with_the_issue() { + let mut s = session(); + rect(&mut s, 200.0, 20.0, 80.0, 60.0); + let stray = path(&mut s, json!([{"x": 30, "y": 30}]), false); + select(&mut s, &[]); + let r = s.execute("addon.cleanup.show", &json!({"kind": "strayPoints"})).unwrap(); + assert_eq!(r["count"], 1); + assert_eq!(s.doc().unwrap().selection.objects, [stray]); + assert!(s.execute("addon.cleanup.show", &json!({"kind": "emptyText", "scope": "document"})).is_err(), "no such issue"); + assert!(s.execute("addon.cleanup.show", &json!({})).is_err() && s.execute("addon.cleanup.show", &json!({"kind": "x"})).is_err()); +} + +#[test] +fn deep_ungroup_takes_apart_every_level_but_clipping_groups() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + select(&mut s, &[b, c]); + let inner = id_of(&s.execute("object.group", &json!({})).unwrap()); + change(&mut s, inner, |n| n.opacity = 0.5); + let clipped = rect(&mut s, 100.0, 10.0, 10.0, 10.0); + let clip = rect(&mut s, 95.0, 5.0, 30.0, 30.0); + select(&mut s, &[clipped, clip]); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + let clip_group = s.doc().unwrap().selection.objects[0]; + select(&mut s, &[a, inner, clip_group]); + let outer = id_of(&s.execute("object.group", &json!({})).unwrap()); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap(); + assert_eq!(r["ungrouped"], 2); + assert_eq!(undo_steps(&s), steps + 1); + let d = &s.doc().unwrap().doc; + let layer: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [a, b, c, clip_group]); + assert!(d.node(outer).is_none() && d.node(inner).is_none()); + assert_eq!(d.node(b).unwrap().opacity, 0.5, "members keep their groups' opacity"); + assert_eq!(s.doc().unwrap().selection.objects.len(), 4); + // Asked to, clipping groups come apart too, their clipping paths kept as plain paths. + select(&mut s, &[clip_group]); + s.execute("addon.cleanup.deepUngroup", &json!({"includeClipGroups": true})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(clip_group).is_none()); + assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. })); +} + +#[test] +fn rejoin_joins_pieces_within_the_gap_only() { + let mut s = session(); + let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]); + let b = line(&mut s, &[(50.05, 10.0), (90.0, 50.0)]); + let far = line(&mut s, &[(92.0, 50.0), (120.0, 50.0)]); + select(&mut s, &[a, b, far]); + let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap(); + assert_eq!((r["joined"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1))); + // The topmost piece takes the joined path. + assert!(node(&s, a).is_none()); + assert_eq!(anchors(&s, b), 3); + assert_eq!(anchors(&s, far), 2, "beyond the gap"); + select(&mut s, &[b, far]); + let r = s.execute("addon.cleanup.rejoin", &json!({"gap": 3})).unwrap(); + assert_eq!(r["joined"], 1); + assert_eq!(anchors(&s, far), 4); + for bad in [json!({"gap": -1}), json!({"gap": 7}), json!({"gap": "1"})] { + assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs new file mode 100644 index 000000000..26a37efbf --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -0,0 +1,865 @@ +//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point +//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset +//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters' +//! styles and stay where the layout drew them: each is moved so the glyph of its first character +//! lands where it was. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, ParaStyle, RubySpan, TextKind, TextObject}; +use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit}; + +use crate::cmd::textedit::editing_range; +use crate::cmd::typecmd::refresh_bounds; +use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// Most text objects one command takes. +const MAX_TEXTS: usize = 10_000; +/// Most pieces Break Text and Split Text at Cursor make at once. +const MAX_PIECES: usize = 20_000; +/// Most breaks Remove Line / Paragraph Breaks removes at once. +const MAX_BREAKS: usize = 200_000; +/// Most pieces Combine Point Text tries to join, counted over every try: past it, what it joined +/// so far is the result. +const MAX_JOIN_TRIES: usize = 200_000; +/// How far (pt) a glyph Combine Point Text sets may land from where it was. +const TOLERANCE: f64 = 0.01; +/// Combine Point Text: pieces whose baselines are within this many ems of each other share a line +/// (the difference becomes baseline shift); a gap wider than `MAX_GAP` ems, or an overlap of more +/// than `MAX_OVERLAP`, starts another object. +const SAME_LINE: f64 = 0.4; +const MAX_GAP: f64 = 10.0; +const MAX_OVERLAP: f64 = 1.0; +/// Tracking Combine Point Text may give a character (thousandths of an em). +const TRACKING: std::ops::RangeInclusive = -1000.0..=10000.0; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.cleanup.breakText", + "Break Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}", + has_selection, + break_text + ), + cmd!( + "addon.cleanup.combineText", + "Combine Point Text", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}", + has_selection, + combine_text + ), + cmd!( + "addon.cleanup.removeBreaks", + "Remove Breaks", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}", + has_selection, + remove_breaks + ), + cmd!( + "addon.cleanup.splitText", + "Split Text at Cursor", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{} while a Type tool edits horizontal point or area type: the text before the selected characters, they and the text after become point type objects of their own (at a cursor: before and after it), each where its characters were, as one undo step; spaces at the ends of each piece go, and editing ends with the pieces selected → {ids}", + editing_text, + split_text + ), + cmd!( + "addon.cleanup.alignInPlace", + "Set Alignment, Keep Position", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}", + has_selection, + align_in_place + ), + cmd!( + "addon.cleanup.resetTextTransform", + "Reset Text Transform", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}", + has_selection, + reset_transform + ), + cmd!( + "addon.cleanup.replaceFonts", + "Replace Missing Fonts…", + ["Object", "Plug-ins", "Artwork Cleanup"], + None, + "{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}", + has_doc, + replace_fonts + ), + ] +} + +/// Enabled while a Type tool edits text. +fn editing_text(s: &Session) -> std::result::Result<(), String> { + match s.tool_options().get("editing") { + Some(v) if !v.is_null() => Ok(()), + _ => Err("Click in type with a Type tool to split it".into()), + } +} + +/// The type objects under `roots` (inside groups and layers, not live art) that can be edited. +fn texts_under(d: &Document, roots: &[NodeId]) -> Result> { + fn visit(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Text(_) => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + _ => {} + } + } + let mut out = vec![]; + roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out)); + let editable = d.editable(); + out.retain(|id| editable(*id)); + out.dedup(); + if out.len() > MAX_TEXTS { + return Err(EngineError::Other("Too many type objects to change at once".into())); + } + Ok(out) +} + +fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { + match &d.node(id)?.kind { + NodeKind::Text(t) => Some(t), + _ => None, + } +} + +fn layout(t: &TextObject) -> TextLayout { + vectorcraft_text::layout(FontDb::global(), t) +} + +/// Where the layout draws the character at `byte` (the one it starts in): its glyph's pen position. +fn glyph_at(lay: &TextLayout, byte: usize) -> Option { + let start = lay.glyphs.iter().filter(|g| g.len > 0 && g.byte <= byte).map(|g| g.byte).max()?; + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == start).map(|g| g.origin) +} + +/// The pen position of the glyph that starts at `byte`, if one does. +fn glyph_starting(lay: &TextLayout, byte: usize) -> Option { + lay.glyphs.iter().find(|g| g.len > 0 && g.byte == byte).map(|g| g.origin) +} + +/// The first glyph of line `l`, in text order. +fn line_start(lay: &TextLayout, l: usize) -> Option { + let line = lay.lines.get(l)?; + let g = lay.glyphs.get(line.glyph_start..line.glyph_end)?.iter().filter(|g| g.len > 0).min_by_key(|g| g.byte)?; + Some(g.origin) +} + +/// `a..b` of `text` without the white space at its ends; None when nothing else is left. +fn trimmed(text: &str, a: usize, b: usize) -> Option<(usize, usize)> { + let s = text.get(a..b)?; + let start = a + (s.len() - s.trim_start().len()); + let end = a + s.trim_end().len(); + (start < end).then_some((start, end)) +} + +/// Paragraph alignment as point type keeps it: justified lines align to their side. +fn point_justify(j: Justify) -> Justify { + match j { + Justify::JustifyLeft | Justify::JustifyAll => Justify::Left, + Justify::JustifyCenter => Justify::Center, + Justify::JustifyRight => Justify::Right, + j => j, + } +} + +#[derive(Clone, Copy, PartialEq)] +enum By { + Lines, + Paragraphs, + Words, + Glyphs, +} + +/// A type object being cut into point type pieces: its text as drawn (Shrink Text to Fit's sizes +/// taken in) and its layout. +struct Source { + t: TextObject, + lay: TextLayout, + text: String, +} + +impl Source { + /// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise. + fn of(d: &Document, id: NodeId) -> Option { + let t = text_of(d, id)?; + if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) { + return None; + } + let lay = layout(t); + // Text left out of a frame would be lost with it. + if lay.overflow { + return None; + } + let t = if (lay.fit_scale - 1.0).abs() > 1e-9 { vectorcraft_text::scaled(t, lay.fit_scale) } else { t.clone() }; + Some(Self { text: t.plain_text(), t, lay }) + } + + /// The byte ranges of the pieces `by` cuts the text into. + fn ranges(&self, by: By) -> Vec<(usize, usize)> { + let text = &self.text; + match by { + By::Paragraphs => { + let mut out = vec![]; + let mut a = 0; + for (i, _) in text.match_indices('\n') { + out.push((a, i)); + a = i + 1; + } + out.push((a, text.len())); + out + } + By::Lines => self.lay.lines.iter().map(|l| (l.start, l.end)).collect(), + By::Words => { + let mut out = vec![]; + for l in &self.lay.lines { + let Some(s) = text.get(l.start..l.end) else { continue }; + let mut word: Option = None; + for (i, c) in s.char_indices() { + match (c.is_whitespace(), word) { + (true, Some(w)) => { + out.push((l.start + w, l.start + i)); + word = None; + } + (false, None) => word = Some(i), + _ => {} + } + } + if let Some(w) = word { + out.push((l.start + w, l.end)); + } + } + out + } + By::Glyphs => { + // One piece per cluster (a ligature's characters stay together). + let mut clusters = std::collections::BTreeMap::new(); + for g in self.lay.glyphs.iter().filter(|g| g.len > 0) { + let end = clusters.entry(g.byte).or_insert(g.byte + g.len); + *end = (*end).max(g.byte + g.len); + } + clusters.into_iter().collect() + } + } + } + + /// Point type holding bytes `a..b` (white space at its ends left out) where they were drawn. + /// Lines the layout wrapped end in a forced line break; `lists` keeps bulleted paragraphs' + /// bullets where the piece starts its paragraph. + fn piece(&self, (a, b): (usize, usize), lists: bool) -> Option { + let (a, b) = trimmed(&self.text, a, b)?; + let mut p = TextObject::point(Point::ZERO, "", CharStyle::default()); + p.runs = edit::slice_runs(&self.t.runs, a, b); + p.xf = self.t.xf; + p.optical_margin = self.t.optical_margin; + p.system_layout = self.t.system_layout; + p.fractional_widths = self.t.fractional_widths; + p.ruby = + self.t.ruby.iter().filter(|r| a <= r.start && r.end <= b).map(|r| RubySpan { start: r.start - a, end: r.end - a, ..r.clone() }).collect(); + // The paragraphs it holds, with their styles; the first keeps its bullet only from its start. + let starts_para = self.text.get(..a).is_some_and(|s| s.rsplit('\n').next().is_some_and(|line| line.trim().is_empty())); + let styles = self.t.paragraph_styles(); + let mut paras: Vec = styles.get(self.t.paragraphs_in(a, b))?.to_vec(); + for (i, para) in paras.iter_mut().enumerate() { + para.justify = point_justify(para.justify); + if !(lists && (i > 0 || starts_para) && para.list.as_ref().is_some_and(|l| l.number.is_none())) { + para.list = None; + } + } + p.set_paragraph_styles(paras); + // Lines the layout wrapped end in a forced line break, in place of the spaces they ended in. + let mut wraps = vec![]; + for next in self.lay.lines.iter().skip(1).filter(|l| a < l.start && l.start < b) { + let content = self.text.get(..next.start)?.trim_end_matches([' ', '\t']); + // A line that ends in a break of its own keeps it. + if !content.ends_with(['\n', '\u{2028}']) { + wraps.push((content.len().max(a) - a, next.start - a)); + } + } + for &(s, e) in wraps.iter().rev() { + edit::replace_range(&mut p.runs, s, e, "\u{2028}"); + p.ruby_after_replace(s, e, '\u{2028}'.len_utf8()); + } + self.place(&mut p, a)?; + Some(p) + } + + /// Move `p` (cut from byte `a`) so the glyph of its first character lands where it was drawn. + /// A piece of several lines that starts inside a left-aligned line takes a first-line indent, + /// so its later lines stay where they were too. + fn place(&self, p: &mut TextObject, a: usize) -> Option<()> { + let lay = layout(p); + let mut shift: Vec2 = glyph_at(&self.lay, a)? - glyph_at(&lay, 0)?; + let first = self.lay.lines.iter().rposition(|l| l.start <= a); + if let (Some(now), Some(was)) = (line_start(&lay, 1), first.and_then(|l| line_start(&self.lay, l + 1))) { + let off = was.x - (now.x + shift.x); + let left = match p.para.justify { + Justify::Left => true, + Justify::Auto => lay.lines.first().is_some_and(|l| !l.rtl), + _ => false, + }; + if left && off.abs() > 1e-6 { + p.edit_paras(Some(0..1), |para| para.first_line_indent -= off); + shift.x += off; + } + } + p.xf = self.t.xf * Affine::translate(shift); + Some(()) + } +} + +/// Put each object of `items` (id, the node, its pieces) out of the document, its pieces in its +/// place, as one undo step labelled `label`, the pieces selected → their ids. +fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, Vec)>) -> Result> { + s.edit(label, |d, sel| { + let (mut made, mut places) = (vec![], vec![]); + for (id, mut node, pieces) in items { + // A light template: the pieces take its paint and attributes, not its text. + node.kind = NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "", CharStyle::default()))); + let nodes: Vec = pieces + .into_iter() + .map(|mut t| { + refresh_bounds(&mut t); + let mut n = d.reid(&node); + n.kind = NodeKind::Text(Box::new(t)); + n + }) + .collect(); + made.extend(nodes.iter().map(|n| n.id)); + places.push((id, nodes)); + } + d.insert_beside(places, true)?; + sel.set(made.iter().copied()); + Ok(made.iter().map(|i| i.0).collect()) + }) +} + +fn ids_json(ids: &[NodeId]) -> Vec { + ids.iter().map(|i| i.0).collect() +} + +fn break_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.breakText"; + let by = match text_param(p, "by", C)?.unwrap_or("lines") { + "lines" => By::Lines, + "paragraphs" => By::Paragraphs, + "words" => By::Words, + "glyphs" => By::Glyphs, + other => return Err(bad(C, format!("`by` must be lines, paragraphs, words or glyphs, not {other:?}"))), + }; + let d = &s.doc()?.doc; + let (mut items, mut skipped, mut count) = (vec![], vec![], 0); + for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? { + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else { + skipped.push(id); + continue; + }; + let ranges = src.ranges(by); + count += ranges.len(); + if count > MAX_PIECES { + return Err(EngineError::Other("Too many pieces to make at once".into())); + } + let pieces: Vec = ranges.into_iter().filter_map(|r| src.piece(r, by == By::Paragraphs)).collect(); + if pieces.is_empty() { + skipped.push(id); + } else { + items.push((id, node.clone(), pieces)); + } + } + if items.is_empty() { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + } + let ids = replace_with_pieces(s, "Break Text", items)?; + Ok(json!({ "ids": ids, "skipped": ids_json(&skipped) })) +} + +fn split_text(s: &mut Session, _: &Value) -> Result { + let Some((id, a, b)) = editing_range(s)? else { + return Err(EngineError::Other("Click in type with a Type tool to split it".into())); + }; + let d = &s.doc()?.doc; + let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else { + return Err(EngineError::Other("Select horizontal point or area type whose text fits".into())); + }; + let text = &src.text; + let (a, b) = (edit::floor_char(text, a.min(b)), edit::floor_char(text, a.max(b))); + let cuts: Vec<(usize, usize)> = if a == b { vec![(0, a), (a, text.len())] } else { vec![(0, a), (a, b), (b, text.len())] }; + let pieces: Vec = cuts.into_iter().filter_map(|r| src.piece(r, true)).collect(); + if pieces.len() < 2 { + return Err(EngineError::Other("Nothing to split at the cursor".into())); + } + let node = node.clone(); + let ids = replace_with_pieces(s, "Split Text at Cursor", vec![(id, node, pieces)])?; + // The text edited is gone: the pieces stay selected. + s.set_tool_option("stopEditing", &Value::Bool(true)); + Ok(json!({ "ids": ids })) +} + +/// One piece of point type Combine Point Text takes: its text with its object's scale taken into +/// its characters, and where it starts and ends along its line in the frame of its turn and shear. +struct Piece { + id: NodeId, + t: TextObject, + /// The object's turn and shear (`xf` without its move). + turn: [f64; 4], + /// Where its first character's glyph starts on its baseline, in that frame. + at: Point, + /// Where its last glyph ends along the line, in that frame. + end: f64, + /// Its first character's size. + size: f64, + /// Each character's pen position from the first's (byte, x), as it lays out alone. + pens: Vec<(usize, f64)>, +} + +impl Piece { + fn of(d: &Document, id: NodeId) -> Option<(Self, &Node)> { + let node = d.node(id)?; + let NodeKind::Text(t) = &node.kind else { return None }; + let text = t.plain_text(); + if t.vertical || !matches!(t.kind, TextKind::Point) || text.trim().is_empty() || text.contains(['\n', '\u{2028}']) { + return None; + } + let mut t = (**t).clone(); + t.scale_text_attributes(); + t.style_scale()?; + let lay = layout(&t); + let line = lay.lines.first().filter(|l| !l.rtl && lay.lines.len() == 1)?; + let first = glyph_at(&lay, 0)?; + // The first glyph of each cluster (a mark drawn over it shares its byte). + let mut seen = std::collections::HashSet::new(); + let pens = lay.glyphs.iter().filter(|g| g.len > 0 && seen.insert(g.byte)).map(|g| (g.byte, g.origin.x - first.x)).collect(); + let end = lay.glyphs.iter().map(|g| g.origin.x + g.advance).fold(first.x, f64::max) - first.x; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + let turn = Affine::new([a, b, c, dd, 0.0, 0.0]); + let at = turn.inverse() * (t.xf * Point::new(first.x, line.baseline)); + let size = t.runs.first()?.style.size; + if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) { + return None; + } + Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node)) + } + + /// Whether `o` is turned and sheared alike. + fn turned_like(&self, o: &Piece) -> bool { + self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0)) + } +} + +/// Whether two objects look alike apart from their text (paint, opacity, blending, effects). +fn same_look(a: &Node, b: &Node) -> bool { + a.appearance == b.appearance + && a.opacity == b.opacity + && a.blend == b.blend + && a.isolate == b.isolate + && a.knockout == b.knockout + && a.extras == b.extras +} + +/// How wide a space of `style` is. +fn space_width(style: &CharStyle) -> f64 { + let probe = TextObject::point(Point::ZERO, " ", CharStyle { tracking: 0.0, ..style.clone() }); + layout(&probe).glyphs.first().map_or(style.size * 0.25, |g| g.advance) +} + +/// `chain` (pieces left to right on one line) as one point type object whose glyphs land where +/// theirs were, or the index of the first piece that can't join the ones before it so. +fn merge(chain: &[&Piece]) -> std::result::Result { + let Some(first) = chain.first() else { return Err(0) }; + let mut runs = vec![]; + // Each piece's first byte, and the byte range of the character before it (whose tracking sets + // the gap). + let (mut starts, mut before) = (vec![], vec![]); + let mut text = String::new(); + for (k, p) in chain.iter().enumerate() { + if let Some(prev) = k.checked_sub(1).and_then(|i| chain.get(i)) { + let starts_blank = p.t.plain_text().starts_with(char::is_whitespace); + let last = prev.t.runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default(); + if !text.ends_with(char::is_whitespace) && !starts_blank && p.at.x - prev.end >= 0.5 * space_width(&last) { + runs.push(vectorcraft_doc::TextRun::new(" ", last)); + text.push(' '); + } + let c = text.chars().next_back().map_or(1, char::len_utf8); + before.push((text.len() - c, text.len())); + } + starts.push(text.len()); + // Baselines a little apart: the difference is baseline shift (up is positive). + let shift = first.at.y - p.at.y; + for r in &p.t.runs { + let mut r = r.clone(); + r.style.baseline_shift += shift; + text.push_str(&r.text); + runs.push(r); + } + } + let mut m = first.t.clone(); + m.set_all_paras(ParaStyle { justify: Justify::Left, ..first.t.para.clone() }); + let [a, b, c, d] = first.turn; + let base = Affine::new([a, b, c, d, 0.0, 0.0]) * Affine::translate(first.at.to_vec2()); + m.xf = base; + m.runs = runs; + m.ruby.clear(); + // Tracking after the character before each piece, until each lands where it was. + let targets: Vec = chain.iter().map(|p| p.at.x - first.at.x).collect(); + let mut lay = layout(&m); + for _ in 0..4 { + // A piece whose first character joins the one before it (a ligature) can't keep its place. + let xs: Vec = match starts.iter().enumerate().map(|(k, b)| glyph_starting(&lay, *b).map(|g| g.x).ok_or(k)).collect() { + Ok(xs) => xs, + Err(k) => return Err(k.max(1)), + }; + let x0 = xs.first().copied().unwrap_or(0.0); + let off: Vec = targets.iter().zip(&xs).map(|(t, x)| t - (x - x0)).collect(); + if off.iter().all(|o| o.abs() <= TOLERANCE * 0.5) { + break; + } + // Moving piece k moves those after it as far. + for (k, w) in off.windows(2).enumerate() { + let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue }; + if delta.abs() > 1e-9 { + edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0); + if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) { + return Err(k + 1); + } + } + } + lay = layout(&m); + } + // Every glyph where it was, within the tolerance; else the run ends before the piece that moved. + let x0 = glyph_starting(&lay, 0).map_or(0.0, |g| g.x); + for (k, p) in chain.iter().enumerate() { + let (Some(&start), Some(&target)) = (starts.get(k), targets.get(k)) else { return Err(k.max(1)) }; + let moved = p.pens.iter().any(|(byte, x)| glyph_starting(&lay, start + byte).is_none_or(|g| (g.x - x0 - target - x).abs() > TOLERANCE)); + if moved { + return Err(k.max(1)); + } + } + m.xf = base * Affine::translate((-x0, 0.0)); + Ok(m) +} + +fn combine_text(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.combineText"; + let d = &s.doc()?.doc; + let mut pieces: Vec<(Piece, &Node)> = + texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect(); + // Lines: pieces turned alike and looking alike, by baseline, then left to right. + pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x))); + let mut lines: Vec> = vec![]; + for (piece, node) in &pieces { + let fits = |l: &Vec<(&Piece, &Node)>| { + l.first() + .is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size)) + }; + match lines.iter_mut().find(|l| fits(l)) { + Some(l) => l.push((piece, node)), + None => lines.push(vec![(piece, node)]), + } + } + // Runs along each line: a wide gap or an overlap starts another. + let mut chains: Vec> = vec![]; + for mut l in lines { + l.sort_by(|x, y| x.0.at.x.total_cmp(&y.0.at.x)); + let mut chain: Vec<&Piece> = vec![]; + for (piece, _) in l { + if let Some(prev) = chain.last() { + let gap = piece.at.x - prev.end; + if gap > MAX_GAP * prev.size || gap < -MAX_OVERLAP * prev.size { + chains.push(std::mem::take(&mut chain)); + } + } + chain.push(piece); + } + chains.push(chain); + } + // Each run as one object, or as several where a piece can't keep its place joined. + let mut made: Vec<(NodeId, TextObject, Vec)> = vec![]; + let mut tries = 0; + 'chains: for chain in chains { + let mut rest: &[&Piece] = &chain; + while rest.len() > 1 { + let mut upto = rest.len(); + while let Some(part) = rest.get(..upto) { + tries += part.len(); + if tries > MAX_JOIN_TRIES { + break 'chains; + } + match merge(part) { + Ok(m) => { + let gone = part.iter().skip(1).map(|p| p.id).collect(); + if let Some(first) = part.first() { + made.push((first.id, m, gone)); + } + break; + } + // A run of one stays as it is. + Err(k) if k <= 1 => { + upto = 1; + break; + } + Err(k) => upto = k, + } + } + rest = rest.get(upto.max(1)..).unwrap_or_default(); + } + } + if made.is_empty() { + return Err(EngineError::Other("Select two or more one-line point type objects on one baseline to combine".into())); + } + let combined: usize = made.iter().map(|m| m.2.len()).sum(); + let ids: Vec = made.iter().map(|m| m.0.0).collect(); + s.edit("Combine Point Text", |d, sel| { + let mut gone = vec![]; + for (id, mut t, others) in made { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + } + gone.extend(others); + } + d.remove_many(&gone)?; + sel.set(ids.iter().map(|i| NodeId(*i))); + Ok(()) + })?; + Ok(json!({ "ids": ids, "combined": combined })) +} + +/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many +/// went. +fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize { + let text = t.plain_text(); + let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b)); + let Some(range) = text.get(a..b) else { return 0 }; + let mut prev = text.get(..a).and_then(|s| s.chars().next_back()); + let mut cuts = vec![]; + for (i, c) in range.char_indices() { + let at = a + i; + if c != brk { + prev = Some(c); + continue; + } + // The next character left: past the breaks that go too. + let after = at + c.len_utf8(); + let next = text.get(after..).and_then(|rest| rest.char_indices().find(|(j, n)| *n != brk || after + j >= b).map(|(_, n)| n)); + let space = prev.is_some_and(|p| !p.is_whitespace()) && next.is_some_and(|n| !n.is_whitespace()); + if space { + prev = Some(' '); + } + cuts.push((at, space)); + } + for &(at, space) in cuts.iter().rev() { + let insert = if space { " " } else { "" }; + let end = at + brk.len_utf8(); + t.splice_paras(at, end, insert); + edit::replace_range(&mut t.runs, at, end, insert); + t.ruby_after_replace(at, end, insert.len()); + } + cuts.len() +} + +fn remove_breaks(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.removeBreaks"; + let (brk, label, none) = match text_param(p, "kind", C)? { + Some("line") => ('\u{2028}', "Remove Line Breaks", "No line breaks to remove"), + Some("paragraph") => ('\n', "Remove Paragraph Breaks", "No paragraph breaks to remove"), + Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))), + None => return Err(bad(C, "missing `kind`")), + }; + // The characters a Type tool has selected, else all the text of the selected type. + let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let d = &s.doc()?.doc; + let targets: Vec<(NodeId, usize, usize)> = match editing { + Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(), + None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(), + }; + let count: usize = targets + .iter() + .filter_map(|(id, a, b)| { + let text = text_of(d, *id)?.plain_text(); + let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b)); + Some(text.get(a..b)?.matches(brk).count()) + }) + .sum(); + if count == 0 { + return Err(EngineError::Other(none.into())); + } + if count > MAX_BREAKS { + return Err(EngineError::Other("Too many breaks to remove at once".into())); + } + let (removed, ids) = s.edit(label, |d, _| { + let (mut removed, mut ids) = (0, vec![]); + for (id, a, b) in targets { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let n = strip_breaks(t, a, b, brk); + if n > 0 { + refresh_bounds(t); + removed += n; + ids.push(id.0); + } + } + Ok((removed, ids)) + })?; + Ok(json!({ "ids": ids, "removed": removed })) +} + +/// Horizontal point type among the objects `p` names. +fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result> { + let d = &s.doc()?.doc; + let ids: Vec = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)? + .into_iter() + .filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point))) + .collect(); + if ids.is_empty() { + return Err(EngineError::Other("Select point type".into())); + } + Ok(ids) +} + +/// Replace the type objects of `changed` as one undo step labelled `label` → their ids. +fn set_texts(s: &mut Session, label: &str, changed: Vec<(NodeId, TextObject)>, reset_angle: bool) -> Result { + let ids: Vec = changed.iter().map(|c| c.0.0).collect(); + s.edit(label, |d, _| { + for (id, mut t) in changed { + refresh_bounds(&mut t); + if let Some(n) = d.node_mut(id) { + n.kind = NodeKind::Text(Box::new(t)); + if reset_angle { + n.bbox_angle = 0.0; + } + } + } + Ok(()) + })?; + Ok(json!({ "ids": ids })) +} + +fn align_in_place(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.alignInPlace"; + let justify = match text_param(p, "align", C)? { + Some("left") => Justify::Left, + Some("center") => Justify::Center, + Some("right") => Justify::Right, + Some(other) => return Err(bad(C, format!("`align` must be \"left\", \"center\" or \"right\", not {other:?}"))), + None => return Err(bad(C, "missing `align`")), + }; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id).filter(|t| !t.vertical && t.para_styles().any(|para| para.justify != justify)) else { continue }; + let was = layout(t); + // The longest line stays where it is. + let Some((i, line)) = was.lines.iter().enumerate().max_by(|a, b| (a.1.x1 - a.1.x0).total_cmp(&(b.1.x1 - b.1.x0))) else { continue }; + let mut n = t.clone(); + n.edit_paras(None, |para| para.justify = justify); + let Some(now) = layout(&n).lines.get(i).map(|l| l.x0) else { continue }; + n.xf *= Affine::translate((line.x0 - now, 0.0)); + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type is aligned that way already".into())); + } + set_texts(s, "Set Alignment, Keep Position", changed, false) +} + +fn reset_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.resetTextTransform"; + let scale = switch_or(p, "scale", false, C)?; + let ids = point_texts(s, p, C)?; + let d = &s.doc()?.doc; + let mut changed = vec![]; + for id in ids { + let Some(t) = text_of(d, id) else { continue }; + let [a, b, c, dd, _, _] = t.xf.as_coeffs(); + // The object's own axes: its width along its baseline, and its height square to it. + let (sx, det) = (a.hypot(b), a * dd - b * c); + if !(sx > 1e-12 && sx.is_finite() && det.is_finite() && det.abs() > 1e-12) { + continue; + } + let sy = det.abs() / sx; + let unscaled = |t: &TextObject| t.runs.iter().all(|r| r.style.h_scale == 100.0); + let upright = b.abs() <= 1e-9 * sx && c.abs() <= 1e-9 * sy && a > 0.0 && dd > 0.0; + if upright && (!scale || ((sx - sy).abs() <= 1e-9 * sy && unscaled(t))) { + continue; + } + let mut n = t.clone(); + let w = if scale { + n.runs.iter_mut().for_each(|r| r.style.h_scale = 100.0); + sy + } else { + sx + }; + // The centre of the text stays where it is. + let centre = t.xf * layout(t).bounds.center(); + let turn = Affine::scale_non_uniform(w, sy); + let now = turn * layout(&n).bounds.center(); + n.xf = Affine::translate(centre - now) * turn; + changed.push((id, n)); + } + if changed.is_empty() { + return Err(EngineError::Other("The point type has no transform to reset".into())); + } + set_texts(s, "Reset Text Transform", changed, true) +} + +fn replace_fonts(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.cleanup.replaceFonts"; + let default = CharStyle::default().font_family; + let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default); + let db = FontDb::global(); + // Any name of the family is stored as its own name. + let family = match db.resolve(wanted, "Regular") { + Some((f, m)) if m != FontMatch::Missing => f.family.clone(), + _ => return Err(bad(C, "the replacement font is not available")), + }; + let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing); + let d = &s.doc()?.doc; + let mut ids = vec![]; + for l in &d.layers { + l.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.runs.iter().any(|r| missing(&r.style)) + { + ids.push(n.id); + } + }); + } + if ids.is_empty() { + return Err(EngineError::Other("No fonts are missing".into())); + } + let runs = s.edit("Replace Missing Fonts", |d, _| { + let mut runs = 0; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + for r in t.runs.iter_mut().filter(|r| missing(&r.style)) { + // The style the font has closest to the one the character had. + r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone()); + r.style.font_family = family.clone(); + r.style.font_version = None; + r.style.font_axes.clear(); + runs += 1; + } + edit::normalize(&mut t.runs); + refresh_bounds(t); + } + Ok(runs) + })?; + Ok(json!({ "runs": runs, "font": family })) +} diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs new file mode 100644 index 000000000..a2fa3225f --- /dev/null +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -0,0 +1,307 @@ +//! Artwork Cleanup's text tools: pieces stay where their glyphs were drawn and keep their styles, +//! each command is one undo step and refuses cleanly when there is nothing to do. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Justify, NodeId, NodeKind, TextKind, TextObject}; +use vectorcraft_geom::Point; + +use crate::{EngineError, Session}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, p: Value) -> NodeId { + NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +/// Each drawn character of `ids` (spaces left out) with where its glyph starts on the page, in +/// the order of the objects and their text. +fn glyphs(s: &Session, ids: &[NodeId]) -> Vec<(char, Point)> { + let mut out = vec![]; + for id in ids { + let t = obj(s, *id); + let text = t.plain_text(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let mut gs: Vec<_> = lay.glyphs.iter().filter(|g| g.len > 0).collect(); + gs.sort_by_key(|g| g.byte); + gs.dedup_by_key(|g| g.byte); + for g in gs { + let c = text[g.byte..].chars().next().unwrap(); + if !c.is_whitespace() { + out.push((c, t.xf * g.origin)); + } + } + } + out +} + +fn assert_same_places(was: &[(char, Point)], now: &[(char, Point)]) { + assert_eq!(was.iter().map(|g| g.0).collect::(), now.iter().map(|g| g.0).collect::()); + for (a, b) in was.iter().zip(now) { + assert!((a.1 - b.1).hypot() < 0.02, "{:?} moved to {:?}", a, b); + } +} + +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn doc_json(s: &Session) -> String { + serde_json::to_string(&s.doc().unwrap().doc.layers).unwrap() +} + +fn other(r: Result) -> String { + match r { + Err(EngineError::Other(m)) => m, + r => panic!("expected a refusal, got {r:?}"), + } +} + +#[test] +fn break_into_words_keeps_places_styles_and_is_one_step() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 100, "text": "Hello big World", "size": 24})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 9, "size": 36, "tracking": 50})).unwrap(); + let rotated = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned words", "size": 18, "rotation": 30})); + let was = glyphs(&s, &[id, rotated]); + let before = doc_json(&s); + let steps = undo_steps(&s); + select(&mut s, &[id, rotated]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap(); + let pieces = ids(&r); + assert_eq!(pieces.len(), 5); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.doc().unwrap().selection.objects.len(), 5, "the pieces are selected"); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, ["Hello", "big", "World", "Turned", "words"]); + assert_same_places(&was, &glyphs(&s, &pieces)); + let big = obj(&s, pieces[1]); + assert!(matches!(big.kind, TextKind::Point)); + assert_eq!((big.runs[0].style.size, big.runs[0].style.tracking), (36.0, 50.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); +} + +#[test] +fn break_area_type_into_lines_paragraphs_and_glyphs() { + for (by, count) in [("lines", None), ("paragraphs", Some(2)), ("glyphs", None)] { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "A paragraph long enough to wrap in its frame\nSecond one", "size": 14, "area": {"width": 140, "height": 200}}), + ); + let t = obj(&s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.lines.len() >= 3, "the first paragraph wraps"); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap(); + let pieces = ids(&r); + let want = count.unwrap_or(if by == "lines" { lay.lines.len() } else { was.len() }); + assert_eq!(pieces.len(), want, "{by}"); + assert_same_places(&was, &glyphs(&s, &pieces)); + if by == "paragraphs" { + // The wrapped lines end in line breaks. + assert!(obj(&s, pieces[0]).plain_text().contains('\u{2028}')); + } + } +} + +#[test] +fn break_text_refuses_what_it_cannot_break() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); + assert!(other(s.execute("addon.cleanup.breakText", &json!({}))).contains("point or area type")); + let r = s.execute("addon.cleanup.breakText", &json!({"by": "sentences"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} + +#[test] +fn combine_puts_glyph_pieces_back_together_where_they_were() { + let mut s = session(); + let id = text(&mut s, json!({"x": 50, "y": 120, "text": "Water Lily", "size": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 10, "fill": "#ff0000"})).unwrap(); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": "glyphs"})).unwrap()); + assert_eq!(pieces.len(), 9); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let made = ids(&r); + assert_eq!((made.len(), r["combined"].as_u64()), (1, Some(8))); + let t = obj(&s, made[0]); + assert_eq!(t.plain_text(), "Water Lily", "a space goes back between the words"); + assert_same_places(&was, &glyphs(&s, &made)); + let red = t.runs.iter().find(|r| r.text.contains('L')).unwrap(); + assert_ne!(red.style.fill, t.runs[0].style.fill, "styles stay"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(pieces[1]).is_some(), "undo brings the pieces back"); +} + +#[test] +fn combine_joins_only_pieces_on_one_line_turned_alike() { + let mut s = session(); + let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20})); + let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20})); + let c = text(&mut s, json!({"x": 50, "y": 200, "text": "Below", "size": 20})); + let d = text(&mut s, json!({"x": 300, "y": 100, "text": "Turned", "size": 20, "rotation": 45})); + let was = glyphs(&s, &[a, b]); + select(&mut s, &[a, b, c, d]); + let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap(); + assert_eq!(ids(&r), [a]); + assert_eq!(obj(&s, a).plain_text(), "Left Right"); + assert_same_places(&was, &glyphs(&s, &[a])); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(b).is_none() && doc.node(c).is_some() && doc.node(d).is_some()); + select(&mut s, &[c, d]); + assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine")); +} + +#[test] +fn remove_line_and_paragraph_breaks() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "one\u{2028}two \u{2028}three\nfour\n\nfive", "size": 12})); + select(&mut s, &[id]); + let before = doc_json(&s); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"})).unwrap(); + assert_eq!(r["removed"], 2); + assert_eq!(obj(&s, id).plain_text(), "one two three\nfour\n\nfive"); + let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(r["removed"], 3); + let t = obj(&s, id); + assert_eq!((t.plain_text().as_str(), t.paragraph_count()), ("one two three four five", 1)); + assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"}))), "No line breaks to remove"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(doc_json(&s), before); + assert!(matches!(s.execute("addon.cleanup.removeBreaks", &json!({})), Err(EngineError::BadParams { .. }))); +} + +/// The Type tool editing `id` with bytes `anchor..caret` selected. +fn edit_text(s: &mut Session, id: NodeId, anchor: usize, caret: usize) { + s.select_tool("type", Default::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"editing": id.0, "anchor": anchor, "caret": caret})); +} + +#[test] +fn remove_breaks_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + +#[test] +fn split_at_the_cursor_and_around_a_selection() { + for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "Split here please", "size": 20, "justify": "center"})); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + let r = s.execute("addon.cleanup.splitText", &json!({})); + assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("Type tool")), "{r:?}"); + edit_text(&mut s, id, anchor, caret); + let steps = undo_steps(&s); + let r = s.execute("addon.cleanup.splitText", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + let pieces = ids(&r); + let texts: Vec = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect(); + assert_eq!(texts, want); + assert_same_places(&was, &glyphs(&s, &pieces)); + assert!(s.tool_options()["editing"].is_null(), "editing ends"); + } +} + +#[test] +fn split_keeps_the_later_lines_of_a_wrapped_piece() { + let mut s = session(); + let id = text( + &mut s, + json!({"x": 40, "y": 40, "text": "First words then a long run of text that wraps in the frame", "size": 14, "area": {"width": 160, "height": 200}}), + ); + let was = glyphs(&s, &[id]); + select(&mut s, &[id]); + edit_text(&mut s, id, 6, 6); + let pieces = ids(&s.execute("addon.cleanup.splitText", &json!({})).unwrap()); + assert_eq!(pieces.len(), 2); + assert_same_places(&was, &glyphs(&s, &pieces)); +} + +#[test] +fn set_alignment_keeps_the_text_in_place() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 60, "text": "Stay put", "size": 20})); + let multi = text(&mut s, json!({"x": 100, "y": 160, "text": "Short\u{2028}The longest line", "size": 20})); + let was = glyphs(&s, &[id]); + let longest: Vec<_> = glyphs(&s, &[multi]).into_iter().skip(5).collect(); + select(&mut s, &[id, multi]); + for align in ["center", "right", "left"] { + s.execute("addon.cleanup.alignInPlace", &json!({"align": align})).unwrap(); + assert_same_places(&was, &glyphs(&s, &[id])); + assert_same_places(&longest, &glyphs(&s, &[multi]).into_iter().skip(5).collect::>()); + } + assert_eq!(obj(&s, id).para.justify, Justify::Left); + assert!(other(s.execute("addon.cleanup.alignInPlace", &json!({"align": "left"}))).contains("already")); + assert!(matches!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "up"})), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn reset_text_transform_takes_off_rotation_and_with_scale_the_horizontal_scale() { + let mut s = session(); + let id = text(&mut s, json!({"x": 100, "y": 100, "text": "Tilted", "size": 20, "rotation": 30})); + s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150})).unwrap(); + let centre = |s: &Session| { + let t = obj(s, id); + t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds.center() + }; + let was = centre(&s); + let size = obj(&s, id).effective_char_style(&obj(&s, id).runs[0].style).unwrap().size; + select(&mut s, &[id]); + s.execute("addon.cleanup.resetTextTransform", &json!({})).unwrap(); + let t = obj(&s, id); + let [_, b, c, _, _, _] = t.xf.as_coeffs(); + assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "{:?}", t.xf); + assert!((centre(&s) - was).hypot() < 1e-6); + assert!((t.effective_char_style(&t.runs[0].style).unwrap().size - size).abs() < 1e-6, "the size stays"); + assert!(t.runs[0].style.h_scale > 100.0, "the horizontal scale stays"); + assert!(other(s.execute("addon.cleanup.resetTextTransform", &json!({}))).contains("no transform")); + s.execute("addon.cleanup.resetTextTransform", &json!({"scale": true})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.h_scale, 100.0); +} + +#[test] +fn replace_missing_fonts_with_the_default_or_a_chosen_font() { + let mut s = session(); + let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"})); + let kept = text(&mut s, json!({"x": 40, "y": 120, "text": "Fine", "size": 20})); + let fine = obj(&s, kept); + let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap(); + assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3"))); + let t = obj(&s, gone); + assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("Source Sans 3", "Bold")); + assert_eq!(obj(&s, kept), fine); + assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing"); + let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"})); + assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}"); +} diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs new file mode 100644 index 000000000..4426624cc --- /dev/null +++ b/crates/engine/src/cmd/addons/color.rs @@ -0,0 +1,133 @@ +//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate, +//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as +//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the +//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model, +//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and +//! its submenu, Object › Plug-ins › Color Effects, are the UI's. + +mod print_fixes; +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_render::effects; + +use super::super::colorcmds::recolor_links; +use super::super::select::walk_selectable; +use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, text_param}; +use crate::{Result, Session}; + +/// The kinds Select by Color Kind selects (`addon.color.select`). +pub const SELECT_KINDS: [&str; 5] = ["spotColors", "colorEffects", "rgbImages", "cmykImages", "grayImages"]; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.color.select", + "Select by Color Kind", + ["Object", "Plug-ins", "Color Effects"], + None, + "{kind: \"spotColors\" (objects painted with a spot colour swatch, solid or in a gradient's stops) | \"colorEffects\" (objects carrying a visible colour adjustment effect, on themselves or on a fill or stroke) | \"rgbImages\" | \"cmykImages\" | \"grayImages\" (embedded or linked images whose pixels are RGB, CMYK, or grayscale or 1-bit)} select those objects among the ones Select All reaches, inside groups too; locked and hidden ones are left out → {count}", + has_doc, + select + ), + cmd!( + "addon.color.filter", + "Apply as Filter", + ["Object", "Plug-ins", "Color Effects"], + None, + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}", + has_selection, + filter + ), + cmd!( + "addon.color.printFixes", + "Print Ink Fixes", + ["Object", "Plug-ins", "Color Effects"], + None, + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + has_doc, + print_fixes::run + ), + ] +} + +fn select(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.color.select"; + let kind = text_param(p, "kind", C)?.ok_or_else(|| bad(C, "missing `kind`"))?; + if !SELECT_KINDS.contains(&kind) { + return Err(bad(C, "`kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages")); + } + let st = s.doc()?; + let mut ids: Vec = vec![]; + walk_selectable(&st.doc, st.isolation, |n| { + if is_of_kind(&st.doc, n, kind) { + ids.push(n.id); + // An object taken whole: what is inside it isn't taken again. + return false; + } + true + }); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": s.doc()?.selection.objects.len() })) +} + +/// Is `n` an object of Select by Color Kind's `kind`? Containers count for colour effects only: +/// for the other kinds, the objects inside them are looked at one by one. +fn is_of_kind(d: &Document, n: &Node, kind: &str) -> bool { + match kind { + "colorEffects" => effects::has_adjustment(n), + "spotColors" if !n.is_container() => { + let mut spot = false; + vectorcraft_doc::swatches::node_colors(n, &mut |_, swatch| { + spot |= swatch.is_some_and(|name| d.swatch(name).is_some_and(|w| w.spot)); + }); + spot + } + _ => { + let NodeKind::Image(img) = &n.kind else { return false }; + let Some(blob) = d.images.get(&img.key) else { return false }; + let model = if blob.is_cmyk() { + "cmykImages" + } else if is_gray(&blob.bytes) { + "grayImages" + } else { + "rgbImages" + }; + model == kind + } + } +} + +/// Are the encoded image `bytes` grayscale or 1-bit? Reads only the header. +fn is_gray(bytes: &[u8]) -> bool { + use image::ImageDecoder as _; + let decoder = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|r| r.into_decoder().ok()); + decoder.is_some_and(|d| matches!(d.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16)) +} + +fn filter(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.color.filter"; + let id = text_param(p, "effect", C)?.ok_or_else(|| bad(C, "missing `effect`"))?; + if !effects::is_adjustment(id) { + return Err(bad(C, format!("`{id}` is not a colour adjustment"))); + } + let params = match p.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err(bad(C, "`params` must be an object")), + }; + let fx = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; + let ids = checked_ids_or_selection(s, p, C)?; + // Settings that change nothing still make the pass, which then leaves every colour as it was. + let Some(map) = effects::color_map(&[fx]) else { return Ok(json!({ "changed": 0 })) }; + let mut given = p.clone(); + if let Some(o) = given.as_object_mut() { + o.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::>())); + } + let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…')); + let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc); + let swatches = |name: &str| kinds.get(name).copied(); + recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches)) +} diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs new file mode 100644 index 000000000..7a4cfee9c --- /dev/null +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -0,0 +1,170 @@ +//! Print Ink Fixes (`addon.color.printFixes`, Object › Plug-ins › Color Effects › Print Ink +//! Fixes…): a CMYK document's inks made ready for print in one undo step. Colours with more total +//! ink than the limit are scaled down to it, white fills and strokes stop overprinting, and black +//! ones start to, as Edit › Edit Colors › Overprint Black sets it. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::overprint::{OverprintBlack, is_white}; +use vectorcraft_doc::{ColorMode, Node, NodeKind}; + +use super::super::super::colorcmds::{Recolor, Scope}; +use super::super::super::overprint::edit_counted; +use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param}; +use crate::{Result, Session}; + +const C: &str = "addon.color.printFixes"; + +/// What `p` asks for. +struct Fixes { + /// Most total ink, 0..4 (0: off). + ink_limit: f32, + exclude_pure_black: bool, + remove_white_overprint: bool, + /// Overprint Black's least black, 0..1 (0: off). + overprint_black: f32, + text_only: bool, + fill: bool, + stroke: bool, +} + +/// The percentage `p` gives for `key` in 0..=`max`, as a fraction (0 when left out). +fn percent(p: &Value, key: &str, max: f64) -> Result { + let v = number_or(p, key, 0.0, C)?; + if !(0.0..=max).contains(&v) { + return Err(bad(C, format!("`{key}` must be 0..{max}"))); + } + Ok((v / 100.0) as f32) +} + +impl Fixes { + fn of(p: &Value) -> Result { + let f = Fixes { + ink_limit: percent(p, "inkLimit", 400.0)?, + exclude_pure_black: switch_or(p, "excludePureBlack", true, C)?, + remove_white_overprint: switch_or(p, "removeWhiteOverprint", false, C)?, + overprint_black: percent(p, "overprintBlack", 100.0)?, + text_only: switch_or(p, "textOnly", false, C)?, + fill: switch_or(p, "fill", true, C)?, + stroke: switch_or(p, "stroke", true, C)?, + }; + if f.ink_limit <= 0.0 && f.overprint_black <= 0.0 && !f.remove_white_overprint { + return Err(bad(C, "give `inkLimit`, `removeWhiteOverprint` or `overprintBlack`")); + } + Ok(f) + } + + /// `c` with its total ink scaled down to the limit: CMYK, and grey (black ink) unless + /// pure blacks are excluded, which CMYK with black ink only is too. + fn limit(&self, c: Color) -> Color { + const E: f32 = 0.005; + let max = self.ink_limit; + match c { + Color::Cmyk { c: cy, m, y, k } => { + let total = cy + m + y + k; + let pure_black = cy <= E && m <= E && y <= E; + if total <= max || (self.exclude_pure_black && pure_black) || !total.is_finite() { + return c; + } + let s = max / total; + Color::cmyk(cy * s, m * s, y * s, k * s) + } + Color::Gray { k } if k > max && !self.exclude_pure_black => Color::gray(max), + _ => c, + } + } + + /// Remove white overprint from `n`'s own fills and strokes and its characters': true when + /// that changed anything. + fn unset_white(&self, n: &mut Node) -> bool { + let mut changed = false; + let mut off = |aimed: bool, white: bool, flag: &mut bool| { + if aimed && white && *flag { + *flag = false; + changed = true; + } + }; + for it in &mut n.appearance.items { + let aimed = if it.is_fill() { self.fill } else { self.stroke }; + let white = is_white(it.paint()); + off(aimed, white, it.overprint_mut()); + } + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + off(self.fill, is_white(&r.style.fill), &mut r.style.overprint_fill); + off(self.stroke, is_white(&r.style.stroke), &mut r.style.overprint_stroke); + } + } + changed + } + + /// The overprint fixes on `n` and everything inside it: (objects whose white overprint was + /// removed, objects set to overprint black). + fn overprint(&self, n: &mut Node, black: &OverprintBlack, spots: &[String]) -> (usize, usize) { + let white = usize::from(self.remove_white_overprint && self.unset_white(n)); + let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_))); + let mut counts = (white, usize::from(blacks && black.apply(n, spots))); + for c in n.children_mut().into_iter().flatten() { + let (w, b) = self.overprint(Arc::make_mut(c), black, spots); + counts = (counts.0 + w, counts.1 + b); + } + counts + } +} + +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let fixes = Fixes::of(p)?; + let st = s.doc()?; + if st.doc.color_mode != ColorMode::Cmyk { + return Err(bad(C, "Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first")); + } + let layers = st.doc.layers.iter().map(|l| l.id).collect(); + let roots = checked_ids_or_selection(s, p, C)?; + // As Artwork Cleanup's: the selection, or the document while nothing is selected. + let ids = match text_param(p, "scope", C)? { + Some("document") => layers, + None if roots.is_empty() => layers, + Some("selection") if roots.is_empty() => return Err(bad(C, "nothing selected")), + Some("selection") | None => roots, + Some(other) => return Err(bad(C, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))), + }; + let black = OverprintBlack { min_k: fixes.overprint_black, fill: fixes.fill, stroke: fixes.stroke, ..OverprintBlack::default() }; + let (mut ink, mut white, mut blacks) = (0, 0, 0); + let changed = edit_counted(s, "Print Ink Fixes", |d| { + if fixes.ink_limit > 0.0 { + let spots = d.spot_names(); + // Spot inks and Registration aren't process ink: they keep their colour and link. + let links = |c: &mut Color, link: &mut Option, _: &mut f32| { + if link.as_ref().is_some_and(|n| n == REGISTRATION || spots.contains(n)) { + return false; + } + let new = fixes.limit(*c); + if new == *c { + return false; + } + (*c, *link) = (new, None); + true + }; + let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false }; + ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids); + } + if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 { + return Ok(ink); + } + let spots = d.spot_names(); + for id in &ids { + if let Some(n) = d.node_mut(*id) { + let (w, b) = fixes.overprint(n, &black, &spots); + (white, blacks) = (white + w, blacks + b); + } + } + Ok(ink + white + blacks) + })?; + Ok(json!({ "changed": changed, "inkLimited": ink, "whiteOverprintRemoved": white, "blackOverprinted": blacks })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/addons/color/print_fixes/tests.rs b/crates/engine/src/cmd/addons/color/print_fixes/tests.rs new file mode 100644 index 000000000..e4c191587 --- /dev/null +++ b/crates/engine/src/cmd/addons/color/print_fixes/tests.rs @@ -0,0 +1,129 @@ +//! Print Ink Fixes. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const C: &str = "addon.color.printFixes"; + +/// A CMYK document with a rectangle per fill, each with overprint as given; nothing selected. +fn doc(fills: &[(Value, bool)]) -> (Session, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "colorMode": "cmyk"})).unwrap(); + let ids = fills + .iter() + .enumerate() + .map(|(i, (fill, overprint))| { + let id = s.execute("shape.rectangle", &json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({ "color": fill })).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("object.setOverprint", &json!({ "fill": overprint })).unwrap(); + NodeId(id) + }) + .collect(); + s.execute("select.none", &json!({})).unwrap(); + (s, ids) +} + +fn fill(s: &Session, id: NodeId) -> (Color, bool) { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let item = n.appearance.items.iter().find(|i| i.is_fill()).unwrap().clone(); + (item.paint().color().unwrap(), item.overprint()) +} + +fn cmyk(c: f64, m: f64, y: f64, k: f64) -> Value { + json!({"c": c, "m": m, "y": y, "k": k}) +} + +fn close(a: Color, b: [f32; 4]) -> bool { + let Color::Cmyk { c, m, y, k } = a else { return false }; + [c, m, y, k].iter().zip(b).all(|(x, y)| (x - y).abs() < 0.001) +} + +#[test] +fn the_ink_limit_scales_heavy_colours_down_to_it() { + let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), false), (cmyk(40.0, 30.0, 20.0, 10.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false)]); + let undo = s.doc().unwrap().history.undo.len(); + // Nothing selected: the whole document. + let v = s.execute(C, &json!({"inkLimit": 300})).unwrap(); + assert_eq!((v["inkLimited"].clone(), v["changed"].clone()), (json!(1), json!(1))); + assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "{:?}", fill(&s, ids[0])); + assert!(close(fill(&s, ids[1]).0, [0.4, 0.3, 0.2, 0.1]), "under the limit: as it was"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The selection only. + s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute(C, &json!({"inkLimit": 50})).unwrap(); + assert!(close(fill(&s, ids[1]).0, [0.2, 0.15, 0.1, 0.05])); + assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "left out"); + // Pure black keeps its ink unless it is included. + s.execute(C, &json!({"inkLimit": 50, "scope": "document"})).unwrap(); + assert!(close(fill(&s, ids[0]).0, [0.125; 4])); + assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.8])); + s.execute(C, &json!({"inkLimit": 50, "scope": "document", "excludePureBlack": false})).unwrap(); + assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.5])); +} + +#[test] +fn spot_and_registration_colours_keep_their_ink() { + let (mut s, ids) = doc(&[(cmyk(0.0, 0.0, 0.0, 0.0), false), (cmyk(0.0, 0.0, 0.0, 0.0), false)]); + s.execute("swatch.new", &json!({"name": "Heavy", "color": cmyk(100.0, 100.0, 100.0, 50.0), "spot": true})).unwrap(); + s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "Heavy"})).unwrap(); + s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap(); + s.execute("paint.setFill", &json!({"swatch": "[Registration]"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let before = [fill(&s, ids[0]), fill(&s, ids[1])]; + assert_eq!(s.execute(C, &json!({"inkLimit": 200})).unwrap()["changed"], 0); + assert_eq!([fill(&s, ids[0]), fill(&s, ids[1])], before); +} + +#[test] +fn white_overprint_goes_and_black_overprint_comes_at_the_threshold() { + let white = cmyk(0.0, 0.0, 0.0, 0.0); + let (mut s, ids) = + doc(&[(white.clone(), true), (cmyk(0.0, 0.0, 0.0, 95.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false), (cmyk(30.0, 0.0, 0.0, 100.0), false)]); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute(C, &json!({"removeWhiteOverprint": true, "overprintBlack": 90})).unwrap(); + assert_eq!((v["whiteOverprintRemoved"].clone(), v["blackOverprinted"].clone()), (json!(1), json!(1)), "{v}"); + let overprints: Vec = ids.iter().map(|id| fill(&s, *id).1).collect(); + // White stops; 95 % black starts; 80 % is under the threshold; a rich black isn't pure black. + assert_eq!(overprints, [false, true, false, false]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ids.iter().map(|id| fill(&s, *id).1).collect::>(), [true, false, false, false]); + // On type only: the rectangles stay. + let v = s.execute(C, &json!({"overprintBlack": 90, "textOnly": true})).unwrap(); + assert_eq!(v["changed"], 0); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed, no undo step"); + // Strokes only: the fills stay. + assert_eq!(s.execute(C, &json!({"overprintBlack": 90, "fill": false})).unwrap()["changed"], 0); +} + +#[test] +fn bad_requests_and_rgb_documents_are_refused() { + let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), true)]); + for p in [ + json!({}), + json!({"inkLimit": 0}), + json!({"inkLimit": "300"}), + json!({"inkLimit": 401}), + json!({"inkLimit": -1}), + json!({"overprintBlack": 101}), + json!({"overprintBlack": true}), + json!({"removeWhiteOverprint": "yes"}), + json!({"inkLimit": 300, "fill": 1}), + json!({"inkLimit": 300, "scope": "page"}), + json!({"inkLimit": 300, "scope": "selection"}), + json!({"inkLimit": 300, "ids": [999_999]}), + ] { + assert!(s.execute(C, &p).is_err(), "{p}"); + } + assert!(close(fill(&s, ids[0]).0, [1.0; 4])); + let mut rgb = Session::new(); + rgb.execute("file.new", &json!({})).unwrap(); + rgb.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let e = rgb.execute(C, &json!({"inkLimit": 300})).unwrap_err().to_string(); + assert!(e.contains("CMYK"), "{e}"); +} diff --git a/crates/engine/src/cmd/addons/color/tests.rs b/crates/engine/src/cmd/addons/color/tests.rs new file mode 100644 index 000000000..ed8137d6a --- /dev/null +++ b/crates/engine/src/cmd/addons/color/tests.rs @@ -0,0 +1,316 @@ +//! Color Effects: its live effects through effect.apply, and Apply as Filter. + +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Rect; + +use crate::Session; + +/// A document with a selected red rectangle, unstroked. +fn red_rect() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + (s, id) +} + +fn fill_hex(s: &Session, id: NodeId) -> String { + s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap().to_hex() +} + +fn pixel(s: &Session) -> [u8; 4] { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true).pixel(50, 50) +} + +#[test] +fn the_live_effects_draw_without_changing_the_colours() { + for (effect, params, drawn) in [ + ("addon.color.invert", json!({}), [0, 255, 255, 255]), + ("addon.color.desaturate", json!({}), [128, 128, 128, 255]), + ("addon.color.exposure", json!({"exposure": -20}), [0, 0, 0, 255]), + ] { + let (mut s, id) = red_rect(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + assert_eq!(fill_hex(&s, id), "#ff0000", "{effect}"); + assert_eq!(pixel(&s), drawn, "{effect}"); + } +} + +#[test] +fn a_filter_recolours_the_object_as_one_undo_step() { + let (mut s, id) = red_rect(); + let undo = s.doc().unwrap().history.undo.len(); + let v = s.execute("addon.color.filter", &json!({"effect": "addon.color.invert"})).unwrap(); + assert_eq!(v["changed"], 1); + assert_eq!(fill_hex(&s, id), "#00ffff"); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.appearance.effects.is_empty(), "no effect is added"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(fill_hex(&s, id), "#ff0000"); +} + +#[test] +fn a_filter_takes_any_colour_adjustment_with_its_params() { + let (mut s, id) = red_rect(); + s.execute("addon.color.filter", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + assert_eq!(fill_hex(&s, id), "#00ff00"); + // Live effects already on the object stay live, on top of the new colours. + s.execute("effect.apply", &json!({"effect": "addon.color.invert"})).unwrap(); + s.execute("addon.color.filter", &json!({"effect": "addon.color.desaturate", "ids": [id.0]})).unwrap(); + assert_eq!(fill_hex(&s, id), "#808080"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects.len(), 1); + // The stroke only: the fill stays. + s.execute("addon.color.filter", &json!({"effect": "addon.color.invert", "fill": false})).unwrap(); + assert_eq!(fill_hex(&s, id), "#808080"); +} + +#[test] +fn bad_requests_are_refused() { + let (mut s, id) = red_rect(); + let undo = s.doc().unwrap().history.undo.len(); + for p in [ + json!({}), + json!({"effect": 3}), + json!({"effect": "distort.roughen"}), + json!({"effect": "adjust.nothing"}), + json!({"effect": "addon.color.invert", "params": [1]}), + json!({"effect": "addon.color.invert", "ids": [999_999]}), + json!({"effect": "addon.color.invert", "ids": ["x"]}), + ] { + assert!(s.execute("addon.color.filter", &p).is_err(), "{p}"); + } + assert_eq!(fill_hex(&s, id), "#ff0000"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("addon.color.filter", &json!({"effect": "addon.color.invert"})).is_err(), "nothing selected"); +} + +/// An embedded image object of `blob`, selected. +fn image(s: &mut Session, key: &str, blob: vectorcraft_doc::ImageBlob, x: f64) -> NodeId { + s.edit("Place", |d, sel| { + d.images.insert(key.into(), blob); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: key.into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::translate((x, 0.0)) * vectorcraft_geom::Affine::scale(10.0), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, vectorcraft_doc::Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(im))))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +fn png(img: image::DynamicImage) -> vectorcraft_doc::ImageBlob { + let mut bytes = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap(); + vectorcraft_doc::ImageBlob::png(bytes) +} + +fn selected(s: &Session) -> Vec { + s.doc().unwrap().selection.objects.clone() +} + +#[test] +fn select_by_color_kind_finds_spot_colours_effects_and_image_models() { + let (mut s, plain) = red_rect(); + // A spot colour, inside a group: the path is taken, not the group. + s.execute("swatch.new", &json!({"name": "Gold", "color": {"model": "cmyk", "c": 0.0, "m": 0.2, "y": 0.8, "k": 0.1}, "spot": true})).unwrap(); + let spot = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 20, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"swatch": "Gold"})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // A live colour adjustment. + let adjusted = NodeId(s.execute("shape.rectangle", &json!({"x": 140, "y": 20, "width": 30, "height": 30})).unwrap()["id"].as_u64().unwrap()); + // Its own colour: setting the spot fill made it the default for new art too. + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "addon.color.invert"})).unwrap(); + let rgb = image(&mut s, "rgb", png(image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4))), 0.0); + let gray = image(&mut s, "gray", png(image::DynamicImage::ImageLuma8(image::GrayImage::new(4, 4))), 50.0); + let inks = vectorcraft_doc::cmyk::Inks::new(4, 4, vec![0; 64]).unwrap(); + let cmyk = image(&mut s, "cmyk", vectorcraft_doc::ImageBlob::cmyk_tiff(&inks).unwrap(), 100.0); + for (kind, ids) in [ + ("spotColors", vec![spot]), + ("colorEffects", vec![adjusted]), + ("rgbImages", vec![rgb]), + ("grayImages", vec![gray]), + ("cmykImages", vec![cmyk]), + ] { + let v = s.execute("addon.color.select", &json!({"kind": kind})).unwrap(); + assert_eq!((selected(&s), v["count"].clone()), (ids.clone(), json!(ids.len())), "{kind}"); + } + // Locked objects are left out; none found selects nothing. + s.execute("select.set", &json!({"ids": [spot.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + assert_eq!(s.execute("addon.color.select", &json!({"kind": "spotColors"})).unwrap()["count"], 0); + assert!(!selected(&s).contains(&plain)); + assert!(s.execute("addon.color.select", &json!({"kind": "pantone"})).is_err()); + assert!(s.execute("addon.color.select", &json!({})).is_err()); +} + +/// Vector Halftone, the live effect. +const HALFTONE: &str = "addon.color.halftone"; + +/// The dots in group `g`: each path's geometry, ink and blend mode. +fn dots(g: &vectorcraft_doc::Node) -> Vec<(vectorcraft_geom::PathData, String, vectorcraft_color::BlendMode)> { + let ink = |c: &vectorcraft_doc::Node| c.appearance.fill_paint().color().unwrap().to_hex(); + g.children().unwrap().iter().map(|c| (c.path_data().unwrap().clone(), ink(c), c.blend)).collect() +} + +/// `format` as `document.export` writes the active document. +fn exported(s: &mut Session, format: &str) -> Vec { + let v = s.execute("document.export", &json!({"format": format})).unwrap(); + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// How many subpaths the path data of `svg` starts. +fn svg_moves(svg: &[u8]) -> usize { + let svg = String::from_utf8_lossy(svg); + svg.split(" d=\"").skip(1).map(|d| d.split('"').next().unwrap_or("").matches(['M', 'm']).count()).sum() +} + +#[test] +fn expand_appearance_gives_the_dots_object_vector_halftone_makes() { + for params in [ + json!({"frequency": 18}), + json!({"frequency": 12, "mode": "cmyk", "shape": "square", "angle": 15}), + json!({"shape": "line", "invert": true, "clip": false}), + json!({"shape": "ellipse", "color": "#336699", "angle": -30}), + ] { + let (mut live, id) = red_rect(); + live.execute("effect.apply", &json!({"effect": HALFTONE, "params": params})).unwrap(); + assert_eq!(fill_hex(&live, id), "#ff0000", "the effect leaves the object as it is"); + live.execute("effect.expandAppearance", &json!({})).unwrap(); + let expanded = live.doc().unwrap().doc.node(id).cloned().unwrap(); + assert!(expanded.appearance.effects.is_empty()); + let (mut made, _) = red_rect(); + let r = made.execute("object.vectorHalftone", ¶ms).unwrap(); + let g = made.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).cloned().unwrap(); + assert!(!dots(&g).is_empty(), "{params}"); + assert_eq!(dots(&expanded), dots(&g), "{params}"); + // Ids of their own. + let ids: std::collections::HashSet<_> = expanded.children().unwrap().iter().map(|c| c.id).chain([id]).collect(); + assert_eq!(ids.len(), expanded.children().unwrap().len() + 1); + } +} + +#[test] +fn exports_write_the_dots_as_paths() { + let (mut s, _) = red_rect(); + let plain: Vec = ["pdf", "eps", "ai"].map(|f| exported(&mut s, f).len()).to_vec(); + s.execute("effect.apply", &json!({"effect": HALFTONE, "params": {"frequency": 18}})).unwrap(); + // The exporters' copy of the document has the dots in the object's place. + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + let mut paths = 0; + baked.walk(|n| { + assert!(n.appearance.effects.iter().all(|e| e.id != HALFTONE)); + paths += n.path_data().map_or(0, |p| p.subpaths.len()); + }); + assert!(paths > 100, "{paths} subpaths"); + // SVG: path data of a subpath per dot. + let moves = svg_moves(&exported(&mut s, "svg")); + assert!(moves > 100, "{moves} subpaths"); + // PDF, EPS and AI: the dots' paths make each file larger. + for (f, plain) in ["pdf", "eps", "ai"].into_iter().zip(plain) { + let with = exported(&mut s, f).len(); + assert!(with > plain + 1000, "{f}: {with} bytes, {plain} without the effect"); + } +} + +#[test] +fn a_halftone_past_the_caps_leaves_the_object_as_it_is() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 9000})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": HALFTONE, "params": {"frequency": 300}})).unwrap(); + // Drawn as it is, exported and expanded without dots and without a panic. + pixel(&s); + assert!(svg_moves(&exported(&mut s, "svg")) < 5); + let r = s.execute("effect.expandAppearance", &json!({})); + assert!(!matches!(r, Err(crate::EngineError::Internal { .. })), "{r:?}"); + let n = s.doc().unwrap().doc.node(id).cloned().unwrap(); + assert!(n.children().is_none_or(|c| c.len() < 5), "no dots"); +} + +/// Duotone, the live effect. +const DUOTONE: &str = "addon.color.duotone"; + +/// A document with spot swatch "Ink" and a selected grey (50 %) rectangle, unstroked. +fn grey_rect() -> (Session, NodeId) { + let (mut s, id) = red_rect(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"model": "cmyk", "c": 0.0, "m": 0.6, "y": 1.0, "k": 0.0}, "spot": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": {"gray": 0.5}})).unwrap(); + (s, id) +} + +fn ink_tone(curve: &str) -> serde_json::Value { + json!({"tones": [{"color": {"model": "cmyk", "c": 0.0, "m": 0.6, "y": 1.0, "k": 0.0}, "swatch": "Ink", "curve": curve}]}) +} + +fn fill_paint(s: &Session, id: NodeId) -> vectorcraft_color::Paint { + s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().clone() +} + +#[test] +fn duotone_draws_a_tint_of_its_spot_tone_and_expands_to_it() { + let (mut s, id) = grey_rect(); + let grey = pixel(&s); + s.execute("effect.apply", &json!({"effect": DUOTONE, "params": ink_tone("0,0 100,100")})).unwrap(); + assert_eq!(fill_paint(&s, id).color().unwrap(), vectorcraft_color::Color::gray(0.5), "the colours stay"); + let drawn = pixel(&s); + assert!(drawn != grey && drawn[0] > drawn[2], "drawn in the ink: {drawn:?}"); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill_paint(&s, id) else { panic!("a solid fill") }; + assert_eq!(swatch.as_deref(), Some("Ink"), "linked to the spot swatch, so it separates on its plate"); + assert!((tint - 0.5).abs() < 0.01, "{tint}"); +} + +#[test] +fn duotone_as_a_filter_equals_the_live_effect_expanded() { + for params in [ + ink_tone("0,0 100,100"), + ink_tone("0,0 40,70 100,100"), + json!({"tones": [{"color": "#00ffff", "swatch": "Ink"}, {"color": {"model": "cmyk", "c": 0.0, "m": 0.0, "y": 0.0, "k": 1.0}, "curve": "0,0 100,40"}]}), + json!({"tones": [{"color": "#000000"}], "skipSpot": true}), + ] { + let (mut live, id) = grey_rect(); + live.execute("effect.apply", &json!({"effect": DUOTONE, "params": params})).unwrap(); + live.execute("effect.expandAppearance", &json!({})).unwrap(); + let (mut filtered, _) = grey_rect(); + let undo = filtered.doc().unwrap().history.undo.len(); + assert_eq!(filtered.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": params})).unwrap()["changed"], 1); + assert_eq!(filtered.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + assert_eq!(fill_paint(&filtered, id), fill_paint(&live, id), "{params}"); + } + // The params round-trip: the effect keeps what it was given, and the filter reads the same. + let (mut s, id) = grey_rect(); + let params = ink_tone("0,0 40,70 100,100"); + s.execute("effect.apply", &json!({"effect": DUOTONE, "params": params})).unwrap(); + let kept = s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params.clone(); + assert_eq!(kept["tones"], params["tones"]); + s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": kept})).unwrap(); + let vectorcraft_color::Paint::Solid { swatch, tint, .. } = fill_paint(&s, id) else { panic!("a solid fill") }; + assert_eq!(swatch.as_deref(), Some("Ink")); + let fx = vectorcraft_render::effects::addons::duotone::tone_curve_points(Some(&json!("0,0 40,70 100,100"))); + let want = vectorcraft_render::effects::curve_at(&fx, 0.5); + assert!(want > 0.7 && (tint - want).abs() < 0.01, "the curve lifts the mid tone: {tint}, {want}"); +} + +#[test] +fn duotone_skips_spot_colours_when_asked() { + let (mut s, id) = grey_rect(); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let spot = fill_paint(&s, id); + s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": {"tones": [{"color": "#000000"}], "skipSpot": true}})).unwrap(); + assert_eq!(fill_paint(&s, id), spot); + s.execute("addon.color.filter", &json!({"effect": DUOTONE, "params": {"tones": [{"color": "#000000"}]}})).unwrap(); + assert_ne!(fill_paint(&s, id), spot); +} diff --git a/crates/engine/src/cmd/addons/pathtools.rs b/crates/engine/src/cmd/addons/pathtools.rs new file mode 100644 index 000000000..b78eb0976 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools.rs @@ -0,0 +1,723 @@ +//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke, +//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's +//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners +//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), and the Shared Edge Merge Tool's +//! merges with Keep Shared Points Together ([`shared_edge`]). +//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's +//! and the UI's. + +mod corners; +#[cfg(test)] +mod effect_tests; +mod shape; +mod shared_edge; +#[cfg(test)] +mod tests; + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Value, json}; +use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_pathops::addons::{self as po, pathtools as pt}; + +use super::super::draw2::{replace_with_pieces, usize_opt}; +use super::super::pathops::Beside; +use super::super::{ + CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param, +}; +use crate::{EngineError, Result, Session}; + +/// The Anchor Eraser Brush's tolerance by default (the scale Simplify Anchors (Keep Shape) uses). +const ERASE_TOLERANCE: f64 = 10.0; +/// Most points one brush stroke carries. +const MAX_STROKE_POINTS: usize = 100_000; +/// Most anchors one command edits at once. +const MAX_ANCHORS: usize = 2_000_000; +/// The narrowest a brush gets at the lightest pen pressure (pt). +const MIN_RADIUS: f64 = 0.05; +/// The widest brush (pt). +const MAX_DIAMETER: f64 = 2500.0; +/// The farthest an end is extended at once (pt). +const MAX_EXTEND: f64 = 1e6; +/// Most anchors Add Points puts on each segment. +const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64; + +pub fn specs() -> Vec { + let mut v = vec![ + cmd!( + "addon.pathtools.erase", + "Anchor Eraser Brush", + [], + None, + "{points: [[x, y] | [x, y, pressure 0..1]…] (the brush's path, at most 100000 points), diameter?: pt 0.1..2500 (20; a point's pressure scales it), tolerance?: 1..100 (10; the outline may move up to tolerance × 0.02 pt), ignoreTolerance?: bool (false: every anchor under the brush that can go goes, its neighbours refitted), plain?: bool (false: true deletes them without refitting the handles beside them), ids?: [id…] (default: the selection)} remove the anchors of the paths under the objects that the brush passes over and that can go while the outline stays within the tolerance, as one undo step; open ends stay → {ids, removed}", + has_selection, + erase + ), + cmd!( + "addon.pathtools.extend", + "Extend Path", + [], + None, + "{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step; with Keep Shared Points Together on, anchors coincident with an end that moves go with it → {id, length (pt)}", + has_doc, + extend + ), + cmd!( + query "addon.pathtools.inspect", + "Measure & Inspect", + [], + None, + "{id, x, y, tolerance?: pt (3; how near an anchor counts as over it)} what the path reads at the point nearest (x, y): {subpath, closed, anchors, length, area (pt², an open subpath closed by a straight line), segment, t, point: [x, y], segmentLength, tangent (degrees counter-clockwise), radius (of curvature, null where straight), anchor?: {index, x, y, smooth, angle (at the anchor, null at an open end), in?: {length, angle}, out?: {length, angle}}}", + has_doc, + inspect + ), + cmd!( + "addon.pathtools.setAnchor", + "Set Anchor", + [], + None, + "{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set; with Keep Shared Points Together on, anchors coincident with one that moves go with it → {anchors}", + has_selection, + set_anchor + ), + cmd!( + "addon.pathtools.smooth", + "Make Smooth", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{ratio?: 0.1..0.5 (0.3905: a square's corners make a circle)} make the selected anchors smooth: a lone handle is mirrored; without handles, both stand at right angles to the bisector of the angle at the anchor, ratio × the nearer neighbour's distance long; two handles are put in line; an open end without handles aims at its neighbour's handle. One undo step → {anchors (changed)}", + has_selection, + smooth + ), + cmd!( + "addon.pathtools.corner", + "Make Corner", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{retract?: bool (true: the handles go; false: they stay where they are and move apart)} make the selected anchors corners, as one undo step → {anchors (changed)}", + has_selection, + corner + ), + cmd!( + "addon.pathtools.equalize", + "Equalize Handles", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{} give each selected anchor's two handles their average length (a missing one is made opposite the other), as one undo step → {anchors (changed)}", + has_selection, + equalize + ), + cmd!( + "addon.pathtools.removeKeepShape", + "Remove Points, Keep Shape", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{} remove the direct-selected anchors, refitting the handles beside each so the outline keeps its shape as best it can, as one undo step; open ends stay → {removed}", + has_selection, + remove_keep_shape + ), + cmd!( + "addon.pathtools.addPoints", + "Add Points to Segments…", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{count?: 1..100 (1)} add count anchors to each selected segment (a segment between two selected anchors, or every segment of a path selected whole), splitting it into pieces of equal length; the shape doesn't change. One undo step → {added}", + has_selection, + add_points + ), + cmd!( + "addon.pathtools.close", + "Close Path", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{mode?: \"handles\" (keep the ends' handles)|\"straight\" (a straight closing segment)|\"smooth\" (the ends made smooth), ratio?: 0.1..0.5 (0.3905, for smooth)} close the open subpaths of the selected paths, as one undo step → {closed}", + has_selection, + close + ), + cmd!( + "addon.pathtools.crossings", + "Add Points at Crossings", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{cut?: bool (false: add anchors where the paths cross; true: also cut them there), selfCrossings?: bool (false: where a path crosses itself too), ids?: [id…] (default: the selection)} where the paths under the objects cross one another, as one undo step; at most 10000 segments → {ids, crossings (anchors placed)}", + has_selection, + crossings + ), + cmd!( + "addon.pathtools.slideAnchor", + "Slide Anchor Along Path", + [], + None, + "{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide; with Keep Shared Points Together on, anchors coincident with it go with it → {id, strayed (pt)}", + has_doc, + slide_anchor + ), + ]; + v.extend(shape::specs()); + v.extend(corners::specs()); + v.extend(shared_edge::specs()); + v +} + +/// The editable paths (not guides) under `roots`, compound path members included. +fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { + let editable = d.editable(); + let mut out = vec![]; + for n in roots.iter().filter_map(|id| d.node(*id)) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { guide: false, .. }) && editable(c.id) { + out.push(c.id); + } + }); + } + out +} + +/// Put `np` in path `id`'s place; its live shape goes (its anchors are edited). +fn set_path(d: &mut Document, id: NodeId, np: PathData) { + if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + *path = np; + *live = None; + } +} + +/// Error unless the paths together have at most [`MAX_ANCHORS`] anchors. +fn check_size<'a>(paths: impl IntoIterator, cmd: &str) -> Result<()> { + if paths.into_iter().map(PathData::anchor_count).sum::() > MAX_ANCHORS { + return Err(bad(cmd, "the paths have too many anchors to edit at once")); + } + Ok(()) +} + +// ---------- Anchor Eraser Brush ---------- + +/// The brush's points (with the radius each has), from `points`. +fn stroke(p: &Value, radius: f64, cmd: &str) -> Result> { + let list = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points"))?; + if list.len() > MAX_STROKE_POINTS { + return Err(bad(cmd, "too many `points` (at most 100000)")); + } + list.iter() + .map(|v| { + let point = |[x, y]: [f64; 2], pressure: f64| (Point::new(x, y), (radius * pressure.clamp(0.0, 1.0)).max(MIN_RADIUS)); + finite_numbers::<2>(v) + .map(|xy| point(xy, 1.0)) + .or_else(|| finite_numbers::<3>(v).map(|[x, y, f]| point([x, y], f))) + .ok_or_else(|| bad(cmd, format!("bad point {v}: expected [x, y] or [x, y, pressure]"))) + }) + .collect() +} + +/// Does the brush, sweeping through `pts`, cover `q`? +fn under_brush(pts: &[(Point, f64)], q: Point) -> bool { + let hit = |(a, ra): (Point, f64), (b, rb): (Point, f64)| { + let ab = b - a; + let len2 = ab.hypot2(); + let t = if len2 > 0.0 { ((q - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + q.distance(a + ab * t) <= ra + (rb - ra) * t + }; + match pts { + [only] => hit(*only, *only), + _ => pts.windows(2).any(|w| matches!(w, [a, b] if hit(*a, *b))), + } +} + +/// The anchors of `sp` that go when the brush passes over those marked, removed without refitting +/// the handles beside them: open ends stay, and the subpath keeps two anchors (three closed). +fn delete_marked(sp: &SubPath, marked: &[bool]) -> SubPath { + let n = sp.anchors.len(); + let min = if sp.closed { 3 } else { 2 }; + let mut left = n.saturating_sub(min); + let mut out = sp.clone(); + let keep: Vec = (0..n) + .map(|i| { + let end = !sp.closed && (i == 0 || i + 1 == n); + let go = !end && left > 0 && marked.get(i).copied().unwrap_or(false); + left -= usize::from(go); + !go + }) + .collect(); + let mut it = keep.iter(); + out.anchors.retain(|_| it.next().copied().unwrap_or(true)); + out +} + +fn erase(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.erase"; + let diameter = number_or(p, "diameter", 20.0, C)?; + if !(0.1..=MAX_DIAMETER).contains(&diameter) { + return Err(bad(C, "`diameter` must be from 0.1 to 2500 pt")); + } + let tolerance = number_or(p, "tolerance", ERASE_TOLERANCE, C)?; + if !(1.0..=100.0).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 1 to 100")); + } + let (ignore, plain) = (switch_or(p, "ignoreTolerance", false, C)?, switch_or(p, "plain", false, C)?); + let pts = stroke(p, diameter / 2.0, C)?; + let roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + let ids = paths_under(d, &roots); + let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect(); + check_size(paths.iter().map(|(_, p)| *p), C)?; + let tol = if ignore { f64::INFINITY } else { tolerance * po::PT_PER_TOLERANCE }; + let mut removed = 0; + let mut changed = vec![]; + for (id, path) in paths { + let subs: Vec = path + .subpaths + .iter() + .map(|sp| { + let marked: Vec = sp.anchors.iter().map(|a| !pts.is_empty() && under_brush(&pts, a.p)).collect(); + match marked.contains(&true) { + false => sp.clone(), + true if plain => delete_marked(sp, &marked), + true => po::simplify_anchors_keep_shape(sp, tol, &marked), + } + }) + .collect(); + let np = PathData::new(subs); + if np != *path { + removed += path.anchor_count().saturating_sub(np.anchor_count()); + changed.push((id, np)); + } + } + edit_whole(s, "Anchor Eraser Brush", changed)?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "removed": removed })) +} + +/// Replace paths with the new data given, as one step (none when there is nothing to change); each +/// is selected whole, as its anchors' indexes changed. +fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) -> Result<()> { + if changed.is_empty() { + return Ok(()); + } + s.edit(label, |d, sel| { + for (id, np) in changed { + sel.anchors.remove(&id); + sel.segments.remove(&id); + set_path(d, id, np); + } + Ok(()) + }) +} + +// ---------- Extend Path ---------- + +/// The subpath a request names: (path id, subpath index, its data). +fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> { + let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?; + let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0); + let d = &s.doc()?.doc; + if !d.is_editable(id) { + return Err(bad(cmd, "the path is locked or hidden")); + } + let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?; + let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?; + Ok((id, si, sp.clone())) +} + +/// Put `new` in place of the subpath `old` (from [`named_subpath`]) as one step labelled `label` +/// (none when it is the same), with what Keep Shared Points Together moves too; the path's direct +/// selection goes. +fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> { + if new == old { + return Ok(()); + } + let mut np = s.doc()?.doc.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?; + if let Some(slot) = np.subpaths.get_mut(si) { + *slot = new; + } + let shared = shared_edge::shared_points(s, [(id, &np)])?; + s.edit(label, |d, sel| { + sel.anchors.remove(&id); + sel.segments.remove(&id); + set_path(d, id, np); + shared_edge::move_shared(d, shared); + Ok(()) + }) +} + +fn extend(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.extend"; + let (id, si, sp) = named_subpath(s, p, C)?; + if sp.closed || sp.anchors.len() < 2 { + return Err(bad(C, "the subpath has no ends to extend (it is closed or a single point)")); + } + let at_start = match text_param(p, "end", C)? { + Some("start") => true, + Some("end") => false, + _ => return Err(bad(C, "`end` must be \"start\" or \"end\"")), + }; + let mode = match text_param(p, "mode", C)? { + None => pt::ExtendMode::Straight, + Some(m) => pt::ExtendMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"straight\", \"bezier\" or \"arc\""))?, + }; + let length = match (p.get("to").filter(|v| !v.is_null()), p.get("length").filter(|v| !v.is_null())) { + (Some(to), None) => { + let [x, y] = finite_numbers::<2>(to).ok_or_else(|| bad(C, "`to` must be [x, y]"))?; + pt::extend_length_to(&sp, at_start, Point::new(x, y), mode).ok_or_else(|| bad(C, "the path can't be extended"))? + } + (None, Some(_)) => number_or(p, "length", 0.0, C)?, + _ => return Err(bad(C, "give `length` or `to`")), + }; + if !length.is_finite() || length.abs() > MAX_EXTEND { + return Err(bad(C, "`length` must be a number of at most 1000000 pt either way")); + } + let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?; + set_subpath(s, "Extend Path", (id, si, sp), new)?; + Ok(json!({ "id": id.0, "length": length })) +} + +// ---------- Slide Anchor Along Path ---------- + +fn slide_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.slideAnchor"; + let (id, si, sp) = named_subpath(s, p, C)?; + let anchor = usize_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "give `anchor`, the index of the anchor to slide"))?; + let t = number_or(p, "t", f64::NAN, C)?; + if t.is_nan() || t.abs() > pt::MAX_SLIDE { + return Err(bad(C, "`t` must be a number from -0.98 to 0.98")); + } + let (new, strayed) = + pt::slide_anchor(&sp, anchor, t).ok_or_else(|| bad(C, "that anchor can't slide: an end of an open path has nowhere to go"))?; + set_subpath(s, "Slide Anchor Along Path", (id, si, sp), new)?; + Ok(json!({ "id": id.0, "strayed": strayed })) +} + +// ---------- Measure & Inspect ---------- + +fn polar(h: Option<(f64, f64)>) -> Value { + h.map_or(Value::Null, |(length, angle)| json!({ "length": length, "angle": angle })) +} + +fn inspect(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.inspect"; + let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?; + let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?); + let tolerance = number_or(p, "tolerance", 3.0, C)?; + if !(x.is_finite() && y.is_finite() && tolerance >= 0.0 && tolerance.is_finite()) { + return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)")); + } + let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?; + let i = pt::inspect(path, Point::new(x, y), tolerance).ok_or_else(|| bad(C, "the path has no segments to measure"))?; + let anchor = i.anchor.map(|a| { + json!({ "index": a.index, "x": a.p.x, "y": a.p.y, "smooth": a.smooth, "angle": a.angle, "in": polar(a.handle_in), "out": polar(a.handle_out) }) + }); + Ok(json!({ + "subpath": i.subpath, "closed": i.closed, "anchors": i.anchors, "length": i.length, "area": i.area, + "segment": i.segment, "t": i.t, "point": [i.point.x, i.point.y], "segmentLength": i.segment_length, + "tangent": i.tangent, "radius": i.radius, "anchor": anchor, + })) +} + +// ---------- Anchor Inspector ---------- + +/// The anchors the Anchor Inspector acts on, per path: each path's direct-selected anchors, or +/// every anchor of a path selected whole (with `whole`), keyed by subpath. +type Picked = Vec<(NodeId, BTreeMap>)>; + +/// The selected anchors ([`Picked`]); `whole` counts the anchors of paths selected whole. +fn picked_anchors(s: &Session, whole: bool) -> Result { + let st = s.doc()?; + let (d, sel) = (&st.doc, &st.selection); + let roots = super::super::edit::selected_roots(s)?; + let mut out = vec![]; + for id in paths_under(d, &roots) { + let Some(path) = d.node(id).and_then(|n| n.path_data()) else { continue }; + let refs: Vec = match sel.anchors.get(&id).filter(|a| !a.is_empty()) { + Some(set) => set.iter().copied().collect(), + None if whole => path.anchors().map(|(si, ai, _)| (si, ai)).collect(), + None => continue, + }; + let mut by_sub: BTreeMap> = BTreeMap::new(); + for (si, ai) in refs.into_iter().filter(|(si, ai)| path.subpaths.get(*si).is_some_and(|sp| *ai < sp.anchors.len())) { + by_sub.entry(si).or_default().insert(ai); + } + if !by_sub.is_empty() { + out.push((id, by_sub)); + } + } + Ok(out) +} + +/// Edit each selected anchor ([`picked_anchors`]) in place with `f` (true when it changed one), as +/// one step labelled `label`; the selection stays. → how many anchors changed. +fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath, usize) -> bool) -> Result { + let picked = picked_anchors(s, true)?; + if picked.is_empty() { + return Err(bad(cmd, "select paths or anchor points")); + } + let d = &s.doc()?.doc; + check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), cmd)?; + let mut count = 0; + let mut changed = vec![]; + for (id, by_sub) in &picked { + let Some(mut np) = d.node(*id).and_then(|n| n.path_data()).cloned() else { continue }; + let before = count; + for (si, anchors) in by_sub { + let Some(sp) = np.subpaths.get_mut(*si) else { continue }; + count += anchors.iter().filter(|&&i| f(sp, i)).count(); + } + if count > before { + changed.push((*id, np)); + } + } + if !changed.is_empty() { + let shared = shared_edge::shared_points(s, changed.iter().map(|(id, np)| (*id, np)))?; + s.edit(label, |d, _| { + for (id, np) in changed { + set_path(d, id, np); + } + shared_edge::move_shared(d, shared); + Ok(()) + })?; + } + Ok(count) +} + +/// A smoothing `ratio` param (default [`pt::SMOOTH_RATIO`]). +pub(super) fn ratio(p: &Value, cmd: &str) -> Result { + let r = number_or(p, "ratio", pt::SMOOTH_RATIO, cmd)?; + let (lo, hi) = pt::SMOOTH_RATIO_RANGE; + if !(lo..=hi).contains(&r) { + return Err(bad(cmd, "`ratio` must be from 0.1 to 0.5")); + } + Ok(r) +} + +fn smooth(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.smooth"; + let r = ratio(p, C)?; + let n = edit_anchors(s, "Make Smooth", C, |sp, i| pt::auto_smooth(sp, i, r))?; + Ok(json!({ "anchors": n })) +} + +fn corner(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.corner"; + let retract = switch_or(p, "retract", true, C)?; + let n = edit_anchors(s, "Make Corner", C, |sp, i| pt::make_corner(sp, i, retract))?; + Ok(json!({ "anchors": n })) +} + +fn equalize(s: &mut Session, _: &Value) -> Result { + const C: &str = "addon.pathtools.equalize"; + let n = edit_anchors(s, "Equalize Handles", C, pt::equalize_handles)?; + Ok(json!({ "anchors": n })) +} + +/// A number param that is given, finite, and (with `range`) inside it. +fn given(p: &Value, key: &str, range: Option<(f64, f64)>, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + match range { + Some((lo, hi)) if !(lo..=hi).contains(&x) => Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))), + _ => Ok(Some(x)), + } + } + } +} + +fn set_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.setAnchor"; + const LIMIT: Option<(f64, f64)> = Some((-1e7, 1e7)); + let (x, y) = (given(p, "x", LIMIT, C)?, given(p, "y", LIMIT, C)?); + let lengths = [given(p, "inLength", Some((0.0, 1e7)), C)?, given(p, "outLength", Some((0.0, 1e7)), C)?]; + let angles = [given(p, "inAngle", LIMIT, C)?, given(p, "outAngle", LIMIT, C)?]; + if [x, y].iter().chain(&lengths).chain(&angles).all(Option::is_none) { + return Err(bad(C, "give x, y, or a handle's length or angle")); + } + let n = edit_anchors(s, "Set Anchor", C, |sp, i| { + let Some(a) = sp.anchors.get_mut(i) else { return false }; + let before = *a; + let to = Point::new(x.unwrap_or(a.p.x), y.unwrap_or(a.p.y)); + a.translate(to - a.p); + for (out, (len, ang)) in [false, true].into_iter().zip(lengths.into_iter().zip(angles)) { + if len.is_none() && ang.is_none() { + continue; + } + let h = if out { a.h_out } else { a.h_in }; + let (l0, a0) = pt::handle_polar(a.p, h).unwrap_or((0.0, if out { 0.0 } else { 180.0 })); + let (l, deg) = (len.unwrap_or(l0), ang.unwrap_or(a0)); + // A length alone keeps a smooth anchor's other handle where it is; an angle turns it too. + let other = if out { a.h_in } else { a.h_out }; + a.set_handle(out, pt::handle_at(a.p, l, deg), false); + if ang.is_none() { + if out { a.h_in = other } else { a.h_out = other } + } + } + *a != before + })?; + Ok(json!({ "anchors": n })) +} + +fn remove_keep_shape(s: &mut Session, _: &Value) -> Result { + const C: &str = "addon.pathtools.removeKeepShape"; + let picked = picked_anchors(s, false)?; + if picked.is_empty() { + return Err(bad(C, "direct-select the anchor points to remove")); + } + let d = &s.doc()?.doc; + check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?; + let mut removed = 0; + let mut changed = vec![]; + for (id, by_sub) in &picked { + let Some(mut np) = d.node(*id).and_then(|n| n.path_data()).cloned() else { continue }; + for (si, anchors) in by_sub { + let Some(sp) = np.subpaths.get_mut(*si) else { continue }; + // From the last back, so the indexes left stay right. + for &i in anchors.iter().rev() { + if let Some((out, _)) = po::remove_anchor_keep_shape(sp, i) { + *sp = out; + removed += 1; + } + } + } + if np != *d.node(*id).and_then(|n| n.path_data()).ok_or(EngineError::NoNode(*id))? { + changed.push((*id, np)); + } + } + edit_whole(s, "Remove Points, Keep Shape", changed)?; + Ok(json!({ "removed": removed })) +} + +fn add_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.addPoints"; + let count = number_or(p, "count", 1.0, C)?; + if !(1.0..=MAX_ADD as f64).contains(&count) || count.fract() != 0.0 { + return Err(bad(C, "`count` must be a whole number from 1 to 100")); + } + let picked = picked_anchors(s, true)?; + let st = s.doc()?; + let d = &st.doc; + check_size(picked.iter().filter_map(|(id, _)| d.node(*id)?.path_data()), C)?; + let mut added = 0; + let mut changed = vec![]; + for (id, by_sub) in &picked { + let Some(path) = d.node(*id).and_then(|n| n.path_data()) else { continue }; + let picked_segments = st.selection.segments.get(id); + let mut np = path.clone(); + for (si, sp) in np.subpaths.iter_mut().enumerate() { + let Some(anchors) = by_sub.get(&si) else { continue }; + let n = sp.anchors.len(); + // Picked segments, else those between two selected anchors. + let segs: BTreeSet = match picked_segments { + Some(set) => set.iter().filter(|(s, _)| *s == si).map(|(_, k)| *k).collect(), + None => (0..sp.segment_count()).filter(|k| anchors.contains(k) && anchors.contains(&((k + 1) % n.max(1)))).collect(), + }; + let (out, marks) = pt::add_points(sp, Some(&segs), count as usize); + added += marks.len(); + *sp = out; + } + if np != *path { + changed.push((*id, np)); + } + } + if added == 0 { + return Err(bad(C, "select segments, or paths, to add points to")); + } + edit_whole(s, "Add Points to Segments", changed)?; + Ok(json!({ "added": added })) +} + +pub(super) fn close(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.close"; + let mode = match text_param(p, "mode", C)? { + None => pt::CloseMode::Handles, + Some(m) => pt::CloseMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"handles\", \"straight\" or \"smooth\""))?, + }; + let r = ratio(p, C)?; + let roots = super::super::edit::selected_roots(s)?; + let d = &s.doc()?.doc; + let mut closed = 0; + let mut changed = vec![]; + for id in paths_under(d, &roots) { + let Some(path) = d.node(id).and_then(|n| n.path_data()) else { continue }; + let mut np = path.clone(); + let n = np.subpaths.iter_mut().map(|sp| pt::close(sp, mode, r)).filter(|c| *c).count(); + if n > 0 { + closed += n; + changed.push((id, np)); + } + } + if changed.is_empty() { + return Err(bad(C, "select open paths to close")); + } + s.edit("Close Path", |d, _| { + for (id, np) in changed { + set_path(d, id, np); + } + Ok(()) + })?; + Ok(json!({ "closed": closed })) +} + +// ---------- Split / Add Points at Crossings ---------- + +fn crossings(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.crossings"; + let (cut, self_crossings) = (switch_or(p, "cut", false, C)?, switch_or(p, "selfCrossings", false, C)?); + let roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + let ids = paths_under(d, &roots); + let paths: Vec<&PathData> = ids.iter().filter_map(|id| d.node(*id)?.path_data()).collect(); + if paths.len() != ids.len() { + return Err(EngineError::Other("Add Points at Crossings: a path went missing".into())); + } + let places = pt::crossings(&paths, self_crossings) + .ok_or_else(|| bad(C, "the paths are too many or too tangled to find where they cross (at most 10000 segments)"))?; + let mut count = 0; + // Each path that is crossed: its new subpaths, and the anchors at the crossings on each. + let mut changed: Vec<(NodeId, Vec, Vec>)> = vec![]; + for ((id, path), on) in ids.iter().zip(&paths).zip(places) { + if on.is_empty() { + continue; + } + let mut subs = vec![]; + let mut marks = vec![]; + for (si, sp) in path.subpaths.iter().enumerate() { + let here: Vec<(usize, f64)> = on.iter().filter(|(s, ..)| *s == si).map(|&(_, seg, t)| (seg, t)).collect(); + let (out, m) = pt::insert_at(sp, &here); + count += m.len(); + subs.push(out); + marks.push(m); + } + changed.push((*id, subs, marks)); + } + if changed.is_empty() { + return Ok(json!({ "ids": [], "crossings": 0 })); + } + let label = if cut { "Split at Crossings" } else { "Add Points at Crossings" }; + let out = s.edit(label, |d, sel| { + let mut all = vec![]; + let mut beside = Beside::replacing(d, &changed.iter().map(|(id, ..)| *id).collect::>()); + for (id, subs, marks) in changed { + sel.anchors.remove(&id); + sel.segments.remove(&id); + if !cut { + set_path(d, id, PathData::new(subs)); + all.push(id); + continue; + } + let pieces: Vec = subs.iter().zip(&marks).flat_map(|(sp, m)| sp.cut_at(m)).collect(); + // A path of one subpath becomes a path per piece; a compound shape's pieces stay its subpaths. + if subs.len() == 1 { + let made = replace_with_pieces(d, &mut beside, id, pieces.into_iter().map(PathData::single).collect())?; + for n in &made { + if !sel.contains(*n) { + sel.add(*n); + } + } + all.extend(made); + } else { + set_path(d, id, PathData::new(pieces)); + all.push(id); + } + } + beside.finish(d)?; + Ok(all) + })?; + Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>(), "crossings": count })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/corners.rs b/crates/engine/src/cmd/addons/pathtools/corners.rs new file mode 100644 index 000000000..464fa032b --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/corners.rs @@ -0,0 +1,144 @@ +//! The Corner Tool's commands: the app's Live Corners ([`LiveCorners`]) cut, resized and removed on +//! chosen anchors, each edit one undo step. + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveCorners, NodeId, NodeKind}; +use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_tools::addons::corner_tool::MAX_RADIUS; + +use super::super::super::object::recut; +use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, number_or}; +use super::{check_size, paths_under}; +use crate::{Result, Session}; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.cornerSet", + "Set Corners", + [], + None, + "{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}", + has_doc, + corner_set + ), + cmd!( + "addon.pathtools.cornerRemove", + "Remove Corners", + [], + None, + "{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors)} make those live corners sharp again, as one undo step → {ids, corners (how many)}", + has_doc, + corner_remove + ), + ] +} + +/// The `anchors` of `p`. +fn anchors_param(p: &Value, cmd: &str) -> Result>> { + let Some(v) = p.get("anchors").filter(|v| !v.is_null()) else { return Ok(None) }; + let list = v.as_array().ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {v}")))?; + list.iter() + .map(|k| { + k.as_u64() + .and_then(|k| usize::try_from(k).ok()) + .ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {k}"))) + }) + .collect::>() + .map(Some) +} + +/// The corners a request names: each path with the corners (anchor indices of its uncut outline). +fn named_corners(s: &Session, p: &Value, cmd: &str) -> Result)>> { + let shared = anchors_param(p, cmd)?; + // Each path with its own `anchors`, or sharing the request's. + let mut own: Vec>> = vec![]; + let ids: Vec = match p.get("items").filter(|v| !v.is_null()) { + None => paths_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, cmd)?), + Some(Value::Array(items)) => { + let mut ids = vec![]; + for it in items { + ids.push(checked_id(s, it.get("id").unwrap_or(&Value::Null), cmd)?); + own.push(anchors_param(it, cmd)?); + } + ids + } + Some(v) => return Err(bad(cmd, format!("`items` must be a list of {{id, anchors?}}, not {v}"))), + }; + let st = s.doc()?; + let d = &st.doc; + check_size(ids.iter().filter_map(|id| d.node(*id)?.path_data()), cmd)?; + let editable = d.editable(); + let mut out = vec![]; + for (i, id) in ids.iter().enumerate() { + let Some(lc) = d.node(*id).filter(|_| editable(*id)).and_then(LiveCorners::of) else { continue }; + let corners = match own.get(i).and_then(Option::as_ref).or(shared.as_ref()) { + Some(given) => { + let n = lc.base.anchor_count(); + if let Some(past) = given.iter().find(|k| **k >= n) { + return Err(bad(cmd, format!("object {id} has {n} anchors, so no anchor {past}"))); + } + given.iter().copied().filter(|k| lc.corner(*k).is_some()).collect() + } + None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)), + }; + if !corners.is_empty() { + out.push((*id, corners)); + } + } + if out.is_empty() { + return Err(bad(cmd, "choose anchors where two sides meet at an angle: only those take a corner")); + } + Ok(out) +} + +/// Cut `radius` and `kind` on `corners` as one step (none when they have them already). +fn edit_corners(s: &mut Session, label: &str, corners: Vec<(NodeId, BTreeSet)>, radius: f64, kind: Option) -> Result { + let d = &s.doc()?.doc; + let same = corners.iter().all(|(id, picked)| { + d.node(*id) + .and_then(LiveCorners::of) + .is_some_and(|c| picked.iter().all(|k| (c.drawn_radius(*k) - radius).abs() < 1e-9 && kind.is_none_or(|kd| c.kind(*k) == kd))) + }); + let out = json!({ + "ids": corners.iter().map(|(id, _)| id.0).collect::>(), + "corners": corners.iter().map(|(_, c)| c.len()).sum::(), + }); + if !same { + s.edit(label, |d, sel| { + for (id, picked) in corners { + let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + // A cut changes the anchors, which is all a segment selection goes by. + sel.segments.remove(&id); + recut(path, live, sel.anchors.get_mut(&id), &picked, Some(radius), kind); + } + Ok(()) + })?; + } + Ok(out) +} + +fn corner_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.cornerSet"; + let radius = number_or(p, "radius", f64::NAN, C)?; + if !(0.0..=MAX_RADIUS).contains(&radius) { + return Err(bad(C, "`radius` must be from 0 to 1000000 pt")); + } + let kind = p + .get("kind") + .filter(|v| !v.is_null()) + .map(|v| { + serde_json::from_value::(v.clone()) + .map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}"))) + }) + .transpose()?; + let corners = named_corners(s, p, C)?; + edit_corners(s, "Set Corners", corners, radius, kind) +} + +fn corner_remove(s: &mut Session, p: &Value) -> Result { + let corners = named_corners(s, p, "addon.pathtools.cornerRemove")?; + edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round)) +} diff --git a/crates/engine/src/cmd/addons/pathtools/effect_tests.rs b/crates/engine/src/cmd/addons/pathtools/effect_tests.rs new file mode 100644 index 000000000..3465b7279 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/effect_tests.rs @@ -0,0 +1,186 @@ +//! Path Effects (`addon.path.*`) through the session: live on the canvas, the document's path left +//! alone, and Expand Appearance and a save keeping what they draw. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Effect, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; +use vectorcraft_render::Renderer; +use vectorcraft_render::effects::{GeomContext, apply_geometry_bez, effect_catalog, new_effect, preview, scale_effect}; + +use crate::Session; + +/// A black 4 pt stroke along `anchors`, unfilled. +fn stroked(anchors: Value, closed: bool) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.select(|_, sel| sel.set([id])).unwrap(); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + (s, id) +} + +fn wave() -> (Session, NodeId) { + let anchors = json!([ + {"x": 20, "y": 100}, + {"x": 60, "y": 60, "in": [40, 60], "out": [80, 60]}, + {"x": 100, "y": 100}, + {"x": 140, "y": 140}, + {"x": 180, "y": 100} + ]); + stroked(anchors, false) +} + +/// Every Path Effect, with parameters that change the wave. +fn effects() -> Vec<(&'static str, Value)> { + vec![ + ("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "dash2": 3, "gap2": 6})), + ("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "alignCorners": true})), + ("addon.path.addAnchors", json!({"count": 3})), + ("addon.path.addAnchors", json!({"spacing": "distance", "distance": 15, "target": "curved"})), + ("addon.path.extend", json!({"startLength": 10, "endPercent": 10, "endShape": "arc"})), + ("addon.path.extend", json!({"startLength": -10, "endLength": -20})), + ("addon.path.close", json!({})), + ("addon.path.close", json!({"mode": "smooth"})), + ("addon.path.smooth", json!({"ratio": 0.3})), + ("addon.path.retract", json!({})), + ("addon.path.reverse", json!({})), + ("addon.path.simplify", json!({"tolerance": 200, "protectCorners": false})), + ("addon.path.split", json!({"anchors": "both"})), + ] +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn picture_of(d: &Document) -> Vec { + let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + (0..200).flat_map(|y| (0..200).map(move |x| (x, y))).flat_map(|(x, y)| r.pixel(x, y)).collect() +} + +fn picture(s: &Session) -> Vec { + picture_of(&s.doc().unwrap().doc) +} + +/// The outline of `n`: its path, or those of the compound path it became. +fn outline(n: &Node) -> BezPath { + let mut out = n.path_data().map(|p| p.to_bezpath()).unwrap_or_default(); + n.children().into_iter().flatten().for_each(|c| out.extend(outline(c))); + out +} + +/// How many bytes of two pictures differ by more than antialiasing noise. +fn differing(a: &[u8], b: &[u8]) -> usize { + a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 8).count() +} + +#[test] +fn a_path_effect_draws_live_and_leaves_the_document_path() { + let (mut s, id) = stroked(json!([{"x": 10, "y": 50}, {"x": 190, "y": 50}]), false); + let solid = picture(&s); + let path = node(&s, id).path_data().cloned(); + s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 20, "gap1": 20}})).unwrap(); + assert_eq!(node(&s, id).path_data().cloned(), path, "only the effect holds the geometry"); + let dashed = picture(&s); + let at = |pic: &[u8], x: usize| pic[(50 * 200 + x) * 4]; + assert!(at(&solid, 45) < 40 && at(&dashed, 45) > 200, "a gap: {}", at(&dashed, 45)); + assert!(at(&dashed, 15) < 40, "a dash"); + // Hiding it draws the path again. + s.execute("effect.setParams", &json!({"index": 0, "visible": false})).unwrap(); + assert_eq!(picture(&s), solid); +} + +#[test] +fn a_path_effect_moves_the_bounds_the_object_is_measured_by() { + let (mut s, id) = stroked(json!([{"x": 50, "y": 50}, {"x": 150, "y": 50}]), false); + s.execute("effect.apply", &json!({"effect": "addon.path.extend", "params": {"startLength": 0, "endLength": 30}})).unwrap(); + let drawn = preview::bounds_of(&s.doc().unwrap().doc, &[id], true).unwrap(); + assert!((drawn.x1 - 180.0).abs() < 3.0, "{drawn:?}"); + let pic = picture(&s); + assert!(pic[(50 * 200 + 170) * 4] < 40, "and draws there"); +} + +#[test] +fn expand_appearance_writes_the_outline_the_effect_draws() { + for (effect, params) in effects() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + let n = node(&s, id); + let before = n.path_data().cloned().unwrap(); + let live = apply_geometry_bez(&n.appearance.effects, &before.to_bezpath(), before.bounds().unwrap(), &GeomContext::of(&n)); + let pic = picture(&s); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let expanded = node(&s, id); + assert!(expanded.appearance.effects.is_empty(), "{effect} {params}"); + let (out, want) = (outline(&expanded), live); + assert_eq!(out.elements().len(), want.elements().len(), "{effect} {params}"); + for (a, b) in out.elements().iter().zip(want.elements()) { + assert!(a.is_finite() && format!("{a:.4?}") == format!("{b:.4?}"), "{effect} {params}: {a:?} {b:?}"); + } + assert_eq!(differing(&pic, &picture(&s)), 0, "{effect} {params}"); + } +} + +#[test] +fn path_effects_round_trip_through_a_save_and_scale_their_distances() { + for (effect, params) in effects() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap(); + let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(restored.node(id).unwrap().appearance.effects, node(&s, id).appearance.effects, "{effect} {params}"); + assert_eq!(picture_of(&restored), picture(&s), "{effect} {params}"); + } + let mut e = new_effect("addon.path.dashes", &json!({"dash1": 10})).unwrap(); + scale_effect(&mut e, 2.0); + assert_eq!((e.params["dash1"].as_f64(), e.params["gap1"].as_f64()), (Some(20.0), Some(12.0))); +} + +#[test] +fn undo_takes_a_path_effect_off_in_one_step() { + let (mut s, id) = wave(); + s.execute("effect.apply", &json!({"effect": "addon.path.reverse"})).unwrap(); + assert_eq!(node(&s, id).appearance.effects.len(), 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); +} + +/// A file's effects skip the checks `effect.apply` makes: whatever they hold, the object draws and +/// expands. +#[test] +fn path_effects_with_junk_params_from_a_file_draw_and_expand() { + let junk = [ + json!(null), + json!("x"), + json!({"dash1": 1e308, "gap1": -1e308, "offset": "x", "alignCorners": 3}), + json!({"count": 1e308, "maxCount": 1e308, "distance": -1e308, "spacing": "distance", "target": []}), + json!({"startLength": 1e308, "endPercent": -1e308, "endShape": {}, "mode": 1, "ratio": "x", "handles": "shorter", "length": 1e308}), + json!({"tolerance": 1e308, "protectCorners": "no", "anchors": null, "startPercent": 1e308, "startShape": "arc"}), + ]; + let ids = effect_catalog().into_iter().map(|e| e.id).filter(|id| id.starts_with("addon.path.")); + let stack: Vec = + ids.flat_map(|id| junk.iter().map(move |params| Effect { id: id.into(), params: params.clone(), visible: true })).collect(); + let (mut s, id) = wave(); + s.edit("Junk", |d, _| { + if let Some(n) = d.node_mut(id) { + n.appearance.effects = stack; + } + Ok(()) + }) + .unwrap(); + let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false); + picture_of(&vectorcraft_format::load(&saved).unwrap()); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert!(node(&s, id).appearance.effects.is_empty()); +} + +#[test] +fn exports_write_the_dashes_as_paths() { + let (mut s, _) = wave(); + s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 10, "gap1": 10}})).unwrap(); + let v = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let moves = svg.iter().filter(|c| **c == b'M').count(); + assert!(moves > 5, "the exported art holds the dashes: {moves}"); +} diff --git a/crates/engine/src/cmd/addons/pathtools/shape.rs b/crates/engine/src/cmd/addons/pathtools/shape.rs new file mode 100644 index 000000000..4c1ba7e2d --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shape.rs @@ -0,0 +1,137 @@ +//! The Parametric Shape Tool's commands (`addon.pathtools.shape.*`): draw a parametric shape, change +//! one's numbers, and turn plain rectangles, ellipses and regular polygons into them. A parametric +//! shape is an ordinary path whose numbers sit in one entry of its object data +//! (`vectorcraft_tools::addons::parametric_shape`), which SVG export leaves out. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{FillRule, PathData, Point}; +use vectorcraft_pathops::addons::pathtools::shapes::{ParametricShape, ShapeKind, recognise}; +use vectorcraft_tools::addons::parametric_shape::{CREATE, EDIT, KEY, apply, read, stored, to_json}; + +use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, has_selection}; +use crate::{EngineError, Result, Session}; + +/// The undo step's name and the command's label. +const CONVERT: &str = "Convert to Parametric Shape"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.shape.create", + "Parametric Shape", + [], + None, + "{center: [x, y], kind?: \"rectangle\"|\"ellipse\"|\"polygon\" (default)|\"star\"|\"ring\"|\"arrow\"|\"cross\"|\"heart\"|\"gear\"|\"bubble\" (a speech bubble), width?, height?: 0.01..16384 pt (100), rotation?: degrees clockwise (0: upright), count?: 3..1000 (a polygon's sides, a star's points, a gear's teeth), a?, b?: the kind's own values (star: inner radius 0.01..1 of the outer, 0.5; ring: hole 0.01..0.99 of the size, 0.5; arrow: head length and shaft width 0.05..0.95, 0.4 and 0.4; cross: bar offset −0.9..0.9 (0, − up) and arm thickness 0.05..0.95 (1/3); heart: notch depth and point roundness 0..1 (0.4 and 0); gear: tooth height 0.01..0.9 (0.2); bubble: tail height 0.05..0.9 (0.25) and tail tip −1..1 across (−0.4)), radius?: corner radius 0..16384 pt (0; no corner rounds past the middle of a side)} draw a parametric shape with the current paint, selected, as one undo step; it stays editable with addon.pathtools.shape.set until its anchors are edited (moving, turning or scaling it keeps it) → {id, shape}", + has_doc, + create + ), + cmd!( + "addon.pathtools.shape.set", + "Edit Parametric Shape", + [], + None, + "{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}", + has_doc, + set + ), + cmd!( + "addon.pathtools.shape.convert", + "Convert to Parametric Shape", + ["Object", "Plug-ins", "Path Precision Tools"], + None, + "{ids?: [id…] (default: the selection)} make each plain closed path that is a rectangle, an ellipse or a polygon with equal sides and corners (stretched along its own axes at most) a parametric shape of the same outline, as one undo step → {ids (converted)}", + has_selection, + convert + ), + ] +} + +/// Give path node `n` the outline `path` of shape `s` and keep its numbers; a live shape goes. +fn shape_node(n: &mut Node, s: &ParametricShape, path: PathData) { + let entry = stored(s, &path); + if let NodeKind::Path { path: p, live, .. } = &mut n.kind { + *p = path; + *live = None; + } + n.edit_attrs(|a| match a.data.iter_mut().find(|(k, _)| k == KEY) { + Some(e) => e.1 = entry, + None => a.data.push((KEY.into(), entry)), + }); +} + +/// The outline of `s`, checked already. +fn outline(s: &ParametricShape, cmd: &str) -> Result { + s.path().ok_or_else(|| bad(cmd, "the shape's numbers are out of range")) +} + +fn create(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.shape.create"; + if p.get("center").is_none_or(Value::is_null) { + return Err(bad(C, "`center` must be [x, y]")); + } + let shape = apply(ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), p).map_err(|e| bad(C, e))?; + let path = outline(&shape, C)?; + let parent = s.doc()?.target_parent()?; + let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + // shortcut: always on top of the insertion layer, as Draw Normal; honour Draw Behind/Inside if asked. + let id = s.edit(CREATE, |d, sel| { + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Path { path: PathData::default(), rule: FillRule::NonZero, live: None, clipping: false, guide: false }); + shape_node(&mut n, &shape, path); + look.apply(d, &mut n); + d.insert(parent, usize::MAX, n)?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0, "shape": to_json(&shape) })) +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.shape.set"; + let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?; + let d = &s.doc()?.doc; + if !d.is_editable(id) { + return Err(bad(C, "the path is locked or hidden")); + } + let old = d.node(id).and_then(read).ok_or_else(|| bad(C, "the object is not an editable parametric shape"))?; + let shape = apply(old, p).map_err(|e| bad(C, e))?; + if shape != old { + let path = outline(&shape, C)?; + s.edit(EDIT, |d, sel| { + shape_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, &shape, path); + sel.anchors.remove(&id); + sel.segments.remove(&id); + Ok(()) + })?; + } + Ok(json!({ "id": id.0, "shape": to_json(&shape) })) +} + +fn convert(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.shape.convert"; + let roots = checked_ids_or_selection(s, p, C)?; + let d = &s.doc()?.doc; + let mut found = vec![]; + for id in roots { + let Some(n) = d.node(id).filter(|n| d.is_editable(id) && read(n).is_none()) else { continue }; + if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind + && let Some(shape) = recognise(path) + { + found.push((id, shape, outline(&shape, C)?)); + } + } + if found.is_empty() { + return Err(bad(C, "select plain rectangles, ellipses or regular polygons to convert")); + } + let ids: Vec = found.iter().map(|(id, ..)| id.0).collect(); + s.edit(CONVERT, |d, sel| { + for (id, shape, path) in found { + shape_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, &shape, path); + sel.anchors.remove(&id); + sel.segments.remove(&id); + } + Ok(()) + })?; + Ok(json!({ "ids": ids })) +} diff --git a/crates/engine/src/cmd/addons/pathtools/shared_edge.rs b/crates/engine/src/cmd/addons/pathtools/shared_edge.rs new file mode 100644 index 000000000..e5d61c47b --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shared_edge.rs @@ -0,0 +1,164 @@ +//! The Shared Edge Merge Tool's command and Keep Shared Points Together: with that option on, the +//! add-on's own commands that move an anchor (Slide Anchor Along Path, Extend Path, Set Anchor) take +//! the anchors coincident with it along. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind}; +use vectorcraft_geom::{PathData, Point, Vec2}; +use vectorcraft_pathops::addons::pathtools as pt; +use vectorcraft_tools::addons::shared_edge::{DIAMETER, DIAMETER_RANGE, ID}; + +use super::super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, number_or, switch_or, text_param}; +use super::{edit_whole, set_path}; +use crate::{Result, Session}; + +#[cfg(test)] +mod tests; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "addon.pathtools.mergeEdge", + "Merge Shared Edge", + [], + None, + "{a, b: two path ids (they needn't be selected), at: [x, y] (where), diameter?: pt 0.25..200 (20; how near `at` the edge is looked for), tolerance?: pt 0..3 (0.002; how near two anchors count as coincident), follow?: \"average\" (half way between the two)|\"a\"|\"b\" (keep that path's outline), ends?: bool (false: true makes an end even where the paths could merge)} where the paths touch at two coincident anchors, one each side of `at`, replace the stretches between them with one shared edge; stretches much longer one than the other (by over 10%) or leaving an end at over 120° aren't merged. Otherwise make an end there: two near anchors meet, and an anchor only one path has there is matched by a new one on the other. Each path stays closed or open; one undo step → {a, b, made: \"edge\"|\"end\"|\"nothing\" (merged already)}", + has_doc, + merge_edge + ), + cmd!( + "addon.pathtools.movePointsTogether", + "Keep Shared Points Together", + [], + None, + "{enabled?: bool (default: the opposite of now)} Keep Shared Points Together: whether anchors coincident (within the Shared Edge Merge Tool's `tolerance`) with one that Slide Anchor Along Path, Extend Path or Set Anchor moves move with it, on any editable path (the `together` option of tool `sharedEdge`, kept with the preferences; off by default) → {enabled}", + always, + move_points_together + ), + ] +} + +/// A path a merge may edit. +fn merge_path<'a>(d: &'a Document, id: NodeId, cmd: &str) -> Result<&'a PathData> { + if !d.is_editable(id) { + return Err(bad(cmd, "the path is locked or hidden")); + } + match d.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, guide: false, .. }) => Ok(path), + _ => Err(bad(cmd, "`a` and `b` must be two different paths")), + } +} + +fn merge_edge(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.mergeEdge"; + let a = checked_id(s, p.get("a").unwrap_or(&Value::Null), C)?; + let b = checked_id(s, p.get("b").unwrap_or(&Value::Null), C)?; + if a == b { + return Err(bad(C, "`a` and `b` must be two different paths")); + } + let [x, y] = p.get("at").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + let at = Point::new(x, y); + let diameter = number_or(p, "diameter", DIAMETER, C)?; + if !(DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(&diameter) { + return Err(bad(C, "`diameter` must be from 0.25 to 200 pt")); + } + let tolerance = number_or(p, "tolerance", pt::COINCIDENCE, C)?; + if !(0.0..=pt::MAX_COINCIDENCE).contains(&tolerance) { + return Err(bad(C, "`tolerance` must be from 0 to 3 pt")); + } + let follow = match text_param(p, "follow", C)? { + None => pt::Follow::Average, + Some(f) => pt::Follow::parse(f).ok_or_else(|| bad(C, "`follow` must be \"average\", \"a\" or \"b\""))?, + }; + let ends = switch_or(p, "ends", false, C)?; + let d = &s.doc()?.doc; + let (pa, pb) = (merge_path(d, a, C)?, merge_path(d, b, C)?); + let radius = diameter / 2.0; + let edge = if ends { None } else { pt::stretches_at(pa, pb, at, radius, tolerance) }; + let plan = edge.or_else(|| pt::ends_at(pa, pb, at, radius)).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?; + let (na, nb) = pt::apply_merge(pa, pb, &plan, follow).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?; + let changed: Vec<(NodeId, PathData)> = + [(a, pa, na), (b, pb, nb)].into_iter().filter(|(_, old, new)| *old != new).map(|(id, _, new)| (id, new)).collect(); + let made = match plan { + _ if changed.is_empty() => "nothing", + pt::MergePlan::Edge { .. } => "edge", + pt::MergePlan::Ends { .. } => "end", + }; + edit_whole(s, "Merge Shared Edge", changed)?; + Ok(json!({ "a": a.0, "b": b.0, "made": made })) +} + +fn move_points_together(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pathtools.movePointsTogether"; + let now = s.tool_options_of(ID)["together"].as_bool().unwrap_or(false); + let enabled = switch_or(p, "enabled", !now, C)?; + s.set_tool_options(Some(ID), &Map::from_iter([("together".to_string(), json!(enabled))])); + Ok(json!({ "enabled": enabled })) +} + +/// Anchors to move along with others: each with how far. +pub(super) type SharedPoints = Vec<(NodeId, AnchorRef, Vec2)>; + +/// With Keep Shared Points Together on, the anchors of editable paths coincident with an anchor +/// that `changed` (paths in their new form) moves, and not moving themselves. Subpaths whose +/// anchors `changed` adds or removes move none: their anchors stay put. +pub(super) fn shared_points<'a>(s: &Session, changed: impl IntoIterator) -> Result { + let options = s.tool_options_of(ID); + if options["together"].as_bool() != Some(true) { + return Ok(vec![]); + } + let tol = options["tolerance"].as_f64().unwrap_or(pt::COINCIDENCE); + let d = &s.doc()?.doc; + let mut moved: BTreeSet<(NodeId, AnchorRef)> = BTreeSet::new(); + let mut from: Vec<(Point, Vec2)> = vec![]; + for (id, new) in changed { + let Some(old) = d.node(id).and_then(|n| n.path_data()) else { continue }; + for (si, (o, n)) in old.subpaths.iter().zip(&new.subpaths).enumerate().filter(|(_, (o, n))| o.anchors.len() == n.anchors.len()) { + for (ai, (x, y)) in o.anchors.iter().zip(&n.anchors).enumerate().filter(|(_, (x, y))| x.p != y.p) { + moved.insert((id, (si, ai))); + from.push((x.p, y.p - x.p)); + } + } + } + let mut out = vec![]; + if from.is_empty() { + return Ok(out); + } + from.sort_by(|a, b| a.0.x.total_cmp(&b.0.x)); + let editable = d.editable(); + d.walk(|n| { + let NodeKind::Path { path, guide: false, .. } = &n.kind else { return }; + if !editable(n.id) { + return; + } + for (si, ai, a) in path.anchors() { + let start = from.partition_point(|(p, _)| p.x < a.p.x - tol); + let mut near = from.iter().skip(start).take_while(|(p, _)| p.x <= a.p.x + tol); + if let Some((_, by)) = near.find(|(p, _)| p.distance(a.p) <= tol) + && !moved.contains(&(n.id, (si, ai))) + { + out.push((n.id, (si, ai), *by)); + } + } + }); + Ok(out) +} + +/// Move the anchors [`shared_points`] found; what they are on stops being a live shape. +pub(super) fn move_shared(d: &mut Document, shared: SharedPoints) { + let mut by_path: BTreeMap> = BTreeMap::new(); + for (id, at, by) in shared { + by_path.entry(id).or_default().push((at, by)); + } + for (id, anchors) in by_path { + let Some(mut np) = d.node(id).and_then(|n| n.path_data()).cloned() else { continue }; + for ((si, ai), by) in anchors { + if let Some(a) = np.anchor_mut(si, ai) { + a.translate(by); + } + } + set_path(d, id, np); + } +} diff --git a/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs b/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs new file mode 100644 index 000000000..3ffe2fea7 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/shared_edge/tests.rs @@ -0,0 +1,204 @@ +//! The Shared Edge Merge Tool's command and Keep Shared Points Together: one undo step each, bad +//! params refused, the tool's click through the session, and only the add-on's commands affected. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{PathData, Point}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use crate::{Session, ViewInfo}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn points(s: &Session, id: NodeId) -> Vec { + path(s, id).anchors().map(|(_, _, a)| a.p).collect() +} + +/// Two closed shapes with a bent edge each between (100, 100) and (200, 100). +fn shapes(s: &mut Session) -> (NodeId, NodeId) { + let a = line(s, &[(100.0, 100.0), (150.0, 104.0), (200.0, 100.0), (200.0, 50.0), (100.0, 50.0)], true); + let b = line(s, &[(200.0, 100.0), (150.0, 96.0), (100.0, 100.0), (100.0, 150.0), (200.0, 150.0)], true); + (a, b) +} + +#[test] +fn merge_edge_replaces_both_stretches_as_one_undo_step() { + let mut s = session(); + let (a, b) = shapes(&mut s); + s.execute("select.none", &json!({})).unwrap(); + let (pa, pb) = (path(&s, a), path(&s, b)); + let n = steps(&s); + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap(); + assert_eq!(r, json!({"a": a.0, "b": b.0, "made": "edge"})); + assert_eq!(steps(&s), n + 1); + assert!(path(&s, a).subpaths[0].closed && path(&s, b).subpaths[0].closed); + assert_eq!(points(&s, a)[..3], [Point::new(100.0, 100.0), Point::new(150.0, 100.0), Point::new(200.0, 100.0)]); + assert_eq!(points(&s, b)[..3], [Point::new(200.0, 100.0), Point::new(150.0, 100.0), Point::new(100.0, 100.0)]); + // Nothing left to do: no step. + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap(); + assert_eq!(r["made"], json!("nothing")); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s, a), path(&s, b)), (pa, pb)); +} + +#[test] +fn merge_edge_follows_the_path_asked_and_makes_ends() { + let mut s = session(); + let (a, b) = shapes(&mut s); + let pa = path(&s, a); + s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100], "follow": "a"})).unwrap(); + assert_eq!(path(&s, a), pa); + assert_eq!(points(&s, b)[1], Point::new(150.0, 104.0)); + // Ends asked for: the near anchors of two lines meet, one undo step. + let c = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)], false); + let d = line(&mut s, &[(54.0, 200.0), (90.0, 240.0)], false); + let n = steps(&s); + let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": c.0, "b": d.0, "at": [52, 200], "ends": true})).unwrap(); + assert_eq!((r["made"].clone(), steps(&s)), (json!("end"), n + 1)); + assert_eq!((points(&s, c)[1], points(&s, d)[0]), (Point::new(52.0, 200.0), Point::new(52.0, 200.0))); +} + +#[test] +fn merge_edge_refuses_bad_params_and_locked_paths() { + let mut s = session(); + let (a, b) = shapes(&mut s); + let (i, j) = (a.0, b.0); + let n = steps(&s); + for p in [ + json!({}), + json!({"a": i, "b": i, "at": [125, 100]}), + json!({"a": i, "b": j}), + json!({"a": i, "b": j, "at": [1]}), + json!({"a": i, "b": j, "at": [1, "x"]}), + json!({"a": i, "b": j, "at": [125, 100], "diameter": 0.1}), + json!({"a": i, "b": j, "at": [125, 100], "diameter": 201}), + json!({"a": i, "b": j, "at": [125, 100], "tolerance": 3.5}), + json!({"a": i, "b": j, "at": [125, 100], "tolerance": -1}), + json!({"a": i, "b": j, "at": [125, 100], "follow": "up"}), + json!({"a": i, "b": j, "at": [125, 100], "ends": 1}), + json!({"a": i, "b": u64::MAX, "at": [125, 100]}), + json!({"a": i, "b": j, "at": [390, 290]}), + ] { + assert!(s.execute("addon.pathtools.mergeEdge", &p).is_err(), "{p}"); + } + assert_eq!(steps(&s), n); + s.execute("select.set", &json!({"ids": [i]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.execute("addon.pathtools.mergeEdge", &json!({"a": i, "b": j, "at": [125, 100]})).is_err()); +} + +#[test] +fn the_tool_merges_unselected_paths_on_a_click_with_shift_following_the_upper() { + let mut s = session(); + let (lower, upper) = shapes(&mut s); + s.execute("select.none", &json!({})).unwrap(); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("sharedEdge", view).unwrap(); + let keep = path(&s, upper); + let n = steps(&s); + let shifted = |kind| { + let mut e = PointerEvent::new(kind, 125.0, 100.0); + e.mods = Mods { shift: true, ..Mods::default() }; + e + }; + s.pointer(&shifted(PointerKind::Down), view).unwrap(); + s.pointer(&shifted(PointerKind::Up), view).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(path(&s, upper), keep); + assert_eq!(points(&s, lower)[1], Point::new(150.0, 96.0)); +} + +#[test] +fn keep_shared_points_together_is_off_by_default_and_a_preference_of_the_tool() { + let mut s = session(); + assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(false)); + assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(), json!({"enabled": true})); + assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(true), "kept with another tool active"); + assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({})).unwrap(), json!({"enabled": false})); + assert!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": 1})).is_err()); +} + +#[test] +fn a_slide_takes_coincident_anchors_along_in_one_step_when_the_option_is_on() { + let mut s = session(); + let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false); + let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false); + let far = line(&mut s, &[(200.0, 101.0), (250.0, 200.0)], false); + let slide = json!({"id": a.0, "anchor": 1, "t": 0.5}); + let n = steps(&s); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0), "off: it stays"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert_eq!(steps(&s), n + 1); + let moved = points(&s, a)[1]; + assert!(moved.distance(Point::new(250.0, 100.0)) < 1e-6, "{moved:?}"); + assert!(points(&s, b)[0].distance(moved) < 1e-9); + assert_eq!(points(&s, b)[1], Point::new(200.0, 200.0)); + assert_eq!(points(&s, far)[0], Point::new(200.0, 101.0), "not coincident"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0)); + // A wider coincidence reaches it, and a locked path stays. + s.set_tool_options(Some("sharedEdge"), &serde_json::Map::from_iter([("tolerance".to_string(), json!(2))])); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.lock", &json!({})).unwrap(); + s.execute("addon.pathtools.slideAnchor", &slide).unwrap(); + assert!(points(&s, far)[0].distance(Point::new(250.0, 101.0)) < 1e-6); + assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0)); +} + +#[test] +fn set_anchor_and_extend_take_coincident_anchors_along_but_built_in_moves_do_not() { + let mut s = session(); + let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0)], false); + let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false); + s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(); + // Set Anchor on one selected anchor. + s.select(|_, sel| { + sel.set([a]); + sel.anchors.insert(a, [(0, 1)].into_iter().collect()); + }) + .unwrap(); + let n = steps(&s); + s.execute("addon.pathtools.setAnchor", &json!({"x": 220, "y": 90})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert_eq!(points(&s, b), [Point::new(220.0, 90.0), Point::new(200.0, 200.0)]); + // An edit that moves no anchor moves none. + s.execute("addon.pathtools.corner", &json!({})).unwrap(); + assert_eq!(points(&s, b)[0], Point::new(220.0, 90.0)); + // Extend Path moves an end only when it adds no anchor. + s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10, "mode": "bezier"})).unwrap(); + let end = points(&s, a)[1]; + assert!(points(&s, b)[0].distance(end) < 1e-9); + let end = points(&s, b)[0]; + s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10})).unwrap(); + assert_eq!(points(&s, a).len(), 3); + assert_eq!(points(&s, b)[0], end, "the end it left stays with what shares it"); + // The app's own moves are untouched by the option. + s.select(|_, sel| { + sel.set([a]); + sel.anchors.insert(a, [(0, 1)].into_iter().collect()); + }) + .unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 5})).unwrap(); + assert_eq!(points(&s, b)[0], end); +} diff --git a/crates/engine/src/cmd/addons/pathtools/tests.rs b/crates/engine/src/cmd/addons/pathtools/tests.rs new file mode 100644 index 000000000..52f0c7078 --- /dev/null +++ b/crates/engine/src/cmd/addons/pathtools/tests.rs @@ -0,0 +1,676 @@ +//! Path Precision Tools' commands: each one undo step, bad params refused, and the tools' gestures +//! through the session. + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveCorners, LiveShape, NodeId}; +use vectorcraft_geom::shapes::CornerKind; +use vectorcraft_geom::{AnchorKind, PathData, Point}; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use crate::{Session, ViewInfo}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()) +} + +/// A circle of 16 anchors about (100, 100), radius 50, selected. +fn circle(s: &mut Session) -> NodeId { + let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + e +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.select(|_, sel| sel.set(ids.iter().copied())).unwrap(); +} + +/// Direct-select anchors `anchors` (of subpath 0) of `id`. +fn select_anchors(s: &mut Session, id: NodeId, anchors: &[usize]) { + s.select(|_, sel| { + sel.set([id]); + sel.anchors.insert(id, anchors.iter().map(|a| (0, *a)).collect::>()); + }) + .unwrap(); +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Each of `bad` is refused and leaves the document as it was. +fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) { + let (before, n) = (s.doc().unwrap().doc.clone(), steps(s)); + for p in bad { + assert!(s.execute(cmd, p).is_err(), "{cmd} {p}"); + } + assert_eq!(steps(s), n, "{cmd}"); + assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document"); +} + +#[test] +fn the_brush_removes_anchors_under_it_keeping_the_shape() { + let mut s = session(); + let e = circle(&mut s); + assert_eq!(path(&s, e).anchor_count(), 16); + let bounds = path(&s, e).bounds().unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "tolerance": 100})).unwrap(); + let left = path(&s, e).anchor_count(); + assert!(left < 16 && left > 8, "{left}"); + assert_eq!(r["removed"], json!(16 - left)); + assert_eq!(steps(&s), n + 1, "one undo step"); + let b = path(&s, e).bounds().unwrap(); + assert!((b.x1 - bounds.x1).abs() < 2.5 && (b.y0 - bounds.y0).abs() < 0.01, "{b:?} vs {bounds:?}"); + // The anchors away from the brush stay: those on the left of the circle. + assert!(path(&s, e).anchors().filter(|(_, _, a)| a.p.x < 100.0).count() >= 7); + // A plain delete takes every anchor under it, whatever the shape. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.pathtools.erase", &json!({"points": [[150, 40], [150, 160]], "diameter": 60, "plain": true})).unwrap(); + assert!(path(&s, e).anchors().all(|(_, _, a)| a.p.x < 121.0)); + // Nothing under the brush: no step. + let n = steps(&s); + s.execute("addon.pathtools.erase", &json!({"points": [[390, 290]], "diameter": 5})).unwrap(); + assert_eq!(steps(&s), n); + refuses( + &mut s, + "addon.pathtools.erase", + &[ + json!({}), + json!({"points": "x"}), + json!({"points": [[1, "y"]]}), + json!({"points": [[100, 100]], "diameter": 0}), + json!({"points": [[100, 100]], "diameter": 3000}), + json!({"points": [[100, 100]], "tolerance": 0}), + json!({"points": [[100, 100]], "plain": "yes"}), + ], + ); +} + +#[test] +fn the_brush_tool_is_one_undo_step_per_stroke() { + let mut s = session(); + let e = circle(&mut s); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("anchorEraserBrush", view).unwrap(); + assert_eq!(s.tool_id(), "anchorEraserBrush"); + s.set_tool_option("size", &json!(60)); + s.set_tool_option("tolerance", &json!(100)); + let n = steps(&s); + for (k, y) in + [(PointerKind::Down, 40.0), (PointerKind::Drag, 80.0), (PointerKind::Drag, 120.0), (PointerKind::Drag, 160.0), (PointerKind::Up, 160.0)] + { + s.pointer(&PointerEvent::new(k, 150.0, y), view).unwrap(); + } + assert_eq!(steps(&s), n + 1); + assert!(path(&s, e).anchor_count() < 16); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, e).anchor_count(), 16); +} + +#[test] +fn extend_lengthens_and_shortens_an_end() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 100.0), (100.0, 100.0)], false); + let n = steps(&s); + s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": 50})).unwrap(); + assert_eq!(path(&s, l).subpaths[0].anchors.last().unwrap().p, Point::new(150.0, 100.0)); + assert_eq!(steps(&s), n + 1); + s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "start", "to": [-30, 120], "mode": "bezier"})).unwrap(); + assert!((path(&s, l).subpaths[0].anchors[0].p.x + 30.0).abs() < 1e-6); + let r = s.execute("addon.pathtools.extend", &json!({"id": l.0, "end": "end", "length": -60})).unwrap(); + assert_eq!(r["length"], json!(-60.0)); + assert!((path(&s, l).length() - 120.0).abs() < 1e-3, "{}", path(&s, l).length()); + assert_eq!(steps(&s), n + 3); + let sq = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()); + refuses( + &mut s, + "addon.pathtools.extend", + &[ + json!({"id": l.0, "end": "end"}), + json!({"id": l.0, "end": "middle", "length": 5}), + json!({"id": l.0, "end": "end", "length": 5, "to": [1, 1]}), + json!({"id": l.0, "end": "end", "length": 5, "mode": "spiral"}), + json!({"id": l.0, "end": "end", "length": -1000}), + json!({"id": l.0, "end": "end", "length": 1e9}), + json!({"id": l.0, "end": "end", "to": [1]}), + json!({"id": l.0, "subpath": 3, "end": "end", "length": 5}), + json!({"id": sq.0, "end": "end", "length": 5}), + json!({"id": 99999, "end": "end", "length": 5}), + ], + ); +} + +#[test] +fn inspect_reads_the_path_under_a_point() { + let mut s = session(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap()); + let v = s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 99, "y": 1})).unwrap(); + assert_eq!((v["closed"].clone(), v["anchors"].clone()), (json!(true), json!(4))); + assert!((v["length"].as_f64().unwrap() - 300.0).abs() < 1e-6 && (v["area"].as_f64().unwrap() - 5000.0).abs() < 1e-6); + assert!((v["anchor"]["angle"].as_f64().unwrap() - 90.0).abs() < 1e-9); + assert!(v["radius"].is_null()); + assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1})).is_err()); + assert!(s.execute("addon.pathtools.inspect", &json!({"id": r.0, "x": 1, "y": 1, "tolerance": -1})).is_err()); +} + +#[test] +fn smooth_corner_and_equalize_edit_the_selected_anchors_in_one_step_each() { + let mut s = session(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + select(&mut s, &[r]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.smooth", &json!({})).unwrap()["anchors"], json!(4)); + assert!(path(&s, r).anchors().all(|(_, _, a)| a.kind == AnchorKind::Smooth && (a.h_out - a.p).hypot() > 39.0)); + assert_eq!(steps(&s), n + 1); + // Only the direct-selected anchor. + select_anchors(&mut s, r, &[0]); + s.execute("addon.pathtools.corner", &json!({})).unwrap(); + let p = path(&s, r); + assert!(!p.subpaths[0].anchors[0].has_out() && p.subpaths[0].anchors[1].has_out()); + assert_eq!(steps(&s), n + 2); + // Equalise: one handle made to match the other. + select_anchors(&mut s, r, &[1]); + s.execute("addon.pathtools.setAnchor", &json!({"outLength": 10})).unwrap(); + let a = path(&s, r).subpaths[0].anchors[1]; + assert!(((a.h_out - a.p).hypot() - 10.0).abs() < 1e-9 && (a.h_in - a.p).hypot() > 39.0); + s.execute("addon.pathtools.equalize", &json!({})).unwrap(); + let a = path(&s, r).subpaths[0].anchors[1]; + assert!(((a.h_out - a.p).hypot() - (a.h_in - a.p).hypot()).abs() < 1e-9); + // A selection with nothing to change makes no step. + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.equalize", &json!({})).unwrap()["anchors"], json!(0)); + assert_eq!(steps(&s), n); + refuses(&mut s, "addon.pathtools.smooth", &[json!({"ratio": 0.9}), json!({"ratio": "x"})]); + refuses(&mut s, "addon.pathtools.corner", &[json!({"retract": 1})]); +} + +#[test] +fn set_anchor_moves_anchors_and_sets_handles() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)], false); + select_anchors(&mut s, l, &[1]); + let n = steps(&s); + s.execute("addon.pathtools.setAnchor", &json!({"x": 60, "y": 20, "outLength": 30, "outAngle": 90})).unwrap(); + let a = path(&s, l).subpaths[0].anchors[1]; + assert_eq!(a.p, Point::new(60.0, 20.0)); + assert!(a.h_out.distance(Point::new(60.0, -10.0)) < 1e-9, "{a:?}"); + assert_eq!(steps(&s), n + 1); + refuses(&mut s, "addon.pathtools.setAnchor", &[json!({}), json!({"x": "1"}), json!({"inLength": -2}), json!({"y": f64::MAX})]); +} + +#[test] +fn remove_keep_shape_takes_the_direct_selected_anchors() { + let mut s = session(); + let e = circle(&mut s); + // Selected whole: nothing to say which anchors go. + refuses(&mut s, "addon.pathtools.removeKeepShape", &[json!({})]); + select_anchors(&mut s, e, &[1, 5]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.removeKeepShape", &json!({})).unwrap()["removed"], json!(2)); + assert_eq!(path(&s, e).anchor_count(), 14); + assert_eq!(steps(&s), n + 1); +} + +#[test] +fn add_points_splits_selected_segments_evenly() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false); + select(&mut s, &[l]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.addPoints", &json!({"count": 3})).unwrap()["added"], json!(3)); + let xs: Vec = path(&s, l).anchors().map(|(_, _, a)| a.p.x).collect(); + assert!(xs.iter().zip([0.0, 25.0, 50.0, 75.0, 100.0]).all(|(x, w)| (x - w).abs() < 1e-3), "{xs:?}"); + assert_eq!(steps(&s), n + 1); + refuses(&mut s, "addon.pathtools.addPoints", &[json!({"count": 0}), json!({"count": 101}), json!({"count": 1.5})]); + // An anchor alone selects no segment. + select_anchors(&mut s, l, &[1]); + refuses(&mut s, "addon.pathtools.addPoints", &[json!({})]); +} + +#[test] +fn close_joins_open_ends() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (50.0, 80.0)], false); + select(&mut s, &[l]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.close", &json!({"mode": "smooth"})).unwrap()["closed"], json!(1)); + assert!(path(&s, l).subpaths[0].closed); + assert_eq!(steps(&s), n + 1); + refuses(&mut s, "addon.pathtools.close", &[json!({}), json!({"mode": "round"})]); +} + +#[test] +fn crossings_add_anchors_or_cut_in_one_step() { + let mut s = session(); + let a = line(&mut s, &[(0.0, 0.0), (100.0, 100.0)], false); + let b = line(&mut s, &[(0.0, 100.0), (100.0, 0.0)], false); + select(&mut s, &[a, b]); + let n = steps(&s); + assert_eq!(s.execute("addon.pathtools.crossings", &json!({})).unwrap()["crossings"], json!(2)); + assert_eq!((path(&s, a).anchor_count(), path(&s, b).anchor_count()), (3, 3)); + assert!(path(&s, a).subpaths[0].anchors[1].p.distance(Point::new(50.0, 50.0)) < 1e-6); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("addon.pathtools.crossings", &json!({"cut": true})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 4, "{r}"); + assert_eq!(steps(&s), n + 1); + refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]); +} + +// ---------- Parametric Shape Tool ---------- + +/// The parametric shape object `id` is now, as the tool reads it. +fn shape_of(s: &Session, id: NodeId) -> Option { + vectorcraft_tools::addons::parametric_shape::read(s.doc().unwrap().doc.node(id).unwrap()) +} + +fn star(s: &mut Session) -> NodeId { + let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "star", "center": [200, 150], "width": 100, "height": 100})).unwrap(); + id_of(&r) +} + +#[test] +fn create_draws_a_selected_parametric_shape_in_one_step() { + let mut s = session(); + let n = steps(&s); + let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "gear", "center": [200, 150], "count": 10, "a": 0.3, "radius": 2})).unwrap(); + let id = id_of(&r); + assert_eq!(steps(&s), n + 1, "one undo step"); + assert_eq!((r["shape"]["kind"].clone(), r["shape"]["count"].clone(), r["shape"]["width"].clone()), (json!("gear"), json!(10), json!(100.0))); + assert_eq!(s.doc().unwrap().selection.objects, [id]); + let g = shape_of(&s, id).unwrap(); + assert_eq!((g.count, g.a, g.radius), (10, 0.3, 2.0)); + assert!(path(&s, id).anchor_count() > 40, "40 tooth corners, rounded"); + // Every kind, with its defaults. + for kind in ["rectangle", "ellipse", "polygon", "star", "ring", "arrow", "cross", "heart", "gear", "bubble"] { + let id = id_of(&s.execute("addon.pathtools.shape.create", &json!({"kind": kind, "center": [50, 50]})).unwrap()); + assert_eq!(shape_of(&s, id).map(|s| s.kind.id()), Some(kind)); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 10); +} + +#[test] +fn set_changes_the_numbers_in_one_step_and_nothing_without_a_change() { + let mut s = session(); + let id = star(&mut s); + assert_eq!(path(&s, id).anchor_count(), 10); + let n = steps(&s); + let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8, "a": 0.3, "rotation": 15})).unwrap(); + assert_eq!((r["shape"]["count"].clone(), r["shape"]["rotation"].clone()), (json!(8), json!(15.0))); + assert_eq!(path(&s, id).anchor_count(), 16); + assert_eq!(steps(&s), n + 1); + // The same numbers again, or none: no step. + s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8})).unwrap(); + s.execute("addon.pathtools.shape.set", &json!({"id": id.0})).unwrap(); + assert_eq!(steps(&s), n + 1); + // Another kind takes its own defaults. + let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "kind": "arrow"})).unwrap(); + assert_eq!((r["shape"]["a"].clone(), r["shape"]["rotation"].clone()), (json!(0.4), json!(15.0))); + assert_eq!(path(&s, id).anchor_count(), 7); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, id).anchor_count(), 16); +} + +#[test] +fn bad_shape_params_are_refused() { + let mut s = session(); + let id = star(&mut s); + let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let c = json!([10, 10]); + refuses( + &mut s, + "addon.pathtools.shape.create", + &[ + json!({}), + json!({"center": null}), + json!({"center": [1]}), + json!({"center": "here"}), + json!({"center": [1e300, 0]}), + json!({"center": c, "kind": "cloud"}), + json!({"center": c, "kind": 3}), + json!({"center": c, "count": 2}), + json!({"center": c, "count": 1001}), + json!({"center": c, "count": 4.5}), + json!({"center": c, "width": 0}), + json!({"center": c, "height": 16385}), + json!({"center": c, "width": "40"}), + json!({"center": c, "kind": "star", "a": 2}), + json!({"center": c, "kind": "bubble", "b": -1.5}), + json!({"center": c, "radius": -1}), + json!({"center": c, "rotation": "up"}), + ], + ); + refuses( + &mut s, + "addon.pathtools.shape.set", + &[json!({}), json!({"id": rect.0, "count": 6}), json!({"id": 999_999}), json!({"id": id.0, "count": 0}), json!({"id": id.0, "width": -5})], + ); + s.execute("object.setProps", &json!({"ids": [id.0], "locked": true})).unwrap(); + refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 6})]); +} + +#[test] +fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() { + let mut s = session(); + let id = star(&mut s); + // Moved, turned and scaled with the ordinary commands: still the shape, at its new place. + s.execute("object.move", &json!({"dx": 20, "dy": -10})).unwrap(); + assert!(shape_of(&s, id).is_some_and(|s| s.center.distance(Point::new(220.0, 140.0)) < 1e-6)); + s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 140]})).unwrap(); + let r = shape_of(&s, id).unwrap(); + assert!((r.rotation + 30.0).abs() < 1e-6, "30° counter-clockwise is −30 here: {r:?}"); + s.execute("object.scale", &json!({"sx": 200, "origin": [220, 140]})).unwrap(); + let r = shape_of(&s, id).unwrap(); + assert!((r.width - 200.0).abs() < 1e-6 && r.center.distance(Point::new(220.0, 140.0)) < 1e-6, "{r:?}"); + let n = steps(&s); + s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap(); + assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1)); + assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays"); + // Stretched across its own axes (it is turned): skewed, so no longer. + s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap(); + assert_eq!(shape_of(&s, id), None); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(shape_of(&s, id).is_some(), "undo brings it back"); + // An anchor moved by hand: a plain path, which `set` refuses. + select_anchors(&mut s, id, &[2]); + s.execute("addon.pathtools.setAnchor", &json!({"x": 0})).unwrap(); + assert_eq!(shape_of(&s, id), None); + refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 7})]); +} + +#[test] +fn convert_makes_plain_shapes_parametric_in_one_step() { + let mut s = session(); + let r = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 100, "height": 50})).unwrap()); + let p = id_of(&s.execute("shape.polygon", &json!({"cx": 200, "cy": 150, "radius": 40, "sides": 7})).unwrap()); + let st = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 150, "radius1": 40, "radius2": 15})).unwrap()); + select(&mut s, &[r, p, st]); + let n = steps(&s); + let out = s.execute("addon.pathtools.shape.convert", &json!({})).unwrap(); + assert_eq!(out["ids"], json!([r.0, p.0])); + assert_eq!(steps(&s), n + 1); + assert_eq!(shape_of(&s, r).map(|s| s.kind.id()), Some("rectangle")); + assert_eq!(shape_of(&s, p).map(|s| (s.kind.id(), s.count)), Some(("polygon", 7))); + assert_eq!(shape_of(&s, st), None); + assert_eq!(path(&s, r).bounds().map(|b| b.center()), Some(Point::new(60.0, 45.0))); + // Nothing left to convert. + select(&mut s, &[r, st]); + refuses(&mut s, "addon.pathtools.shape.convert", &[json!({}), json!({"ids": [st.0]}), json!({"ids": [123456]})]); +} + +#[test] +fn the_shape_data_stays_out_of_svg_export() { + let mut s = session(); + let id = star(&mut s); + s.execute("object.setProps", &json!({"ids": [id.0], "data": {"pivot": "1,2"}})).unwrap(); + let key = vectorcraft_tools::addons::parametric_shape::KEY; + let node = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(node.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, _)| k == key)), "kept with the object"); + assert!(shape_of(&s, id).is_some(), "setting other data keeps it a shape"); + let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); + assert!(svg.contains("data-pivot=\"1,2\""), "the object's own data still goes out: {svg}"); + assert!(!svg.contains(key) && !svg.contains("fp"") && !svg.contains("\"fp\""), "{svg}"); +} + +#[test] +fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() { + let mut s = session(); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("parametricShape", view).unwrap(); + s.set_tool_option("kind", &json!("star")); + let n = steps(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 100.0), view).unwrap(); + s.tool_key(vectorcraft_tools::ToolKey::Up, vectorcraft_tools::Mods::default(), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 90.0), view).unwrap(); + assert_eq!(steps(&s), n + 1); + let id = *s.doc().unwrap().selection.objects.first().unwrap(); + let shape = shape_of(&s, id).unwrap(); + assert_eq!((shape.kind.id(), shape.count, shape.height, shape.rotation), ("star", 6, 120.0, 0.0)); + // Its size handle (lower right of its box) drags into one more step. + let corner = shape.frame() * Point::new(shape.width / 2.0, shape.height / 2.0); + for (k, x) in [(PointerKind::Down, corner.x), (PointerKind::Drag, corner.x + 30.0), (PointerKind::Up, corner.x + 30.0)] { + s.pointer(&PointerEvent::new(k, x, corner.y), view).unwrap(); + } + assert_eq!(steps(&s), n + 2); + assert!((shape_of(&s, id).unwrap().width - 180.0).abs() < 1e-9); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0)); +} + +// ---------- Corner Tool ---------- + +/// A square (100, 100)–(300, 300), plain and selected: anchors from the top left, clockwise. +fn square(s: &mut Session) -> NodeId { + let id = line(s, &[(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)], true); + select(s, &[id]); + id +} + +fn live(s: &Session, id: NodeId) -> Option { + s.doc().unwrap().doc.node(id).unwrap().live_shape().cloned() +} + +fn drawn(s: &Session, id: NodeId, corner: usize) -> f64 { + LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap().drawn_radius(corner) +} + +#[test] +fn corner_set_cuts_live_corners_as_one_undo_step() { + let mut s = session(); + let id = square(&mut s); + let plain = path(&s, id); + let n = steps(&s); + // The selected path: all four. + let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap(); + assert_eq!(r, json!({"ids": [id.0], "corners": 4})); + assert_eq!((steps(&s), path(&s, id).anchor_count()), (n + 1, 8)); + let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).map(|c| (c.drawn_radius(3), c.kind(3))).unwrap(); + assert_eq!(lc, (20.0, CornerKind::Chamfer)); + // The same again changes nothing: no step. + s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap(); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((path(&s, id), live(&s, id)), (plain, None)); +} + +#[test] +fn corner_set_goes_by_anchors_the_selection_and_items_and_fits() { + let mut s = session(); + let id = square(&mut s); + let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false); + // Named anchors, of each path by `items`, at once; an open end takes none. + let items = json!([{"id": id.0, "anchors": [1]}, {"id": other.0, "anchors": [0, 1]}]); + let n = steps(&s); + let r = s.execute("addon.pathtools.cornerSet", &json!({"items": items, "radius": 5})).unwrap(); + assert_eq!((r["corners"].clone(), steps(&s)), (json!(2), n + 1)); + assert_eq!((drawn(&s, id, 1), drawn(&s, id, 0), drawn(&s, other, 1)), (5.0, 0.0, 5.0)); + // `ids` with `anchors`, keeping the kind each has. + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [1], "radius": 9})).unwrap(); + assert_eq!(drawn(&s, id, 1), 9.0); + // Direct-selected anchors: only their corners, which stay selected as they gain anchors. + select_anchors(&mut s, id, &[0]); + s.execute("addon.pathtools.cornerSet", &json!({"radius": 7})).unwrap(); + assert_eq!((drawn(&s, id, 0), drawn(&s, id, 2)), (7.0, 0.0)); + let sel = s.doc().unwrap().selection.anchors[&id].clone(); + let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap(); + assert_eq!(c.corners_of(&sel), BTreeSet::from([0])); + // A radius that doesn't fit is drawn at what does. + s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [2], "radius": 5000})).unwrap(); + assert!(drawn(&s, id, 2) <= 200.0 - 7.0 + 1e-9, "{}", drawn(&s, id, 2)); +} + +#[test] +fn corner_remove_makes_corners_sharp_and_a_path_plain_again() { + let mut s = session(); + let id = square(&mut s); + let plain = path(&s, id); + s.execute("addon.pathtools.cornerSet", &json!({"anchors": [0, 1], "radius": 10, "kind": "invertedRound"})).unwrap(); + let n = steps(&s); + let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "anchors": [0]}]})).unwrap(); + assert_eq!((r["corners"].clone(), steps(&s), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), n + 1, 0.0, 10.0)); + s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap(); + assert_eq!((steps(&s), path(&s, id), live(&s, id)), (n + 2, plain, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(drawn(&s, id, 1), 10.0); +} + +#[test] +fn corner_commands_refuse_bad_params() { + let mut s = session(); + let id = square(&mut s); + let smooth = circle(&mut s); + let i = id.0; + refuses( + &mut s, + "addon.pathtools.cornerSet", + &[ + json!({"ids": [i]}), + json!({"ids": [i], "radius": -1}), + json!({"ids": [i], "radius": 1e308}), + json!({"ids": [i], "radius": "big"}), + json!({"ids": [i], "radius": 5, "kind": "spiky"}), + json!({"ids": [i], "anchors": "all", "radius": 5}), + json!({"ids": [i], "anchors": [-1], "radius": 5}), + json!({"ids": [i], "anchors": [9], "radius": 5}), + json!({"ids": [u64::MAX], "radius": 5}), + json!({"items": 3, "radius": 5}), + json!({"items": [{"anchors": [0]}], "radius": 5}), + json!({"ids": [smooth.0], "radius": 5}), + ], + ); + refuses(&mut s, "addon.pathtools.cornerRemove", &[json!({"anchors": [9], "ids": [i]}), json!({"ids": [smooth.0]})]); + // Locked: left alone. + s.execute("object.lock", &json!({"ids": [i]})).ok(); + if !s.doc().unwrap().doc.is_editable(id) { + refuses(&mut s, "addon.pathtools.cornerSet", &[json!({"items": [{"id": i}], "radius": 5})]); + } +} + +#[test] +fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() { + let mut s = session(); + let id = square(&mut s); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("cornerTool", view).unwrap(); + s.set_tool_option("radius", &json!(15)); + let n = steps(&s); + let press = |s: &mut Session, k, x, y| s.pointer(&PointerEvent::new(k, x, y), view).unwrap(); + // A click on the top right anchor. + press(&mut s, PointerKind::Down, 300.0, 100.0); + press(&mut s, PointerKind::Up, 300.0, 100.0); + assert_eq!((steps(&s), drawn(&s, id, 1)), (n + 1, 15.0)); + // A drag from the bottom right anchor, previewed, one step. + press(&mut s, PointerKind::Down, 300.0, 300.0); + press(&mut s, PointerKind::Drag, 280.0, 280.0); + assert_eq!(drawn(&s, id, 2), 20.0, "the preview shows"); + press(&mut s, PointerKind::Drag, 270.0, 270.0); + press(&mut s, PointerKind::Up, 270.0, 270.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 2, 30.0)); + // A drag on the marker resizes; a double-click on it removes. + press(&mut s, PointerKind::Down, 270.0, 270.0); + press(&mut s, PointerKind::Drag, 260.0, 260.0); + press(&mut s, PointerKind::Up, 260.0, 260.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 3, 40.0)); + press(&mut s, PointerKind::DoubleClick, 260.0, 260.0); + assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 4, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(drawn(&s, id, 2), 40.0); + // A click on a marker selects the corner and loads its values. + press(&mut s, PointerKind::Down, 285.0, 115.0); + press(&mut s, PointerKind::Up, 285.0, 115.0); + assert_eq!(steps(&s), n + 3, "selecting is no edit"); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(2)); + // The bottom right one, resized by its marker, loads its radius. + press(&mut s, PointerKind::Down, 260.0, 260.0); + press(&mut s, PointerKind::Up, 260.0, 260.0); + assert_eq!(s.tool_options_of("cornerTool")["radius"], json!(40.0)); + // An Alt-marquee over the left side: both anchors, in one step. + let alt = vectorcraft_tools::Mods { alt: true, ..Default::default() }; + for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 350.0), (PointerKind::Up, 150.0, 350.0)] { + s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), view).unwrap(); + } + assert_eq!((steps(&s), drawn(&s, id, 0), drawn(&s, id, 3)), (n + 4, 40.0, 40.0)); +} + +// ---------- Slide Anchor Along Path ---------- + +#[test] +fn slide_anchor_moves_it_along_the_path_as_one_undo_step() { + let mut s = session(); + let e = circle(&mut s); + let before = path(&s, e); + let area = kurbo::Shape::area(&before.to_bezpath()).abs(); + let want = before.subpaths[0].segment(3); + let n = steps(&s); + let r = s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0.5})).unwrap(); + assert!(r["strayed"].as_f64().unwrap() < 0.05, "{r}"); + let after = path(&s, e); + assert_eq!((steps(&s), after.anchor_count()), (n + 1, 16)); + assert!(after.subpaths[0].anchors[3].p.distance(kurbo::ParamCurve::eval(&want, 0.5)) < 1e-9); + assert!((kurbo::Shape::area(&after.to_bezpath()).abs() / area - 1.0).abs() < 1e-3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path(&s, e), before); + // No move, no step. + s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn slide_anchor_refuses_bad_params() { + let mut s = session(); + let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false); + let i = l.0; + refuses( + &mut s, + "addon.pathtools.slideAnchor", + &[ + json!({"id": i, "anchor": 0, "t": 0.5}), + json!({"id": i, "anchor": 2, "t": -0.5}), + json!({"id": i, "anchor": 9, "t": 0.5}), + json!({"id": i, "anchor": 1, "t": 1.5}), + json!({"id": i, "anchor": 1}), + json!({"id": i, "t": 0.5}), + json!({"id": i, "subpath": 4, "anchor": 1, "t": 0.5}), + json!({"id": u64::MAX, "anchor": 1, "t": 0.5}), + ], + ); +} + +#[test] +fn the_slide_tool_previews_and_commits_one_step() { + let mut s = session(); + let l = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false); + let view = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("slideAnchor", view).unwrap(); + let n = steps(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 100.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 120.0), view).unwrap(); + let at = |s: &Session| path(s, l).subpaths[0].anchors[1].p; + assert!(at(&s).distance(Point::new(250.0, 100.0)) < 1e-3, "the preview shows"); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), view).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), view).unwrap(); + assert!(steps(&s) == n + 1 && at(&s).distance(Point::new(280.0, 100.0)) < 1e-3); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(at(&s), Point::new(200.0, 100.0)); +} diff --git a/crates/engine/src/cmd/addons/pen.rs b/crates/engine/src/cmd/addons/pen.rs new file mode 100644 index 000000000..35d0ea5b5 --- /dev/null +++ b/crates/engine/src/cmd/addons/pen.rs @@ -0,0 +1,349 @@ +//! Smart Pen (`addon.pen.*`), a built-in add-on: one tool that draws paths and edits their points, +//! handles and segments without switching tools, and a panel. The commands here are what the tool +//! and the panel do that the standard path commands don't: anchors added with a straight or curved +//! segment into them, segments switched between straight and curved, anchors removed with or +//! without refitting their neighbours, smooth/corner toggles, segments bent by a dragged point, and +//! the panel's next-segment choice and Close or Join. The tool, panel and submenu (Object › +//! Plug-ins › Smart Pen) are the tools crate's and the UI's. + +#[cfg(test)] +mod tests; + +use std::collections::BTreeSet; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{Anchor, AnchorKind, Point, SubPath, Vec2}; +use vectorcraft_pathops::addons::{self as po, pathtools as pt, pen}; +use vectorcraft_tools::addons::smart_pen; + +use super::super::draw2::usize_opt; +use super::super::path::{compound_or_self, join, path_mut}; +use super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param}; +use crate::{EngineError, Result, Session}; + +/// The largest coordinate or move (pt) the commands take. +const LIMIT: f64 = 1e9; + +pub fn specs() -> Vec { + vec![ + cmd!( + "addon.pen.append", + "Smart Pen", + [], + None, + "{id, subpath?: index (default: the last open subpath), x, y, in?: [x, y], out?: [x, y], segment?: \"straight\" (the segment into the new anchor is a line: the previous anchor's handle on it goes, and the new anchor's incoming one)|\"curved\" (the previous anchor gets a handle on it if it has none: in line with its other handle, or with a straight segment before it)} add an anchor at the end of an open subpath of path id (default: the handles as given, the previous anchor as it is), as one undo step; the new anchor is selected → {anchor}", + has_doc, + append + ), + cmd!( + "addon.pen.close", + "Close Path", + [], + None, + "{id, subpath?: index (default: the last open subpath), segment?: \"straight\"|\"curved\" (default: the closing segment takes the handles its ends have)} close an open subpath of two anchors or more, as one undo step", + has_doc, + close + ), + cmd!( + "addon.pen.segment", + "Straight or Curved Segment", + [], + None, + "{id, subpath?: index (0), segment: index, curved: bool} make a segment straight (its handles retracted) or curved (each end without a handle on it gets one, in line with its other handle or with a straight segment before it), as one undo step → {changed}", + has_doc, + segment + ), + cmd!( + "addon.pen.removePoint", + "Remove Points, Keep Shape", + [], + None, + "{id, subpath?: index (0), anchor: index, plain?: bool (false: the handles beside it are refitted so the outline keeps its shape, and an open end takes its segment with it; true: it goes and the handles beside it stay as they are)} remove one anchor, as one undo step; a subpath keeps two anchors (three when closed)", + has_doc, + remove_point + ), + cmd!( + "addon.pen.togglePoint", + "Convert Anchor Point", + [], + None, + "{id, subpath?: index (0), anchor: index, ratio?: 0.1..0.5 (0.3905)} a smooth anchor becomes a corner with its handles where they are; any other anchor is made smooth by the auto-smooth rule (a lone handle mirrored; without handles both at right angles to the bisector of the angle at it, ratio × the nearer neighbour's distance long). One undo step → {smooth}", + has_doc, + toggle_point + ), + cmd!( + "addon.pen.reshape", + "Reshape Segment", + [], + None, + "{id, subpath?: index (0), segment: index, t?: 0..1 (where it was grabbed, 0.5; held to 0.05..0.95), dx, dy: pt, mode?: \"free\" (both handles move, the one nearer the grab more)|\"keepAngles\" (each handle keeps its direction)|\"symmetric\" (both handles at right angles to the chord, at one length) (\"free\")} bend a segment so the point grabbed moves by (dx, dy); missing handles grow out of their anchors and a smooth anchor stays smooth. One undo step", + has_doc, + reshape + ), + cmd!( + "addon.pen.nextSegment", + "Next Segment", + ["Object", "Plug-ins", "Smart Pen"], + None, + "{curved: bool} whether a click with the Smart Pen Tool adds a curved segment or a straight one (the tool's `next` option, kept with the preferences) → {next: \"straight\"|\"curved\"}", + always, + next_segment + ), + cmd!( + "addon.pen.closeJoin", + "Close or Join", + ["Object", "Plug-ins", "Smart Pen"], + None, + "{} with exactly two open path ends direct-selected, join them as path.join does; else close the selected open paths keeping their ends' handles, as addon.pathtools.close does. One undo step", + has_selection, + close_join + ), + ] +} + +/// The `segment` param: straight (false) or curved (true), `None` when not given. +fn segment_kind(p: &Value, cmd: &str) -> Result> { + match text_param(p, "segment", cmd)? { + None => Ok(None), + Some("straight") => Ok(Some(false)), + Some("curved") => Ok(Some(true)), + Some(_) => Err(bad(cmd, "`segment` must be \"straight\" or \"curved\"")), + } +} + +/// An index param that must be given. +fn index_req(p: &Value, key: &str, cmd: &str) -> Result { + usize_opt(p, key, cmd)?.ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) +} + +/// A true or false param that must be given. +fn switch_req(p: &Value, key: &str, cmd: &str) -> Result { + p.get(key).and_then(Value::as_bool).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))) +} + +/// Two number params that must be given, finite and within [`LIMIT`]. +fn pair(p: &Value, (a, b): (&str, &str), cmd: &str) -> Result<(f64, f64)> { + let (x, y) = (number_or(p, a, f64::NAN, cmd)?, number_or(p, b, f64::NAN, cmd)?); + if !(x.abs() < LIMIT && y.abs() < LIMIT) { + return Err(bad(cmd, format!("give `{a}` and `{b}`"))); + } + Ok((x, y)) +} + +/// The subpath a request names: its path (`id`, editable), its index (`subpath`; without one the +/// last open subpath with `open`, else the first) and its data. +fn subpath_of(s: &Session, p: &Value, cmd: &str, open: bool) -> Result<(NodeId, usize, SubPath)> { + let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?; + let d = &s.doc()?.doc; + if !d.is_editable(id) { + return Err(bad(cmd, "the path is locked or hidden")); + } + let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?; + let si = match usize_opt(p, "subpath", cmd)? { + Some(si) => si, + None if open => path.subpaths.iter().rposition(|sp| !sp.closed).ok_or_else(|| bad(cmd, "the path has no open subpath"))?, + None => 0, + }; + let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?; + Ok((id, si, sp.clone())) +} + +/// What the selection becomes once a subpath is edited. +enum Pick { + /// As it was (no anchor moved to another index). + Keep, + /// The path (its compound path) selected whole. + Whole, + /// Only this anchor of the subpath, direct-selected. + Anchor(usize), +} + +/// Put `sp` in place of subpath `si` of path `id`, as one undo step labelled `label`. +fn put(s: &mut Session, label: &str, id: NodeId, si: usize, sp: SubPath, pick: Pick) -> Result<()> { + s.edit(label, |d, sel| { + let slot = path_mut(d, id)?.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; + *slot = sp; + let top = compound_or_self(d, id); + match pick { + Pick::Keep => {} + Pick::Whole => sel.set([top]), + Pick::Anchor(ai) => { + sel.set([top]); + sel.anchors.insert(id, BTreeSet::from([(si, ai)])); + } + } + Ok(()) + }) +} + +/// An optional `[x, y]` param, finite and within [`LIMIT`]. +fn point_opt(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => finite_numbers::<2>(v) + .filter(|[x, y]| x.abs() < LIMIT && y.abs() < LIMIT) + .map(|[x, y]| Some(Point::new(x, y))) + .ok_or_else(|| bad(cmd, format!("`{key}` must be [x, y]"))), + } +} + +fn append(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.append"; + let (x, y) = pair(p, ("x", "y"), C)?; + let at = Point::new(x, y); + let (h_in, h_out) = (point_opt(p, "in", C)?.unwrap_or(at), point_opt(p, "out", C)?.unwrap_or(at)); + let curved = segment_kind(p, C)?; + let (id, si, mut sp) = subpath_of(s, p, C, true)?; + if sp.closed { + return Err(bad(C, "the subpath is closed")); + } + sp.anchors.push(Anchor::with_handles(at, h_in, h_out)); + let k = sp.anchors.len() - 1; + if let Some(prev) = k.checked_sub(1) { + match curved { + Some(true) => { + pen::leave_handle(&mut sp, prev, true, pt::SMOOTH_RATIO); + } + Some(false) => { + pen::straighten_segment(&mut sp, prev); + } + None => {} + } + } + put(s, "Smart Pen", id, si, sp, Pick::Anchor(k))?; + Ok(json!({ "anchor": k })) +} + +fn close(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.close"; + let curved = segment_kind(p, C)?; + let (id, si, mut sp) = subpath_of(s, p, C, true)?; + if sp.closed || sp.anchors.len() < 2 { + return Err(bad(C, "the subpath is closed or too short to close")); + } + sp.closed = true; + let last = sp.anchors.len() - 1; + match curved { + Some(true) => { + pen::curve_segment(&mut sp, last, pt::SMOOTH_RATIO); + } + Some(false) => { + pen::straighten_segment(&mut sp, last); + } + None => {} + } + put(s, "Close Path", id, si, sp, Pick::Whole)?; + Ok(json!({})) +} + +fn segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.segment"; + let seg = index_req(p, "segment", C)?; + let curved = switch_req(p, "curved", C)?; + let (id, si, mut sp) = subpath_of(s, p, C, false)?; + if seg >= sp.segment_count() { + return Err(bad(C, "no such segment")); + } + let changed = if curved { pen::curve_segment(&mut sp, seg, pt::SMOOTH_RATIO) } else { pen::straighten_segment(&mut sp, seg) }; + if changed { + put(s, "Smart Pen", id, si, sp, Pick::Keep)?; + } + Ok(json!({ "changed": changed })) +} + +fn remove_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.removePoint"; + let ai = index_req(p, "anchor", C)?; + let plain = switch_or(p, "plain", false, C)?; + let (id, si, mut sp) = subpath_of(s, p, C, false)?; + let n = sp.anchors.len(); + if ai >= n { + return Err(bad(C, "no such anchor")); + } + let end = !sp.closed && (ai == 0 || ai + 1 == n); + let removed = if plain { + pen::delete_anchor(&mut sp, ai) + } else if let Some((out, _)) = po::remove_anchor_keep_shape(&sp, ai) { + sp = out; + true + } else { + // An open end takes its segment with it. + end && n > 2 && vectorcraft_pathops::remove_anchor(&mut sp, ai) + }; + if !removed { + return Err(bad(C, "the path is too short to remove a point from")); + } + let label = if plain { "Delete Anchor Point" } else { "Remove Points, Keep Shape" }; + put(s, label, id, si, sp, Pick::Whole)?; + Ok(json!({})) +} + +fn toggle_point(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.togglePoint"; + let ai = index_req(p, "anchor", C)?; + let r = super::pathtools::ratio(p, C)?; + let (id, si, mut sp) = subpath_of(s, p, C, false)?; + if ai >= sp.anchors.len() { + return Err(bad(C, "no such anchor")); + } + let changed = pen::toggle_smooth(&mut sp, ai, r); + let smooth = sp.anchors.get(ai).is_some_and(|a| a.kind == AnchorKind::Smooth); + if changed { + put(s, "Convert Anchor Point", id, si, sp, Pick::Keep)?; + } + Ok(json!({ "smooth": smooth })) +} + +fn reshape(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.reshape"; + let seg = index_req(p, "segment", C)?; + let t = number_or(p, "t", 0.5, C)?; + if !(0.0..=1.0).contains(&t) { + return Err(bad(C, "`t` must be from 0 to 1")); + } + let (dx, dy) = pair(p, ("dx", "dy"), C)?; + let dv = Vec2::new(dx, dy); + let mode = match text_param(p, "mode", C)? { + None => po::ReshapeMode::Free, + Some(m) => po::ReshapeMode::parse(m).ok_or_else(|| bad(C, "`mode` must be \"free\", \"keepAngles\" or \"symmetric\""))?, + }; + let (id, si, mut sp) = subpath_of(s, p, C, false)?; + if seg >= sp.segment_count() { + return Err(bad(C, "no such segment")); + } + if !po::reshape_segment(&mut sp, seg, t, dv, mode) { + return Err(bad(C, "the segment can't be bent that far")); + } + put(s, "Reshape Segment", id, si, sp, Pick::Keep)?; + Ok(json!({})) +} + +fn next_segment(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.pen.nextSegment"; + let curved = switch_req(p, "curved", C)?; + let next = if curved { "curved" } else { "straight" }; + let values = Map::from_iter([("next".to_string(), json!(next))]); + s.set_tool_options(Some(smart_pen::ID), &values); + Ok(json!({ "next": next })) +} + +fn close_join(s: &mut Session, _: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + // The direct-selected anchors that are ends of open subpaths. + let mut picked = 0; + let mut ends = 0; + for (id, set) in &st.selection.anchors { + let path = d.node(*id).and_then(|n| n.path_data()); + for &(si, ai) in set { + picked += 1; + let sp = path.and_then(|pd| pd.subpaths.get(si)); + ends += usize::from(sp.is_some_and(|sp| !sp.closed && (ai == 0 || ai + 1 == sp.anchors.len()))); + } + } + if picked == 2 && ends == 2 { + return join(s, &json!({})); + } + super::pathtools::close(s, &json!({ "mode": "handles" })) +} diff --git a/crates/engine/src/cmd/addons/pen/tests.rs b/crates/engine/src/cmd/addons/pen/tests.rs new file mode 100644 index 000000000..1bb9f3345 --- /dev/null +++ b/crates/engine/src/cmd/addons/pen/tests.rs @@ -0,0 +1,287 @@ +//! Smart Pen's commands: each one undo step, bad params refused, and the tool's gestures through +//! the session. + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{AnchorKind, PathData, Point, Vec2}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::{Session, ViewInfo}; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s +} + +fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId { + let anchors: Vec = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].as_u64().unwrap()) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Each of `bad` is refused and leaves the document as it was. +fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) { + let (before, n) = (s.doc().unwrap().doc.clone(), steps(s)); + for p in bad { + assert!(s.execute(cmd, p).is_err(), "{cmd} {p}"); + } + assert_eq!(steps(s), n, "{cmd}"); + assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document"); +} + +fn parallel(a: Vec2, b: Vec2) -> bool { + a.cross(b).abs() < 1e-9 && a.dot(b) > 0.0 +} + +#[test] +fn append_adds_a_straight_or_curved_segment() { + let mut s = session(); + let id = line(&mut s, &[(0.0, 100.0), (100.0, 100.0)]); + let n = steps(&s); + let r = s.execute("addon.pen.append", &json!({"id": id.0, "x": 150, "y": 150, "segment": "curved"})).unwrap(); + assert_eq!(r["anchor"], json!(2)); + assert_eq!(steps(&s), n + 1, "one undo step"); + let sp = &path(&s, id).subpaths[0]; + // The curve leaves (100, 100) along the straight segment before it. + let a = sp.anchors[1]; + assert!(a.has_out() && !a.has_in() && parallel(a.h_out - a.p, Vec2::new(1.0, 0.0)), "{a:?}"); + assert!(sp.segment_is_line(0) && !sp.segment_is_line(1)); + // The new anchor is the one selected. + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id), Some(&BTreeSet::from([(0, 2)]))); + // Straight: the previous anchor's handle on it goes, and the new anchor's incoming one. + s.execute("addon.pen.append", &json!({"id": id.0, "subpath": 0, "x": 250, "y": 150, "in": [200, 100], "out": [300, 200], "segment": "straight"})) + .unwrap(); + let sp = &path(&s, id).subpaths[0]; + assert!(sp.segment_is_line(2) && sp.anchors[3].has_out() && !sp.anchors[3].has_in()); + assert_eq!(sp.anchors[3].kind, AnchorKind::Corner); + // Without a segment, the handles as given. + s.execute("addon.pen.append", &json!({"id": id.0, "x": 350, "y": 150, "in": [330, 140], "out": [370, 160]})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors[4].kind, AnchorKind::Smooth); + let closed = line(&mut s, &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]); + s.execute("path.close", &json!({"id": closed.0})).unwrap(); + refuses( + &mut s, + "addon.pen.append", + &[ + json!({}), + json!({"id": id.0}), + json!({"id": id.0, "x": 1, "y": f64::MAX}), + json!({"id": id.0, "x": 1, "y": 1, "segment": "wavy"}), + json!({"id": id.0, "x": 1, "y": 1, "in": [1]}), + json!({"id": id.0, "x": 1, "y": 1, "subpath": 3}), + json!({"id": closed.0, "x": 1, "y": 1}), + json!({"id": closed.0, "x": 1, "y": 1, "subpath": 0}), + json!({"id": 999_999, "x": 1, "y": 1}), + ], + ); +} + +#[test] +fn close_makes_the_closing_segment_straight_or_curved() { + let mut s = session(); + let id = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]); + let n = steps(&s); + s.execute("addon.pen.close", &json!({"id": id.0, "segment": "curved"})).unwrap(); + let sp = &path(&s, id).subpaths[0]; + assert!(sp.closed && !sp.segment_is_line(2)); + assert_eq!(steps(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.pen.close", &json!({"id": id.0, "segment": "straight"})).unwrap(); + assert!(path(&s, id).subpaths[0].segment_is_line(2)); + let dot = line(&mut s, &[(5.0, 5.0)]); + refuses(&mut s, "addon.pen.close", &[json!({}), json!({"id": id.0}), json!({"id": dot.0}), json!({"id": id.0, "segment": 1})]); +} + +#[test] +fn segment_switches_between_straight_and_curved() { + let mut s = session(); + let id = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]); + let n = steps(&s); + let r = s.execute("addon.pen.segment", &json!({"id": id.0, "segment": 1, "curved": true})).unwrap(); + assert_eq!(r["changed"], json!(true)); + assert!(!path(&s, id).subpaths[0].segment_is_line(1)); + s.execute("addon.pen.segment", &json!({"id": id.0, "segment": 1, "curved": false})).unwrap(); + assert!(path(&s, id).subpaths[0].segment_is_line(1)); + assert_eq!(steps(&s), n + 2); + // Already straight: no step. + let r = s.execute("addon.pen.segment", &json!({"id": id.0, "segment": 1, "curved": false})).unwrap(); + assert_eq!((r["changed"].clone(), steps(&s)), (json!(false), n + 2)); + refuses( + &mut s, + "addon.pen.segment", + &[ + json!({"id": id.0, "segment": 1}), + json!({"id": id.0, "segment": 2, "curved": true}), + json!({"id": id.0, "curved": true}), + json!({"id": id.0, "segment": -1, "curved": true}), + ], + ); +} + +#[test] +fn remove_point_keeps_the_shape_or_deletes_plainly() { + let mut s = session(); + let e = NodeId(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + let before = path(&s, e); + let n = steps(&s); + s.execute("addon.pen.removePoint", &json!({"id": e.0, "anchor": 1})).unwrap(); + let after = path(&s, e); + assert_eq!(after.anchor_count(), 7); + assert_eq!(steps(&s), n + 1); + // The outline barely moved: its bounds are the circle's. + let (b0, b1) = (before.bounds().unwrap(), after.bounds().unwrap()); + assert!((b0.width() - b1.width()).abs() < 2.0 && (b0.height() - b1.height()).abs() < 2.0, "{b0:?} {b1:?}"); + // Plain: the handles beside it stay as they were. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.pen.removePoint", &json!({"id": e.0, "anchor": 1, "plain": true})).unwrap(); + let plain = path(&s, e); + assert_eq!( + (plain.subpaths[0].anchors[0].h_out, plain.subpaths[0].anchors[1].h_in), + (before.subpaths[0].anchors[0].h_out, before.subpaths[0].anchors[2].h_in) + ); + // An open end goes with its segment; two anchors stay. + let l = line(&mut s, &[(0.0, 0.0), (50.0, 0.0), (100.0, 0.0)]); + s.execute("addon.pen.removePoint", &json!({"id": l.0, "anchor": 2})).unwrap(); + assert_eq!(path(&s, l).subpaths[0].anchors.len(), 2); + refuses( + &mut s, + "addon.pen.removePoint", + &[ + json!({"id": l.0, "anchor": 0}), + json!({"id": l.0, "anchor": 0, "plain": true}), + json!({"id": l.0, "anchor": 5}), + json!({"id": l.0}), + json!({"id": l.0, "anchor": 1, "plain": 1}), + ], + ); +} + +#[test] +fn toggle_point_and_reshape() { + let mut s = session(); + let id = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)]); + let n = steps(&s); + assert_eq!(s.execute("addon.pen.togglePoint", &json!({"id": id.0, "anchor": 1})).unwrap()["smooth"], json!(true)); + let smooth = path(&s, id).subpaths[0].anchors[1]; + assert_eq!(s.execute("addon.pen.togglePoint", &json!({"id": id.0, "anchor": 1})).unwrap()["smooth"], json!(false)); + let corner = path(&s, id).subpaths[0].anchors[1]; + assert_eq!((corner.h_in, corner.h_out), (smooth.h_in, smooth.h_out), "nothing moved"); + assert_eq!(steps(&s), n + 2); + refuses(&mut s, "addon.pen.togglePoint", &[json!({"id": id.0, "anchor": 9}), json!({"id": id.0, "anchor": 1, "ratio": 2})]); + // The grabbed point follows the drag. + let l = line(&mut s, &[(0.0, 200.0), (100.0, 200.0)]); + for mode in ["free", "keepAngles", "symmetric"] { + s.execute("addon.pen.reshape", &json!({"id": l.0, "segment": 0, "t": 0.5, "dx": 0, "dy": -40, "mode": mode})).unwrap(); + let p = kurbo::ParamCurve::eval(&path(&s, l).subpaths[0].segment(0), 0.5); + assert!(p.distance(Point::new(50.0, 160.0)) < 1e-6, "{mode}: {p:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + refuses( + &mut s, + "addon.pen.reshape", + &[ + json!({"id": l.0, "segment": 0}), + json!({"id": l.0, "segment": 1, "dx": 1, "dy": 1}), + json!({"id": l.0, "segment": 0, "dx": 1, "dy": 1, "t": 2}), + json!({"id": l.0, "segment": 0, "dx": 1e300, "dy": 1}), + json!({"id": l.0, "segment": 0, "dx": 1, "dy": 1, "mode": "twist"}), + ], + ); +} + +#[test] +fn the_panel_commands_set_the_next_segment_and_close_or_join() { + let mut s = session(); + assert_eq!(s.execute("addon.pen.nextSegment", &json!({"curved": true})).unwrap()["next"], json!("curved")); + assert_eq!(s.tool_options_of("smartPen")["next"], json!("curved")); + assert!(s.execute("addon.pen.nextSegment", &json!({})).is_err()); + // Two open ends direct-selected: joined. + let a = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)]); + let b = line(&mut s, &[(110.0, 0.0), (200.0, 0.0)]); + s.select(|_, sel| { + sel.set([a, b]); + sel.anchors.insert(a, BTreeSet::from([(0, 1)])); + sel.anchors.insert(b, BTreeSet::from([(0, 0)])); + }) + .unwrap(); + let n = steps(&s); + s.execute("addon.pen.closeJoin", &json!({})).unwrap(); + assert_eq!(steps(&s), n + 1); + assert!(s.doc().unwrap().doc.node(b).is_none() || s.doc().unwrap().doc.node(a).is_none(), "one path is left"); + // Otherwise the selected open paths close. + let c = line(&mut s, &[(0.0, 100.0), (100.0, 100.0), (100.0, 200.0)]); + s.execute("addon.pen.closeJoin", &json!({})).unwrap(); + assert!(path(&s, c).subpaths[0].closed); +} + +fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..ViewInfo::default() } +} + +/// A gesture of the active tool, `m` held throughout: press at the first point, drag through the +/// others, release at the last. +fn gesture(s: &mut Session, pts: &[(f64, f64)], m: Mods) { + let mut kinds = vec![PointerKind::Down]; + kinds.extend(pts.iter().skip(1).map(|_| PointerKind::Drag)); + for (k, (x, y)) in kinds.into_iter().zip(pts.iter().copied()) { + s.pointer(&PointerEvent::new(k, x, y).with_mods(m), view()).unwrap(); + } + let (x, y) = pts.last().copied().unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y).with_mods(m), view()).unwrap(); +} + +/// The one path of the session's document. +fn only_path(s: &Session) -> (NodeId, PathData) { + let mut found = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let Some(p) = n.path_data() { + found.push((n.id, p.clone())); + } + }); + assert_eq!(found.len(), 1, "{found:?}"); + found.remove(0) +} + +#[test] +fn the_tool_draws_a_path_one_step_per_anchor_and_closes_it() { + let mut s = session(); + s.select_tool("smartPen", view()).unwrap(); + let n = steps(&s); + gesture(&mut s, &[(50.0, 50.0)], Mods::default()); + gesture(&mut s, &[(150.0, 50.0)], Mods::default()); + s.execute("addon.pen.nextSegment", &json!({"curved": true})).unwrap(); + gesture(&mut s, &[(150.0, 150.0)], Mods::default()); + let (id, p) = only_path(&s); + let sp = &p.subpaths[0]; + assert_eq!(sp.anchors.len(), 3); + assert!(sp.segment_is_line(0) && !sp.segment_is_line(1), "a straight segment, then a curved one leaving it tangent"); + assert!(parallel(sp.anchors[1].h_out - sp.anchors[1].p, Vec2::new(1.0, 0.0))); + // A drag makes a smooth anchor. + gesture(&mut s, &[(50.0, 150.0), (30.0, 170.0)], Mods::default()); + assert_eq!(path(&s, id).subpaths[0].anchors[3].kind, AnchorKind::Smooth); + // Tab between anchors: the last segment straight again. + s.tool_key(ToolKey::Tab, Mods::default(), view()).unwrap(); + assert!(path(&s, id).subpaths[0].segment_is_line(2)); + gesture(&mut s, &[(50.0, 50.0)], Mods::default()); + assert!(path(&s, id).subpaths[0].closed); + assert_eq!(steps(&s), n + 6, "one step per anchor, the Tab and the close"); + // Edits with the same tool: a drag on an anchor moves it, Alt-click removes one keeping the shape. + s.select(|_, sel| sel.set([id])).unwrap(); + gesture(&mut s, &[(150.0, 50.0), (160.0, 40.0)], Mods::default()); + assert_eq!(path(&s, id).subpaths[0].anchors[1].p, Point::new(160.0, 40.0)); + gesture(&mut s, &[(160.0, 40.0)], Mods { alt: true, ..Mods::default() }); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3); + assert_eq!(steps(&s), n + 8); +} diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index c52b0003f..f6aecc32d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -125,7 +125,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -840,11 +840,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); + let defaults_only = switch_or(p, "defaultsOnly", false, C)?; + if defaults_only && (reverse || append || p.get("ids").is_some()) { + return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`")); + } let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; - let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None }; - let selected = match ids_param(p, "ids") { - Some(v) => outermost(&s.doc()?.doc, v), - None => selected_roots(s)?, + let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() { + super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) + } else { + None + }; + let selected = if defaults_only { + vec![] + } else { + match ids_param(p, "ids") { + Some(v) => outermost(&s.doc()?.doc, v), + None => selected_roots(s)?, + } }; // Reversed, the first selected object's attributes go onto the clicked object; with nothing // selected, what the Eyedropper picked up last does (#4382). @@ -864,7 +876,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?, }; let picked = Picked::of(&node); - if !reverse { + if !reverse && !defaults_only { s.eyedropper_sample = Some(node); } let range = range.filter(|(id, _, _)| targets.contains(id)); diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 308e300ec..00bcc1c83 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use vectorcraft_color::Swatch; use vectorcraft_doc::ImageObject; @@ -220,18 +222,16 @@ fn trace_params(s: &Session, c: &str, p: &Value) -> Result { Ok(params) } -/// Document swatch `w`'s solid color with the paint a trace fills with it: a global or spot color, -/// or a tint of one, linked to its swatch as `swatch_solid` (paint.rs) links it; any other color -/// unlinked. The color is the one the paint shows (a Lab spot color's CMYK equivalent when the Spot -/// Colors options use CMYK values). +/// Document swatch `w`'s displayed solid color and unlinked trace paint. A tint uses its tinted +/// color; a Lab spot color uses its CMYK equivalent when the Spot Colors options use CMYK values. fn trace_swatch_paint(d: &Document, w: &Swatch) -> Option<(Color, Paint)> { let linked = d.swatch_link(&w.name).and_then(|(base, tint)| d.tint_paint(&base, tint)); let color = linked.as_ref().and_then(Paint::color).or_else(|| w.paint.color())?; - Some((color, linked.unwrap_or_else(|| Paint::solid(color)))) + Some((color, Paint::solid(color))) } /// Document Library with `colorGroup`: the solid colors of the active document's color group -/// `name` (so named exactly, else ignoring case), each with its paint ([`trace_swatch_paint`]). +/// `name` (so named exactly, else ignoring case), each with its unlinked paint ([`trace_swatch_paint`]). /// Errors name command `c`. ([`trace_params`] has checked that the library is the document's /// swatches.) fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result> { @@ -247,9 +247,7 @@ fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result Result> { if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary { return Ok(vec![]); @@ -557,6 +555,12 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result = HashMap::new(); + for (rgb, paint) in &library { + by_color.entry(*rgb).or_insert(paint); + } let id = s.edit(label, |d, sel| { let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; let mut image = if is_trace_group(&src) { @@ -584,12 +588,8 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}", has_selection, |s, p| recolor(s, p, "Invert Colors", &invert) ), @@ -178,10 +178,10 @@ impl Scope { /// One recolouring pass, the colour visitor Edit Colors and Recolor Artwork share: `f` maps the /// solid colours and gradient stops of fills, strokes and text runs, gradient-mesh points, the -/// pixels of embedded images and the art of patterns used as fills or strokes. A recoloured image -/// becomes a new image and a recoloured pattern a new pattern swatch ("Dots 2"); the originals stay -/// as they are for other objects. Colours that change lose their swatch link. Only nodes that -/// change are kept as copies. +/// pixels of embedded images without a colorize paint and the art of patterns used as fills or +/// strokes. A recoloured image becomes a new image and a recoloured pattern a new pattern swatch +/// ("Dots 2"); the originals stay as they are for other objects. Colours that change lose their +/// swatch link. Only nodes that change are kept as copies. pub(crate) struct Recolor<'a> { f: &'a dyn Fn(Color) -> Color, /// Maps solid colours and gradient stops with their link and tint in place of `f`. @@ -335,13 +335,24 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) run_pass(s, p, label, Recolor::new(Scope::of(p), f)) } +/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch +/// link and tint. +pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result { + run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links)) +} + /// Run `pass` on the `ids` param's objects (default: the selection) as one undo step. fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result { let ids = match ids_param(p, "ids") { Some(v) => v, None => selected_roots(s)?, }; - let n = s.edit(label, |d, _| Ok(pass.run(d, &ids)))?; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); + let n = s.edit(label, |d, _| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; + Ok(n) + })?; Ok(json!({ "changed": n })) } @@ -451,6 +462,7 @@ fn filled_leaves(d: &Document, roots: &[NodeId]) -> Vec { fn fill_color(n: &Node) -> Option { match &n.kind { NodeKind::Text(t) => t.first_style().fill.color(), + NodeKind::Image(_) => n.extras().colorize.as_deref().and_then(Paint::color).or_else(|| n.appearance.fill_paint().color()), NodeKind::Mesh(m) => { let k = m.points.len() as f32; let sum = m.points.iter().map(|p| p.color.to_rgb()).reduce(|a, c| [a[0] + c[0], a[1] + c[1], a[2] + c[2]])?; @@ -487,6 +499,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { BlendOrder::Vertical => "Blend Vertically", }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let changed = s.edit(label, |d, _| { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { @@ -504,6 +517,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; changed += Recolor::new(fills, &*f).run(d, &[*id]); } + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) })?; Ok(json!({ "changed": changed })) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index d7c584d9d..ec659abe8 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -646,6 +646,17 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br out.extend(expanded.into_iter().map(|(id, at)| (id, at + 1))); return Some(m); } + // Vector Halftone: the object becomes its dots, a group (with `id`) of paths with ids of their own. + if let Some(mut m) = vectorcraft_render::addons::halftone::art(d, &n) { + let mut seen = std::collections::HashSet::from([id]); + for c in m.children_mut().into_iter().flatten() { + effects::fresh_ids(d, Arc::make_mut(c), &mut seen); + } + if !listed_n { + out.push((id, 0)); + } + return Some(m); + } let image = raster_image(d, &n); let mut v = n.clone(); rasterfx::strip_raster(&mut v, false); @@ -721,6 +732,7 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br fn expand_appearance(s: &mut Session, p: &Value) -> Result { let roots = appearance_targets(s, p)?; let failed = vectorcraft_render::decode_failures(); + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let brushes = vectorcraft_brush::library(d); let mut out = vec![]; @@ -734,6 +746,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { if out.is_empty() { return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into())); } + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?; Ok(out) })?; let mut out = json!({ "ids": ids_json(&ids) }); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index c23c578d0..d85b3358c 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -68,7 +68,7 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() { assert!(s.execute("effect.setParams", &json!({"index": 0, "params": {"bevel": "nope"}, "ids": [id.0], "item": null})).is_err()); let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); - assert!(svg.matches("= 2, "{effect}: exported as vector faces"); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 2, "{effect}: exported as vector faces"); s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap(); assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }), "{effect}"); assert!(!effects::has_solid(&node(&s, id))); @@ -77,12 +77,35 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle"); } - // Type has no outline to extrude. +} + +/// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays +/// type: editing its text reshapes the solid. +#[test] +fn type_extrudes_as_its_glyphs_and_stays_editable() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); - let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap(); - let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err(); - assert!(err.to_string().contains("requires a path"), "{err}"); + let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "3D", "size": 72})).unwrap(); + let id = NodeId(t["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap(); + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + let art = effects::solid_art(&n).expect("a solid of the glyphs"); + let mut faces = 0; + let mut tinted = false; + art.walk(&mut |c| { + if c.path_data().is_some() { + faces += 1; + // Shaded from the characters' red: red stays the strongest channel. + if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) { + tinted |= r > g && r > b; + } + } + }); + assert!(faces > 2 && tinted, "{faces} faces, tinted {tinted}"); + s.execute("text.setText", &json!({"ids": [id.0], "text": "3DD"})).unwrap(); + assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text"); } /// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 8dc49b089..5ad8054fa 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -189,6 +189,15 @@ fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a F } fn expand(s: &mut Session, p: &Value) -> Result { + let scope = s.begin_undo_scope(); + let result = expand_steps(s, p); + if let Some(scope) = scope { + s.end_undo_scope(scope, result.is_err()); + } + result +} + +fn expand_steps(s: &mut Session, p: &Value) -> Result { let object = bool_or(p, "object", true); let fill = bool_or(p, "fill", true); let stroke = bool_or(p, "stroke", true); @@ -218,7 +227,13 @@ fn expand(s: &mut Session, p: &Value) -> Result { s.edit("Expand", |d, _| super::patterncmds::expand_repeats(d, &roots).map(|_| ()))?; } if object && e.effects { - let _ = run_raw(s, "effect.expandAppearance", &json!({})); + // Effects that Expand Appearance can't expand are left as they are. When it refuses to + // leave a process color on a grayscale image with transparency, Expand fails as a whole. + if let Err(e) = run_raw(s, "effect.expandAppearance", &json!({})) + && super::paint::is_colorize_refusal(&e) + { + return Err(e); + } // What is left to expand is in the baked art: Expand Appearance leaves strokes strokes // (#3089), a group's own as stroked paths among its members, which Stroke outlines below. e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); @@ -429,10 +444,10 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { }) } -/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint +/// Expand the pattern fills under `root` that paint a transparent or colorized image into the tiles they paint /// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other -/// pattern fills stay. A tile image counts as -/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group +/// pattern fills stay. A tile image counts when its key is in `see_through` (it has see-through +/// pixels), when it has a colorize paint, or when it, a group /// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending /// mode. Returns how many shapes changed. pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet) -> Result { @@ -458,12 +473,12 @@ fn blended(n: &Node) -> bool { n.opacity < 1.0 || n.blend != BlendMode::Normal } -/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or -/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. +/// Whether `n` is or holds an image with see-through pixels (key in `keys`), a colorize paint, +/// opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { let on = on || blended(n); match &n.kind { - NodeKind::Image(im) => on || keys.contains(&im.key), + NodeKind::Image(im) => on || keys.contains(&im.key) || n.extras().colorize.is_some(), _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), } } diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index c7c467dc8..0e53a5a18 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -161,9 +161,8 @@ fn expand_appearances(d: &mut Document) { } } -/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those -/// images flatten like any other transparent image. The writer would otherwise draw them opaque -/// over white. +/// Expand every layer's pattern fills that paint an image with see-through pixels, a colorize paint, +/// opacity or blending into their tiles, so those images can be flattened. fn expand_image_patterns(d: &mut Document) -> Result<()> { let keys = super::super::flatten::see_through_images(d); let layers: Vec = d.layers.iter().map(|l| l.id).collect(); @@ -174,8 +173,8 @@ fn expand_image_patterns(d: &mut Document) -> Result<()> { } /// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as -/// [`Document::walk`] counts). These are the images with see-through pixels, and the images -/// that have opacity or a blending mode of their own or, with `flatten`, of a group they are in. +/// [`Document::walk`] counts). These have see-through pixels or a colorize paint, or have opacity +/// or a blending mode of their own or, with `flatten`, of a group they are in. /// With `flatten` only visible images are returned, because a hidden one (which the hidden objects /// option may write) is written over white. With `omit` all of them are returned. fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { @@ -184,7 +183,11 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { let mut at = 0; d.walk(|n| { if let NodeKind::Image(im) = &n.kind - && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal || (mode == SeeThrough::Flatten && transparent(d, n.id))) + && (keys.contains(&im.key) + || n.extras().colorize.is_some() + || n.opacity < 1.0 + || n.blend != BlendMode::Normal + || (mode == SeeThrough::Flatten && transparent(d, n.id))) { out.push((at, n.id)); } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index cf1ecc6a8..c2e58e39e 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -197,6 +197,49 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option bool { + w == vectorcraft_pdf::OFF_ARTBOARD_NOTE || w == vectorcraft_pdf::PAST_PAGE_NOTE +} + +/// The document read from the editing data alone with its type as outlines when `opts` asks for +/// them (`textAs: outlines`): the page that would have them has none to give. Threaded type first +/// flows through its frames (the editing data gives a story in its first frame), and the outlined +/// frames leave their threads. Each outline group keeps its type's transparency, opacity mask and +/// effects. +pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: &LoadOptions) -> (Document, Vec) { + if opts.text_as != vectorcraft_pdf::TextAs::Outlines { + return (doc, warnings); + } + if !doc.text_threads.is_empty() { + crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc); + } + let threaded = crate::cmd::threads::frames(&doc); + let mut texts = Vec::new(); + doc.walk(|n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) { + texts.push(n.clone()); + } + }); + for t in texts { + let Some(o) = crate::cmd::flatten::outlined_text(&t) else { continue }; + let o = Node { visible: t.visible, locked: t.locked, ..doc.reid(&o) }; + if let Some(slot) = doc.node_mut(t.id) { + *slot = o; + } + } + crate::cmd::threads::leave_threads(&mut doc, &threaded); + (doc, warnings) +} + +/// Does `doc` hold no art: only layers, with nothing in them? +fn draws_nothing(doc: &Document) -> bool { + let mut art = false; + doc.walk(|n| art |= !n.is_layer()); + !art +} + /// Is `e` the error of a file whose pages are only a placeholder /// ([`vectorcraft_pdf::PdfError::PlaceholderOnly`])? pub(crate) fn is_placeholder(e: &EngineError) -> bool { @@ -272,8 +315,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result restore_or_import(editing, || match super::pdfimport::import(bytes, opts, format.id != "pdf") { Ok(r) => { // Pages of text alone: the editing copy alone where it reads, as for a file - // saved without its PDF part (#1387: else the page, which is the art). - let alone = r.only_text.then(|| from_editing_data_alone(bytes, opts)).flatten().and_then(|read| read.ok()); + // saved without its PDF part (#1387: else the page, which is the art). So too + // pages that draw nothing at all: there is no page art to check the layers + // against, and the empty page would lose all of it. + // A page is empty only when nothing on it was skipped (an image that can't be + // decoded draws nothing either): its notes are at most the page's own. + let empty = draws_nothing(&r.document) && r.warnings.iter().all(|w| is_page_note(w)); + let alone = (r.only_text || empty) + .then(|| from_editing_data_alone(bytes, opts)) + .flatten() + .and_then(|read| read.ok()) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)); let (doc, warnings, read) = match alone { Some((doc, warnings)) => (doc, warnings, true), None => through_editing_data(bytes, opts, r.document, r.warnings, r.user_unit), @@ -287,6 +339,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let whole = LoadOptions { pages: None, ..opts.clone() }; from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }) .unwrap_or(Err(e)) + .map(|(doc, warnings)| outlined_if_asked(doc, warnings, opts)) .inspect(|_| editing_data = true) } Err(e) => Err(e), @@ -590,7 +643,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { reader.limits(limits); let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?; let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; + // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is + // seen, so it places the way it was taken (#4284); the others keep their own bytes. + let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some(); let kept = match f.id { + _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), "tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi), _ => None, @@ -609,13 +666,16 @@ pub fn raster_image(bytes: &[u8]) -> Result { Some(read) => read.map_err(err)?, None => { use image::ImageDecoder as _; - let decoder = reader.into_decoder().map_err(err)?; + let mut decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } - image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8() + let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); + let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?; + img.apply_orientation(orientation); + img.to_rgba8() } }, }; @@ -625,6 +685,14 @@ pub fn raster_image(bytes: &[u8]) -> Result { as_png(img, ppi) } +/// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when +/// it leaves them as stored, or the image has none. +pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { + use image::ImageDecoder as _; + let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; + decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms) +} + /// `img` stored as a PNG that keeps the file's resolution. fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result { let size = img.dimensions(); diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 7902a0fed..dfa7d1cfa 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -317,9 +317,9 @@ pub const DEFAULT_FLATTENER: &str = "High Resolution"; /// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3, /// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener` -/// options over it; images with see-through pixels count as transparency, rasterized areas are -/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or -/// the file keeps transparency). +/// options over it; images with see-through pixels and colorized images count as transparency, +/// rasterized areas are clipped to their regions, and discarded overprints are dropped. `None` +/// when nothing changes (or the file keeps transparency). fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result> { if !set.pdf13() { return Ok(None); diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index f2b770682..f781a87b5 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -837,3 +837,33 @@ fn a_failed_save_does_not_stamp_the_documents_dates() { s.execute("edit.undo", &json!({})).unwrap(); assert!(!s.doc().unwrap().is_dirty()); } + +/// #4284: a photo whose EXIF orientation says the camera was turned places upright: its pixels +/// turned as the orientation says, its width and height swapped. One without keeps its own bytes. +#[test] +fn a_photo_places_the_way_its_exif_orientation_says() { + // 16 × 8, red on the left half. + let mut rgb = image::RgbImage::from_pixel(16, 8, image::Rgb([255, 255, 255])); + for y in 0..8 { + for x in 0..8 { + rgb.put_pixel(x, y, image::Rgb([220, 0, 0])); + } + } + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + // An EXIF segment with Orientation 6: turn a quarter clockwise to show. + let exif: Vec = + [b"Exif\0\0".as_slice(), b"II", &[0x2a, 0, 8, 0, 0, 0], &[1, 0], &[0x12, 0x01, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0], &[0, 0, 0, 0]].concat(); + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let plain = raster_image(&jpg).unwrap(); + assert_eq!((plain.width, plain.height, plain.blob.bytes.as_slice()), (16, 8, jpg.as_slice()), "a photo as taken keeps its bytes"); + let img = raster_image(&turned).unwrap(); + assert_eq!((img.width, img.height), (8, 16), "turned a quarter"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |p: &image::Rgb| p[0] > 150 && p[1] < 80; + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); +} diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 3f0dce5c1..edccacd50 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -6,7 +6,7 @@ use std::io::Write as _; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::{Node, NodeId, NodeKind}; use vectorcraft_testkit::ai; use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; @@ -535,6 +535,194 @@ fn an_ai_file_whose_pdf_part_is_only_text_still_opens_from_editing_data_that_rea check_sample(&s); } +#[test] +fn an_ai_file_whose_pdf_part_is_cropped_to_its_art_opens_from_its_layers() { + // A 400 × 200 pt artboard with two red bars across (100, 50)–(300, 150). The PDF part's page + // is that art's box, 200 × 100 pt (the editing data's bounding box), not the artboard. + let red = "0 1 1 0 1 0 0 Xa\n"; + let art = ai::layer("Art", &format!("{red}{}f\n{red}{}f\n", ai::rect(100.0, 50.0, 180.0, 150.0), ai::rect(220.0, 50.0, 300.0, 150.0))); + let data = ai::editing_data_in([100.0, 50.0, 300.0, 150.0], [0.0, 0.0, 400.0, 200.0], &art); + let page = "1 0 0 rg 0 0 80 100 re f 120 0 80 100 re f"; + let mut s = Session::new(); + let r = open(&mut s, "cropped.ai", &ai::ai(&compressed(data.as_bytes()), page), json!({})).unwrap(); + assert!(!r["warnings"].to_string().contains("weren't read"), "{r}"); + let doc = &s.doc().unwrap().doc; + assert_eq!(names(&doc.layers), ["Art"]); + assert_eq!(doc.artboards.len(), 1); + assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), "the file's artboard, not its page"); + let bars = doc.layers[0].children().unwrap(); + assert_eq!(bars.len(), 2); + let b = bars[0].geometric_bounds().unwrap(); + assert!((b.x0 - 100.0).abs() < 0.01 && (b.y0 - 50.0).abs() < 0.01, "where the artboard has it: {b:?}"); +} + +#[test] +fn an_ai_file_whose_pdf_part_draws_nothing_opens_from_its_editing_data() { + // A PDF-compatible `.ai` whose PDF part is a page with nothing on it (no notice text either), + // with or without the boxes that say where its art is: its editing data is the art. + for boxed in ["", "/ArtBox [0 0 200 100] /TrimBox [0 0 200 100]"] { + let mut s = Session::new(); + let r = open(&mut s, "blank.ai", &ai::ai_with(&compressed(&ai::sample_data()), "", boxed), json!({})).unwrap(); + assert_eq!(r["warnings"], json!([]), "{boxed:?}: {r}"); + check_sample(&s); + // The PDF part alone, as asked: its empty page. + open(&mut s, "blank.ai", &ai::ai_with(&compressed(&ai::sample_data()), "", boxed), json!({"editingData": false})).unwrap(); + let layers = &s.doc().unwrap().doc.layers; + assert!(layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "{boxed:?}"); + } + // Editing data that can't be read leaves the empty page, as before. + let mut s = Session::new(); + open(&mut s, "blank.ai", &ai::ai(b"%AI24_ZStandard_Data\0\0\0\0\0\0\0\0", ""), json!({})).unwrap(); + assert_ne!(names(&s.doc().unwrap().doc.layers), ["Art", "Hidden"]); +} + +/// A page that draws nothing only because what it holds couldn't be read (an image that can't be +/// decoded) isn't an empty page: its notes stay, and the editing data isn't taken in its place +/// unchecked. +#[test] +fn a_page_whose_content_was_skipped_is_not_taken_for_an_empty_one() { + let broken = "q 100 0 0 50 0 0 cm BI /W 2 /H 2 /CS /RGB /BPC 8 /F /DCT ID notajpeg EI Q"; + let mut s = Session::new(); + let r = open(&mut s, "broken.ai", &ai::ai(&compressed(&ai::sample_data()), broken), json!({})).unwrap(); + let warnings = r["warnings"].to_string(); + assert_ne!(r["warnings"], json!([]), "the skipped image is told: {r}"); + assert!(!warnings.contains("saved without PDF compatibility"), "{r}"); +} + +/// Editing data with one layer, "Art", holding one left-aligned story, "Hi", 14 pt Helvetica, +/// anchored at the canvas centre (the template box's centre), as the editing data's text document +/// gives it; compressed. +fn typed_data() -> Vec { + let story = "<< /0 << /0 (Hi\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 3 >> ] >> \ + /6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /0 0 /1 14.0 /53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> >> >> >> /1 3 >> ] >> >> \ + /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC /6 [ << /99 /F /0 << /0 [ 8191.5 8191.5 ] >> /6 [ << /99 /R /6 [ << /99 /R /6 [ << /99 /L /6 [ \ + << /99 /S /0 << /0 [ 0.0 0.0 ] >> /15 << /0 3 >> >> ] >> ] >> ] >> ] >> ] >> ] >> >>"; + let document = format!( + "/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ << /0 << /0 [ 0 0 ] /2 << /2 [ 1 0 0 1 0 0 ] >> >> >> ] >> >>\n/1 << /1 [ {story} ] /2 << /1 12.0 >> >>\n" + ); + let lines: Vec = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect(); + let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n2 /TextAntialiasing ,\n;\n"; + let data = 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%AI3_TemplateBox: 50 50 50 50\n\ + %AI5_NumLayers: 1\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n", + ai::layer("Art", text), + lines.join("\n") + ); + compressed(data.as_bytes()) +} + +/// How many text objects and how many paths the open document holds. +fn texts_and_paths(s: &Session) -> (usize, usize) { + let (mut texts, mut paths) = (0, 0); + s.doc().unwrap().doc.walk(|c| { + texts += usize::from(matches!(c.kind, NodeKind::Text(_))); + paths += usize::from(matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })); + }); + (texts, paths) +} + +/// Type asked for as outlines on a page that draws nothing: the editing data's type, outlined (the +/// page has none to give). +#[test] +fn an_empty_page_with_type_as_outlines_outlines_the_editing_datas_type() { + let file = ai::ai(&typed_data(), ""); + let mut s = Session::new(); + let r = open(&mut s, "typed.ai", &file, json!({})).unwrap(); + assert_eq!(texts_and_paths(&s).0, 1, "by default the editing data's type is type: {r}"); + let r = open(&mut s, "typed.ai", &file, json!({"textAs": "outlines"})).unwrap(); + let (texts, paths) = texts_and_paths(&s); + assert_eq!(texts, 0, "no type left: {r}"); + assert!(paths > 0, "the outlines are there: {r}"); + assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers"); +} + +/// Type asked for as outlines in a `.ai` saved without PDF compatibility (its page only a +/// placeholder): the editing data's type, outlined, as for an empty page. +#[test] +fn a_file_saved_without_its_pdf_part_with_type_as_outlines_outlines_its_type() { + let file = ai::ai(&typed_data(), PLACEHOLDER); + let mut s = Session::new(); + let r = open(&mut s, "typed.ai", &file, json!({})).unwrap(); + assert_eq!(texts_and_paths(&s).0, 1, "by default the editing data's type is type: {r}"); + let r = open(&mut s, "typed.ai", &file, json!({"textAs": "outlines"})).unwrap(); + let (texts, paths) = texts_and_paths(&s); + assert_eq!(texts, 0, "no type left: {r}"); + assert!(paths > 0, "the outlines are there: {r}"); + assert_eq!(names(&s.doc().unwrap().doc.layers), ["Art"], "the editing data's layers"); +} + +/// Type of the editing data outlined keeps its transparency, opacity mask and effects, and is +/// shown and locked as the type was. +#[test] +fn outlined_type_of_the_editing_data_keeps_its_transparency_and_effects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let mut doc = s.doc().unwrap().doc.as_ref().clone(); + let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: json!({}), visible: true }; + { + let t = doc.node_mut(id).unwrap(); + t.opacity = 0.5; + t.blend = vectorcraft_color::BlendMode::Multiply; + t.isolate = true; + t.locked = true; + t.name = Some("Title".into()); + t.appearance.effects = vec![shadow.clone()]; + } + let opts = LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }; + let (doc, _) = load::outlined_if_asked(doc, vec![], &opts); + let mut outlines = vec![]; + doc.walk(|n| { + if n.extras().type_outlines { + outlines.push(n.clone()); + } + }); + let [o] = &outlines[..] else { panic!("one group of outlines: {outlines:?}") }; + assert_eq!((o.opacity, o.blend, o.isolate), (0.5, vectorcraft_color::BlendMode::Multiply, true)); + assert_eq!(o.appearance.effects, [shadow]); + assert!(o.locked && o.visible); + assert_eq!(o.name.as_deref(), Some("Title")); +} + +/// Threaded type of the editing data, its story all in its first frame as the editing data gives +/// it, outlined: each frame's outlines show the text that flows into it. +#[test] +fn outlined_threaded_type_of_the_editing_data_shows_the_text_of_every_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame. ".repeat(4); + let a = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + // As the editing data gives it: the first frame holds the story, the second nothing yet. + let mut doc = s.doc().unwrap().doc.as_ref().clone(); + let runs = |id: u64| match &doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("area type"), + }; + let (shown, flowed) = (runs(a), runs(b)); + let empty: Vec<_> = flowed.iter().take(1).map(|r| vectorcraft_doc::TextRun { text: String::new(), ..r.clone() }).collect(); + for (id, put) in [(a, [shown, flowed].concat()), (b, empty)] { + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.runs = put; + t.cached_bounds = None; + t.cached_baselines.clear(); + } + } + let opts = LoadOptions { text_as: vectorcraft_pdf::TextAs::Outlines, ..Default::default() }; + let (doc, _) = load::outlined_if_asked(doc, vec![], &opts); + let mut right = 0; + doc.walk(|n| { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && n.geometric_bounds().is_some_and(|r| r.x0 >= 190.0) { + right += 1; + } + }); + assert!(right > 0, "the second frame's text is outlined where it flows"); + assert!(doc.text_threads.is_empty(), "outlined frames are in no thread: {:?}", doc.text_threads); +} + #[test] fn editing_data_false_opens_only_what_the_page_prints() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 6bea6dad7..eec800d74 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -101,7 +101,7 @@ fn selected_only_exports_the_selection_in_its_layers() { // Other formats take it too. s.execute("select.set", &json!({"ids": [line]})).unwrap(); let svg = s.execute("document.serialize", &json!({"format": "svg", "selectedOnly": true})).unwrap()["text"].as_str().unwrap().to_string(); - assert_eq!((svg.matches("") && o.contains("class=\"cls-1\"")); changed(&mut s, json!({"objectIds": "minimal"}), &|o| !o.contains(" id=\"")); changed(&mut s, json!({"objectIds": "unique"}), &|o| o.contains(" id=\"u") && o.contains("-Layer_1\"")); - changed(&mut s, json!({"decimals": 1}), &|o| o.contains("M10.1 ") && !o.contains("10.123")); + changed(&mut s, json!({"decimals": 1}), &|o| o.contains("x=\"10.1\"") && !o.contains("10.123")); changed(&mut s, json!({"decimals": 6}), &|o| o.contains("10.123456")); changed(&mut s, json!({"minify": true}), &|o| !o.trim_end().contains('\n') && !o.contains("") && text.matches("") && vectorcraft_testkit::svg_shapes(&text) == 2, "Save reuses the options: {text}"); assert!(s.execute("document.save", &json!({"path": dir.join("x.png").to_string_lossy()})).is_err(), "PNG is an export"); // A native save switches back. let native = dir.join("doc.vectorcraft").to_string_lossy().to_string(); diff --git a/crates/engine/src/cmd/fileio/tests_svgenc.rs b/crates/engine/src/cmd/fileio/tests_svgenc.rs index f901115d1..d1d912313 100644 --- a/crates/engine/src/cmd/fileio/tests_svgenc.rs +++ b/crates/engine/src/cmd/fileio/tests_svgenc.rs @@ -61,7 +61,7 @@ fn a_chosen_artboard_s_svg_holds_only_its_own_art() { s.execute("shape.rectangle", &json!({"x": first + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); s.execute("shape.ellipse", &json!({"x": third + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); // A rectangle has straight sides only; the ellipse is curves. - let shapes = |svg: &str| (svg.matches(" Option { +pub(crate) fn outlined_text(n: &Node) -> Option { effects::outline_text(n).map(|g| Node { knockout: Knockout::Off, ..carry(n, g) }) } diff --git a/crates/engine/src/cmd/gradpresets.rs b/crates/engine/src/cmd/gradpresets.rs new file mode 100644 index 000000000..2f69a9df3 --- /dev/null +++ b/crates/engine/src/cmd/gradpresets.rs @@ -0,0 +1,224 @@ +//! The Gradient panel's presets: ready-made gradients grouped by theme, applied to the gradient +//! behind the active proxy in one click, and the user's own gradients saved beside them. +//! +//! The built-in presets are the gradient swatches of the built-in swatch libraries +//! ([`vectorcraft_color::libraries`]: the app's own computed palettes), each listed under its +//! library's name. Saved presets are kept with the preferences ([`crate::Prefs::gradient_presets`]), +//! so every document has them. A preset is the colour ramp (type, stops, method, dither), not a +//! placement: applying one edits the gradient in place, as `paint.editGradient` does. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_color::libraries::{GRADIENT_LIBRARIES, SWATCH_LIBRARIES, builtin_library}; +use vectorcraft_color::{Gradient, GradientPaint, Paint}; +use vectorcraft_tools::params::{gradient_params, stops_json}; + +use super::appearance::{edits_stroke, item_target}; +use super::gradient::active_gradient; +use super::{CommandSpec, Result, Session, always, bad, cmd, has_doc, text_param}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "gradient.presets.list", + "Gradient Presets", + [], + None, + "{} → {presets: [{name, library: the built-in library (theme) it comes from, null for a saved one, builtIn, gradient: {kind, stops, method?, dither?} (as paint.setFill's `gradient`)}]} the Gradient panel's presets: the built-in ones by library, then the saved ones; names are unique", + always, + list + ), + cmd!( + "gradient.presets.apply", + "Apply Gradient Preset", + [], + None, + "{name: a preset's name (gradient.presets.list), stroke?, item?, ids?|id?, target? (as paint.editGradient)} give the gradient behind the active proxy (or the targeted fill or stroke) the preset's type, stops and method, and a fill its dither, keeping its placement; a solid or no paint becomes the gradient. One undo step, as paint.editGradient → {name, ids: the objects painted (none: only the default changed)}", + has_doc, + apply + ), + cmd!( + query "gradient.presets.save", + "Save Gradient Preset", + [], + None, + "{name?: (default: a new \"Gradient Preset N\"; unique, any case, and not a built-in preset's)} save the gradient behind the active proxy (paint.toggleActive picks Fill or Stroke) as a preset, kept with the preferences: its type, stops, method and dither (stops linked to a swatch keep their colour, unlinked) → {name}", + can_save, + save + ), + cmd!( + query "gradient.presets.delete", + "Delete Gradient Preset", + [], + None, + "{name?: a saved preset (default: the saved preset the active proxy's gradient is)} remove a saved preset; built-in presets can't be deleted, and painted art keeps its gradient → {deleted}", + has_saved_presets, + delete + ), + ] +} + +/// A gradient saved to the Gradient panel's presets ([`crate::Prefs::gradient_presets`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct GradientPreset { + pub name: String, + pub gradient: Gradient, +} + +/// A row of the Presets list: a built-in preset (with its library) or a saved one. +#[derive(Clone, Debug, PartialEq)] +pub struct GradientPresetEntry { + pub name: String, + /// The built-in library (theme) it comes from; `None` for a saved preset. + pub library: Option<&'static str>, + pub gradient: Gradient, +} + +/// The built-in presets: every built-in library's gradient swatches, the gradient libraries first. +fn builtin_presets() -> impl Iterator { + GRADIENT_LIBRARIES.iter().chain(SWATCH_LIBRARIES).flat_map(|b| { + let lib = builtin_library(b.id); + let gradients: Vec = lib + .iter() + .flat_map(|lib| lib.iter()) + .filter_map(|w| match &w.paint { + Paint::Gradient(g) => Some(GradientPresetEntry { name: w.name.clone(), library: Some(b.name), gradient: g.gradient.clone() }), + _ => None, + }) + .collect(); + gradients + }) +} + +/// Do `a` and `b` have the same colour ramp (type, method, dither and stops), swatch links aside? +fn same_ramp(a: &Gradient, b: &Gradient) -> bool { + let stop = |x: &vectorcraft_color::GradientStop, y: &vectorcraft_color::GradientStop| { + (x.offset, x.color, x.opacity, x.midpoint) == (y.offset, y.color, y.opacity, y.midpoint) + }; + (a.kind, a.method, a.dither) == (b.kind, b.method, b.dither) + && a.stops.len() == b.stops.len() + && a.stops.iter().zip(&b.stops).all(|(x, y)| stop(x, y)) +} + +/// `g` without links to document swatches: each linked stop keeps the colour it shows. +fn unlinked(g: &Gradient) -> Gradient { + let mut g = g.clone(); + for s in &mut g.stops { + s.set_color(s.color, None); + } + g +} + +impl Session { + /// The Gradient panel's presets: the built-in ones by library, then the saved ones. + pub fn gradient_presets(&self) -> Vec { + let saved = + self.prefs.gradient_presets.iter().map(|p| GradientPresetEntry { name: p.name.clone(), library: None, gradient: p.gradient.clone() }); + builtin_presets().chain(saved).collect() + } + + /// The preset called `name` (any case). + pub fn gradient_preset(&self, name: &str) -> Option { + let name = name.trim(); + self.gradient_presets().into_iter().find(|e| e.name.eq_ignore_ascii_case(name)) + } + + /// The name Save Gradient Preset suggests: the first free "Gradient Preset N". + pub fn next_gradient_preset_name(&self) -> String { + let presets = self.gradient_presets(); + (1..).map(|n| format!("Gradient Preset {n}")).find(|n| !presets.iter().any(|e| e.name.eq_ignore_ascii_case(n))).unwrap_or_default() + } + + /// The name of the preset `g` is (its colour ramp, swatch links aside): a built-in one first, + /// then a saved one. Cheap enough for every frame: nothing is copied but the name found. + pub fn gradient_preset_name(&self, g: &Gradient) -> Option { + let builtin = GRADIENT_LIBRARIES.iter().chain(SWATCH_LIBRARIES).find_map(|b| { + let lib = builtin_library(b.id)?; + lib.iter().find(|w| matches!(&w.paint, Paint::Gradient(x) if same_ramp(&x.gradient, g))).map(|w| w.name.clone()) + }); + builtin.or_else(|| self.prefs.gradient_presets.iter().find(|p| same_ramp(&p.gradient, g)).map(|p| p.name.clone())) + } + + /// The saved preset the gradient behind the active proxy is (its colour ramp, links aside). + pub fn active_saved_gradient_preset(&self) -> Option<&str> { + let g = active_gradient(self)?; + self.prefs.gradient_presets.iter().find(|p| same_ramp(&p.gradient, &g.gradient)).map(|p| p.name.as_str()) + } +} + +fn can_save(s: &Session) -> std::result::Result<(), String> { + active_gradient(s).map(|_| ()).ok_or_else(|| "the active paint is not a gradient".into()) +} + +fn has_saved_presets(s: &Session) -> std::result::Result<(), String> { + if s.prefs.gradient_presets.is_empty() { Err("no saved gradient presets".into()) } else { Ok(()) } +} + +/// `p`'s `name`: a string, required. +fn name_param<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { + text_param(p, "name", cmd)?.ok_or_else(|| bad(cmd, "missing `name` (see gradient.presets.list)")) +} + +fn list(s: &mut Session, _: &Value) -> Result { + let rows: Vec = s + .gradient_presets() + .into_iter() + .map(|e| json!({"name": e.name, "library": e.library, "builtIn": e.library.is_some(), "gradient": gradient_params(&GradientPaint::new(e.gradient))})) + .collect(); + Ok(json!({ "presets": rows })) +} + +fn apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "gradient.presets.apply"; + let name = name_param(p, C)?; + let preset = s.gradient_preset(name).ok_or_else(|| bad(C, format!("no gradient preset `{name}` (see gradient.presets.list)")))?; + let stroke = edits_stroke(s, p, item_target(s, p, C)?, !s.fill_active, C)?; + let g = &preset.gradient; + let mut edit = p.clone(); + let Some(o) = edit.as_object_mut() else { return Err(bad(C, "params must be an object")) }; + o.remove("name"); + o.insert("stroke".into(), json!(stroke)); + o.insert("kind".into(), json!(g.kind.label().to_lowercase())); + o.insert("stops".into(), stops_json(&g.stops)); + o.insert("method".into(), json!(g.method.label().to_lowercase())); + // Dither is a fill's (paint.editGradient refuses it on a stroke). + if !stroke { + o.insert("dither".into(), json!(g.dither)); + } + let out = s.execute("paint.editGradient", &edit)?; + Ok(json!({"name": preset.name, "ids": out.get("ids").cloned().unwrap_or_else(|| json!([]))})) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "gradient.presets.save"; + let g = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?; + let name = match text_param(p, "name", C)?.map(str::trim) { + None => s.next_gradient_preset_name(), + Some("") => return Err(bad(C, "the name is empty")), + Some(n) if s.gradient_preset(n).is_some() => return Err(bad(C, format!("a gradient preset is already called {n}"))), + Some(n) => n.to_string(), + }; + s.prefs.gradient_presets.push(GradientPreset { name: name.clone(), gradient: unlinked(&g.gradient) }); + Ok(json!({ "name": name })) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "gradient.presets.delete"; + let name = match text_param(p, "name", C)? { + Some(n) => n.trim().to_string(), + None => s + .active_saved_gradient_preset() + .map(str::to_string) + .ok_or_else(|| bad(C, "give a name, or paint the active proxy with a saved preset"))?, + }; + if builtin_presets().any(|e| e.name.eq_ignore_ascii_case(&name)) { + return Err(bad(C, format!("{name} is built in and can't be deleted"))); + } + let i = s + .prefs + .gradient_presets + .iter() + .position(|g| g.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(C, format!("no saved gradient preset called {name}")))?; + Ok(json!({ "deleted": s.prefs.gradient_presets.remove(i).name })) +} diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index b97d92ee4..a205adc46 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -1,24 +1,16 @@ //! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or -//! shapes sized by its tone. +//! shapes sized by its tone, as one undo step. //! -//! The art is rendered offscreen and sampled once per halftone cell on a grid turned by the screen -//! angle; each cell gets a shape whose area follows the tone there. Mono screens follow lightness -//! in one colour; CMYK screens follow the art's ink amounts (as CMYK output separates it), one -//! screen per ink at the classic angles, multiplied over each other. The dots can be clipped to the -//! art's outline as real geometry. - -use std::sync::Arc; +//! The screens, dots and caps are Color Effects' Vector Halftone's +//! ([`vectorcraft_render::effects::addons::halftone`]), which draws the same dots live; the art is +//! sampled as [`vectorcraft_render::addons::halftone::sample`] draws it. use serde_json::{Value, json}; -use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId}; -use vectorcraft_geom::hit::fill_contains; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; -use vectorcraft_pathops as po; -use vectorcraft_pathops::BoolOp; +use vectorcraft_color::Color; +use vectorcraft_doc::Node; +use vectorcraft_render::effects::addons::halftone::{DotShape, Halftone, art_outline, dot_nodes}; use super::edit::selected_roots; -use super::menucmds::isolated_doc; use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, color_value, f64_or, has_selection, str_param}; pub fn specs() -> Vec { @@ -33,102 +25,6 @@ pub fn specs() -> Vec { )] } -/// Most halftone cells per screen. -const MAX_CELLS: f64 = 100_000.0; -/// Most pixels the tone is sampled from. -const MAX_SAMPLE_PIXELS: f64 = 16.0e6; -/// Cells lighter than this get no dot. -const MIN_TONE: f64 = 0.01; - -#[derive(Clone, Copy, Debug, PartialEq)] -enum DotShape { - Circle, - Ellipse, - Square, - Diamond, - Line, -} - -impl DotShape { - fn parse(s: &str) -> Option { - Some(match s.to_ascii_lowercase().as_str() { - "circle" | "round" | "dot" => DotShape::Circle, - "ellipse" => DotShape::Ellipse, - "square" => DotShape::Square, - "diamond" => DotShape::Diamond, - "line" | "lines" => DotShape::Line, - _ => return None, - }) - } - - /// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen - /// running along x. - fn path(self, t: f64, s: f64) -> PathData { - let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side))); - match self { - DotShape::Circle => { - let r = s * (t / std::f64::consts::PI).sqrt(); - shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r))) - } - DotShape::Ellipse => { - let r = s * (t / std::f64::consts::PI).sqrt(); - shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3))) - } - DotShape::Square => square(s * t.sqrt()), - DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)), - // Cells along a row join into a line as thick as the tone. - DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))), - } - } -} - -/// One screen: its ink and angle, and how to read its tone from a sampled pixel. -struct Screen { - paint: Color, - angle: f64, - /// Index into the sampled pixel (RGBA lightness for mono, CMYK inks for cmyk). - channel: usize, -} - -/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt. -struct Samples { - px: Vec, - w: usize, - h: usize, - region: Rect, - scale: f64, - /// Mono: tones are 1 − lightness of RGBA pixels; CMYK: ink amounts. - mono: bool, - invert: bool, -} - -impl Samples { - /// The tone (0..1) of `channel` at document point `p`. - fn tone(&self, p: Point, channel: usize) -> f64 { - let x = ((p.x - self.region.x0) * self.scale).floor(); - let y = ((p.y - self.region.y0) * self.scale).floor(); - if !(x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h) { - return if self.invert { 1.0 } else { 0.0 }; - } - let i = (y as usize * self.w + x as usize) * 4; - let byte = |k: usize| self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0; - let t = if self.mono { 1.0 - (0.2126 * byte(0) + 0.7152 * byte(1) + 0.0722 * byte(2)) } else { byte(channel) }; - if self.invert { 1.0 - t } else { t } - } - - /// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples). - fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 { - let mut sum = 0.0; - for i in 0..3 { - for j in 0..3 { - let off = Point::new((i as f64 - 1.0) * s / 3.0, (j as f64 - 1.0) * s / 3.0); - sum += self.tone(c + (rot * off).to_vec2(), channel); - } - } - sum / 9.0 - } -} - fn halftone(s: &mut Session, p: &Value) -> Result { const C: &str = "object.vectorHalftone"; let shape = match str_param(p, "shape") { @@ -153,7 +49,7 @@ fn halftone(s: &mut Session, p: &Value) -> Result { None => Color::BLACK, }; let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false)); - let cell = 72.0 / frequency; + let ht = Halftone { shape, frequency, angle, mono, color, from_art: false, invert, clip }; let roots = selected_roots(s)?; let st = s.doc()?; @@ -161,10 +57,7 @@ fn halftone(s: &mut Session, p: &Value) -> Result { if b.width() <= 0.0 || b.height() <= 0.0 || !b.area().is_finite() { return Err(bad(C, "the selection has no area")); } - // The grid turned by the screen angle covers at most (w + h)² / 2. - if (b.width() + b.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS { - return Err(bad(C, "too many dots for this size: lower the frequency")); - } + let (region, scale) = ht.plan(b).ok_or_else(|| bad(C, "too many dots for this size: lower the frequency"))?; let nodes: Vec = roots .iter() .filter_map(|id| st.doc.node(*id).cloned()) @@ -173,64 +66,18 @@ fn halftone(s: &mut Session, p: &Value) -> Result { n }) .collect(); - let outline = if clip { - vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())) - } else { - None - }; - // Sample the art at about four pixels per cell. - let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / b.area()).sqrt()).clamp(0.01, 16.0); - let region = b.inflate(cell, cell); - let tmp = isolated_doc(&st.doc, nodes); - let mut r = vectorcraft_render::Renderer::new(); - let (w, h) = vectorcraft_render::region_pixels(region, scale); - let (w, h) = (w as usize, h as usize); + let outline = if clip { art_outline(&nodes) } else { None }; let failed = vectorcraft_render::decode_failures(); - let px = if mono { - r.render_region(&tmp, region, scale, true).pixels - } else { - r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() }) - }; + let samples = vectorcraft_render::addons::halftone::sample(&st.doc, nodes, region, scale, mono, invert); let warning = super::rasterfx::undecoded_warning(failed); - if px.len() < w * h * 4 { - return Err(EngineError::Other("the art could not be sampled".into())); - } - let samples = Samples { px, w, h, region, scale, mono, invert }; - let screens: Vec = if mono { - vec![Screen { paint: color, angle, channel: 0 }] - } else { - // Bottom to top: yellow, magenta, cyan, black. - vec![ - Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 }, - Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 }, - Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 }, - Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 }, - ] - }; - let mut layers = vec![]; - let mut dots = 0; - for sc in &screens { - let (path, n) = screen_dots(&samples, b, cell, sc, shape, outline.as_ref()); - dots += n; - if !path.is_empty() { - layers.push((path, sc.paint)); - } - } + let samples = samples.ok_or_else(|| EngineError::Other("the art could not be sampled".into()))?; + let (layers, dots) = ht.layers(&samples, b, outline.as_ref()); if layers.is_empty() { return Err(EngineError::Other("the art is too light for any dots".into())); } let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?; let id = s.edit("Vector Halftone", |d, sel| { - let children = layers - .into_iter() - .map(|(path, ink)| { - let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0)); - if !mono { - n.blend = BlendMode::Multiply; - } - Arc::new(n) - }) - .collect(); + let children = dot_nodes(layers, mono, &mut || d.alloc_id()); let gid = d.alloc_id(); let mut g = Node::group(gid, children); g.name = Some("Vector Halftone".into()); @@ -249,55 +96,3 @@ fn halftone(s: &mut Session, p: &Value) -> Result { } Ok(out) } - -/// The dots of one screen over `bounds` as one path, with their count. -fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape, outline: Option<&(BezPath, FillRule)>) -> (PathData, usize) { - let rot = Affine::rotate(sc.angle.to_radians()); - let inv = rot.inverse(); - // The grid covers the bounds in screen space. - let corners = - [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q); - let (mut lo, mut hi) = (corners[0], corners[0]); - for q in &corners[1..] { - lo = Point::new(lo.x.min(q.x), lo.y.min(q.y)); - hi = Point::new(hi.x.max(q.x), hi.y.max(q.y)); - } - let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1); - let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1); - let near = bounds.inflate(cell, cell); - let (mut inside, mut edge) = (vec![], vec![]); - for j in j0..=j1 { - for i in i0..=i1 { - let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell); - if !near.contains(c) { - continue; - } - let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0); - if t < MIN_TONE { - continue; - } - let place = Affine::translate(c.to_vec2()) * rot; - let dot = shape.path(t, cell).transformed(place); - // Dots wholly inside the outline need no clipping. - let whole = match (outline, dot.control_bounds()) { - (Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)] - .iter() - .all(|q| fill_contains(bp, *rule, *q)), - _ => true, - }; - if whole { inside.push(dot) } else { edge.push(dot) } - } - } - let count = inside.len() + edge.len(); - let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect(); - if let Some((bp, rule)) = outline - && !edge.is_empty() - { - let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect()); - let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect); - subs.extend(clipped.subpaths); - } else { - subs.extend(edge.into_iter().flat_map(|p| p.subpaths)); - } - (PathData::new(subs), count) -} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index f0e4307a3..70aa9561f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -248,22 +248,24 @@ impl Libraries { }) } - /// Change library `id` with `f` and save it; on a failed save the change is undone. + /// Change library `id` with `f` and save it; when `f` fails, or the save does, the change is + /// undone. fn change(&mut self, id: &str, f: impl FnOnce(&mut Library) -> Result) -> Result { let i = self.libs.iter().position(|(i, _)| i == id).ok_or_else(|| EngineError::Other(format!("no library `{id}`")))?; let before = self.libs.get(i).map(|(_, l)| l.clone()).unwrap_or_default(); let r = match self.libs.get_mut(i) { - Some((_, lib)) => f(lib)?, + Some((_, lib)) => f(lib), None => return Err(EngineError::Other(format!("no library `{id}`"))), }; - if let Err(e) = self.save(id) { + let r = r.and_then(|r| self.save(id).map(|()| r)); + if let Err(e) = r { if let Some((_, lib)) = self.libs.get_mut(i) { *lib = before; } return Err(e); } self.sort(); - Ok(r) + r } fn delete(&mut self, id: &str) -> Result<()> { @@ -341,6 +343,23 @@ impl Library { } } + /// Put item `r` in group `at` (an index; None: in none), out of the one it was in, and with + /// `next_to` just before or after that item of its kind in its kind's list: in the group too, + /// next to that item when it is there, else last. → the group's name. + fn put(&mut self, r: ItemRef, at: Option, next_to: Option<&(ItemRef, bool)>) -> Option { + if let Some((other, after)) = next_to + && *other != r + { + self.place_next_to(&r, other, *after); + } + self.ungroup(&r); + at.and_then(|i| self.groups.get_mut(i)).map(|g| { + let near = next_to.and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after))); + g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r); + g.name.clone() + }) + } + /// Make a library read from a file (hand-edited, imported) safe and consistent: at most /// [`MAX_ITEMS`] items of each kind and [`MAX_GROUPS`] groups, names clipped to [`MAX_NAME`] /// characters and unique within their kind (a repeated one gets a number), sizes finite, a @@ -617,7 +636,7 @@ pub fn specs() -> Vec { "Add to Library", [], None, - "{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). An item the library already has (same kind and contents) isn't added again → {library, kind, name, id?: graphic id, existing: bool}", + "{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint), group?: name, before?: item | after?: item (an item of the same kind)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). A new item goes into `group` (default: none) and, with `before` or `after`, just before or after that item in the order its kind is shown in, as dropping it among the Libraries panel's items does; an item the library already has (same kind and contents) isn't added again, nor moved → {library, kind, name, id?: graphic id, existing: bool, group?: name or null (a new item's)}", has_doc, add ), @@ -789,14 +808,6 @@ fn delete(s: &mut Session, p: &Value) -> Result { ok() } -/// The library `p` names, else the current one, else a new "My Library". -fn target_library(s: &mut Session, p: &Value, cmd: &str) -> Result { - if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() { - return library_id(s, p, cmd); - } - s.libraries.create("My Library") -} - /// `base`, or `base 2`, `base 3`… the first that `taken` doesn't hold. fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { std::iter::once(base.to_string()).chain((2..).map(|n| format!("{base} {n}"))).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) @@ -818,9 +829,10 @@ fn add(s: &mut Session, p: &Value) -> Result { Item::Style(para, attrs) } }; - let id = target_library(s, p, C)?; + let place = Placement::of(p, C)?; let kind_name = str_param(p, "kind").unwrap_or_default().to_string(); - let r = s.libraries.change(&id, |lib| { + let item_kind = kind.item_kind(); + let put = |lib: &mut Library| { let full = |n: usize| if n >= MAX_ITEMS { Err(bad(C, format!("the library holds {MAX_ITEMS} items of this kind already"))) } else { Ok(()) }; Ok(match item { Item::Graphic(mut g) => { @@ -833,8 +845,9 @@ fn add(s: &mut Session, p: &Value) -> Result { // Ids are `graphic`, `graphic-2`…: taken ones are looked for as they are kept. g.id = unique("graphic", |n| lib.graphics.iter().any(|o| o.id == n.replace(' ', "-"))).replace(' ', "-"); let v = json!({ "name": g.name, "id": g.id, "existing": false }); + let key = g.id.clone(); lib.graphics.push(g); - v + placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)? } Item::Color((name, color)) => { full(lib.colors.len())?; @@ -843,7 +856,7 @@ fn add(s: &mut Session, p: &Value) -> Result { } let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n)); lib.colors.push(LibraryColor { name: name.clone(), color }); - json!({ "name": name, "existing": false }) + placed(lib, &place, ItemRef { kind: item_kind, item: name.clone() }, json!({ "name": name, "existing": false }))? } Item::Gradient(mut g) => { full(lib.gradients.len())?; @@ -852,8 +865,9 @@ fn add(s: &mut Session, p: &Value) -> Result { } g.name = unique(&given.clone().unwrap_or(g.name), |n| lib.gradients.iter().any(|o| o.name == n)); let v = json!({ "name": g.name, "existing": false }); + let key = g.name.clone(); lib.gradients.push(g); - v + placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)? } Item::Pattern(mut pat) => { full(lib.patterns.len())?; @@ -862,8 +876,9 @@ fn add(s: &mut Session, p: &Value) -> Result { } pat.name = unique(&given.clone().unwrap_or(pat.name), |n| lib.patterns.iter().any(|o| o.name == n)); let v = json!({ "name": pat.name, "existing": false }); + let key = pat.name.clone(); lib.patterns.push(pat); - v + placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)? } Item::Style(para, attrs) => { let list = if para { &mut lib.para_styles } else { &mut lib.char_styles }; @@ -874,16 +889,33 @@ fn add(s: &mut Session, p: &Value) -> Result { let base = given.clone().unwrap_or_else(|| style_name(&attrs, para)); let name = unique(&base, |n| list.iter().any(|o| o.name == n)); list.push(TextStyleDef { name: name.clone(), attrs }); - json!({ "name": name, "existing": false }) + placed(lib, &place, ItemRef { kind: item_kind, item: name.clone() }, json!({ "name": name, "existing": false }))? } }) - })?; - let mut r = r; + }; + // Into the library `p` names, else the current one, else a new "My Library", which is kept + // only once the item is in it (a `group` or item to place it by that it lacks makes none). + let (id, mut r) = if p.get("library").is_some_and(|v| !v.is_null()) || s.libraries.current().is_some() { + let id = library_id(s, p, C)?; + let r = s.libraries.change(&id, put)?; + (id, r) + } else { + let mut lib = Library { name: "My Library".to_string(), ..Default::default() }; + let r = put(&mut lib)?; + (s.libraries.insert(lib)?, r) + }; r["library"] = json!(id); r["kind"] = json!(kind_name); Ok(r) } +/// New item `r` of `lib` put where `place` says, its group added to `v` (what `library.add` +/// reports). +fn placed(lib: &mut Library, place: &Placement, r: ItemRef, mut v: Value) -> Result { + v["group"] = json!(place.apply(lib, r, "library.add")?); + Ok(v) +} + enum Item { Graphic(LibraryGraphic), Color((String, Color)), @@ -1320,38 +1352,47 @@ fn delete_group(s: &mut Session, p: &Value) -> Result { fn move_item(s: &mut Session, p: &Value) -> Result { const C: &str = "library.moveItem"; let (id, kind, item) = item_param(s, p, C)?; - let group = str_param(p, "group"); - let next_to = match (str_param(p, "before"), str_param(p, "after")) { - (Some(_), Some(_)) => return Err(bad(C, "give `before` or `after`, not both")), - (Some(b), None) => Some((b, false)), - (None, Some(a)) => Some((a, true)), - (None, None) => None, - }; + let place = Placement::of(p, C)?; let (key, to) = s.libraries.change(&id, |lib| { - let missing = |item: &str| bad(C, format!("the library has no item `{item}`")); - let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| missing(item))?; - let at = group.map(|g| group_at(lib, g, C)).transpose()?; - let next_to = next_to - .map(|(other, after)| Ok::<_, EngineError>((lib.item_ref(kind.item_kind(), other).ok_or_else(|| missing(other))?, after))) - .transpose()?; - if let Some((other, after)) = &next_to - && *other != r - { - lib.place_next_to(&r, other, *after); - } - lib.ungroup(&r); + let r = lib.item_ref(kind.item_kind(), item).ok_or_else(|| bad(C, format!("the library has no item `{item}`")))?; let key = r.item.clone(); - let to = at.and_then(|i| lib.groups.get_mut(i)).map(|g| { - // Next to the other item when that is in the group too, else last. - let near = next_to.as_ref().and_then(|(other, after)| g.items.iter().position(|x| x == other).map(|j| j + usize::from(*after))); - g.items.insert(near.unwrap_or(g.items.len()).min(g.items.len()), r); - g.name.clone() - }); + let to = place.apply(lib, r, C)?; Ok((key, to)) })?; Ok(json!({ "library": id, "kind": str_param(p, "kind"), "item": key, "group": to })) } +/// Where `library.add` and `library.moveItem` put an item: its `group` (none: out of any), and +/// the item of its kind it goes `before` or `after`. +struct Placement<'a> { + group: Option<&'a str>, + next_to: Option<(&'a str, bool)>, +} + +impl<'a> Placement<'a> { + fn of(p: &'a Value, cmd: &str) -> Result { + let next_to = match (str_param(p, "before"), str_param(p, "after")) { + (Some(_), Some(_)) => return Err(bad(cmd, "give `before` or `after`, not both")), + (Some(b), None) => Some((b, false)), + (None, Some(a)) => Some((a, true)), + (None, None) => None, + }; + Ok(Self { group: str_param(p, "group"), next_to }) + } + + /// Put item `r` of `lib` where this says ([`Library::put`]) → its group's name. + fn apply(&self, lib: &mut Library, r: ItemRef, cmd: &str) -> Result> { + let at = self.group.map(|g| group_at(lib, g, cmd)).transpose()?; + let next_to = self + .next_to + .map(|(other, after)| { + lib.item_ref(r.kind, other).map(|o| (o, after)).ok_or_else(|| bad(cmd, format!("the library has no item `{other}`"))) + }) + .transpose()?; + Ok(lib.put(r, at, next_to.as_ref())) + } +} + fn move_group(s: &mut Session, p: &Value) -> Result { const C: &str = "library.moveGroup"; let id = library_id(s, p, C)?; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 42aafc21d..c0aacef93 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -374,7 +374,11 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone()); + // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became. + let mime = match fileio::format_for_name(g.link.name()) { + Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(), + _ => img.blob.mime.clone(), + }; out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview }); } let mut warnings = vec![]; diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 82105b00e..aec4e32d0 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -7,7 +7,7 @@ use super::envelope::{ env_edit_contents, env_expand, env_info, env_make_mesh, env_make_top, env_make_warp, env_options, env_release, env_reset_mesh, env_reset_warp, env_set_mesh_point, }; -use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_set_color}; +use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_move_points, mesh_set_color}; use super::{CommandSpec, always, cmd, has_doc, has_multi, has_selection}; pub fn specs() -> Vec { @@ -245,7 +245,7 @@ pub fn specs() -> Vec { "Set Mesh Point Color", [], None, - "{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point", + "{id, index | points: [index…] | items: [{id, index | points}…], color?, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing)", has_doc, mesh_set_color ), @@ -258,6 +258,15 @@ pub fn specs() -> Vec { has_doc, mesh_move_point ), + cmd!( + "object.mesh.movePoints", + "Move Mesh Points", + [], + None, + "{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh envelopes by (dx, dy), their handles with them (one undo step; an index out of range changes nothing)", + has_doc, + mesh_move_points + ), cmd!( "object.mesh.addLine", "Add Mesh Line", diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 1d11080fb..602e2e3e9 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -1,7 +1,9 @@ +use std::collections::{BTreeMap, BTreeSet}; + use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selected_of}; use super::envelope::{outline_of, rows_cols}; use super::{ - Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeKind, Paint, Point, Result, Session, Value, bad, + Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, check_numbers, color_value, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, }; @@ -106,25 +108,86 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: } /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh -/// envelope's grid too (its points and handles are written back: editing fills in the handles). +/// envelope's grid too (see [`edit_grid`]). fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; - let c = cmd.to_string(); - s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) { + s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) +} + +/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its +/// points and handles are written back: editing fills in the handles). +fn edit_grid(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { + match d.node_mut(id).map(|n| &mut n.kind) { Some(NodeKind::Mesh(m)) => f(m), Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => { let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else { - return Err(bad(&c, "not a gradient mesh or mesh envelope")); + return Err(bad(cmd, "not a gradient mesh or mesh envelope")); }; - let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?; + let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))?; let r = f(&mut m)?; (*rows, *cols) = (m.rows, m.cols); *points = m.points.iter().map(|q| q.p).collect(); *handles = m.points.iter().map(|q| q.handles).collect(); Ok(r) } - Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), + Some(_) => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })), None => Err(EngineError::NoNode(id)), + } +} + +/// Most mesh points one command names, over all its meshes. +const MAX_POINTS: usize = 1 << 20; + +/// The mesh points `p` names: `{id, index}`, `{id, points: [index…]}` or `{items: [{id, index | +/// points}…]}`. Each mesh comes once, with its points in order and without repeats. +fn point_items(p: &Value, cmd: &str) -> Result)>> { + let one = |q: &Value| -> Result<(NodeId, BTreeSet)> { + let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let points = match q.get("points") { + None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), + Some(Value::Array(v)) if v.len() <= MAX_POINTS => v + .iter() + .map(|i| index_of(i.as_f64().ok_or_else(|| bad(cmd, "`points` must be point indexes"))?, "points", cmd)) + .collect::>()?, + Some(_) => return Err(bad(cmd, "`points` must be a list of point indexes")), + }; + Ok((id, points)) + }; + let items: Vec<(NodeId, BTreeSet)> = match p.get("items") { + None | Some(Value::Null) => vec![one(p)?], + Some(Value::Array(v)) if v.len() <= MAX_POINTS => v.iter().map(one).collect::>()?, + Some(_) => return Err(bad(cmd, "`items` must be a list of {id, points}")), + }; + let mut merged: BTreeMap> = BTreeMap::new(); + for (id, points) in items { + merged.entry(id).or_default().extend(points); + } + if merged.values().map(BTreeSet::len).sum::() > MAX_POINTS { + return Err(bad(cmd, format!("at most {MAX_POINTS} points"))); + } + Ok(merged.into_iter().collect()) +} + +/// Run `f` on each mesh point `items` names, as one undo step `label` (an index out of range +/// fails it, changing nothing); with `envelopes`, on mesh envelopes' points too. +fn with_points( + s: &mut Session, + items: &[(NodeId, BTreeSet)], + cmd: &str, + label: &str, + envelopes: bool, + mut f: impl FnMut(&mut live::MeshPoint) -> Result<()>, +) -> Result<()> { + s.edit(label, |d, _| { + for (id, points) in items { + edit_grid(d, *id, cmd, envelopes, |m| { + for i in points { + f(m.points.get_mut(*i).ok_or_else(|| bad(cmd, "index out of range"))?)?; + } + Ok(()) + })?; + } + Ok(()) }) } @@ -140,7 +203,11 @@ pub(super) fn handle_param(p: &Value, cmd: &str) -> Result> { } pub(super) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - let i = f64_req(p, key, cmd)?; + index_of(f64_req(p, key, cmd)?, key, cmd) +} + +/// `i`, the value of `key`, as an index: a whole number ≥ 0. +fn index_of(i: f64, key: &str, cmd: &str) -> Result { if i < 0.0 { return Err(bad(cmd, "index must be ≥ 0")); } @@ -156,14 +223,13 @@ pub(super) fn mesh_set_color(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.setPointColor"; check_numbers(p, &["opacity"], C)?; crate::cmd::paint::check_keep_model(p, C)?; - let index = index_param(p, "index", C)?; + let items = point_items(p, C)?; let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c)); let opacity = p.get("opacity").and_then(Value::as_f64); if color.is_none() && opacity.is_none() { return Err(bad(C, "missing `color` or `opacity`")); } - with_mesh(s, p, C, "Mesh Point Color", false, |m| { - let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; + with_points(s, &items, C, "Mesh Point Color", false, |pt| { if let Some(c) = color { pt.color = c; } @@ -191,6 +257,18 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result { ok() } +pub(super) fn mesh_move_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.mesh.movePoints"; + let items = point_items(p, C)?; + let (dx, dy) = (f64_req(p, "dx", C)?, f64_req(p, "dy", C)?); + // `Session::edit` refuses a move past the canvas's coordinate range. + with_points(s, &items, C, "Move Mesh Points", true, |pt| { + pt.p = Point::new(pt.p.x + dx, pt.p.y + dy); + Ok(()) + })?; + ok() +} + pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.addLine"; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 518950491..cd8e0c27c 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -56,6 +56,15 @@ pub fn specs() -> Vec { has_selection_outside_isolation, |s, _| other_layers(s, true) ), + cmd!( + "object.lock.unselected", + "Unselected Artwork", + ["Object", "Lock"], + Some("Cmd+Alt+Shift+2"), + "{} lock unselected artwork in one undo step, including unselected members of partly selected groups, clipping groups and compound paths, and guides while Lock Guides is on; hidden art and art on locked layers stay unlocked; a selected layer's art counts as selected; in isolation, only art inside the isolated object is affected; object.unlockAll unlocks locked art document-wide → {count}", + has_selection, + |s, _| unselected_art(s, true) + ), cmd!( "object.hide.above", "All Artwork Above", @@ -81,7 +90,7 @@ pub fn specs() -> Vec { Some("Cmd+Alt+Shift+3"), "{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; the objects of a selected layer count as selected; while Lock Guides is on, guide paths count as selected and stay as they are; object.showAll shows them again; disabled in isolation mode → {count}", has_selection_outside_isolation, - hide_unselected + |s, _| unselected_art(s, false) ), cmd!( "object.transformEach", @@ -321,15 +330,24 @@ fn other_layers(s: &mut Session, lock: bool) -> Result { Ok(json!({ "count": n })) } -/// The objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and hold none of -/// them. The objects of a selected layer count as selected. Guide paths are left out while `guides_locked`, and so are -/// the objects that hold one. The walk goes into a plain group that holds a selected object or such a guide path; a -/// clip group, a Shaper group, a compound path and every other object that holds one is left out with its members. -fn unselected(d: &vectorcraft_doc::Document, iso: Option, selection: &[NodeId], roots: &[NodeId], guides_locked: bool) -> Vec { +/// Returns the objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and +/// hold none of them. A selected layer's objects count as selected. The walk goes into a plain group, but not a Shaper +/// group, that holds a selected object. With `lock`, it also goes into a clip group or compound path that holds one, +/// and includes guide paths while `guides_locked`. Without `lock`, guide paths and the objects that hold one are left +/// out while `guides_locked`; a clip group, Shaper group, compound path and every other object that holds a selected +/// object or such a guide are left out with their members. +fn unselected( + d: &vectorcraft_doc::Document, + iso: Option, + selection: &[NodeId], + roots: &[NodeId], + guides_locked: bool, + lock: bool, +) -> Vec { let mut roots: HashSet = roots.iter().copied().collect(); roots.extend(selection.iter().filter_map(|id| d.node(*id)).filter(|n| n.is_layer()).flat_map(|l| l.layer_art(true))); let mut kept: Vec = roots.iter().copied().collect(); - if guides_locked { + if guides_locked && !lock { super::select::walk_selectable(d, iso, |n| { if matches!(n.kind, NodeKind::Path { guide: true, .. }) { kept.push(n.id); @@ -344,24 +362,30 @@ fn unselected(d: &vectorcraft_doc::Document, iso: Option, selection: &[N out.push(n.id); return false; } - !roots.contains(&n.id) && matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none() + !roots.contains(&n.id) + && (matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none() + || lock && (matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Compound { .. }))) }); out } -fn hide_unselected(s: &mut Session, _: &Value) -> Result { +fn unselected_art(s: &mut Session, lock: bool) -> Result { let roots = selected_roots_to_transform(s)?; let guides_locked = s.menu.guides_locked; let st = s.doc()?; - let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked); - // With nothing to hide, the document and its undo history stay as they are. + let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked, lock); + // With nothing to lock (Lock) or hide (Hide), the document and its undo history stay as they are. if !ids.is_empty() { - s.edit("Hide", |d, _| { - // Hiding moves no node in the tree, so the paths found up front stay right. + s.edit(if lock { "Lock" } else { "Hide" }, |d, _| { + // Locking and hiding move no node in the tree, so the paths found up front stay right. let paths = d.index_paths(ids.iter().copied()); for id in &ids { if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) { - n.visible = false; + if lock { + n.locked = true; + } else { + n.visible = false; + } } } Ok(()) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index a2c37a09a..4839a0ac4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -35,4 +35,5 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::bounds_rewrite; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; +pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index c035a163d..eddc05763 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -1,3 +1,6 @@ +use vectorcraft_doc::AnchorRef; +use vectorcraft_geom::PathData; + use super::spans::Span; use super::{ BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches, @@ -249,17 +252,29 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result { Some(k) => k.clone(), None => c.picked(partial.as_deref()), }; - let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()); - set_corners(path, live, &picked, radius, kind); - // Direct-Selected corners stay selected as they gain or lose anchors. - if let (Some(anchors), Some(selected)) = (partial, selected) - && let Some(c) = LiveCorners::new(path, live.as_deref()) - && c.sources() != layout - { - *anchors = c.anchors_of(&selected); - } + recut(path, live, partial, &picked, radius, kind); } Ok(()) })?; ok() } + +/// [`set_corners`] with the Direct-Selected corners (`partial`, the path's selected anchors) +/// staying selected as they gain or lose anchors. +pub(in crate::cmd) fn recut( + path: &mut PathData, + live: &mut Option>, + partial: Option<&mut BTreeSet>, + picked: &BTreeSet, + radius: Option, + kind: Option, +) { + let before = LiveCorners::new(path, live.as_deref()).map(|c| (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec())); + set_corners(path, live, picked, radius, kind); + if let (Some(anchors), Some((Some(selected), layout))) = (partial, before) + && let Some(c) = LiveCorners::new(path, live.as_deref()) + && c.sources() != layout + { + *anchors = c.anchors_of(&selected); + } +} diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 50fcc349a..d5a7f600d 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; use vectorcraft_color::BlendMode; -use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection}; +use vectorcraft_doc::{Document, Knockout, Node, NodeId, NodeKind, OpacityMask, Selection}; use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing}; use super::pathops::{Beside, separate}; @@ -368,12 +368,19 @@ impl TransparencyInfo { } } - /// The opacity of the focused gradient mesh `point` (see `transparency.set`) instead of its - /// object's. - fn with_mesh_point(mut self, point: Option<(NodeId, usize, f32)>) -> Self { - if let Some((_, _, o)) = point { - self.opacity = Some((o as f64 * 10_000.0).round() / 100.0); - } + /// The opacity of the picked gradient mesh `points` (see `transparency.set`) instead of their + /// objects': `None` where they differ. + fn with_mesh_points(mut self, d: &Document, points: Option)>>) -> Self { + let Some(points) = points else { return self }; + let mut all = points.iter().flat_map(|(id, v)| { + let m = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Mesh(m)) => Some(m), + _ => None, + }; + v.iter().filter_map(move |i| m?.points.get(*i)).map(|q| (q.opacity as f64 * 10_000.0).round() as i64) + }); + let first = all.next(); + self.opacity = first.filter(|f| all.all(|o| o == *f)).map(|o| o as f64 / 100.0); self } } @@ -404,8 +411,8 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let point = super::paint::focused_mesh_point_of(self, &ids); - TransparencyInfo::of(&st.doc, ids).with_mesh_point(point) + let points = super::paint::picked_mesh_points_of(self, &ids); + TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), } @@ -415,8 +422,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let point = super::paint::focused_mesh_point_of(s, &ids); - let i = TransparencyInfo::of(&st.doc, ids).with_mesh_point(point); + let points = super::paint::picked_mesh_points_of(s, &ids); + let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::>(), "opacity": i.opacity, diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 005fedc2a..33c69589b 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -462,9 +462,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // 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 }; + // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill + // recolours just those points of their meshes. + let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))? @@ -486,11 +486,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) } if index.is_none() && let Paint::Solid { color, .. } = &paint - && let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id) + && let Some((_, points)) = mesh_points.iter().find(|(id, _)| *id == n.id) && let NodeKind::Mesh(m) = &mut n.kind - && let Some(q) = m.points.get_mut(i) { - q.color = *color; + for i in points { + if let Some(q) = m.points.get_mut(*i) { + q.color = *color; + } + } return Ok(()); } if index.is_none() @@ -678,6 +681,11 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R if was_ok { check_colorized(d) } else { Ok(()) } } +/// Is `e` the refusal of [`check_colorized`]? +pub(super) fn is_colorize_refusal(e: &EngineError) -> bool { + e.to_string().contains(PROCESS_ON_TRANSPARENCY) +} + /// Is `c` black or white (white, or black alone at 100%)? fn black_or_white(c: &Color) -> bool { let (none, full) = (|v: f32| v <= 0.0, |v: f32| v >= 1.0); @@ -689,20 +697,31 @@ fn black_or_white(c: &Color) -> bool { } } -/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct -/// Selection's point last clicked). -fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> { +/// The points the active tool has picked (its `meshPoints` option: the Mesh tool's or Direct +/// Selection's) on the gradient meshes among `ids`, in range, each mesh with its points. +fn picked_mesh_points(s: &Session, ids: &[NodeId]) -> Vec<(NodeId, Vec)> { 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()?)) + let (Some(picked), Ok(st)) = (o.get("meshPoints").and_then(Value::as_array), s.doc()) else { return vec![] }; + let mut out: Vec<(NodeId, Vec)> = vec![]; + for pt in picked { + let (Some(id), Some(index)) = (pt.get("id").and_then(Value::as_u64).map(NodeId), pt.get("index").and_then(Value::as_u64)) else { continue }; + let Ok(index) = usize::try_from(index) else { continue }; + if !ids.contains(&id) || !matches!(st.doc.node(id).map(|n| &n.kind), Some(NodeKind::Mesh(m)) if index < m.points.len()) { + continue; + } + match out.iter_mut().find(|(m, _)| *m == id) { + Some((_, v)) => v.push(index), + None => out.push((id, vec![index])), + } + } + out } -/// The focused mesh point (see [`focused_mesh_point`]) with its opacity, when `ids` is just the -/// gradient mesh it belongs to. -pub(crate) fn focused_mesh_point_of(s: &Session, ids: &[NodeId]) -> Option<(NodeId, usize, f32)> { - let (id, index) = focused_mesh_point(s).filter(|(id, _)| ids == [*id])?; - let NodeKind::Mesh(m) = &s.doc().ok()?.doc.node(id)?.kind else { return None }; - Some((id, index, m.points.get(index)?.opacity)) +/// The picked gradient mesh points (see [`picked_mesh_points`]) when each of `ids` is a gradient +/// mesh with points picked: the Transparency panel's opacity is theirs then. +pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option)>> { + let v = picked_mesh_points(s, ids); + (!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v) } /// A type run's stroke; a painted stroke on a run without one gets 1 pt. @@ -821,7 +840,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( })?; (json!({ "changed": changed }), Some(index)) } - // The proxy's colours only: images and pattern tiles stay as they are. + // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); (super::colorcmds::recolor(s, &q, label, f)?, None) @@ -848,15 +867,16 @@ fn transparency(s: &mut Session, p: &Value) -> Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - // The gradient mesh point last clicked with the Mesh tool or Direct Selection takes the - // opacity itself, so only the mesh around it fades (#4206). + // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity + // themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) - && let Some((id, index, _)) = focused_mesh_point_of(s, &ids) + && let Some(points) = picked_mesh_points_of(s, &ids) { q.remove("opacity"); let opacity = f64::from(super::opacitymask::percent(o)); - s.execute("object.mesh.setPointColor", &json!({"id": id.0, "index": index, "opacity": opacity}))?; + let items: Vec = points.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect(); + s.execute("object.mesh.setPointColor", &json!({"items": items, "opacity": opacity}))?; if q.keys().all(|k| k == "ids") { return ok(); } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index c285fd15e..5450f3d3c 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -98,6 +98,15 @@ pub fn specs() -> Vec { has_selection, average ), + cmd!( + "path.averageJoin", + "Average & Join…", + ["Object", "Path"], + Some("Cmd+Alt+Shift+J"), + "{to: \"corner\"|\"smooth\" (the joined anchor: corner retracts both handles, smooth pulls handles in line with the neighbours), ids?: [path, ...] (default: the selection; a compound path ID expands to its open member paths)} move two open path ends to their midpoint and join them there, as one undo step: the two direct-selected anchors when they are open path ends, else the nearest ends of the two open paths in ids or the selection (or both ends of one open path, closing it) → the joined path stays selected", + has_selection, + average_join + ), cmd!( "path.convertAnchors", "Convert Anchor Points", @@ -332,7 +341,7 @@ fn set_anchors(s: &mut Session, p: &Value) -> Result { } /// The compound path enclosing `id`, or `id` itself if it is not inside a compound path. -fn compound_or_self(d: &Document, id: NodeId) -> NodeId { +pub(super) fn compound_or_self(d: &Document, id: NodeId) -> NodeId { d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).unwrap_or(id) } @@ -538,7 +547,7 @@ fn join_subpaths_in_node(d: &mut Document, id: NodeId, (sa, sb): (usize, usize), } /// Join open paths into a single path, or close an open path, preserving compound path structure. -fn join(s: &mut Session, p: &Value) -> Result { +pub(super) fn join(s: &mut Session, p: &Value) -> Result { const C: &str = "path.join"; let join_kind = match p.get("to").and_then(Value::as_str) { None => None, @@ -802,29 +811,11 @@ fn join(s: &mut Session, p: &Value) -> Result { } } }; - if ida == idb { - if sa == sb { - s.edit("Join", |d, sel| { - close_subpath_in_node(d, ida, sa, join_kind)?; - sel.set([compound_or_self(d, ida)]); - Ok(()) - })?; - } else { - s.edit("Join", |d, sel| { - join_subpaths_in_node(d, ida, (sa, sb), (rx, ry), join_kind)?; - sel.set([compound_or_self(d, ida)]); - Ok(()) - })?; - } - return ok(); - } else { - s.edit("Join", |d, sel| { - join_two(d, ida, idb, (sa, sb), (rx, ry), join_kind)?; - sel.set([compound_or_self(d, ida)]); - Ok(()) - })?; - return ok(); - } + s.edit("Join", |d, sel| { + sel.set([join_pair(d, (ida, sa), (idb, sb), (rx, ry), join_kind)?]); + Ok(()) + })?; + return ok(); } let ids = selected_paths(s)?; @@ -865,21 +856,11 @@ fn join(s: &mut Session, p: &Value) -> Result { if let Some((rx, ry)) = ends && open_subs.len() == 2 { - let (a, sa) = open_subs[0]; - let (b, sb) = open_subs[1]; - if a == b { - s.edit("Join", |d, sel| { - join_subpaths_in_node(d, a, (sa, sb), (rx, ry), join_kind)?; - sel.set([compound_or_self(d, a)]); - Ok(()) - })?; - } else { - s.edit("Join", |d, sel| { - join_two(d, a, b, (sa, sb), (rx, ry), join_kind)?; - sel.set([compound_or_self(d, a)]); - Ok(()) - })?; - } + let (a, b) = (open_subs[0], open_subs[1]); + s.edit("Join", |d, sel| { + sel.set([join_pair(d, a, b, (rx, ry), join_kind)?]); + Ok(()) + })?; return ok(); } @@ -992,6 +973,17 @@ fn join_two(d: &mut Document, a: NodeId, b: NodeId, (sa, sb): (usize, usize), (r Ok(()) } +/// Join subpath `sa` of path `a` and subpath `sb` of path `b` oriented as [`join_two`] takes them +/// (one subpath: close it). Returns what to select: the joined path, or its compound path. +fn join_pair(d: &mut Document, (a, sa): (NodeId, usize), (b, sb): (NodeId, usize), r: (bool, bool), join_kind: Option) -> Result { + if a == b { + join_subpaths_in_node(d, a, (sa, sb), r, join_kind)?; + } else { + join_two(d, a, b, (sa, sb), r, join_kind)?; + } + Ok(compound_or_self(d, a)) +} + pub(super) type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>; pub(super) fn anchor_targets(s: &Session) -> Result { @@ -1039,6 +1031,90 @@ fn average(s: &mut Session, p: &Value) -> Result { ok() } +/// An open path end: (path, subpath, anchor). +type End = (NodeId, usize, usize); + +/// Average & Join: the two ends Average moves to their midpoint, joined there as Join joins them. +fn average_join(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.averageJoin"; + let smooth = match str_param(p, "to") { + Some("smooth") => true, + Some("corner") => false, + _ => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")), + }; + let ends = average_join_ends(s, p, C)?; + let [a, b] = ends.ok_or_else(|| bad(C, "select two open path ends, or two open paths, to average and join"))?; + let doc = &s.doc()?.doc; + let point = |(id, si, ai): End| doc.node(id)?.path_data()?.subpaths.get(si)?.anchors.get(ai).map(|a| a.p); + let (Some(pa), Some(pb)) = (point(a), point(b)) else { return Err(bad(C, "join needs open paths")) }; + let mid = pa.midpoint(pb); + // The first end must come last in its subpath, the second first (as `ends` takes them in Join). + let r = (a.2 == 0, b.2 != 0); + s.edit("Average & Join", |d, sel| { + for (id, si, ai) in [a, b] { + if let Some(anchor) = path_mut(d, id)?.anchor_mut(si, ai) { + anchor.translate(mid - anchor.p); + } + } + sel.set([join_pair(d, (a.0, a.1), (b.0, b.1), r, Some(smooth))?]); + Ok(()) + })?; + ok() +} + +/// The two ends Average & Join brings together: the two direct-selected anchors when they are +/// open path ends; else, of the paths in `ids` or the selected ones, the nearest ends of their two +/// open subpaths, or both ends of their one. `None` when there are no such two ends, or when they +/// are the two ends of a line, which the midpoint would collapse. +fn average_join_ends(s: &Session, p: &Value, c: &str) -> Result> { + let st = s.doc()?; + let doc = &st.doc; + let subpath = |id: NodeId, si: usize| doc.node(id)?.path_data()?.subpaths.get(si).filter(|sp| !sp.closed && !sp.anchors.is_empty()); + let last = |sp: &SubPath| sp.anchors.len().saturating_sub(1); + let ids = ids_param(p, "ids"); + let ends = if ids.is_none() && !st.selection.anchors.is_empty() { + let picked: Vec = st.selection.anchors.iter().flat_map(|(&id, set)| set.iter().map(move |&(si, ai)| (id, si, ai))).collect(); + match picked.as_slice() { + &[a, b] if [a, b].iter().all(|&(id, si, ai)| subpath(id, si).is_some_and(|sp| ai == 0 || ai == last(sp))) => [a, b], + _ => return Ok(None), + } + } else { + let paths = match ids { + None => selected_paths(s)?, + Some(ids) => { + let mut out = vec![]; + for id in ids { + match doc.node(id).map(|n| (n, &n.kind)) { + Some((_, NodeKind::Path { .. })) => out.push(id), + Some((n, NodeKind::Compound { .. })) => out.extend(open_members(n)), + _ => return Err(bad(c, format!("{} isn't a path", id.0))), + } + } + out + } + }; + let open: Vec<(NodeId, usize, &SubPath)> = paths + .iter() + .filter_map(|&id| Some((id, doc.node(id)?.path_data()?))) + .flat_map(|(id, pd)| { + pd.subpaths.iter().enumerate().filter(|(_, sp)| !sp.closed && !sp.anchors.is_empty()).map(move |(si, sp)| (id, si, sp)) + }) + .collect(); + match *open.as_slice() { + [(id, si, sp)] => [(id, si, 0), (id, si, last(sp))], + [(ia, sa, x), (ib, sb, y)] => { + let Some((rx, ry)) = nearest_ends(x, y) else { return Ok(None) }; + [(ia, sa, if rx { 0 } else { last(x) }), (ib, sb, if ry { last(y) } else { 0 })] + } + _ => return Ok(None), + } + }; + // A subpath's two ends: it needs an anchor between them to close around. + let [a, b] = ends; + let line = (a.0, a.1) == (b.0, b.1) && subpath(a.0, a.1).is_none_or(|sp| sp.anchors.len() <= 2); + Ok((!line).then_some(ends)) +} + fn convert_anchors(s: &mut Session, p: &Value) -> Result { const C: &str = "path.convertAnchors"; let smooth = match str_param(p, "to") { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index d65ecfbd9..82d1f1cfd 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -480,6 +480,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = text_param(p, "rename", C)?; + let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let (changed, edit, added, converted) = s.edit(label, |d, _| { // The rows' library swatches join the document (one it has with that name and colour is // used as it is). @@ -519,6 +520,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; Ok((changed, edit, added, map.converted.get())) })?; let mut out = json!({ "changed": changed, "swatches": added }); diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 1f4792c08..2864f574b 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -194,10 +194,11 @@ fn add(s: &mut Session, p: &Value) -> Result { "existing": existing.iter().map(|g| &g.name).collect::>(), }); let first = picked.first().map(|g| doc_name(&g.name)); - let targets = p.get("ids").is_some() || !super::appearance::appearance_targets(s, &json!({}))?.is_empty(); + let ids = p.get("ids").filter(|v| !v.is_null()); + let targets = ids.is_some() || !super::appearance::appearance_targets(s, &json!({}))?.is_empty(); if let Some(name) = first.filter(|_| apply && targets) { let mut params = json!({"name": name, "add": add}); - if let Some(ids) = p.get("ids") { + if let Some(ids) = ids { params["ids"] = ids.clone(); } s.execute("graphicStyle.apply", ¶ms)?; diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index f88130763..b7a0e2f29 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -232,13 +232,16 @@ impl Libraries { /// others are listed as loaded until the app quits. pub(crate) fn load(&mut self, p: &Value, cmd: &str, parse: impl FnOnce(&[u8], &str) -> Result) -> Result<(LibraryInfo, Arc)> { let path = text_param(p, "path", cmd)?; - let bytes = match (path, text_param(p, "data", cmd)?, text_param(p, "dataBase64", cmd)?) { + let data = text_param(p, "data", cmd)?; + let data_base64 = text_param(p, "dataBase64", cmd)?; + let name = text_param(p, "name", cmd)?; + let bytes = match (path, data, data_base64) { (Some(path), ..) => read_file(path)?, (None, Some(text), _) => text.as_bytes().to_vec(), (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, _ => return Err(bad(cmd, "give `path`, `data` or `dataBase64`")), }; - let file = text_param(p, "name", cmd)?.map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); + let file = name.map(str::to_string).or_else(|| path.map(file_name)).unwrap_or_else(|| "Library".into()); let lib = parse(&bytes, &file)?; self.rescan(); let user = diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 92a2758ee..ae272f93a 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -1132,7 +1132,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result { /// Cmd/Ctrl+Alt+Q: tracking 0 and kerning Auto (0 in the Character panel) in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ kerns there. +/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. fn reset_tracking(s: &mut Session, p: &Value) -> Result { let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; if !ranges.is_empty() { diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 9b3b14456..2a8d48fab 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -99,10 +99,18 @@ macro_rules! style_cmds { concat!($p, ".delete"), concat!("Delete ", $what, " Style"), &["Window", "Type", $menu], - "{name} (text keeps its look)", + "{name} | {names: [name…]} delete styles in one undo step (text keeps its look; never the Normal style)", |s, p| delete(s, p, $kind), true, ), + spec( + concat!($p, ".unused"), + "Select All Unused", + &["Window", "Type", $menu], + "{} → {names: [the styles no text uses, in panel order; never the Normal style]} (the panel highlights them)", + |s, _| unused(s, $kind), + false, + ), ] }; } @@ -291,21 +299,33 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb } } -/// How many runs (char) or paragraphs (para) use `name`. -fn uses(d: &Document, kind: Kind, name: &str) -> usize { - let target = (name != kind.normal()).then_some(name); - let mut n = 0; - d.walk(|node| { - if let NodeKind::Text(t) = &node.kind { - n += match kind { - Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(), - Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(), - }; - } - }); +/// How many runs (char) or paragraphs (para) use each style, by style name (`None`: the Normal +/// style). Type in the art (opacity masks included), symbols, graph designs, pattern tiles and the +/// art of brushes counts. +fn uses(d: &Document, kind: Kind) -> std::collections::HashMap, usize> { + let mut n = std::collections::HashMap::new(); + let mut count = |name: &Option| *n.entry(name.clone()).or_insert(0) += 1; + let brushes = vectorcraft_brush::library(d); + let trees = d.layers.iter().map(|l| &**l).chain(d.symbols.iter().map(|s| &*s.art)).chain(d.graph_designs.iter().map(|g| &*g.art)); + let trees = trees.chain(d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a))).chain(brushes.iter().flat_map(|b| b.art())); + for tree in trees { + vectorcraft_doc::swatches::walk_with_masks(tree, &mut |node| { + if let NodeKind::Text(t) = &node.kind { + match kind { + Kind::Char => t.runs.iter().for_each(|r| count(&r.style.style_name)), + Kind::Para => (0..t.paragraph_count()).for_each(|i| count(&t.para_at(i).style_name)), + } + } + }); + } n } +/// How many runs (char) or paragraphs (para) use `name` (see [`uses`]). +fn uses_of(uses: &std::collections::HashMap, usize>, kind: Kind, name: &str) -> usize { + uses.get(&(name != kind.normal()).then(|| name.to_string())).copied().unwrap_or(0) +} + /// Update every user of `name` from `old` to `new` attributes (overrides kept). fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map, new: &Map) { let target = (name != kind.normal()).then_some(name.to_string()); @@ -434,7 +454,8 @@ fn list(s: &mut Session, kind: Kind) -> Result { let d = &s.doc()?.doc; let mut names = vec![kind.normal().to_string()]; names.extend(kind.defs(d).iter().map(|s| s.name.clone()).filter(|n| n != kind.normal())); - let styles: Vec = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses(d, kind, n) })).collect(); + let uses = uses(d, kind); + let styles: Vec = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses_of(&uses, kind, n) })).collect(); Ok(json!({ "styles": styles })) } @@ -616,16 +637,26 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result { fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { let c = kind.cmd("delete"); - let name = name_param(p, kind, "delete")?.to_string(); - if name == kind.normal() || !kind.defs(&s.doc()?.doc).iter().any(|s| s.name == name) { + let names: Vec = match p.get("names") { + Some(v) => v + .as_array() + .and_then(|a| a.iter().map(|n| n.as_str().map(str::to_string)).collect::>>()) + .ok_or_else(|| bad(&c, "`names` must be an array of style names"))?, + None => vec![name_param(p, kind, "delete")?.to_string()], + }; + if names.is_empty() { + return Err(bad(&c, "missing `name`")); + } + let defs = kind.defs(&s.doc()?.doc); + if let Some(name) = names.iter().find(|n| *n == kind.normal() || !defs.iter().any(|s| s.name == **n)) { return Err(bad(&c, format!("`{name}` can't be deleted"))); } - s.edit("Delete Style", |d, _| { - kind.defs_mut(d).retain(|s| s.name != name); + s.edit(if names.len() > 1 { "Delete Styles" } else { "Delete Style" }, |d, _| { + kind.defs_mut(d).retain(|s| !names.contains(&s.name)); for id in all_text(d) { let Some(t) = text_mut(d, id) else { continue }; let fix = |n: &mut Option| { - if n.as_deref() == Some(name.as_str()) { + if n.as_ref().is_some_and(|n| names.contains(n)) { *n = None; } }; @@ -639,5 +670,14 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result { ok() } +/// The styles no text uses (see [`uses`]), in the order the panel lists them, without the Normal +/// style. +fn unused(s: &mut Session, kind: Kind) -> Result { + let d = &s.doc()?.doc; + let uses = uses(d, kind); + let names: Vec<&str> = kind.defs(d).iter().map(|s| s.name.as_str()).filter(|n| *n != kind.normal() && uses_of(&uses, kind, n) == 0).collect(); + Ok(json!({ "names": names })) +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index e322e814a..2cdcaf77a 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -122,6 +122,100 @@ fn new_styles_trim_their_names_as_a_rename_does() { assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); } +fn unused(s: &mut Session, p: &str) -> Vec { + serde_json::from_value(s.execute(&format!("{p}.unused"), &json!({})).unwrap()["names"].clone()).unwrap() +} + +fn listed(s: &mut Session, p: &str) -> Vec { + let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); + list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).map(str::to_string).collect() +} + +/// Select All Unused: `charStyle.unused` and `paraStyle.unused` list, in panel order and without an +/// undo step, the styles no text uses, never the Normal style. Type in a symbol, an opacity mask, a +/// graph design, a pattern tile or a brush's art counts, as type in the layers does. +#[test] +fn unused_styles_are_those_no_text_uses_wherever_the_text_is() { + let (mut s, id) = session_with_text("Used"); + let names = ["Spare", "Used", "Symbol", "Mask", "Design", "Pattern", "Brush", "Also spare"]; + for p in ["charStyle", "paraStyle"] { + for n in names { + s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": {}})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "Used", "id": id})).unwrap(); + } + // Type in style `name`, selected. + let styled = |s: &mut Session, name: &str| { + let t = s.execute("text.create", &json!({"x": 10, "y": 150, "text": name})).unwrap()["id"].as_u64().unwrap(); + for p in ["charStyle", "paraStyle"] { + s.execute(&format!("{p}.apply"), &json!({"name": name, "id": t})).unwrap(); + } + s.execute("select.set", &json!({"ids": [t]})).unwrap(); + NodeId(t) + }; + let clear = |s: &mut Session, id: NodeId| { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + }; + styled(&mut s, "Symbol"); + let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); + let masked = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let mask = styled(&mut s, "Mask"); + s.execute("select.set", &json!({"ids": [masked, mask.0]})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let design = styled(&mut s, "Design"); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + clear(&mut s, design); + let tile = styled(&mut s, "Pattern"); + s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); + if s.doc().unwrap().doc.node(tile).is_some() { + clear(&mut s, tile); + } + let art = styled(&mut s, "Brush"); + s.execute("brush.new", &json!({"type": "art", "name": "Lettering"})).unwrap(); + clear(&mut s, art); + let undo = s.doc().unwrap().history.undo.len(); + for p in ["charStyle", "paraStyle"] { + assert_eq!(unused(&mut s, p), ["Spare", "Also spare"], "{p}"); + } + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + let list = s.execute("charStyle.list", &json!({})).unwrap(); + let uses = |name: &str| list["styles"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap()["uses"].as_u64(); + for name in ["Symbol", "Mask", "Design", "Pattern", "Brush"] { + assert_eq!(uses(name), Some(1), "{name}"); + } +} + +/// `charStyle.delete` and `paraStyle.delete` take `names` and delete them in one undo step; text +/// that used them keeps its look. A name that is not a style, the Normal style, or `names` that is +/// not an array of strings deletes none. +#[test] +fn several_styles_are_deleted_in_one_step() { + let (mut s, id) = session_with_text("Title"); + s.execute("text.setStyle", &json!({"size": 40})).unwrap(); + for p in ["charStyle", "paraStyle"] { + for n in ["A", "B", "C"] { + s.execute(&format!("{p}.new"), &json!({"name": n})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "B", "id": id})).unwrap(); + let before = listed(&mut s, p); + let undo = s.doc().unwrap().history.undo.len(); + let normal = before[0].clone(); + for bad in [json!(["A", "Nope"]), json!(["A", normal]), json!([]), json!(["A", 7]), json!("A")] { + assert!(s.execute(&format!("{p}.delete"), &json!({"names": bad})).is_err(), "{p} {bad}"); + } + assert_eq!((listed(&mut s, p), s.doc().unwrap().history.undo.len()), (before.clone(), undo), "{p}"); + s.execute(&format!("{p}.delete"), &json!({"names": ["A", "B"]})).unwrap(); + assert_eq!(listed(&mut s, p), [normal.as_str(), "C"], "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Styles", "{p}"); + assert_eq!(listed(&mut s, p), before, "{p}"); + } + s.execute("charStyle.delete", &json!({"names": ["B"]})).unwrap(); + assert_eq!((runs(&s, id)[0].style.style_name.as_deref(), runs(&s, id)[0].style.size), (None, 40.0)); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style"); +} + /// #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. diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 579dda124..9e4969e27 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Sample Color", [], None, - "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it", + "{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index, clearStroke?: bool} sample a color into the active fill/stroke (and the selection; `ids: []` changes only the paint default); `clearStroke: true` requires `stroke: false`, `ids: []` and no `stop`, and clears the default stroke after sampling an image into the fill; with `stop`, recolor that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it", has_doc, sample_color ), @@ -343,7 +343,11 @@ fn eyedropper_options(s: &mut Session, p: &Value) -> Result { fn sample_color(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.sampleColor"; - let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); + let stroke = super::switch_or(p, "stroke", !s.fill_active, C)?; + let clear_stroke = super::switch_or(p, "clearStroke", false, C)?; + if clear_stroke && (stroke || p.get("stroke") != Some(&Value::Bool(false)) || p.get("ids") != Some(&json!([])) || p.get("stop").is_some()) { + return Err(bad(C, "`clearStroke` requires `stroke: false`, `ids: []` and no `stop`")); + } let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?; let (id, mut q) = match p.get("stop") { None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })), @@ -364,7 +368,11 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { q["ids"] = ids.clone(); } let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; - (spec.run)(s, &q) + let result = (spec.run)(s, &q)?; + if clear_stroke { + s.paint.stroke = Paint::None; + } + Ok(result) } // ---------- artboards ---------- diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4c70712f8..05f7880ba 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -216,6 +216,11 @@ pub struct Prefs { /// `imageTrace.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub trace_presets: Vec, + /// Gradient panel › Presets: the user's saved gradients (the built-in presets aren't stored). + /// A local library, not a Preferences dialog field: resetting the preferences keeps it; + /// `gradient.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub gradient_presets: Vec, /// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a /// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row. #[serde(skip_serializing_if = "Vec::is_empty")] @@ -448,6 +453,7 @@ impl Default for Prefs { glyph_snap: Default::default(), flattener_presets: vec![], trace_presets: vec![], + gradient_presets: vec![], width_profiles: vec![], japanese_crop_marks: false, new_art_basic: true, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 472ce8ab2..a331144d5 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -5,8 +5,9 @@ use super::{ impl Session { /// A session with no document open, the default preferences and the Selection tool. It also - /// installs the hooks the document crate calls back into: the loader and file reader of placed - /// documents, and where an image's effects draw its fills and strokes, for clicks. + /// installs the hooks the document and effects crates call back into: the loader and file reader + /// of placed documents, where an image's effects draw its fills and strokes, for clicks, and how + /// Vector Halftone's dots are sampled, for exports. pub fn new() -> Self { // Placed documents are read by the native format's loader. vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); @@ -19,6 +20,8 @@ impl Session { }); // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); + // Exports bake Vector Halftone into the dots the renderer samples. + vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art); // OpenType SVG glyphs are drawn as the SVG importer reads their documents. vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art); Self { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 46d74f362..c0eb9e253 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -62,6 +62,7 @@ mod globaledit; mod glyphbounds; mod gradient; mod gradpanel; +mod gradpresets; mod gradspan; mod halftone; mod history_mem; diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index cb4c2d018..bd9bd02a2 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -2316,6 +2316,88 @@ fn live_paint_bucket_tool_fills_face() { assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]); } +#[test] +fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() { + let mut s = session(); + let g = live(&mut s); + let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 12})).unwrap(); + let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0); + s.execute("paint.default", &json!({})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()); + let face_before = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint(); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap(); + assert_eq!(s.paint.fill.color(), Some(Color::rgb8(255, 0, 0))); + assert_eq!(s.paint.stroke.color(), Some(Color::rgb8(0, 0, 255))); + assert_eq!(s.paint.stroke_width, 12.0); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before); + assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(Color::WHITE)); + assert_eq!(node(&s, src).appearance.fill_paint().color(), Some(Color::rgb8(255, 0, 0))); + assert_eq!(vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint(), face_before); + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap(); + assert_eq!( + vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint().color(), + Some(Color::rgb8(255, 0, 0)) + ); +} + +#[test] +fn live_paint_bucket_alt_image_samples_fill_and_clears_stroke() { + let mut s = session(); + let img = image::RgbaImage::from_raw(1, 1, vec![0, 255, 0, 255]).unwrap(); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, _| { + d.images.insert("px".into(), ImageBlob::png(png)); + let id = d.alloc_id(); + let im = ImageObject { + key: "px".into(), + width: 1, + height: 1, + xf: Affine::translate((300.0, 0.0)) * Affine::scale(100.0), + link: None, + placement: Default::default(), + pixels: None, + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| EngineError::Other(e.to_string())) + }) + .unwrap(); + let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap(); + assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0))); + assert!(s.paint.stroke.is_none()); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before); + assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::rgb8(0, 0, 255))); +} + +#[test] +fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() { + let mut s = session(); + let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap(); + let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0); + s.execute("paint.setStroke", &json!({"color": "#ffffff"})).unwrap(); + let fill = s.paint.fill.clone(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()); + let v = ViewInfo::default(); + s.select_tool("livePaintBucket", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, shift: true, ..Default::default() }), v).unwrap(); + let sampled = s.paint.stroke.color().unwrap().to_rgb(); + assert!(sampled[0] > 0.0 && sampled[2] > 0.0 && sampled[1] < 0.01, "{sampled:?}"); + assert_eq!(s.paint.fill, fill); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before); + assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::WHITE)); + assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_))); +} + #[test] fn live_paint_selection_marquee_selects_what_it_touches() { let mut s = session(); @@ -3106,7 +3188,7 @@ fn image_trace_document_library_uses_a_color_group() { } #[test] -fn image_trace_document_library_links_fills_to_global_swatches() { +fn image_trace_document_library_fills_are_unlinked() { let mut s = session(); // A global color, a tint of another global color and a process color. s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); @@ -3126,7 +3208,7 @@ fn image_trace_document_library_links_fills_to_global_swatches() { }); let img = add_image(&mut s, &r, Affine::IDENTITY); let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3}); - // Each fill as (its color, the swatch it links to, its tint), one per color. + // Each fill as (its color, its swatch link, its tint), one per color. let fills = |s: &Session, id: &Value| { let mut v: Vec<(String, Option, f32)> = node(s, NodeId(id.as_u64().unwrap())) .children() @@ -3148,27 +3230,26 @@ fn image_trace_document_library_links_fills_to_global_swatches() { let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); let got = fills(&s, &res["id"]); assert_eq!(got.len(), 3, "{got:?}"); - assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)), "{got:?}"); - assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}"); + assert!(got.contains(&fill("#c82828", None, 1.0)), "{got:?}"); + assert!(got.contains(&fill(&tint, None, 1.0)), "{got:?}"); assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); - // Editing the global swatches recolors the trace: the tint at its own tint. + // Editing the global swatches leaves the traced colors unchanged. s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap(); s.execute("swatch.edit", &json!({"name": "Sky", "color": "#000000"})).unwrap(); - let tint = hex_of(&mut s, "Sky 50%"); let got = fills(&s, &res["id"]); - assert!(got.contains(&fill("#ff8800", Some("Ink"), 1.0)), "{got:?}"); - assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}"); + assert!(got.contains(&fill("#c82828", None, 1.0)), "{got:?}"); + assert!(got.contains(&fill(&tint, None, 1.0)), "{got:?}"); assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); for _ in 0..3 { s.execute("edit.undo", &json!({})).unwrap(); } - // A color group's global swatches link too. + // A color group's global swatches also fill without links. s.execute("swatch.newGroup", &json!({"name": "Brand", "swatches": ["Ink", "Leaf"]})).unwrap(); let mut group = params.clone(); group["colorGroup"] = json!("Brand"); let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": group})).unwrap(); let got = fills(&s, &res["id"]); - assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); + assert!(got.contains(&fill("#c82828", None, 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}"); s.execute("edit.undo", &json!({})).unwrap(); // A swatch library's colors stay unlinked. let mut web = params.clone(); @@ -3178,6 +3259,71 @@ fn image_trace_document_library_links_fills_to_global_swatches() { assert!(!got.is_empty() && got.iter().all(|f| f.1.is_none()), "{got:?}"); } +#[test] +fn image_trace_document_library_same_color_stays_unlinked_in_swatch_order() { + let mut s = session(); + // A process swatch, then two global swatches, all of one color. + s.execute("swatch.new", &json!({"name": "Red", "color": "#c82828"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink 2", "color": "#c82828", "global": true})).unwrap(); + s.execute("swatch.new", &json!({"name": "Leaf", "color": "#28a03c"})).unwrap(); + s.execute("swatch.new", &json!({"name": "Sky", "color": "#2846c8", "global": true})).unwrap(); + let [tr, tg, tb, _] = s.doc().unwrap().doc.tint_paint("Sky", 0.5).unwrap().color().unwrap().to_rgba8(1.0); + let tint_hex = format!("#{tr:02x}{tg:02x}{tb:02x}"); + s.execute("swatch.new", &json!({"name": "Mist", "color": tint_hex})).unwrap(); + s.execute("swatch.new", &json!({"swatch": "Sky", "tint": 50})).unwrap(); + let r = vectorcraft_trace::Raster::from_fn(60, 60, |x, _| match x { + 0..20 => [200, 40, 40, 255], + 20..40 => [40, 160, 60, 255], + _ => [tr, tg, tb, 255], + }); + add_image(&mut s, &r, Affine::IDENTITY); + let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 8}); + let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); + let g = node(&s, NodeId(res["id"].as_u64().unwrap())); + let got: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); + let red = Color::rgb8(200, 40, 40); + let reds: Vec<&Paint> = got.iter().filter(|p| p.color().map(|c| c.to_rgba8(1.0)) == Some(red.to_rgba8(1.0))).collect(); + assert!(!reds.is_empty(), "{got:?}"); + assert!(reds.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{got:?}"); + let tints: Vec<&Paint> = got.iter().filter(|p| p.color().map(|c| c.to_rgba8(1.0)) == Some([tr, tg, tb, 255])).collect(); + assert!(!tints.is_empty(), "{got:?}"); + assert!(tints.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{got:?}"); + // Editing the global swatch leaves the trace unchanged. + s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap(); + let g = node(&s, NodeId(res["id"].as_u64().unwrap())); + let orange = Color::rgb8(255, 136, 0).to_rgba8(1.0); + assert!(!g.children().unwrap().iter().any(|n| n.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) == Some(orange))); + assert_eq!(g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect::>(), got); +} + +#[test] +fn image_trace_document_library_same_color_is_unlinked_in_both_swatch_orders() { + let trace_fill = |global_first: bool| { + let mut s = session(); + for global in [global_first, !global_first] { + s.execute("swatch.new", &json!({"name": if global { "Ink" } else { "Red" }, "color": "#c82828", "global": global})).unwrap(); + } + let r = vectorcraft_trace::Raster::from_fn(60, 60, |_, _| [200, 40, 40, 255]); + add_image(&mut s, &r, Affine::IDENTITY); + let res = s + .execute( + "imageTrace.makeAndExpand", + &json!({"params": {"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 2}}), + ) + .unwrap(); + let g = node(&s, NodeId(res["id"].as_u64().unwrap())); + let fills: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); + assert!(!fills.is_empty()); + assert!(fills.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{fills:?}"); + fills + }; + let process_first = trace_fill(false); + let global_first = trace_fill(true); + assert_eq!(process_first, global_first); + assert!(process_first.iter().all(|p| p.color() == Some(Color::rgb8(200, 40, 40)))); +} + #[test] fn image_trace_document_library_matches_a_lab_spot_by_its_cmyk_equivalent() { let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); @@ -3218,7 +3364,7 @@ fn image_trace_document_library_matches_a_lab_spot_by_its_cmyk_equivalent() { let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap(); let g = node(&s, NodeId(res["id"].as_u64().unwrap())); let got: Vec = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect(); - let spot = Paint::Solid { color: shown, swatch: Some("Lab Ink".into()), tint: 1.0 }; + let spot = Paint::solid(shown); assert!(got.contains(&spot), "{got:?}"); assert!(!got.iter().any(|p| p.color() == Some(process)), "{got:?}"); } diff --git a/crates/engine/src/tests/clippaint.rs b/crates/engine/src/tests/clippaint.rs index efcb1df70..de637870a 100644 --- a/crates/engine/src/tests/clippaint.rs +++ b/crates/engine/src/tests/clippaint.rs @@ -69,7 +69,7 @@ fn a_clipping_paths_fill_and_stroke_show_in_render_svg_and_pdf() { assert_eq!(rgb(&doc, 120), [255, 255, 255]); assert_eq!(rgb(&doc, 45), [255, 255, 255]); let svg = vectorcraft_svg::export(&doc, &Default::default()); - assert_eq!(svg.matches(" Option { s.doc().unwrap().doc.node(id).unwrap().extras().colorize.as_deref().cloned() } +fn image_key(s: &Session, id: NodeId) -> String { + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!("not an image") }; + im.key.clone() +} + +fn colorized_image_with_adjustment(alpha: bool) -> (Session, NodeId, Paint) { + let mut s = session(); + let id = image(&mut s, "gray", gray(alpha)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorize(&s, id).unwrap(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + (s, id, before) +} + +fn assert_adjustment_expand_rejected(command: &str) { + let (mut s, id, before) = colorized_image_with_adjustment(true); + let doc_before = s.doc().unwrap().doc.clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let e = s.execute(command, &json!({})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{command}: {e}"); + assert_eq!(colorize(&s, id), Some(before)); + assert_eq!(s.doc().unwrap().doc, doc_before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +fn assert_adjustment_expand_succeeds(command: &str) { + let (mut s, id, before) = colorized_image_with_adjustment(false); + s.execute(command, &json!({})).unwrap(); + assert_ne!(colorize(&s, id), Some(before)); + assert!(s.doc().unwrap().doc.node(id).unwrap().appearance.effects.is_empty()); +} + +#[test] +fn expand_appearance_rejects_adjusted_colorized_gray_image_with_alpha() { + assert_adjustment_expand_rejected("effect.expandAppearance"); +} + +#[test] +fn expand_appearance_adjusts_colorized_gray_image_without_alpha() { + assert_adjustment_expand_succeeds("effect.expandAppearance"); +} + +#[test] +fn object_expand_rejects_adjusted_colorized_gray_image_with_alpha() { + assert_adjustment_expand_rejected("object.expand"); +} + +#[test] +fn object_expand_adjusts_colorized_gray_image_without_alpha() { + assert_adjustment_expand_succeeds("object.expand"); +} + +#[test] +fn object_expand_still_expands_a_gradient_beside_an_effect_that_cannot_expand() { + let mut s = session(); + let guide = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + s.execute("select.set", &json!({"ids": [guide, rect]})).unwrap(); + s.execute("object.expand", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(NodeId(rect)).is_none_or(|n| !matches!(n.appearance.fill_paint(), Paint::Gradient(_))), "the gradient expanded"); + assert_eq!(doc.node(NodeId(guide)).unwrap().appearance.effects.len(), 1, "the guide keeps its effect"); +} + +#[test] +fn object_expand_restores_earlier_expansions_when_adjusted_colorize_is_rejected() { + let (mut s, id, before) = colorized_image_with_adjustment(true); + let a = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let blend = s.execute("object.blend.make", &json!({"ids": [a, b], "steps": 1})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id.0, blend]})).unwrap(); + let doc_before = s.doc().unwrap().doc.clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let e = s.execute("object.expand", &json!({})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), Some(before)); + assert_eq!(s.doc().unwrap().doc, doc_before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +fn blend_with_colorized_middle(alpha: bool) -> (Session, NodeId, Paint) { + let mut s = session(); + let back = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + let back = NodeId(back["id"].as_u64().unwrap()); + let middle = image(&mut s, "gray", gray(alpha)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + s.execute("object.transform", &json!({"ids": [middle.0], "matrix": [1, 0, 0, 1, 30, 0]})).unwrap(); + let before = colorize(&s, middle).unwrap(); + let front = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 20, "height": 20})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + let front = NodeId(front["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [back.0, middle.0, front.0]})).unwrap(); + (s, middle, before) +} + +#[test] +fn blend_rejects_a_process_color_on_a_colorized_gray_image_with_alpha() { + let (mut s, middle, before) = blend_with_colorized_middle(true); + let doc_before = s.doc().unwrap().doc.clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let e = s.execute("edit.colors.blendHorizontally", &json!({})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, middle), Some(before)); + assert_eq!(s.doc().unwrap().doc, doc_before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +#[test] +fn blend_recolors_a_colorized_gray_image_without_alpha() { + let (mut s, middle, _) = blend_with_colorized_middle(false); + assert_eq!(s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, middle), Some(Paint::solid(Color::rgb(0.5, 0.5, 0.5)))); +} + +#[test] +fn fill_proxy_invert_recolors_a_colorized_gray_image_and_undo_restores_it() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id).unwrap(); + assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into())); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#cc9966".into())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(before)); +} + +#[test] +fn fill_proxy_complement_recolors_a_colorized_gray_image_and_undo_restores_it() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let before = colorize(&s, id).unwrap(); + let expected = Paint::solid(Color::rgb8(0, 255, 255)); + assert_eq!(s.execute("paint.complement", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id), Some(expected.clone())); + assert_eq!(s.proxy_paints().0, expected); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(before)); +} + +#[test] +fn stroke_proxy_invert_leaves_a_colorized_image_fill_unchanged() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(255, 0, 0))); + assert_eq!(s.execute("paint.invert", &json!({"stroke": true})).unwrap(), json!({"changed": 1})); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255))); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn edit_colors_invert_without_fill_recolors_a_colorized_images_stroke() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false, "stroke": true})).unwrap(), json!({"changed": 1})); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().paint.color(), Some(Color::rgb8(0, 255, 255))); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn colorized_alpha_image_rejects_a_recolored_spot_but_accepts_inverted_black() { + let mut s = session(); + let id = image(&mut s, "alpha", gray(true)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorize(&s, id); + for cmd in ["paint.invert", "paint.complement", "edit.colors.invert"] { + let e = s.execute(cmd, &json!({"includeImages": false})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{cmd}: {e}"); + assert_eq!(colorize(&s, id), before, "{cmd}"); + } + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + s.execute("paint.invert", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 255, 255)))); +} + +#[test] +fn edit_colors_recolors_an_image_colorize_with_pixels_excluded() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let before = colorize(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({"includeImages": false})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255)))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), before); +} + +#[test] +fn edit_colors_invert_recolors_colorize_without_changing_pixels_and_undo_restores() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({})).unwrap(), json!({"changed": 1})); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into())); + assert_eq!(image_key(&s, id), key); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn edit_colors_invert_recolors_colorized_image_inside_group() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let shape = NodeId(shape["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0, shape.0]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap(); + assert!(s.execute("edit.colors.invert", &json!({"ids": [group["id"]]})).unwrap()["changed"].as_u64().unwrap() >= 1); + assert_eq!(colorize(&s, id).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#cc9966".into())); +} + +#[test] +fn edit_colors_invert_without_fill_leaves_colorized_image_unchanged() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap(); + let before = colorize(&s, id); + let key = image_key(&s, id); + assert_eq!(s.execute("edit.colors.invert", &json!({"fill": false})).unwrap(), json!({"changed": 0})); + assert_eq!(colorize(&s, id), before); + assert_eq!(image_key(&s, id), key); +} + +#[test] +fn recolor_apply_recolors_a_colorized_image_and_checks_alpha() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(s.execute("recolor.apply", &json!({"map": {"#ff0000": "#00ff00"}, "includeImages": false})).unwrap()["changed"], 1); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 0)))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); + + let mut s = session(); + let id = image(&mut s, "alpha", gray(true)); + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let before = colorize(&s, id); + let e = s.execute("recolor.apply", &json!({"map": {"cmyk 0 100 100 0": "#00ff00"}, "includeImages": false})).unwrap_err().to_string(); + assert!(e.contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!(colorize(&s, id), before); +} + +#[test] +fn proxy_invert_recolors_a_colorized_image_and_another_fill_in_one_undo_step() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let shape = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let shape = NodeId(shape["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0, shape.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(s.execute("paint.invert", &json!({})).unwrap(), json!({"changed": 2})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(0, 255, 255)))); + assert_eq!(s.doc().unwrap().doc.node(shape).unwrap().appearance.fill().unwrap().paint.color(), Some(Color::rgb8(255, 255, 0))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(colorize(&s, id), Some(Paint::solid(Color::rgb8(255, 0, 0)))); + assert_eq!(s.doc().unwrap().doc.node(shape).unwrap().appearance.fill().unwrap().paint.color(), Some(Color::rgb8(0, 0, 255))); +} + +#[test] +fn colorized_gray_image_is_flattened_for_pdf_13() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + assert!(!s.doc().unwrap().doc.layers[0].shows_transparency()); + let plain = s.execute("document.exportPdf", &json!({"compatibility": "1.3"})).unwrap(); + assert!( + plain["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("transparency is flattened")), + "{}", + plain["warnings"] + ); + + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + assert!(s.doc().unwrap().doc.layers[0].shows_transparency()); + let tinted = s.execute("document.exportPdf", &json!({"compatibility": "1.3"})).unwrap(); + assert!( + tinted["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("transparency is flattened")), + "{}", + tinted["warnings"] + ); + let bytes = vectorcraft_format::base64_decode(tinted["dataBase64"].as_str().unwrap()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("/SMask"), "PDF 1.3 has no soft mask"); +} + +#[test] +fn colorized_gray_image_is_flattened_for_pdf_x_1a() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + let out = s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("transparency is flattened")), "{}", out["warnings"]); + let bytes = vectorcraft_format::base64_decode(out["dataBase64"].as_str().unwrap()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("/SMask"), "PDF/X-1a has no soft mask"); +} + +#[test] +fn colorized_gray_image_is_omitted_from_legacy_ai_when_requested() { + let mut s = session(); + let id = image(&mut s, "gray", gray(false)); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + let out = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit"})).unwrap(); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("are left out")), "{}", out["warnings"]); +} + +#[test] +fn flattened_colorized_image_shows_the_art_under_white_pixels() { + let mut s = session(); + let background = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [background]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [background]})).unwrap(); + let id = image(&mut s, "gray", gray(false)); + s.edit("Scale image", |d, _| { + if let NodeKind::Image(im) = &mut d.node_mut(id).unwrap().kind { + im.xf = vectorcraft_geom::Affine::scale(20.0); + } + Ok(()) + }) + .unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({"ids": [id.0]})).unwrap(); + let doc = &s.doc().unwrap().doc; + let mut options = crate::cmd::flatten::FlattenOptions::preset("low").unwrap(); + options.no_transparency = Some(Default::default()); + options.clip_complex_regions = true; + let flat = crate::cmd::flatten::flatten_document(doc, &options).unwrap().unwrap(); + let region = vectorcraft_geom::Rect::new(0.0, 0.0, 40.0, 20.0); + let before = vectorcraft_render::Renderer::new().render_region(doc, region, 1.0, true); + let after = vectorcraft_render::Renderer::new().render_region(&flat, region, 1.0, true); + for (x, expected) in [(10, [255, 0, 0]), (30, [0, 0, 255])] { + let a = before.pixel(x, 10); + let b = after.pixel(x, 10); + for channel in 0..3 { + assert!((i16::from(a[channel]) - i16::from(b[channel])).abs() <= 10, "x={x}: {a:?} became {b:?}"); + assert!((i16::from(b[channel]) - expected[channel]).abs() <= 10, "x={x}: {b:?}"); + } + } +} + #[test] fn a_fill_color_colorizes_a_gray_image_and_none_takes_it_off() { let mut s = session(); @@ -71,12 +430,33 @@ fn a_fill_color_colorizes_a_gray_image_and_none_takes_it_off() { assert!(matches!(colorize(&s, id), Some(Paint::Solid { swatch: Some(_), .. })), "undo restores the spot color"); s.execute("paint.setFill", &json!({"none": true})).unwrap(); assert_eq!(colorize(&s, id), None); - // `none` takes the color off after an edit of the pixels leaves the image in color. + // `none` takes the color off after an edit that leaves the image pixels gray. s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + let key = image_key(&s, id); s.execute("edit.colors.invert", &json!({"includeImages": true})).unwrap(); let d = &s.doc().unwrap().doc; let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(im.key, key); + assert!(d.image_blob(im).and_then(|b| b.gray_source()).is_some(), "the pixels remain gray"); + s.execute("paint.setFill", &json!({"none": true})).unwrap(); + assert_eq!(colorize(&s, id), None); + + // `none` also takes the color off when a colorized image has color pixels. + s.execute("paint.setFill", &json!({"swatch": "Ink"})).unwrap(); + s.edit("Replace Image", |d, _| { + d.images.insert("rgb".into(), png(image::DynamicImage::ImageRgb8(image::RgbImage::from_raw(2, 1, vec![255, 0, 0, 0, 0, 255]).unwrap()))); + if let Some(node) = d.node_mut(id) + && let NodeKind::Image(im) = &mut node.kind + { + im.key = "rgb".into(); + } + Ok(()) + }) + .unwrap(); + let d = &s.doc().unwrap().doc; + let NodeKind::Image(im) = &d.node(id).unwrap().kind else { panic!() }; assert_eq!(d.image_blob(im).and_then(|b| b.gray_source()), None, "the pixels are no longer gray"); + assert!(colorize(&s, id).is_some()); s.execute("paint.setFill", &json!({"none": true})).unwrap(); assert_eq!(colorize(&s, id), None); } diff --git a/crates/engine/src/tests/containers.rs b/crates/engine/src/tests/containers.rs index a445077b2..aa72ebecc 100644 --- a/crates/engine/src/tests/containers.rs +++ b/crates/engine/src/tests/containers.rs @@ -336,7 +336,7 @@ fn type_in_a_group_with_a_geometry_effect_exports_and_expands_with_its_look() { run(&mut s, "select.set", json!({"ids": [t.0]})); run(&mut s, "object.hide", json!({})); let hidden = svg(&s); - assert_eq!(hidden.matches("