diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0cbc97bab..34b0d9290 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -30,6 +30,13 @@ jobs: timeout-minutes: 300 steps: - uses: actions/checkout@v4 + # The optional craft-fonts build input (Japanese fonts), so the tests that need it run here + # rather than skip. Pinned; bump deliberately (craftrules standards/fonts.md). + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 with: @@ -47,6 +54,8 @@ jobs: # The checkout is synced in under another owner; let tests that call git read it. git config --global --add safe.directory "*" rustc --version + # The checkout above is synced into the VM with the workspace. + export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index addead6b4..23db4e93f 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -47,6 +47,10 @@ jobs: - name: XML, desktop entry, AppStream, Flatpak manifest run: | xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* + # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. + if grep -oE '"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f103e8c2b..2eacf20e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,13 @@ jobs: VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build + # step). Bump the commit deliberately; see craftrules standards/fonts.md. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin @@ -90,6 +97,8 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/macos/package.sh --arch universal - uses: actions/upload-artifact@v4 with: @@ -112,11 +121,21 @@ jobs: include: - { arch: x64, target: x86_64-pc-windows-msvc } - { arch: x86, target: i686-pc-windows-msvc } + # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. + # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. + - { arch: arm64, target: aarch64-pc-windows-msvc } env: VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }} VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - uses: actions/checkout@v4 + # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build + # step). Bump the commit deliberately; see craftrules standards/fonts.md. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} @@ -141,6 +160,8 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - uses: actions/upload-artifact@v4 with: @@ -167,6 +188,13 @@ jobs: NFPM_VERSION: 2.47.0 steps: - uses: actions/checkout@v4 + # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build + # step). Bump the commit deliberately; see craftrules standards/fonts.md. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - name: Install build and packaging tools run: | sudo apt-get update @@ -184,6 +212,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$VECTORCRAFT_VERSION" - name: Build and package (AppImage, deb, rpm, tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/linux/package.sh - name: Check the packages run: | @@ -253,6 +284,13 @@ jobs: TRUNK_VERSION: 0.21.14 steps: - uses: actions/checkout@v4 + # Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build + # step). Bump the commit deliberately; see craftrules standards/fonts.md. + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + path: craft-fonts - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown @@ -268,6 +306,9 @@ jobs: if: inputs.version != '' run: cargo xtask version set "$VECTORCRAFT_VERSION" - name: Build and zip + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml new file mode 100644 index 000000000..d1cb4c9c4 --- /dev/null +++ b/.github/workflows/windows-arm64.yml @@ -0,0 +1,95 @@ +# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 +# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed +# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left +# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.) +name: Windows ARM64 + +on: + push: + branches: [main] + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - "**/Cargo.toml" + - "**/build.rs" + - .github/workflows/windows-arm64.yml + - packaging/windows/** + schedule: + - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + workflow_dispatch: + +concurrency: + group: windows-arm64-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + +jobs: + package: + name: package (cross-compiled on x64) + runs-on: windows-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + with: + targets: aarch64-pc-windows-msvc + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-arm64-package + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Build and package (unsigned) + shell: pwsh + env: + DIST: ${{ github.workspace }}\dist\arm64 + run: ./packaging/windows/package.ps1 -Arch arm64 + - uses: actions/upload-artifact@v4 + with: + name: vectorcraft-windows-arm64 + path: dist/arm64/* + if-no-files-found: error + retention-days: 7 + + install: + name: install and run on ARM64 + needs: package + runs-on: windows-11-arm + timeout-minutes: 30 + steps: + - uses: actions/download-artifact@v4 + with: + name: vectorcraft-windows-arm64 + path: dist + - name: Install the MSI, check it, run it, uninstall it + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName + $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } + $dir = Join-Path $env:ProgramFiles 'VectorCraft' + foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') { + $path = Join-Path $dir $exe + if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } + $bytes = [System.IO.File]::ReadAllBytes($path) + $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) + if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } + Write-Output "ok $path (ARM64)" + } + # The installed CLI runs natively on ARM64. + $cli = Join-Path $dir 'vectorcraft-cli.exe' + $out = & $cli --version 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" } + Write-Output "ok vectorcraft-cli --version: $($out.Trim())" + $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru + if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } + if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' } + Write-Output 'ok uninstalled' diff --git a/.gitignore b/.gitignore index 0d78b09de..8c493ff12 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ plan/ /corpus /log .DS_Store +# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here) +/craft-fonts diff --git a/AGENTS.md b/AGENTS.md index 5ed3a9019..1dc0569bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,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. - 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. diff --git a/ASSETS.md b/ASSETS.md index 55a7e82fd..77bd061a3 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -6,6 +6,10 @@ 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. + +**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. + | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | @@ -143,6 +147,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | @@ -150,6 +155,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | @@ -182,6 +188,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/CLAUDE.md b/CLAUDE.md index 5ed3a9019..1dc0569bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,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. - 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. diff --git a/Cargo.lock b/Cargo.lock index 6967f29e2..b5c65f733 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,6 +1231,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1439,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1455,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1603,6 +1609,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1611,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1620,7 +1635,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2059,6 +2074,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" @@ -3667,6 +3688,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -3697,6 +3724,16 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4174,7 +4211,7 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.3.0" +version = "0.3.1" dependencies = [ "arboard", "clipboard-win", @@ -4188,12 +4225,13 @@ dependencies = [ "vectorcraft-engine", "vectorcraft-ui-egui", "webbrowser", + "winit", "winresource", ] [[package]] name = "vectorcraft-brush" -version = "0.3.0" +version = "0.3.1" dependencies = [ "kurbo 0.13.1", "serde", @@ -4205,7 +4243,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "kurbo 0.13.1", @@ -4221,7 +4259,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde_json", "vectorcraft-color", @@ -4237,7 +4275,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.3.0" +version = "0.3.1" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4249,7 +4287,7 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "kurbo 0.13.1", @@ -4263,7 +4301,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.3.0" +version = "0.3.1" dependencies = [ "kurbo 0.13.1", "serde_json", @@ -4271,12 +4309,13 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-geom", "vectorcraft-pathops", + "vectorcraft-plugins", "vectorcraft-text", ] [[package]] name = "vectorcraft-engine" -version = "0.3.0" +version = "0.3.1" dependencies = [ "flate2", "image", @@ -4296,17 +4335,19 @@ dependencies = [ "vectorcraft-metafile", "vectorcraft-pathops", "vectorcraft-pdf", + "vectorcraft-plugins", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", "vectorcraft-tools", "vectorcraft-trace", + "wat", ] [[package]] name = "vectorcraft-eps" -version = "0.3.0" +version = "0.3.1" dependencies = [ "flate2", "image", @@ -4323,7 +4364,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.3.0" +version = "0.3.1" dependencies = [ "flate2", "proptest", @@ -4337,7 +4378,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.3.0" +version = "0.3.1" dependencies = [ "kurbo 0.13.1", "proptest", @@ -4349,7 +4390,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "log", @@ -4368,7 +4409,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "kurbo 0.13.1", @@ -4382,7 +4423,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.3.0" +version = "0.3.1" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -4394,7 +4435,7 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.3.0" +version = "0.3.1" dependencies = [ "aes", "flate2", @@ -4419,9 +4460,23 @@ dependencies = [ "vectorcraft-text", ] +[[package]] +name = "vectorcraft-plugins" +version = "0.3.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-geom", + "wasmi", + "wat", +] + [[package]] name = "vectorcraft-render" -version = "0.3.0" +version = "0.3.1" dependencies = [ "flate2", "gif", @@ -4446,7 +4501,7 @@ dependencies = [ [[package]] name = "vectorcraft-svg" -version = "0.3.0" +version = "0.3.1" dependencies = [ "flate2", "image", @@ -4470,7 +4525,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "kurbo 0.13.1", @@ -4487,7 +4542,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.3.0" +version = "0.3.1" dependencies = [ "harfrust 0.13.3", "kurbo 0.13.1", @@ -4500,7 +4555,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "kurbo 0.13.1", @@ -4515,7 +4570,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.3.0" +version = "0.3.1" dependencies = [ "image", "kurbo 0.13.1", @@ -4528,7 +4583,7 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.3.0" +version = "0.3.1" dependencies = [ "egui", "egui_extras", @@ -4545,16 +4600,18 @@ dependencies = [ "vectorcraft-format", "vectorcraft-geom", "vectorcraft-pdf", + "vectorcraft-plugins", "vectorcraft-render", "vectorcraft-svg", "vectorcraft-testkit", "vectorcraft-text", "vectorcraft-tools", + "wat", ] [[package]] name = "vectorcraft-web" -version = "0.3.0" +version = "0.3.1" dependencies = [ "eframe", "egui", @@ -4719,6 +4776,99 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +dependencies = [ + "leb128fmt", + "wasmparser 0.261.0", +] + +[[package]] +name = "wasmi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +dependencies = [ + "bitflags 2.13.2", + "indexmap", + "semver", +] + +[[package]] +name = "wast" +version = "261.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.17" @@ -5535,7 +5685,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.3.0" +version = "0.3.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 4450c0787..6cb58119a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"] [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] @@ -32,6 +32,7 @@ vectorcraft-testkit = { path = "crates/testkit" } vectorcraft-cad = { path = "crates/cad" } vectorcraft-eps = { path = "crates/eps" } vectorcraft-metafile = { path = "crates/metafile" } +vectorcraft-plugins = { path = "crates/plugins" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -43,6 +44,12 @@ log = "0.4" proptest = "1" # tests: a TCP port bound without listening (connections to it are refused) socket2 = "0.6" +# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32 +# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module +# validation on; the portable dispatcher can't overflow the host stack on long-running modules. +wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] } +# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception). +wat = "1.228" # rendering / formats vello_cpu = "0.2" diff --git a/NOTICE b/NOTICE index 5f53d3f57..ba97f8526 100644 --- a/NOTICE +++ b/NOTICE @@ -26,3 +26,11 @@ BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0). JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is based in part on the work of the Independent JPEG Group. + +WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is +under the Apache License 2.0 with the LLVM exception. + +Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the +fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ +UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL +Open Font License 1.1. Their license texts ship with the release packages as OFL-.txt. diff --git a/README.md b/README.md index 08e3dd98f..d01305167 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,10 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names ``` +Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional +build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path). +Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input). + ### Use it from Claude Code and other agents Register the MCP server with Claude Code: @@ -169,7 +173,7 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`] VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the milestones, and honest time-to-parity estimates. -**Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of +**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of "a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools, Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The scores are @@ -178,7 +182,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0 **What's missing:** - 3D and Materials; - the Photoshop-style raster effects (Effect Gallery); -- vertical and CJK type; +- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - Variables and scripting; - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - packaging for Windows and Linux. @@ -241,7 +245,9 @@ VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-AP Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE). Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed -with its author, source and license in [ASSETS.md](ASSETS.md). +with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the +Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) +(SIL Open Font License 1.1). The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its palette and files are in [`assets/app-icon/`](assets/app-icon/README.md). diff --git a/ROADMAP.md b/ROADMAP.md index 7c39a1ba0..614485198 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-06 (after M4.14–M4.98, M8.1–M8.20 and M14.4–M14.7; see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -12,10 +12,10 @@ _Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest as |---|---| | Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** | | Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** | -| Feature surface vs full Illustrator (weighted, see below) | **~74%** on the rubric · **68–74%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) | +| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) | | Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** | -| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~230–350 h** one agent · **~60–100 h** with 4–6 agents | -| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~335–520 h** one agent · **~85–150 h** with 4–6 agents | +| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents | +| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents | ## Honest assessment (2026-10-05) @@ -33,9 +33,9 @@ since 2026-10-01. | Dimension | Estimate | Evidence and what's missing | |---|---|---| -| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type and vertical type ×3; 51 panel modules | -| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) | -| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting | +| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | +| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) | +| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type has initial support), Variables (data merge), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec | @@ -58,9 +58,10 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent 3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors. The largest single gap. 50–80 h. -4. **Advanced type:** vertical type and its tools, CJK composition, Optical Margin Alignment, the - composer/hyphenation options, tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph. - 41–55 h across type core and advanced. +4. **Advanced type:** CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical font metrics, + mixed-script orientation), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a + spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have + initial support. 35–48 h across type core and advanced. 5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h. 6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h. @@ -101,7 +102,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - - **Cutting:** Eraser, Scissors, Knife. + - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). - **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective). - **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. @@ -109,10 +110,11 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes. - **Geometry and effects:** - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). + - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. +- **Japanese interface and vertical type:** VectorCraft ▸ Language switches the menus between English and Japanese (untranslated labels stay English) and persists; the bundled OFL Shippori Mincho gives Japanese glyphs without system fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs with `vert`/`vrt2` alternates); Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. -- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). +- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. @@ -137,23 +139,23 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist | # | Milestone | Status | Est. remaining, one agent (h) | |---|---|---|---| | M0 | Skeleton + vertical slice | ✅ done | — | -| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | +| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 | -| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | -| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | -| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | +| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, CJK names in the UI, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | +| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | +| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan and print tiling done; multiple windows, presentation polish) | 🟡 | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 | -| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 | +| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 | | — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 | -| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** | +| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** | ## Parity estimate @@ -169,7 +171,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Area | Weight | Done | Missing (main items) | One agent (h) | |---|---:|---:|---|---:| -| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 | +| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 | | Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform and mixed spot/process gradients in SVG/PDF (exported as stops or process colours), pattern fills in Expand | 2–4 | @@ -179,9 +181,9 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 20% | ~55 Photoshop-style filters (Artistic, Brush Strokes, Distort, Pixelate, Sketch, Stylize, Texture, Video) and the Effect Gallery; Document Raster Effects Settings and raster effects in PDF are done | 28–42 | | 3D and Materials | 4 | 0% | Extrude & Bevel, Revolve, Inflate, Rotate, lighting, materials (software renderer) | 50–80 | | Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | -| Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 | -| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 | +| Type advanced | 4 | 45% | CJK composition for vertical type (kinsoku, ruby, tate-chu-yoko, vertical metrics, mixed-script orientation), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 | +| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 | | Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 | @@ -191,17 +193,17 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 | | UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 | | Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 | -| **Feature parity** | **103** | **~74%** | | **~230–350** | +| **Feature parity** | **103** | **~75%** | | **~210–330** | | Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 | | Hardening (big-file corpus, fuzzing, cross-platform and browser QA, accessibility, packaging) | | | | 50–80 | -| **Full parity** | | **~55%** | | **~335–520** | +| **Full parity** | | **~56%** | | **~320–500** | With 4–6 agents working on disjoint crates (as the layering allows) the wall-clock time divides by roughly 3.5–4 -(integration, review and shared files such as `menus.rs` serialize some work): **~60–100 h** to feature parity, -**~85–150 h** to full parity. +(integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity, +**~80–145 h** to full parity. -_Inventories (2026-10-05):_ 18 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool -implemented except four (Touch Type, vertical type ×3); 51 panel modules; Illustrator-style live +_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool +implemented except Touch Type; 51 panel modules; Illustrator-style live effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. _Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out, diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 5820e2e53..06dba69a8 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -22,6 +22,8 @@ rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } image = { workspace = true } webbrowser = { workspace = true } +# The window's monitors, size and position (the same winit eframe runs on). +winit = { version = "0.30", default-features = false } [target.'cfg(windows)'.dependencies] clipboard-win = { workspace = true } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fa8c93b71..21e533643 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -5,12 +5,14 @@ //! `--control ` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. //! See `vectorcraft_ui_egui::control` for the methods. +#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] mod clipboard; mod control_server; #[cfg(target_os = "macos")] mod native_menu; mod printing; +mod window; use vectorcraft_engine::Session; use vectorcraft_engine::cmd::fileio; @@ -30,6 +32,7 @@ impl eframe::App for App { } } self.0.logic(ctx); + window::track(ctx, &mut self.0.ui.window); if self.0.ui.status == "quit" { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } @@ -71,21 +74,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option { base.map(|b| b.join("ui.json")) } -fn load_prefs(app: &mut VectorcraftApp) { - if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { +/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. +fn prefs_enabled() -> bool { + std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() +} + +/// The saved UI preferences, read before the window opens (they hold its size and position). +fn read_prefs() -> Option { + if !prefs_enabled() { + return None; + } + let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?; + serde_json::from_slice(&bytes).ok() +} + +fn load_prefs(app: &mut VectorcraftApp, saved: Option) { + if !prefs_enabled() { return; } - let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok())); - if let Some(bytes) = bytes - && let Ok(ui) = serde_json::from_slice::(&bytes) - { + if let Some(ui) = saved { app.ui = ui.sanitized(); } vectorcraft_ui_egui::prefs_dialog::restore(app); } fn save_prefs(app: &VectorcraftApp) { - if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } if let Some(p) = prefs_path() { @@ -230,11 +244,13 @@ fn main() -> eframe::Result { _ => files.push(a), } } + let saved = read_prefs(); + let saved_window = saved.as_ref().and_then(|ui| ui.window); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("VectorCraft") - .with_inner_size([1440.0, 900.0]) - .with_min_inner_size([800.0, 500.0]) + .with_inner_size(window::DEFAULT_SIZE) + .with_min_inner_size(window::MIN_SIZE) .with_drag_and_drop(true) .with_decorations(!CUSTOM_TITLEBAR) .with_fullsize_content_view(true) @@ -249,7 +265,11 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = VectorcraftApp::new(Session::new(), services()); - load_prefs(&mut app); + load_prefs(&mut app, saved); + // Fit the window to its monitor, or put it back where it was (still hidden). + if let Some(w) = cc.winit_window() { + app.ui.window = Some(window::restore(w, saved_window)); + } // User Defined swatch and graphic style libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs index 022b8dff1..d85d5f0d6 100644 --- a/apps/vectorcraft/src/native_menu.rs +++ b/apps/vectorcraft/src/native_menu.rs @@ -18,6 +18,7 @@ enum Handle { } pub struct NativeMenu { + language: vectorcraft_ui_egui::i18n::Language, _menu: Menu, items: HashMap, last_refresh: f64, @@ -25,6 +26,8 @@ pub struct NativeMenu { generation: u64, /// Saved views and recent files listed (empty slots are left out, so a change rebuilds). slots: usize, + /// Plug-in registry revision the Plug-ins submenus were built for. + plugins: u64, } /// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they @@ -45,7 +48,7 @@ impl NativeMenu { let mut items = HashMap::new(); let mut counter = 0usize; for (title, entries) in menus::menu_tree() { - let sub = Submenu::new(title, true); + let sub = Submenu::new(app.ui.language.tr(title), true); build(app, &sub, &entries, &mut items, &mut counter); let _ = menu.append(&sub); } @@ -56,7 +59,15 @@ impl NativeMenu { .filter(|(_, p, _, c)| (p.is_null() || p.as_object().is_some_and(|o| o.is_empty())) && menus::shortcut_of(c).and_then(accel).is_some()) .map(|(_, _, _, c)| c.clone()) .collect(); - Self { _menu: menu, items, last_refresh: 0.0, generation, slots: menus::listed_slots(app) } + Self { + _menu: menu, + items, + last_refresh: 0.0, + generation, + slots: menus::listed_slots(app), + plugins: menus::plugin_revision(), + language: app.ui.language, + } } /// Dispatch clicked items and refresh state. @@ -72,10 +83,12 @@ impl NativeMenu { return; } self.last_refresh = now; - // Shortcuts edited, workspaces added, saved views or recent files changed: rebuild so + // Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so // accelerators and lists are current. if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation || menus::listed_slots(app) != self.slots + || menus::plugin_revision() != self.plugins + || app.ui.language != self.language { *self = NativeMenu::install(app); return; @@ -108,7 +121,7 @@ impl NativeMenu { "view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters" ) { - i.set_text(menus::dynamic_label(app, cmd, "")); + i.set_text(app.ui.language.tr(&menus::dynamic_label(app, cmd, ""))); } } Handle::Check(c) => { @@ -133,13 +146,13 @@ fn build( let _ = parent.append(&PredefinedMenuItem::separator()); } Item::Header(h) => { - let _ = parent.append(&MenuItem::new(*h, false, None)); + let _ = parent.append(&MenuItem::new(app.ui.language.tr(h), false, None)); } Item::Todo(label, sc) => { - let _ = parent.append(&MenuItem::new(*label, false, accel(sc))); + let _ = parent.append(&MenuItem::new(app.ui.language.tr(label), false, accel(sc))); } Item::Sub(label, children) => { - let sub = Submenu::new(*label, true); + let sub = Submenu::new(app.ui.language.tr(label), true); build(app, &sub, children, items, counter); let _ = parent.append(&sub); } @@ -150,11 +163,11 @@ fn build( let id = format!("dc{counter}"); let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None }; let handle = if menus::checked(app, cmd, params).is_some() { - let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc); + let c = CheckMenuItem::with_id(id.clone(), app.ui.language.tr(label), true, false, sc); let _ = parent.append(&c); Handle::Check(c) } else { - let i = MenuItem::with_id(id.clone(), *label, true, sc); + let i = MenuItem::with_id(id.clone(), app.ui.language.tr(label), true, sc); let _ = parent.append(&i); Handle::Plain(i) }; diff --git a/apps/vectorcraft/src/window.rs b/apps/vectorcraft/src/window.rs new file mode 100644 index 000000000..e95326423 --- /dev/null +++ b/apps/vectorcraft/src/window.rs @@ -0,0 +1,256 @@ +//! The main window's size and position. The first launch fits the window to its monitor, opening +//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore +//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved +//! onto a monitor that is still connected. + +use vectorcraft_ui_egui::state::WindowGeometry; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; + +/// The first window's size, in points. +pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0]; +/// The smallest window, in points. +pub const MIN_SIZE: [f32; 2] = [800.0, 500.0]; +/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't +/// say how much of the screen they take): a monitor with less room opens it maximized. +const SCREEN_ROOM: [f32; 2] = [64.0, 128.0]; + +/// A monitor: its top-left corner and size in physical pixels, and its scale factor. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Monitor { + pos: [i64; 2], + size: [i64; 2], + scale: f32, +} + +impl Monitor { + pub fn new(pos: PhysicalPosition, size: PhysicalSize, scale: f64) -> Self { + let scale = scale as f32; + let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 }; + Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale } + } + + /// Its size in points. + fn points(&self) -> [f32; 2] { + self.size.map(|s| s as f32 / self.scale) + } + + /// Its size in points less room for a taskbar, Dock or menu bar. + fn usable(&self) -> [f32; 2] { + let points = self.points(); + std::array::from_fn(|i| points[i] - SCREEN_ROOM[i]) + } + + /// A window of `size` points on this monitor, in physical pixels. + fn pixels(&self, size: [f32; 2]) -> [i64; 2] { + size.map(|s| (s * self.scale) as i64) + } + + /// How much of a window at `pos` of `size` points this monitor shows, in square pixels. + fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 { + let px = self.pixels(size); + let span = |i: usize| { + let start = i64::from(pos[i]).max(self.pos[i]); + let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]); + (end - start).max(0) + }; + span(0).saturating_mul(span(1)) + } + + /// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size` + /// points on this monitor. + fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] { + let px = self.pixels(size); + std::array::from_fn(|i| { + let lo = self.pos[i]; + let hi = lo + self.size[i].saturating_sub(px[i]).max(0); + let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i])); + i32::try_from(want.max(lo).min(hi)).unwrap_or_default() + }) + } +} + +/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else +/// the default size on `home` (the monitor the system opens it on). Without monitors (the system +/// doesn't list them, so nothing to check a saved geometry against) the default size. +pub fn place(saved: Option, monitors: &[Monitor], home: Option) -> WindowGeometry { + let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false }; + let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else { + return default; + }; + let Some(saved) = saved else { + let usable = home.usable(); + if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) { + return default; + } + // A small screen: maximized, with a size that fits once un-maximized. + let size = fit(DEFAULT_SIZE, usable); + return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true }; + }; + // The monitor showing most of the window; none when it was on a monitor that is gone. + let shown = saved.pos.and_then(|p| { + let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?; + (area > 0).then_some(m) + }); + let m = shown.unwrap_or(home); + // Bigger than its monitor (a smaller screen than last time): maximized, like the first window. + let room = m.points(); + let too_big = (0..2).any(|i| saved.size[i] > room[i]); + let size = fit(saved.size, if too_big { m.usable() } else { room }); + WindowGeometry { + // Moved fully onto its monitor, or centred on the home monitor. + pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)), + size, + maximized: saved.maximized || too_big, + } +} + +/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is. +fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] { + std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0)) +} + +/// Size and place the new window (eframe shows it after drawing the first frame) and return its +/// geometry. +pub fn restore(window: &winit::window::Window, saved: Option) -> WindowGeometry { + let handles: Vec<_> = window.available_monitors().collect(); + let monitors: Vec = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect(); + let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c)); + let g = place(saved, &monitors, home); + if let Some([x, y]) = g.pos { + window.set_outer_position(PhysicalPosition::new(x, y)); + } + // Returns the new size when it applies at once; either way the window gets it. + let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1])); + if g.maximized { + window.set_maximized(true); + } + g +} + +/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps +/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size. +pub fn track(ctx: &egui::Context, geometry: &mut Option) { + let zoom = ctx.zoom_factor(); + ctx.input(|i| { + let v = i.viewport(); + if v.minimized == Some(true) || v.fullscreen == Some(true) { + return; + } + if v.maximized == Some(true) { + if let Some(g) = geometry { + g.maximized = true; + } + return; + } + let Some(inner) = v.inner_rect else { return }; + let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32)); + *geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor { + Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale) + } + + fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option { + Some(WindowGeometry { pos, size, maximized }) + } + + #[test] + fn the_first_window_fits_its_screen() { + // 1920 × 1080: the default size, where the system puts it. + let big = [monitor(0, 0, 1920, 1080, 1.0)]; + assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false }); + // 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a + // size that fits the screen with room for the taskbar once un-maximized. + for (m, size) in [ + (monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]), + (monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]), + (monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]), + ] { + let g = place(None, &[m], Some(0)); + assert!(g.maximized, "{m:?}"); + assert_eq!(g.size, size, "{m:?}"); + let [x, y] = g.pos.unwrap(); + let px = m.pixels(g.size); + assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}"); + } + // No monitor information: the default size, whatever was saved. + assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false }); + assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE); + } + + #[test] + fn the_saved_window_comes_back_where_it_was() { + let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)]; + for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)] + { + assert_eq!(Some(place(g, &two, Some(0))), g); + } + } + + #[test] + fn a_saved_window_stays_on_a_connected_monitor() { + let laptop = [monitor(0, 0, 1600, 900, 1.0)]; + // Last time on a second monitor that is gone: centred on the remaining one. + let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false }); + // Half off the monitor's right and bottom edges: moved fully onto it. + let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0)); + assert_eq!(g.pos, Some([600, 200])); + // Bigger than the screen now (it was on a larger one): it fits, and opens maximized. + let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true }); + // A monitor left of the primary one (negative coordinates) still counts. + let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)]; + assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40])); + // Nonsense sizes from a damaged preferences file are kept within the screen. + let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true }); + let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0)); + assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true }); + } + + #[test] + fn the_window_geometry_is_tracked_for_the_next_launch() { + let ctx = egui::Context::default(); + let mut geometry = None; + let mut frame = |info: egui::ViewportInfo| { + let mut input = egui::RawInput::default(); + input.viewports.insert(egui::ViewportId::ROOT, info); + ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear(); + geometry + }; + let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0)); + let normal = egui::ViewportInfo { + native_pixels_per_point: Some(1.5), + inner_rect: Some(rect), + outer_rect: Some(rect), + maximized: Some(false), + ..Default::default() + }; + // Position in physical pixels, size in points. + let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false }; + assert_eq!(frame(normal.clone()), Some(g)); + // Maximized or minimized: the size to restore stays. + let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0)); + let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() }; + assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g })); + let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() }; + assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g })); + assert_eq!(frame(normal), Some(g)); + // Saved with the preferences; older preference files have no window. + let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() }; + let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap(); + assert_eq!(back.window, Some(g)); + ui.window = None; + let json = serde_json::to_value(&ui).unwrap(); + assert!(json.get("window").is_none()); + let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap(); + assert_eq!(back.window, None); + } +} diff --git a/assets/icons/dc-line-cut.svg b/assets/icons/dc-line-cut.svg new file mode 100644 index 000000000..5cd5f647f --- /dev/null +++ b/assets/icons/dc-line-cut.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-mirror-cut.svg b/assets/icons/dc-mirror-cut.svg new file mode 100644 index 000000000..2deb180f9 --- /dev/null +++ b/assets/icons/dc-mirror-cut.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rect-cut.svg b/assets/icons/dc-rect-cut.svg new file mode 100644 index 000000000..74b7b0115 --- /dev/null +++ b/assets/icons/dc-rect-cut.svg @@ -0,0 +1 @@ + diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index 124d196a4..fe5b95f07 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -547,7 +547,14 @@ fn edge(c: &mut Cursor<'_, '_>) -> Option { ctrl.push(c.point(10)?); weights.push(c.num(42).unwrap_or(1.0)); } - let fit: Vec = match c.count(97) { + // Fit data (DXF 2010 and later): a count, then that many points. Older files have none, + // and the 97 that may follow is the boundary's count of source objects (then their + // handles), so it is only the fit count when it is 0 or points follow. + let fit_count = match (c.g.get(c.i), c.g.get(c.i + 1)) { + (Some(count), next) if count.code == 97 => number(count.value) == Some(0.0) || next.is_some_and(|p| p.code == 11), + _ => false, + }; + let fit: Vec = match fit_count.then(|| c.count(97)).flatten() { Some(n) => (0..n).map(|_| c.point(11)).collect::>()?, None => vec![], }; diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 5a4b1ec6c..a2ed7633d 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -319,6 +319,41 @@ fn solid_hatches_fill_and_pattern_hatches_outline() { assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none())); } +/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by +/// `source` (the loop's source-object count and handles), and a square hole. +fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> { + let mut e = vec![(0, "HATCH"), (62, "1"), (10, "0"), (20, "0"), (30, "0"), (2, "SOLID"), (70, "1"), (71, "1"), (91, "2")]; + e.extend([(92, "1"), (93, "2"), (72, "1"), (10, "0"), (20, "0"), (11, "100"), (21, "0")]); + e.extend([(72, "4"), (94, "3"), (73, "0"), (74, "0"), (95, "8"), (96, "4")]); + e.extend(["0", "0", "0", "0", "1", "1", "1", "1"].map(|k| (40, k))); + e.extend([(10, "100"), (20, "0"), (10, "100"), (20, "100"), (10, "0"), (20, "100"), (10, "0"), (20, "0")]); + e.extend_from_slice(fit); + e.extend_from_slice(source); + e.extend([(92, "2"), (72, "0"), (73, "1"), (93, "4"), (10, "40"), (20, "20"), (10, "60"), (20, "20")]); + e.extend([(10, "60"), (20, "40"), (10, "40"), (20, "40"), (97, "0"), (75, "0"), (76, "1"), (98, "0")]); + e +} + +#[test] +fn hatch_spline_edges_with_or_without_fit_data() { + // Before DXF 2010 a spline edge has no fit data, so a 97 right after it is the loop's own + // count of source objects; from 2010 on it has a fit count, here 0, before that. + let source = [(97, "1"), (330, "1F")]; + for fit in [&[][..], &[(97, "0")][..]] { + let r = open(&entities(&spline_edged_hatch(fit, &source)), &points()); + let [hatch] = &r.document.layers[0].children().unwrap()[..] else { panic!("fit data {fit:?}: the hatch is lost") }; + let NodeKind::Compound { children, .. } = &hatch.kind else { panic!("{:?}", hatch.kind) }; + assert_eq!(children.len(), 2, "the outline and the hole, fit data {fit:?}"); + // The spline edge peaks three quarters of the way up its control polygon. + let b = bounds(hatch); + assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 75.0).abs() < 1e-6, "{b:?}"); + } + // Fit points after the count are still read (and ignored when there are control points). + let fit = [(97, "2"), (11, "100"), (21, "0"), (11, "0"), (21, "0")]; + let r = open(&entities(&spline_edged_hatch(&fit, &source)), &points()); + assert_eq!(r.document.layers[0].children().unwrap().len(), 1); +} + #[test] fn text_and_multiline_text() { let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Notes"), (3, "DejaVuSans.ttf"), (0, "ENDTAB")]; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 11d15e43f..96766913c 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -316,7 +316,7 @@ impl<'a> Scene<'a> { } // Live blends, envelopes, meshes and repeats are written as their evaluated art. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 3e6f9aaa3..3fee2615c 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -892,6 +892,30 @@ impl Appearance { g.transform(a); } } + /// Envelope Options → Distort Linear Gradients: bend the linear gradients of an object whose + /// geometric bounds are `bounds` with a warp, given its local affine approximation at a point + /// (taken at the middle of each gradient's vector). Unplaced ones are fixed to their fit first; + /// other gradients stay as they are. + pub fn warp_linear_gradients(&mut self, bounds: vectorcraft_geom::Rect, near: &dyn Fn(vectorcraft_geom::Point) -> vectorcraft_geom::Affine) { + for i in &mut self.items { + let (g, b) = match i { + AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => (g, bounds), + AppearanceItem::Stroke(s) => { + let b = s.paint_bounds(bounds); + let Paint::Gradient(g) = &mut s.paint else { continue }; + (g, b) + } + _ => continue, + }; + if g.gradient.kind != vectorcraft_color::GradientKind::Linear { + continue; + } + g.pin(b); + if let Some(geom) = g.geom { + g.transform(near(geom.start.midpoint(geom.end))); + } + } + } /// Map placed gradients through a warp, given its local affine approximation at a point (taken /// at each gradient's centre: the start of a radial, the middle of a linear vector). Freeform /// points each follow the warp at their own position. diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs new file mode 100644 index 000000000..3608bf5e4 --- /dev/null +++ b/crates/doc/src/blend.rs @@ -0,0 +1,1155 @@ +//! Live blends: interpolating key objects and laying the steps out along the spine. +//! +//! - **Paths** are matched anchor by anchor ([`PathPair`]): subpaths paired in order (a missing +//! one grows out of a point), resampled to equal anchor counts, closed ones turned to the same +//! winding and to the start points that keep them from twisting, or to the anchors the Blend +//! tool clicked. +//! - **Paint**: colours, gradients (stops resampled to a common set when their counts differ), +//! opacity, stroke weight, dashes, miter limit and width profiles interpolate; what can't +//! (caps, joins, brushes, fonts, symbols, text content) switches halfway. +//! - **Groups** pair their children in stacking order; the children one group has more of grow +//! out of the other group's centre. Compound paths stay compound paths. +//! - **Text, symbols and images** interpolate their transforms (rotation, scale, shear and +//! position separately); text interpolates its colours and sizes run by run. +//! - **Spine** ([`blend_spine`]): the straight lines between the key centres until it is edited +//! or replaced; then a path whose anchors the keys sit on (`key_anchors`). Steps follow it by +//! arc length; Align to Path turns them to its direction. + +use std::f64::consts::PI; +use std::sync::Arc; + +use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint}; +use vectorcraft_geom::kurbo::ParamCurveArclen; +use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2}; + +use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile}; +use crate::live::{BlendOrientation, BlendSpacing, BlendSpec}; +use crate::node::{Node, NodeId, NodeKind}; +use crate::text::TextObject; + +// ===================================================================================== +// Colour / paint interpolation +// ===================================================================================== + +/// Interpolate two colours in their shared model (display RGB when the models differ). +pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color { + let l = |x: f32, y: f32| x + (y - x) * t; + match (*a, *b) { + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), + (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), + (Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)), + _ => a.lerp(b, t), + } +} + +fn lerp_f32(x: f32, y: f32, t: f32) -> f32 { + x + (y - x) * t +} + +fn lerp_f64(x: f64, y: f64, t: f64) -> f64 { + x + (y - x) * t +} + +fn solid_gradient(c: Color, like: &Gradient) -> Gradient { + let mut g = like.clone(); + for s in &mut g.stops { + s.color = c; + s.opacity = 1.0; + } + g +} + +/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). +fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { + let mut out = g.clone(); + out.stops = offsets + .iter() + .map(|&o| { + let (color, opacity) = g.sample_with(o, lerp_color); + GradientStop { opacity, ..GradientStop::new(o, color) } + }) + .collect(); + out +} + +/// Where a gradient changes: its stop offsets and its off-centre midpoints. +fn gradient_breaks(g: &Gradient, out: &mut Vec) { + for (i, s) in g.stops.iter().enumerate() { + out.push(s.offset); + if let Some(n) = g.stops.get(i + 1) + && (s.midpoint - 0.5).abs() > 1e-3 + { + out.push(s.offset + (n.offset - s.offset) * s.midpoint); + } + } +} + +/// Two gradients of one kind with the same number of stops: as they are when the counts match, +/// else both resampled at every place either changes. +fn common_stops(a: &Gradient, b: &Gradient) -> Option<(Gradient, Gradient)> { + if a.kind != b.kind { + return None; + } + if a.stops.len() == b.stops.len() { + return Some((a.clone(), b.clone())); + } + if a.stops.is_empty() || b.stops.is_empty() { + return None; + } + let mut offs = Vec::with_capacity(2 * (a.stops.len() + b.stops.len())); + gradient_breaks(a, &mut offs); + gradient_breaks(b, &mut offs); + offs.retain(|o| o.is_finite()); + offs.sort_by(f32::total_cmp); + offs.dedup_by(|x, y| (*x - *y).abs() < 1e-4); + Some((resample_stops(a, &offs), resample_stops(b, &offs))) +} + +fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option { + let (ga, gb) = common_stops(&a.gradient, &b.gradient)?; + let mut out = if t < 0.5 { a.clone() } else { b.clone() }; + out.gradient.stops = ga + .stops + .iter() + .zip(&gb.stops) + .map(|(sa, sb)| GradientStop { + opacity: lerp_f32(sa.opacity, sb.opacity, t), + midpoint: lerp_f32(sa.midpoint, sb.midpoint, t), + ..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t)) + }) + .collect(); + out.angle = a.angle + (b.angle - a.angle) * t as f64; + out.swatch = None; + if let (Some(ga), Some(gb)) = (a.geom, b.geom) { + let mut g = ga; + g.start = ga.start.lerp(gb.start, t as f64); + g.end = ga.end.lerp(gb.end, t as f64); + g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64; + g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64)); + out.geom = Some(g); + } else { + out.geom = None; + } + Some(out) +} + +/// Interpolate paints: solid↔solid, gradients of one kind (stops resampled to a common set), +/// solid↔gradient; otherwise switch halfway. +pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { + match (a, b) { + (Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)), + (Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) { + Some(g) => Paint::Gradient(Box::new(g)), + None => (if t < 0.5 { a } else { b }).clone(), + }, + (Paint::Solid { color, .. }, Paint::Gradient(g)) => { + let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; + Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone()))) + } + (Paint::Gradient(g), Paint::Solid { color, .. }) => { + let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; + Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone()))) + } + _ => (if t < 0.5 { a } else { b }).clone(), + } +} + +/// Interpolate dash patterns. A solid stroke counts as the other's pattern with its gaps closed, +/// 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(), + offset: d.offset, + align_corners: d.align_corners, + }; + let (x, y) = match (x.filter(|d| d.is_dashed()), y.filter(|d| d.is_dashed())) { + (None, None) => return None, + (Some(d), None) => (d.clone(), solid_like(d)), + (None, Some(d)) => (solid_like(d), d.clone()), + (Some(a), Some(b)) => (a.clone(), b.clone()), + }; + // An odd pattern repeats twice per period (dash, gap, dash / gap, dash, gap). + let even = |p: &[f64]| if p.len() % 2 == 1 { p.repeat(2) } else { p.to_vec() }; + let (pa, pb) = (even(&x.pattern), even(&y.pattern)); + let n = if pa.is_empty() || pb.is_empty() { 0 } else { lcm(pa.len(), pb.len()).min(12) }; + let at = |p: &[f64], i: usize| p.get(i % p.len().max(1)).copied().unwrap_or(0.0); + Some(Dash { + pattern: (0..n).map(|i| lerp_f64(at(&pa, i), at(&pb, i), t)).collect(), + offset: lerp_f64(x.offset, y.offset, t), + align_corners: if t < 0.5 { x.align_corners } else { y.align_corners }, + }) +} + +fn lcm(a: usize, b: usize) -> usize { + let (mut x, mut y) = (a, b); + while y != 0 { + (x, y) = (y, x % y); + } + a.checked_div(x).map_or(0, |q| q * b) +} + +/// Interpolate width profiles (none is uniform) at every position either has a width point. +fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option { + if x.is_none() && y.is_none() { + return None; + } + let uniform = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform)); + let mut at: Vec = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect(); + at.sort_by(f64::total_cmp); + at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let points = at + .into_iter() + .map(|p| { + let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p)); + (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)) + }) + .collect(); + Some(WidthProfile { points }) +} + +/// Interpolate a stroke's paint, weight, opacity, miter limit, dashes, arrowhead scale and width +/// profile into `o` (a copy of the nearer stroke, so caps, joins, alignment, arrowheads and +/// brushes switch halfway). +fn lerp_stroke(o: &mut StrokeLayer, x: &StrokeLayer, y: &StrokeLayer, t: f64) { + let tf = t as f32; + o.paint = lerp_paint(&x.paint, &y.paint, tf); + o.width = lerp_f64(x.width, y.width, t); + o.opacity = lerp_f32(x.opacity, y.opacity, tf); + o.miter_limit = lerp_f64(x.miter_limit, y.miter_limit, t); + o.dash = lerp_dash(x.dash.as_ref(), y.dash.as_ref(), t); + o.arrow_scale = (lerp_f64(x.arrow_scale.0, y.arrow_scale.0, t), lerp_f64(x.arrow_scale.1, y.arrow_scale.1, t)); + o.profile = lerp_profile(x.profile.as_ref(), y.profile.as_ref(), t); +} + +/// Interpolate appearance stacks: item by item when their structure matches, else the top fill +/// and stroke only (on the structure of the nearer key). +pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { + let tf = t as f32; + let mut out = if t < 0.5 { a.clone() } else { b.clone() }; + let same = a.items.len() == b.items.len() + && a.items.iter().zip(&b.items).all(|(x, y)| { + matches!((x, y), (AppearanceItem::Fill(_), AppearanceItem::Fill(_)) | (AppearanceItem::Stroke(_), AppearanceItem::Stroke(_))) + }); + if same { + for ((it, x), y) in out.items.iter_mut().zip(&a.items).zip(&b.items) { + match (it, x, y) { + (AppearanceItem::Fill(o), AppearanceItem::Fill(x), AppearanceItem::Fill(y)) => { + o.paint = lerp_paint(&x.paint, &y.paint, tf); + o.opacity = lerp_f32(x.opacity, y.opacity, tf); + } + (AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => lerp_stroke(o, x, y, t), + _ => {} + } + } + return out; + } + let (fa, fb) = (a.fill_paint(), b.fill_paint()); + if !(fa.is_none() && fb.is_none()) { + out.set_fill(lerp_paint(&fa, &fb, tf)); + } + let (sa, sb) = (a.stroke_paint(), b.stroke_paint()); + if !(sa.is_none() && sb.is_none()) { + out.set_stroke(lerp_paint(&sa, &sb, tf)); + let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t; + let (xs, ys) = (a.stroke().cloned(), b.stroke().cloned()); + if let Some(s) = out.stroke_mut() { + if let (Some(x), Some(y)) = (&xs, &ys) { + lerp_stroke(s, x, y, t); + } + s.width = w; + } + } + out +} + +// ===================================================================================== +// Transforms +// ===================================================================================== + +/// An affine split into translation, rotation, x scale, shear and y scale (`R · [sx sh; 0 sy]`). +fn decompose(m: Affine) -> [f64; 6] { + let [a, b, c, d, e, f] = m.as_coeffs(); + let sx = a.hypot(b); + let ang = if sx > 1e-12 { b.atan2(a) } else { 0.0 }; + let (s, co) = ang.sin_cos(); + [e, f, ang, sx, co * c + s * d, -s * c + co * d] +} + +fn compose([e, f, ang, sx, sh, sy]: [f64; 6]) -> Affine { + let (s, co) = ang.sin_cos(); + Affine::new([co * sx, s * sx, co * sh - s * sy, s * sh + co * sy, e, f]) +} + +/// Interpolate two transforms by their parts: the rotation takes the shorter way round. +pub fn lerp_affine(x: Affine, y: Affine, t: f64) -> Affine { + let (p, mut q) = (decompose(x), decompose(y)); + let turn = q[2] - p[2]; + q[2] = p[2] + (turn + PI).rem_euclid(2.0 * PI) - PI; + let mut out = [0.0; 6]; + for (o, (a, b)) in out.iter_mut().zip(p.iter().zip(&q)) { + *o = lerp_f64(*a, *b, t); + } + compose(out) +} + +// ===================================================================================== +// Path interpolation +// ===================================================================================== + +/// Grow a subpath to `n` anchors by splitting its longest segments. +fn grow(sp: &mut SubPath, n: usize) { + let mut guard = 0; + while sp.anchors.len() < n && guard < 20_000 { + guard += 1; + if sp.segment_count() == 0 { + match sp.anchors.last().copied() { + Some(a) => sp.anchors.push(Anchor::corner(a.p)), + None => return, + } + continue; + } + let seg = (0..sp.segment_count()) + .max_by(|i, j| { + let (ci, cj) = (sp.segment(*i), sp.segment(*j)); + (ci.p3 - ci.p0).hypot().total_cmp(&(cj.p3 - cj.p0).hypot()) + }) + .unwrap_or(0); + sp.insert_anchor(seg, 0.5); + } +} + +fn lerp_anchor(x: &Anchor, y: &Anchor, t: f64) -> Anchor { + Anchor { p: x.p.lerp(y.p, t), h_in: x.h_in.lerp(y.h_in, t), h_out: x.h_out.lerp(y.h_out, t), kind: if t < 0.5 { x.kind } else { y.kind } } +} + +/// Start `sp` at anchor `i`: a closed subpath turns round to it, an open one clicked at its last +/// anchor runs the other way (a click on any other anchor of an open path is ignored). +fn start_at(sp: &mut SubPath, i: usize) { + let n = sp.anchors.len(); + if i >= n { + return; + } + if sp.closed { + sp.anchors.rotate_left(i); + } else if i + 1 == n && n > 1 { + sp.reverse(); + } +} + +/// Reverse a closed subpath keeping its start anchor first. +fn reverse_closed(sp: &mut SubPath) { + sp.reverse(); + sp.anchors.rotate_right(1); +} + +/// Anchor positions of a subpath relative to its box, scaled to unit size. +fn normalised(sp: &SubPath) -> Vec { + let pts = sp.anchors.iter().map(|a| a.p); + let (mut lo, mut hi) = (Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)); + for p in pts.clone() { + lo = Point::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Point::new(hi.x.max(p.x), hi.y.max(p.y)); + } + let c = lo.midpoint(hi); + let s = (hi.x - lo.x).max(hi.y - lo.y).max(1e-9); + pts.map(|p| (p - c) / s).collect() +} + +/// The rotation of `b`'s anchors that best matches `a`'s (least squared distance between +/// corresponding anchors, each shape centred and scaled to unit size): the start point that keeps +/// a closed blend from twisting. Long subpaths try every few rotations first, then refine. +fn best_rotation(a: &SubPath, b: &SubPath) -> usize { + let (qa, qb) = (normalised(a), normalised(b)); + let m = qa.len().min(qb.len()); + if m < 2 { + return 0; + } + let cost = |s: usize| -> f64 { (0..m).map(|j| (qa[j] - qb[(j + s) % m]).hypot2()).sum() }; + let stride = m.div_ceil(512); + let mut best = (0..m).step_by(stride).min_by(|x, y| cost(*x).total_cmp(&cost(*y))).unwrap_or(0); + if stride > 1 { + best = (best + m - stride..=best + m + stride).map(|s| s % m).min_by(|x, y| cost(*x).total_cmp(&cost(*y))).unwrap_or(best); + } + best +} + +/// Two paths prepared for interpolation: subpaths paired in order, with equal anchor counts, the +/// same winding and matching start points. +#[derive(Clone, Debug)] +pub struct PathPair { + a: Vec, + b: Vec, +} + +impl PathPair { + /// `starts`: the anchor of each path's first subpath its blend starts from (the Blend tool's + /// clicks); with neither, closed subpaths start where they twist least. + pub fn new(a: &PathData, b: &PathData, starts: (Option, Option)) -> Self { + let n = a.subpaths.len().max(b.subpaths.len()); + let ca = a.bounds().map(|r| r.center()).unwrap_or_default(); + let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); + let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); + let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + for i in 0..n { + let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { + (Some(x), Some(y)) => (x.clone(), y.clone()), + (Some(x), None) => (x.clone(), degenerate(x, cb)), + (None, Some(y)) => (degenerate(y, ca), y.clone()), + (None, None) => continue, + }; + let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some()); + if explicit { + if let Some(s) = starts.0 { + start_at(&mut sa, s); + } + if let Some(s) = starts.1 { + start_at(&mut sb, s); + } + } + let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2; + if closed && (sa.area() * sb.area()) < 0.0 { + reverse_closed(&mut sb); + } + let m = sa.anchors.len().max(sb.anchors.len()); + grow(&mut sa, m); + grow(&mut sb, m); + if closed && !explicit { + let r = best_rotation(&sa, &sb); + sb.anchors.rotate_left(r); + } + va.push(sa); + vb.push(sb); + } + Self { a: va, b: vb } + } + + /// The path at `t` (0 = `a`, 1 = `b`). + pub fn at(&self, t: f64) -> PathData { + let subs = self + .a + .iter() + .zip(&self.b) + .map(|(sa, sb)| { + let anchors = sa.anchors.iter().zip(&sb.anchors).map(|(x, y)| lerp_anchor(x, y, t)).collect(); + SubPath::new(anchors, if t < 0.5 { sa.closed } else { sb.closed }) + }) + .collect(); + PathData::new(subs) + } + + /// Subpaths at `t`, in order (see [`Self::at`]). + fn len(&self) -> usize { + self.a.len() + } +} + +/// Interpolate two paths anchor-by-anchor (see [`PathPair`]). +pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData { + PathPair::new(a, b, (None, None)).at(t) +} + +/// Path data of a path or compound path node (compound children concatenated), its fill rule and +/// the number of subpaths each compound member holds. +pub(crate) fn node_path(n: &Node) -> Option<(PathData, FillRule, Option>)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, None)), + NodeKind::Compound { children, rule } => { + let members: Vec<&PathData> = children.iter().filter_map(|c| c.path_data()).collect(); + let counts = members.iter().map(|p| p.subpaths.len()).collect(); + let subs = members.iter().flat_map(|p| p.subpaths.iter().cloned()).collect(); + Some((PathData::new(subs), *rule, Some(counts))) + } + _ => None, + } +} + +// ===================================================================================== +// Object interpolation +// ===================================================================================== + +/// How two key objects interpolate. +#[derive(Clone, Debug)] +enum Shape { + /// Groups of one kind: children paired in stacking order. + Group { children: Vec, clip: bool }, + /// Gradient meshes with the same grid. + Mesh, + /// Paths and compound paths; `members`: the subpath count of each member when both keys are + /// compound paths. + Path { pair: PathPair, rules: (FillRule, FillRule), members: Option<(Vec, Vec)> }, + /// Text, symbols and images: their transforms interpolate. + Placed, + /// Anything else: a copy of the nearer key moved and scaled into the interpolated box. + Other, +} + +/// Two key objects prepared for interpolation, so each step of a blend only interpolates. +#[derive(Clone, Debug)] +pub struct Lerp { + a: Node, + b: Node, + shape: Shape, +} + +/// `n` shrunk to a point at `c`: the counterpart of an object only one of two groups has. +fn collapsed(n: &Node, c: Point) -> Node { + let mut x = n.clone(); + if let Some(b) = n.geometric_bounds() { + x.transform(Affine::translate(c.to_vec2()) * Affine::scale(1e-6) * Affine::translate(-b.center().to_vec2()), false); + } + x +} + +fn center_of(n: &Node) -> Point { + n.geometric_bounds().map(|b| b.center()).unwrap_or_default() +} + +impl Lerp { + /// Prepare `a` → `b`; `starts` as in [`PathPair::new`]. + pub fn new(a: &Node, b: &Node, starts: (Option, Option)) -> Self { + let shape = match (&a.kind, &b.kind) { + (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) + if k1 == k2 && !(*k1 && ca.len() != cb.len()) => + { + let (oa, ob) = (center_of(a), center_of(b)); + let n = ca.len().max(cb.len()); + let children = (0..n) + .filter_map(|i| match (ca.get(i), cb.get(i)) { + (Some(x), Some(y)) => Some(Lerp::new(x, y, (None, None))), + (Some(x), None) => Some(Lerp::new(x, &collapsed(x, ob), (None, None))), + (None, Some(y)) => Some(Lerp::new(&collapsed(y, oa), y, (None, None))), + (None, None) => None, + }) + .collect(); + Shape::Group { children, clip: *k1 } + } + (NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => Shape::Mesh, + (NodeKind::Text(_), NodeKind::Text(_)) + | (NodeKind::SymbolInstance { .. }, NodeKind::SymbolInstance { .. }) + | (NodeKind::Image(_), NodeKind::Image(_)) => Shape::Placed, + _ => match (node_path(a), node_path(b)) { + (Some((pa, ra, ma)), Some((pb, rb, mb))) => { + Shape::Path { pair: PathPair::new(&pa, &pb, starts), rules: (ra, rb), members: ma.zip(mb) } + } + _ => Shape::Other, + }, + }; + Self { a: a.clone(), b: b.clone(), shape } + } + + /// The object at `t` (0 = `a`, 1 = `b`), with id 0. + pub fn at(&self, t: f64) -> Node { + let (a, b) = (&self.a, &self.b); + let base = if t < 0.5 { a } else { b }; + let mut n = match &self.shape { + Shape::Group { children, clip } => { + let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }); + g.isolate = base.isolate; + g.knockout = base.knockout; + g + } + Shape::Mesh => { + let mut n = base.clone(); + if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) { + for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) { + p.p = x.p.lerp(y.p, t); + p.color = lerp_color(&x.color, &y.color, t as f32); + p.opacity = lerp_f32(x.opacity, y.opacity, t as f32); + for h in 0..4 { + p.handles[h] = x.handles[h].lerp(y.handles[h], t); + } + } + } + n.mask = None; + n + } + Shape::Path { pair, rules, members } => { + let path = pair.at(t); + let rule = if t < 0.5 { rules.0 } else { rules.1 }; + match members { + Some((ma, mb)) => compound(path, rule, if t < 0.5 { ma } else { mb }, pair.len()), + None => { + let mut n = Node::path(NodeId(0), path, Appearance::default()); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + n + } + } + } + Shape::Placed => { + let mut n = base.clone(); + match (&mut n.kind, &a.kind, &b.kind) { + (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t), + (NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => { + *o = lerp_affine(*x, *y, t) + } + (NodeKind::Image(o), NodeKind::Image(x), NodeKind::Image(y)) => o.xf = lerp_affine(x.xf, y.xf, t), + _ => {} + } + n.mask = None; + n + } + Shape::Other => { + // Different structure: move/scale a copy of the nearer key into the interpolated box. + let mut n = base.clone(); + if let (Some(ba), Some(bb), Some(bn)) = (a.geometric_bounds(), b.geometric_bounds(), base.geometric_bounds()) { + let w = ba.width() + (bb.width() - ba.width()) * t; + let h = ba.height() + (bb.height() - ba.height()) * t; + let c = ba.center().lerp(bb.center(), t); + let sx = if bn.width() > 1e-9 { w / bn.width() } else { 1.0 }; + let sy = if bn.height() > 1e-9 { h / bn.height() } else { 1.0 }; + n.transform( + Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-bn.center().to_vec2()), + false, + ); + } + if let NodeKind::Path { live, .. } = &mut n.kind { + *live = None; + } + // A copy's members are new objects too. + for c in n.children_mut().into_iter().flatten() { + zero_ids(Arc::make_mut(c)); + } + n.mask = None; + n + } + }; + n.id = NodeId(0); + n.appearance = lerp_appearance(&a.appearance, &b.appearance, t); + n.opacity = lerp_f32(a.opacity, b.opacity, t as f32); + n.blend = base.blend; + n.visible = true; + n.locked = false; + n.name = None; + n + } +} + +/// `n` and its descendants with id 0 (generated objects). +fn zero_ids(n: &mut Node) { + n.id = NodeId(0); + for c in n.children_mut().into_iter().flatten() { + zero_ids(Arc::make_mut(c)); + } +} + +/// A compound path of `path`'s subpaths, split into members as `counts` says (one member per +/// subpath when the counts don't add up to `total`). +fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> Node { + let mut subs = path.subpaths.into_iter(); + let counts: Vec = if counts.iter().sum::() == total && !counts.contains(&0) { counts.to_vec() } else { vec![1; total] }; + let children = + counts.iter().map(|&c| Arc::new(Node::path(NodeId(0), PathData::new(subs.by_ref().take(c).collect()), Appearance::default()))).collect(); + Node::new(NodeId(0), NodeKind::Compound { children, rule }) +} + +/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an +/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at +/// the same place (by character) of both. +fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { + o.xf = lerp_affine(x.xf, y.xf, t); + if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = + (&mut o.kind, &x.kind, &y.kind) + { + *f = lerp_path(fx, fy, t); + } + // The run of `of` at fraction `u` of its characters. + fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> { + let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum(); + let target = (u * total as f64) as usize; + let mut seen = 0; + for r in &of.runs { + seen += r.text.chars().count(); + if seen > target { + return Some(r); + } + } + of.runs.last() + } + let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::().max(1); + let mut start = 0usize; + let tf = t as f32; + let mut resized = false; + for r in &mut o.runs { + let len = r.text.chars().count(); + let u = (start as f64 + len as f64 / 2.0) / total as f64; + start += len; + let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue }; + let (sx, sy) = (&rx.style, &ry.style); + r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf); + r.style.stroke = lerp_paint(&sx.stroke, &sy.stroke, tf); + r.style.stroke_width = lerp_f64(sx.stroke_width, sy.stroke_width, t); + let size = lerp_f64(sx.size, sy.size, t); + resized |= size != r.style.size; + r.style.size = size; + } + if resized { + o.cached_bounds = None; + } +} + +/// Interpolate two objects at `t` (0 = `a`, 1 = `b`). A blend prepares each pair once with +/// [`Lerp`] instead. +pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node { + Lerp::new(a, b, (None, None)).at(t) +} + +// ===================================================================================== +// Step counts +// ===================================================================================== + +/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke. +fn color_distance(a: &Node, b: &Node) -> f32 { + let mut d = 0.0f32; + let mut cmp = |x: &Paint, y: &Paint| { + let cols = |p: &Paint| -> Vec { + match p { + Paint::Solid { color, .. } => vec![*color], + Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), + _ => vec![], + } + }; + let (cx, cy) = (cols(x), cols(y)); + for (i, c) in cx.iter().enumerate() { + if let Some(o) = cy.get(i).or(cy.first()) { + let (p, q) = (c.to_rgb(), o.to_rgb()); + for k in 0..3 { + d = d.max((p[k] - q[k]).abs()); + } + } + } + }; + cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint()); + cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint()); + d +} + +/// Number of intermediate steps between two keys `len` apart (along the spine). +pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize { + match spacing { + BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, + BlendSpacing::Distance(d) => { + let d = d.max(0.01); + ((len / d).round() as i64 - 1).clamp(0, 1000) as usize + } + BlendSpacing::SmoothColor => { + let cd = color_distance(a, b); + if cd > 1.0 / 255.0 { + ((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256) + } else { + // Same colours: base the count on the distance between the objects. + let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds()); + let dist = match (ba, bb) { + (Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()), + _ => len, + }; + ((dist / 2.0).ceil() as usize).clamp(1, 256) + } + } + } +} + +// ===================================================================================== +// Spine +// ===================================================================================== + +/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the +/// arc length at each of its anchors. +pub struct Spine { + pts: Vec, + cum: Vec, + anchors: Vec, + closed: bool, +} + +impl Spine { + pub fn new(path: &PathData) -> Option { + let sp = path.subpaths.first()?; + let mut pts: Vec = Vec::new(); + let mut cum: Vec = Vec::new(); + let mut anchors = Vec::with_capacity(sp.anchors.len()); + for i in 0..sp.segment_count() { + anchors.push(cum.last().copied().unwrap_or(0.0)); + let c = sp.segment(i); + let mut bp = BezPath::new(); + bp.move_to(c.p0); + if sp.segment_is_line(i) { + bp.line_to(c.p3); + } else { + bp.curve_to(c.p1, c.p2, c.p3); + } + vectorcraft_geom::kurbo::flatten(bp.iter(), 0.1, |el| { + if let vectorcraft_geom::PathEl::MoveTo(p) | vectorcraft_geom::PathEl::LineTo(p) = el + && p.x.is_finite() + && p.y.is_finite() + && pts.last().is_none_or(|q| q.distance(p) > 1e-9) + { + let s = match (pts.last(), cum.last()) { + (Some(q), Some(l)) => l + q.distance(p), + _ => 0.0, + }; + pts.push(p); + cum.push(s); + } + }); + } + if !sp.closed { + anchors.push(cum.last().copied().unwrap_or(0.0)); + } + if pts.len() < 2 { + return None; + } + Some(Self { pts, cum, anchors, closed: sp.closed }) + } + pub fn length(&self) -> f64 { + *self.cum.last().unwrap_or(&0.0) + } + /// Arc length at anchor `i`. + pub fn anchor_length(&self, i: usize) -> Option { + self.anchors.get(i).copied() + } + /// Point and tangent angle (radians) at arc-length fraction `f` (0..1). + pub fn at(&self, f: f64) -> (Point, f64) { + self.at_length(f.clamp(0.0, 1.0) * self.length()) + } + /// Point and tangent angle (radians) at arc length `s` (clamped to the spine). + pub fn at_length(&self, s: f64) -> (Point, f64) { + let last = self.pts.len().saturating_sub(2); + let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 }; + let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { + Ok(i) => i.min(last), + Err(i) => i.saturating_sub(1).min(last), + }; + let (Some(p0), Some(p1), Some(c0), Some(c1)) = (self.pts.get(i), self.pts.get(i + 1), self.cum.get(i), self.cum.get(i + 1)) else { + return (self.pts.first().copied().unwrap_or_default(), 0.0); + }; + let u = ((s - c0) / (c1 - c0).max(1e-12)).clamp(0.0, 1.0); + let d = *p1 - *p0; + (p0.lerp(*p1, u), d.y.atan2(d.x)) + } +} + +/// The anchor of the first subpath each key sits on, when they are all valid for `n` anchors. +fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { + (spec.key_anchors.len() == keys && keys >= 2 && spec.key_anchors.iter().all(|a| (*a as usize) < n)) + .then(|| spec.key_anchors.iter().map(|a| *a as usize).collect()) +} + +/// The spine of a blend of `keys` and the anchor of it each key sits on: +/// - without a stored spine: the straight lines between the key centres; +/// - a pinned spine (`key_anchors`): the stored spine's first subpath with each key's anchor (and +/// its handles) moved onto the key's centre, so moving a key moves its end of the spine; +/// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread +/// evenly along it by arc length). +pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { + if keys.len() < 2 { + return None; + } + let Some(stored) = &spec.spine else { + let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); + return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); + }; + let mut first = stored.subpaths.first()?.clone(); + let Some(ka) = pinned(spec, keys.len(), first.anchors.len()) else { return Some((PathData::single(first), None)) }; + for (k, &ai) in keys.iter().zip(&ka) { + let c = center_of(k); + if let Some(a) = first.anchors.get_mut(ai) { + let d = c - a.p; + a.translate(d); + } + } + Some((PathData::single(first), Some(ka))) +} + +/// [`blend_spine`] with every key on an anchor: a spine from Replace Spine gets anchors where its +/// keys sit (by arc length), so editing it keeps them in place. +pub fn pin_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Vec)> { + let (mut path, anchors) = blend_spine(keys, spec)?; + if let Some(a) = anchors { + return Some((path, a)); + } + let k = keys.len(); + let total = Spine::new(&path)?.length(); + let closed = path.subpaths.first().is_some_and(|s| s.closed); + let mut out = vec![0usize]; + for i in 1..k.saturating_sub(1) { + let s = total * i as f64 / (k - 1) as f64; + out.push(anchor_at_length(&mut path, s)?); + } + let last = path.subpaths.first().map_or(0, |sp| if closed { 0 } else { sp.anchors.len().saturating_sub(1) }); + out.push(last); + Some((path, out)) +} + +/// The anchor of the first subpath at arc length `s`: an existing one within a hundredth of a +/// point, else a new one splitting the segment there. +fn anchor_at_length(path: &mut PathData, s: f64) -> Option { + let spine = Spine::new(path)?; + let sp = path.subpaths.first_mut()?; + let n = sp.segment_count(); + let seg = (0..n).rev().find(|i| spine.anchor_length(*i).is_some_and(|l| l <= s)).unwrap_or(0); + let from = spine.anchor_length(seg).unwrap_or(0.0); + let to = spine.anchor_length(seg + 1).unwrap_or(spine.length()); + if s - from < 0.01 { + return Some(seg); + } + if to - s < 0.01 { + return Some((seg + 1) % sp.anchors.len().max(1)); + } + let c = sp.segment(seg); + // The flattened length and the curve's own differ slightly: scale into the curve's. + let arc = c.arclen(1e-4).max(1e-12); + let t = c.inv_arclen((s - from) / (to - from).max(1e-12) * arc, 1e-4).clamp(1e-4, 1.0 - 1e-4); + Some(sp.insert_anchor(seg, t)) +} + +/// Where a blend's keys and steps go along its spine (see [`blend_spine`]). +struct Rail { + spine: Spine, + /// Arc length of each key. + stops: Vec, + centers: Vec, + rotate: bool, +} + +impl Rail { + /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. + fn new(keys: &[Arc], spec: &BlendSpec) -> Option { + let rotate = spec.orientation == BlendOrientation::AlignToPath; + if spec.spine.is_none() && !rotate { + return None; + } + let (path, anchors) = blend_spine(keys, spec)?; + let spine = Spine::new(&path)?; + let total = spine.length(); + let k = keys.len(); + let stops = match anchors { + Some(ka) => { + let mut v: Vec = Vec::with_capacity(k); + for a in ka { + let mut s = spine.anchor_length(a).unwrap_or(0.0); + // On a closed spine the key back at the start sits at its far end. + if spine.closed + && let Some(prev) = v.last() + && s <= *prev + 1e-9 + { + s += total; + } + v.push(s); + } + v + } + None => (0..k).map(|i| total * i as f64 / (k - 1).max(1) as f64).collect(), + }; + Some(Self { spine, stops, centers: keys.iter().map(|k| center_of(k)).collect(), rotate }) + } + + /// Arc length between keys `i` and `i + 1`. + fn len(&self, i: usize) -> f64 { + match (self.stops.get(i), self.stops.get(i + 1)) { + (Some(a), Some(b)) => (b - a).abs(), + _ => 0.0, + } + } + + /// Move `n`, the object at `t` between keys `i` and `i + 1` (a key at `t` = 0), onto the + /// spine: by how far the spine there is from the straight line between the key centres (so + /// steps keep their own interpolated position on a straight spine), turned to the spine's + /// direction with Align to Path. + fn place(&self, n: &mut Node, i: usize, t: f64) { + let (Some(&s0), Some(&c0)) = (self.stops.get(i), self.centers.get(i)) else { return }; + let (s, base) = match (self.stops.get(i + 1), self.centers.get(i + 1)) { + (Some(&s1), Some(&c1)) if t > 0.0 => (lerp_f64(s0, s1, t), c0.lerp(c1, t)), + _ => (s0, c0), + }; + let (p, ang) = self.spine.at_length(if self.spine.closed { s.rem_euclid(self.spine.length().max(1e-12)) } else { s }); + let off = p - base; + let mut m = if off.hypot() > 1e-9 { Affine::translate(off) } else { Affine::IDENTITY }; + if self.rotate && ang.abs() > 1e-12 { + let c = (center_of(n) + off).to_vec2(); + m = Affine::translate(c) * Affine::rotate(ang) * Affine::translate(-c) * m; + } + if m != Affine::IDENTITY { + n.transform(m, false); + } + } +} + +// ===================================================================================== +// Blend evaluation +// ===================================================================================== + +/// Whether `n`, every part of it, paints opaquely with Normal blending and no opacity mask or +/// effect: knocking such objects out of each other changes nothing. +fn paints_opaquely(n: &Node) -> bool { + let paint = |p: &Paint| match p { + Paint::Gradient(g) => g.gradient.stops.iter().all(|s| s.opacity >= 1.0), + Paint::Pattern { .. } => false, + _ => true, + }; + n.opacity >= 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && n.mask.is_none() + && n.appearance.effects.is_empty() + && n.appearance + .items + .iter() + .all(|i| i.opacity() >= 1.0 && i.blend() == vectorcraft_color::BlendMode::Normal && i.effects().is_empty() && paint(i.paint())) + && n.children().is_none_or(|c| c.iter().all(|c| paints_opaquely(c))) +} + +/// Whether knocking out the children of `n` (a blend's evaluated steps) shows: some step is +/// translucent or blends. +pub fn knockout_shows(n: &Node) -> bool { + n.children().is_some_and(|c| !c.iter().all(|c| paints_opaquely(c))) +} + +/// [`knockout_shows`] for evaluated steps. +pub fn steps_knockout_shows(steps: &[Arc]) -> bool { + !steps.iter().all(|c| paints_opaquely(c)) +} + +/// Evaluate a blend: the keys and generated steps in paint order. +pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { + let k = keys.len(); + if k < 2 { + return keys.iter().map(|n| (**n).clone()).collect(); + } + let rail = Rail::new(keys, spec); + let mut out = Vec::new(); + for (i, a) in keys.iter().enumerate() { + let mut key = (**a).clone(); + if let Some(r) = &rail { + r.place(&mut key, i, 0.0); + } + out.push(key); + let Some(b) = keys.get(i + 1) else { break }; + let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i)); + let n = blend_step_count(a, b, spec.spacing, len); + let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); + for j in 1..=n { + let t = j as f64 / (n + 1) as f64; + let mut s = lerp.at(t); + if let Some(r) = &rail { + r.place(&mut s, i, t); + } + out.push(s); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_geom::{Rect, shapes}; + + #[test] + fn clicked_start_points_pair_up() { + let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut b = shapes::rectangle(Rect::new(100.0, 0.0, 110.0, 10.0)); + b.subpaths[0].anchors.rotate_left(2); + // Starting both at their anchor 0 pairs each corner of `a` with the opposite one of `b`: + // the middle step shrinks to a point. + assert!(PathPair::new(&a, &b, (Some(0), Some(0))).at(0.5).bounds().unwrap().height() < 1e-6); + // Starting `b` at its anchor 2 (its first corner again) pairs like corners. + let r = PathPair::new(&a, &b, (Some(0), Some(2))).at(0.5).bounds().unwrap(); + assert!((r.width() - 10.0).abs() < 1e-6 && (r.height() - 10.0).abs() < 1e-6, "{r:?}"); + // An open path clicked at its end runs the other way. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false)); + let m = PathPair::new(&line(0.0), &line(100.0), (None, Some(1))).at(0.5); + assert_eq!(m.subpaths[0].anchors[0].p, Point::new(55.0, 0.0)); + } + + fn key(x: f64) -> Arc { + Arc::new(Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default())) + } + + #[test] + fn a_replaced_spine_gets_anchors_where_its_keys_sit() { + let keys = vec![key(0.0), key(100.0), key(200.0)]; + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(300.0, 50.0)], false)); + let spec = BlendSpec { spine: Some(line), ..Default::default() }; + let (path, anchors) = pin_spine(&keys, &spec).unwrap(); + assert_eq!(anchors, vec![0, 1, 2]); + assert!((path.subpaths[0].anchors[1].p.x - 150.0).abs() < 1e-6, "the middle key sits halfway"); + // Pinned, each key's anchor follows it. + let pinned = BlendSpec { spine: Some(path), key_anchors: vec![0, 1, 2], ..Default::default() }; + let (moved, _) = blend_spine(&[key(0.0), key(100.0), key(300.0)], &pinned).unwrap(); + assert_eq!(moved.subpaths[0].anchors[2].p, Point::new(305.0, 5.0)); + } + + #[test] + fn steps_follow_a_bent_spine() { + let keys = vec![key(0.0), key(100.0)]; + // A spine through the key centres bent up through (55, -95). + let spine = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(55.0, -95.0), Point::new(105.0, 5.0)], false)); + let spec = BlendSpec { spacing: BlendSpacing::Steps(1), spine: Some(spine), key_anchors: vec![0, 2], ..Default::default() }; + let out = blend_expand(&keys, &spec); + let mid = out[1].geometric_bounds().unwrap().center(); + assert!(mid.distance(Point::new(55.0, -95.0)) < 1e-6, "{mid:?}"); + assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); + } + + #[test] + fn affine_lerp_turns_the_short_way() { + let x = Affine::rotate(170f64.to_radians()); + let y = Affine::rotate(-170f64.to_radians()); + let m = lerp_affine(x, y, 0.5); + let ang = decompose(m)[2].to_degrees(); + assert!((ang.abs() - 180.0).abs() < 1e-6, "{ang}"); + let s = lerp_affine(Affine::scale(1.0), Affine::translate((10.0, 0.0)) * Affine::scale(3.0), 0.5); + assert!((s.as_coeffs()[0] - 2.0).abs() < 1e-9 && (s.as_coeffs()[4] - 5.0).abs() < 1e-9); + } + + #[test] + fn closed_paths_start_where_they_twist_least() { + let a = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let mut b = shapes::rectangle(Rect::new(100.0, 0.0, 110.0, 10.0)); + b.subpaths[0].anchors.rotate_left(2); + let mid = lerp_path(&a, &b, 0.5); + let r = mid.bounds().unwrap(); + assert!((r.width() - 10.0).abs() < 1e-6 && (r.height() - 10.0).abs() < 1e-6, "no twist: {r:?}"); + // Clicked start points win over the automatic match. + let pair = PathPair::new(&a, &b, (Some(0), Some(0))); + assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up"); + } + + #[test] + fn gradients_with_different_stop_counts_interpolate() { + let g = |stops: &[(f32, Color)]| { + let gradient = Gradient { stops: stops.iter().map(|(o, c)| GradientStop::new(*o, *c)).collect(), ..Gradient::default() }; + Paint::Gradient(Box::new(GradientPaint::new(gradient))) + }; + let a = g(&[(0.0, Color::BLACK), (1.0, Color::BLACK)]); + let b = g(&[(0.0, Color::WHITE), (0.5, Color::WHITE), (1.0, Color::WHITE)]); + let Paint::Gradient(m) = lerp_paint(&a, &b, 0.25) else { panic!() }; + assert_eq!(m.gradient.stops.len(), 3); + assert!(m.gradient.stops.iter().all(|s| (s.color.to_rgb()[0] - 0.25).abs() < 1e-4), "{:?}", m.gradient.stops); + } + + #[test] + fn dashes_and_profiles_interpolate() { + let d = lerp_dash(Some(&Dash { pattern: vec![10.0, 10.0], ..Default::default() }), None, 0.5).unwrap(); + assert_eq!(d.pattern, vec![15.0, 5.0]); + let d = + lerp_dash(Some(&Dash { pattern: vec![4.0], ..Default::default() }), Some(&Dash { pattern: vec![2.0, 6.0], ..Default::default() }), 0.5); + assert_eq!(d.unwrap().pattern, vec![3.0, 5.0]); + let p = lerp_profile(Some(&WidthProfile::lens()), None, 0.5).unwrap(); + assert_eq!(p.at(0.0), (0.5, 0.5)); + assert_eq!(p.at(0.5), (1.0, 1.0)); + } + + #[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())); + let a = Node::new(NodeId(1), NodeKind::Group { children: vec![rect(0.0), rect(20.0)], clip: true }); + let b = Node::new(NodeId(2), NodeKind::Group { children: vec![rect(100.0)], clip: true }); + let mid = lerp_node(&a, &b, 0.4); + let mut ids = vec![]; + mid.walk(&mut |n| ids.push(n.id)); + assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}"); + } +} diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index bacce0bb8..1c46747f9 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -20,15 +20,18 @@ pub struct Hit { pub leaf: NodeId, pub ancestry: Vec, pub kind: HitKind, + /// The innermost envelope in the ancestry whose contents are being edited (Edit Contents): + /// its content, not the envelope, is the object clicked. + pub contents_of: Option, } impl Hit { /// The object the Selection tool selects: the child of the layer (the outermost group). /// Inside isolation mode (`scope`), the child of the isolated container instead. pub fn top_object(&self, scope: Option) -> NodeId { - if let Some(s) = scope - && let Some(i) = self.ancestry.iter().position(|x| *x == s) - { + // The innermost of the isolated container and an envelope whose contents are edited. + let at = |s: Option| s.and_then(|s| self.ancestry.iter().position(|x| *x == s)); + if let Some(i) = at(scope).max(at(self.contents_of)) { return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf); } // Skip layers and sublayers. @@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { continue; } chain.push(layer.id); - if let Some(h) = hit_children(layer, p, opt, &mut chain) { + if let Some(mut h) = hit_children(layer, p, opt, &mut chain) { + h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); return Some(h); } chain.pop(); @@ -76,6 +80,11 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option { None } +/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)? +fn edits_contents(n: &Node) -> bool { + matches!(n.kind, NodeKind::Envelope { editing: true, .. }) +} + /// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame: /// glyph outlines need the font engine, above this crate. fn clip_contains(clip: &Node, p: Point) -> bool { @@ -99,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec hit_children(c, p, opt, chain), - _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }), + // Edit Contents: the envelope's content hits, undistorted. + NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain), + // A blend's key objects first (Direct and Group Selection pick them); its steps hit + // as the blend. + NodeKind::Blend { .. } => hit_children(c, p, opt, chain) + .or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })), + _ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }), }; if hit.is_some() { return hit; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 857f2c7c7..3fad4703b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,6 +7,7 @@ pub mod appearance; pub mod assets; +pub mod blend; pub mod clipnest; pub mod graph; pub mod hit; @@ -16,13 +17,17 @@ pub mod live; pub mod marks; pub mod metadata; pub mod node; +pub mod orient; pub mod overprint; pub mod pattern; +pub mod perspective; mod pixels; pub mod profiles; +pub mod puppet; pub mod range; pub mod rastersettings; mod reach; +pub mod recolor; pub mod selection; pub mod setup; pub mod slices; @@ -61,8 +66,11 @@ pub use node::Knockout; pub use node::Scaling; pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; +pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; +pub use perspective::PerspectiveAttachment; pub use profiles::ColorProfiles; +pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; pub use selection::{AnchorRef, Selection}; pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes}; @@ -175,8 +183,11 @@ impl Unit { if s == "-0" { "0".into() } else { s.into() } } /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic. + /// + /// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so + /// math typed before a field's unit suffix works. pub fn parse(self, s: &str) -> Option { - parse_measure(s, self) + parse_measure(s, self).filter(|v| v.is_finite()) } /// The value naming this unit in the Units preferences (`points`, `millimeters`, /// `feetInches`). @@ -219,14 +230,20 @@ fn parse_measure(s: &str, default: Unit) -> Option { if l.trim().is_empty() { continue; } - let a = parse_measure(l, default)?; return match op { - '+' => Some(a + parse_measure(r, default)?), - '-' => Some(a - parse_measure(r, default)?), - '*' => Some(a * r.trim().parse::().ok()?), + '+' => Some(parse_measure(l, default)? + parse_measure(r, default)?), + '-' => Some(parse_measure(l, default)? - parse_measure(r, default)?), _ => { - let d = r.trim().parse::().ok()?; - if d == 0.0 { None } else { Some(a / d) } + // A factor or divisor is a plain number; a unit after it is the expression's. + let (k, unit) = number_unit(r)?; + let a = parse_measure(l, unit.unwrap_or(default))?; + if op == '*' { + Some(a * k) + } else if k == 0.0 { + None + } else { + Some(a / k) + } } }; } @@ -240,22 +257,40 @@ fn parse_measure(s: &str, default: Unit) -> Option { { return Some(p.trim().parse::().ok()? * 12.0 + pt.trim().parse::().unwrap_or(0.0)); } + let (v, unit) = number_unit(s)?; + Some(unit.unwrap_or(default).to_pt(v)) +} + +/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). +fn number_unit(s: &str) -> Option<(f64, Option)> { + let s = s.trim(); let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len()); - let v: f64 = s[..num_end].trim().parse().ok()?; - let unit = match s[num_end..].trim() { - "" => default, - "pt" => Unit::Points, - "px" => Unit::Pixels, - "in" | "\"" => Unit::Inches, - "mm" => Unit::Millimeters, - "cm" => Unit::Centimeters, - "m" => Unit::Meters, - "ft" | "'" => Unit::Feet, - "yd" => Unit::Yards, - "pc" => Unit::Picas, + let v: f64 = s.get(..num_end)?.trim().parse().ok()?; + let unit = match s.get(num_end..)?.trim() { + "" => None, + "pt" => Some(Unit::Points), + "px" => Some(Unit::Pixels), + "in" | "\"" => Some(Unit::Inches), + "mm" => Some(Unit::Millimeters), + "cm" => Some(Unit::Centimeters), + "m" => Some(Unit::Meters), + "ft" | "'" => Some(Unit::Feet), + "yd" => Some(Unit::Yards), + "p" | "pc" => Some(Unit::Picas), _ => return None, }; - Some(unit.to_pt(v)) + Some((v, unit)) +} + +/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as +/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing. +pub fn parse_number(s: &str) -> Option { + if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) { + return None; + } + // A leading `+` is a sign (`+5`), as a plain number read it. + let s = s.trim(); + Unit::Points.parse(s.strip_prefix('+').unwrap_or(s)) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -533,6 +568,10 @@ pub struct Document { /// export settings are Export for Screens' ([`Document::export_settings`]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub assets: Vec, + /// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing + /// state, never saved. + #[serde(skip)] + pub puppet: Option>, } fn ppi72() -> f64 { @@ -600,6 +639,7 @@ impl Document { slices_clip_to_artboard: true, print_setup: None, assets: vec![], + puppet: None, }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index fe0ee33d8..4f20e8f73 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -18,11 +18,11 @@ use std::f64::consts::PI; use std::sync::Arc; use serde::{Deserialize, Serialize}; -use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; -use vectorcraft_geom::{Affine, Anchor, BezPath, CubicBez, FillRule, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_geom::{Affine, Anchor, BezPath, CubicBez, ParamCurve, PathData, Point, Rect, Shape, SubPath, Vec2}; use crate::appearance::{Appearance, AppearanceItem, FillLayer}; -use crate::node::{Node, NodeId, NodeKind}; +use crate::node::{ImageObject, Node, NodeId, NodeKind}; fn yes() -> bool { true @@ -69,6 +69,31 @@ pub struct BlendSpec { /// Replaced spine. `None` = the straight lines between the key objects' centres. #[serde(default, skip_serializing_if = "Option::is_none")] pub spine: Option, + /// The anchor of the spine's first subpath each key object sits on (a spine edited on the + /// canvas: moving a key moves its anchor and the other way round). Empty: the keys spread + /// evenly along the spine by arc length (Replace Spine). + #[serde(default, rename = "keyAnchors", skip_serializing_if = "Vec::is_empty")] + pub key_anchors: Vec, + /// Per key object, the anchor of its first subpath the blend starts from (Blend tool clicks + /// on anchor points); `None`: chosen so closed shapes don't twist. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub starts: Vec>, +} + +impl BlendSpec { + /// The start anchor of key `i` (see [`Self::starts`]). + pub fn start(&self, i: usize) -> Option { + self.starts.get(i).copied().flatten().map(|a| a as usize) + } +} + +/// Blend Options set with no blend selected: what new blends start with. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendDefaults { + #[serde(default)] + pub spacing: BlendSpacing, + #[serde(default)] + pub orientation: BlendOrientation, } /// How an envelope distorts its content. @@ -89,11 +114,95 @@ pub enum EnvelopeKind { horizontal: bool, }, /// A `(rows+1)×(cols+1)` grid of points (row-major) the content's bounding box maps onto. - Mesh { rows: u32, cols: u32, points: Vec }, + Mesh { + rows: u32, + cols: u32, + points: Vec, + /// Each point's bezier handles (offsets, see [`H_RIGHT`] …), row-major like `points`. + /// Empty: the smooth (Catmull-Rom) mesh through the points, as before handles existed; + /// editing the mesh's points or handles on the canvas fills them in. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + handles: Vec<[Vec2; 4]>, + }, /// The content's bounding box maps onto the outline of this path. TopObject { path: PathData }, } +/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PreserveShape { + #[default] + ClippingMask, + Transparency, +} + +impl PreserveShape { + pub fn id(self) -> &'static str { + match self { + Self::ClippingMask => "clippingMask", + Self::Transparency => "transparency", + } + } + pub fn parse(s: &str) -> Option { + match s { + "clippingMask" => Some(Self::ClippingMask), + "transparency" => Some(Self::Transparency), + _ => None, + } + } +} + +/// Envelope Options besides Fidelity. `Default` is what envelopes saved before these options +/// existed do (their appearance applies after the distortion); new envelopes start from +/// [`EnvelopeOptions::NEW`], as in the reference app. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct EnvelopeOptions { + /// Rasters: Anti-Alias. + pub anti_alias: bool, + /// Rasters: Preserve Shape Using. + pub preserve_shape: PreserveShape, + /// Distort Appearance: strokes and live effects are applied before the distortion, so they + /// bend with the art (off: they apply to the distorted paths). + pub distort_appearance: bool, + /// Distort Linear Gradients (with Distort Appearance): placed gradients bend with the art. + pub distort_linear_gradients: bool, + /// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art. + pub distort_pattern_fills: bool, +} + +impl Default for EnvelopeOptions { + fn default() -> Self { + Self { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: false, + distort_linear_gradients: false, + distort_pattern_fills: false, + } + } +} + +impl EnvelopeOptions { + /// The options a new envelope gets (the reference app's defaults). + pub const NEW: Self = Self { + anti_alias: true, + preserve_shape: PreserveShape::ClippingMask, + distort_appearance: true, + distort_linear_gradients: false, + distort_pattern_fills: false, + }; + /// Do gradients bend with the art? + pub fn gradients(&self) -> bool { + self.distort_appearance && self.distort_linear_gradients + } + /// Do pattern tiles bend with the art? + pub fn patterns(&self) -> bool { + self.distort_appearance && self.distort_pattern_fills + } +} + /// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row. pub const H_RIGHT: usize = 0; pub const H_LEFT: usize = 1; @@ -153,374 +262,13 @@ pub fn default_fidelity() -> f64 { } // ===================================================================================== -// Colour / paint interpolation +// Blends (evaluation in `crate::blend`) // ===================================================================================== -/// Interpolate two colours in their shared model (display RGB when the models differ). -pub fn lerp_color(a: &Color, b: &Color, t: f32) -> Color { - let l = |x: f32, y: f32| x + (y - x) * t; - match (*a, *b) { - (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)), - (Color::Gray { k }, Color::Gray { k: k2 }) => Color::gray(l(k, k2)), - (Color::Lab { l: l1, a, b }, Color::Lab { l: l2, a: a2, b: b2 }) => Color::lab(l(l1, l2), l(a, a2), l(b, b2)), - _ => a.lerp(b, t), - } -} - -fn solid_gradient(c: Color, like: &Gradient) -> Gradient { - let mut g = like.clone(); - for s in &mut g.stops { - s.color = c; - s.opacity = 1.0; - } - g -} - -fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option { - if a.gradient.kind != b.gradient.kind || a.gradient.stops.len() != b.gradient.stops.len() { - return None; - } - let mut out = if t < 0.5 { a.clone() } else { b.clone() }; - let l = |x: f32, y: f32| x + (y - x) * t; - for (i, s) in out.gradient.stops.iter_mut().enumerate() { - let (sa, sb) = (&a.gradient.stops[i], &b.gradient.stops[i]); - s.offset = l(sa.offset, sb.offset); - s.color = lerp_color(&sa.color, &sb.color, t); - s.opacity = l(sa.opacity, sb.opacity); - s.midpoint = l(sa.midpoint, sb.midpoint); - } - out.angle = a.angle + (b.angle - a.angle) * t as f64; - out.swatch = None; - if let (Some(ga), Some(gb)) = (a.geom, b.geom) { - let mut g = ga; - g.start = ga.start.lerp(gb.start, t as f64); - g.end = ga.end.lerp(gb.end, t as f64); - g.aspect = ga.aspect + (gb.aspect - ga.aspect) * t as f64; - g.focal = (ga.focal.is_some() || gb.focal.is_some()).then(|| ga.focal_point().lerp(gb.focal_point(), t as f64)); - out.geom = Some(g); - } else { - out.geom = None; - } - Some(out) -} - -/// Interpolate paints: solid↔solid, compatible gradients, solid↔gradient; otherwise switch halfway. -pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint { - match (a, b) { - (Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)), - (Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) { - Some(g) => Paint::Gradient(Box::new(g)), - None => (if t < 0.5 { a } else { b }).clone(), - }, - (Paint::Solid { color, .. }, Paint::Gradient(g)) => { - let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; - Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone()))) - } - (Paint::Gradient(g), Paint::Solid { color, .. }) => { - let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; - Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone()))) - } - _ => (if t < 0.5 { a } else { b }).clone(), - } -} - -/// Interpolate appearance stacks: item by item when their structure matches, else the top fill -/// and stroke only (on the structure of the nearer key). -pub fn lerp_appearance(a: &Appearance, b: &Appearance, t: f64) -> Appearance { - let tf = t as f32; - let mut out = if t < 0.5 { a.clone() } else { b.clone() }; - let same = a.items.len() == b.items.len() - && a.items.iter().zip(&b.items).all(|(x, y)| { - matches!((x, y), (AppearanceItem::Fill(_), AppearanceItem::Fill(_)) | (AppearanceItem::Stroke(_), AppearanceItem::Stroke(_))) - }); - if same { - for (i, it) in out.items.iter_mut().enumerate() { - match (it, &a.items[i], &b.items[i]) { - (AppearanceItem::Fill(o), AppearanceItem::Fill(x), AppearanceItem::Fill(y)) => { - o.paint = lerp_paint(&x.paint, &y.paint, tf); - o.opacity = x.opacity + (y.opacity - x.opacity) * tf; - } - (AppearanceItem::Stroke(o), AppearanceItem::Stroke(x), AppearanceItem::Stroke(y)) => { - o.paint = lerp_paint(&x.paint, &y.paint, tf); - o.width = x.width + (y.width - x.width) * t; - o.opacity = x.opacity + (y.opacity - x.opacity) * tf; - } - _ => {} - } - } - return out; - } - let (fa, fb) = (a.fill_paint(), b.fill_paint()); - if !(fa.is_none() && fb.is_none()) { - out.set_fill(lerp_paint(&fa, &fb, tf)); - } - let (sa, sb) = (a.stroke_paint(), b.stroke_paint()); - if !(sa.is_none() && sb.is_none()) { - out.set_stroke(lerp_paint(&sa, &sb, tf)); - let w = a.stroke_width() + (b.stroke_width() - a.stroke_width()) * t; - if let Some(s) = out.stroke_mut() { - s.width = w; - } - } - out -} - -// ===================================================================================== -// Path interpolation -// ===================================================================================== - -/// Grow a subpath to `n` anchors by splitting its longest segments. -fn grow(sp: &mut SubPath, n: usize) { - let mut guard = 0; - while sp.anchors.len() < n && guard < 20_000 { - guard += 1; - if sp.segment_count() == 0 { - match sp.anchors.last().copied() { - Some(a) => sp.anchors.push(Anchor::corner(a.p)), - None => return, - } - continue; - } - let seg = (0..sp.segment_count()) - .max_by(|i, j| { - let (ci, cj) = (sp.segment(*i), sp.segment(*j)); - (ci.p3 - ci.p0).hypot().total_cmp(&(cj.p3 - cj.p0).hypot()) - }) - .unwrap_or(0); - sp.insert_anchor(seg, 0.5); - } -} - -fn lerp_anchor(x: &Anchor, y: &Anchor, t: f64) -> Anchor { - Anchor { p: x.p.lerp(y.p, t), h_in: x.h_in.lerp(y.h_in, t), h_out: x.h_out.lerp(y.h_out, t), kind: if t < 0.5 { x.kind } else { y.kind } } -} - -/// Interpolate two paths anchor-by-anchor. Subpath and anchor counts are equalised first (missing -/// subpaths grow out of a point; shorter subpaths are resampled); closed subpaths of opposite -/// winding are reversed so they don't flip through themselves. -pub fn lerp_path(a: &PathData, b: &PathData, t: f64) -> PathData { - let n = a.subpaths.len().max(b.subpaths.len()); - let ca = a.bounds().map(|r| r.center()).unwrap_or_default(); - let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); - let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); - let mut out = Vec::with_capacity(n); - for i in 0..n { - let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { - (Some(x), Some(y)) => (x.clone(), y.clone()), - (Some(x), None) => (x.clone(), degenerate(x, cb)), - (None, Some(y)) => (degenerate(y, ca), y.clone()), - (None, None) => continue, - }; - if sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2 && (sa.area() * sb.area()) < 0.0 { - sb.reverse(); - } - let m = sa.anchors.len().max(sb.anchors.len()); - grow(&mut sa, m); - grow(&mut sb, m); - let anchors = sa.anchors.iter().zip(&sb.anchors).map(|(x, y)| lerp_anchor(x, y, t)).collect(); - out.push(SubPath::new(anchors, if t < 0.5 { sa.closed } else { sb.closed })); - } - PathData::new(out) -} - -/// Path data of a path or compound path node (compound children concatenated). -fn node_path(n: &Node) -> Option<(PathData, FillRule)> { - match &n.kind { - NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), - NodeKind::Compound { children, rule } => { - let subs = children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect(); - Some((PathData::new(subs), *rule)) - } - _ => None, - } -} - -/// Interpolate two objects at `t` (0 = `a`, 1 = `b`). -pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node { - let base = if t < 0.5 { a } else { b }; - let mut n = match (&a.kind, &b.kind) { - (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if ca.len() == cb.len() && k1 == k2 => { - let children = ca.iter().zip(cb).map(|(x, y)| Arc::new(lerp_node(x, y, t))).collect(); - Node::new(NodeId(0), NodeKind::Group { children, clip: *k1 }) - } - (NodeKind::Mesh(ma), NodeKind::Mesh(mb)) if ma.rows == mb.rows && ma.cols == mb.cols && ma.points.len() == mb.points.len() => { - let mut m = ma.clone(); - for (i, p) in m.points.iter_mut().enumerate() { - let q = &mb.points[i]; - p.p = p.p.lerp(q.p, t); - p.color = lerp_color(&p.color, &q.color, t as f32); - p.opacity += (q.opacity - p.opacity) * t as f32; - for h in 0..4 { - p.handles[h] = p.handles[h].lerp(q.handles[h], t); - } - } - Node::new(NodeId(0), NodeKind::Mesh(m)) - } - _ => match (node_path(a), node_path(b)) { - (Some((pa, ra)), Some((pb, rb))) => { - let path = lerp_path(&pa, &pb, t); - let mut n = Node::path(NodeId(0), path, Appearance::default()); - if let NodeKind::Path { rule, .. } = &mut n.kind { - *rule = if t < 0.5 { ra } else { rb }; - } - n - } - _ => { - // Different structure: move/scale a copy of the nearer key into the interpolated box. - let mut n = base.clone(); - if let (Some(ba), Some(bb), Some(bn)) = (a.geometric_bounds(), b.geometric_bounds(), base.geometric_bounds()) { - let w = ba.width() + (bb.width() - ba.width()) * t; - let h = ba.height() + (bb.height() - ba.height()) * t; - let c = ba.center().lerp(bb.center(), t); - let sx = if bn.width() > 1e-9 { w / bn.width() } else { 1.0 }; - let sy = if bn.height() > 1e-9 { h / bn.height() } else { 1.0 }; - n.transform( - Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-bn.center().to_vec2()), - false, - ); - } - if let NodeKind::Path { live, .. } = &mut n.kind { - *live = None; - } - n.id = NodeId(0); - n - } - }, - }; - n.appearance = lerp_appearance(&a.appearance, &b.appearance, t); - n.opacity = a.opacity + (b.opacity - a.opacity) * t as f32; - n.blend = base.blend; - n.visible = true; - n -} - -// ===================================================================================== -// Blend evaluation -// ===================================================================================== - -/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke. -fn color_distance(a: &Node, b: &Node) -> f32 { - let mut d = 0.0f32; - let mut cmp = |x: &Paint, y: &Paint| { - let cols = |p: &Paint| -> Vec { - match p { - Paint::Solid { color, .. } => vec![*color], - Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), - _ => vec![], - } - }; - let (cx, cy) = (cols(x), cols(y)); - for (i, c) in cx.iter().enumerate() { - if let Some(o) = cy.get(i).or(cy.first()) { - let (p, q) = (c.to_rgb(), o.to_rgb()); - for k in 0..3 { - d = d.max((p[k] - q[k]).abs()); - } - } - } - }; - cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint()); - cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint()); - d -} - -fn center_of(n: &Node) -> Point { - n.geometric_bounds().map(|b| b.center()).unwrap_or_default() -} - -/// Number of intermediate steps between two keys `len` apart (along the spine). -pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize { - match spacing { - BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, - BlendSpacing::Distance(d) => { - let d = d.max(0.01); - ((len / d).round() as i64 - 1).clamp(0, 1000) as usize - } - BlendSpacing::SmoothColor => { - let cd = color_distance(a, b); - if cd > 1.0 / 255.0 { - ((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256) - } else { - // Same colours: base the count on the distance between the objects. - let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds()); - let dist = match (ba, bb) { - (Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()), - _ => len, - }; - ((dist / 2.0).ceil() as usize).clamp(1, 256) - } - } - } -} - -/// A spine flattened to a polyline with cumulative lengths. -pub struct Spine { - pts: Vec, - cum: Vec, -} - -impl Spine { - pub fn new(path: &PathData) -> Option { - let sp = path.subpaths.first()?; - let pts = flatten_subpath(sp, 0.1); - if pts.len() < 2 { - return None; - } - Some(Self { cum: cumulative_lengths(&pts), pts }) - } - pub fn length(&self) -> f64 { - *self.cum.last().unwrap_or(&0.0) - } - /// Point and tangent angle (radians) at arc-length fraction `f` (0..1). - pub fn at(&self, f: f64) -> (Point, f64) { - let total = self.length(); - let s = f.clamp(0.0, 1.0) * total; - let i = match self.cum.binary_search_by(|c| c.total_cmp(&s)) { - Ok(i) => i.min(self.pts.len() - 2), - Err(i) => i.saturating_sub(1).min(self.pts.len() - 2), - }; - let seg = (self.cum[i + 1] - self.cum[i]).max(1e-12); - let u = ((s - self.cum[i]) / seg).clamp(0.0, 1.0); - let d = self.pts[i + 1] - self.pts[i]; - (self.pts[i].lerp(self.pts[i + 1], u), d.y.atan2(d.x)) - } -} - -/// Evaluate a blend: the keys and generated steps in paint order. -pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { - let k = keys.len(); - if k < 2 { - return keys.iter().map(|n| (**n).clone()).collect(); - } - let spine = spec.spine.as_ref().and_then(Spine::new); - let place = |mut n: Node, f: f64| -> Node { - if let Some(sp) = &spine { - let (p, ang) = sp.at(f); - let c = center_of(&n); - let rot = if spec.orientation == BlendOrientation::AlignToPath { Affine::rotate(ang) } else { Affine::IDENTITY }; - n.transform(Affine::translate(p.to_vec2()) * rot * Affine::translate(-c.to_vec2()), false); - } - n - }; - let mut out = Vec::new(); - for i in 0..k { - let a = &keys[i]; - out.push(place((**a).clone(), i as f64 / (k - 1) as f64)); - let Some(b) = keys.get(i + 1) else { break }; - let len = match &spine { - Some(sp) => sp.length() / (k - 1) as f64, - None => center_of(a).distance(center_of(b)), - }; - let n = blend_step_count(a, b, spec.spacing, len); - for j in 1..=n { - let t = j as f64 / (n + 1) as f64; - let mut s = lerp_node(a, b, t); - s.id = NodeId(0); - out.push(place(s, (i as f64 + t) / (k - 1) as f64)); - } - } - out -} +pub use crate::blend::{ + Spine, blend_expand, blend_spine, blend_step_count, lerp_appearance, lerp_color, lerp_node, lerp_paint, lerp_path, pin_spine, + steps_knockout_shows, +}; // ===================================================================================== // Warp maps (shared with vectorcraft-effects' Warp effects) @@ -800,9 +548,11 @@ impl Coons { Some(Self { top, right, bottom, left }) } + /// The point at `u` along a side; past its ends the end segments go on straight (art an + /// envelope bends can reach past the content's box: outlined strokes). fn sample(side: &[Point], u: f64) -> Point { - let s = u.clamp(0.0, 1.0) * (side.len() - 1) as f64; - let i = (s.floor() as usize).min(side.len() - 2); + let s = u * (side.len() - 1) as f64; + let i = (s.floor().max(0.0) as usize).min(side.len() - 2); side[i].lerp(side[i + 1], s - i as f64) } @@ -1284,7 +1034,7 @@ fn catmull(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: f64) -> Vec2 { } /// Point map from the unit square onto a point grid (tensor-product Catmull-Rom; linear -/// extrapolation at the borders so an undistorted grid is the identity). +/// extrapolation at the borders so an undistorted grid is the identity, also past the square). fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { let at = |r: i64, c: i64| -> Vec2 { let rr = r.clamp(0, rows as i64); @@ -1304,9 +1054,9 @@ fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { } p }; - let su = (u.clamp(0.0, 1.0) * cols as f64).min(cols as f64 - 1e-9); - let sv = (v.clamp(0.0, 1.0) * rows as f64).min(rows as f64 - 1e-9); - let (ci, ri) = (su.floor() as i64, sv.floor() as i64); + // Past the grid's edges the outer patches go on (art can reach past the content's box). + let (su, sv) = (u * cols as f64, v * rows as f64); + let (ci, ri) = ((su.floor() as i64).clamp(0, cols as i64 - 1), (sv.floor() as i64).clamp(0, rows as i64 - 1)); let (fu, fv) = (su - ci as f64, sv - ri as f64); let mut rowsv = [Vec2::ZERO; 4]; for (k, rv) in rowsv.iter_mut().enumerate() { @@ -1316,103 +1066,498 @@ fn grid_eval(rows: usize, cols: usize, pts: &[Point], u: f64, v: f64) -> Point { catmull(rowsv[0], rowsv[1], rowsv[2], rowsv[3], fv).to_point() } -/// The envelope's point map for content whose bounding box is `src`. -pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { - let w = src.width().max(1e-9); - let h = src.height().max(1e-9); +/// The surface (u, v ∈ [0,1]) an envelope of `kind` maps content onto, for content whose bounds in +/// the envelope's own `frame` (its axes → the document) are `src`: a warp bends along the frame's +/// axes; a mesh's points and a top object's path are in the document already. +fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -> Box Point + 'a> { + let flat = move |u: f64, v: f64| frame * Point::new(src.x0 + u * src.width(), src.y0 + v * src.height()); match kind { EnvelopeKind::Warp { style, bend, h: dh, v: dv, horizontal } => { let st = WarpStyle::from_id(style).unwrap_or(WarpStyle::Arc); let b = bend.clamp(-100.0, 100.0) / 100.0; let (dh, dv) = (dh.clamp(-100.0, 100.0) / 100.0, dv.clamp(-100.0, 100.0) / 100.0); let c = src.center(); - let (hw, hh) = (w / 2.0, h / 2.0); + let (hw, hh) = (src.width() / 2.0, src.height() / 2.0); let horizontal = *horizontal; - Box::new(move |q: Point| { - let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh); + Box::new(move |u: f64, v: f64| { + let (x, y) = (2.0 * u - 1.0, 2.0 * v - 1.0); let (x2, y2) = if horizontal { warp_point(st, b, dh, dv, x, y) } else { let (a, b2) = warp_point(st, b, dh, dv, y, x); (b2, a) }; - Point::new(c.x + x2 * hw, c.y + y2 * hh) + frame * Point::new(c.x + x2 * hw, c.y + y2 * hh) }) } - EnvelopeKind::Mesh { rows, cols, points } => { - let (rows, cols) = (*rows as usize, *cols as usize); - if rows == 0 || cols == 0 || points.len() != (rows + 1) * (cols + 1) { - return Box::new(|q| q); + EnvelopeKind::Mesh { rows, cols, points, handles } => { + if !handles.is_empty() + && let Some(m) = envelope_grid(*rows, *cols, points, handles) + { + return Box::new(move |u: f64, v: f64| mesh_at(&m, u, v)); } - Box::new(move |q: Point| grid_eval(rows, cols, points, (q.x - src.x0) / w, (q.y - src.y0) / h)) + if *rows == 0 || *cols == 0 || points.len() as u64 != (u64::from(*rows) + 1) * (u64::from(*cols) + 1) { + return Box::new(flat); + } + let (rows, cols) = (*rows as usize, *cols as usize); + Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v)) } EnvelopeKind::TopObject { path } => match Coons::from_path(path) { - Some(co) => Box::new(move |q: Point| co.eval((q.x - src.x0) / w, (q.y - src.y0) / h)), - None => Box::new(|q| q), + Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)), + None => Box::new(flat), }, } } +/// The envelope's point map for content whose bounding box is `src` (page axes). +pub fn envelope_mapper<'a>(kind: &'a EnvelopeKind, src: Rect) -> Box Point + 'a> { + let s = surface_of(kind, src, Affine::IDENTITY); + let (w, h) = (src.width().max(1e-9), src.height().max(1e-9)); + Box::new(move |q: Point| s((q.x - src.x0) / w, (q.y - src.y0) / h)) +} + +/// Catmull-Rom tangents of a `(rows+1)×(cols+1)` point grid (row-major) as mesh handles, a third +/// of the tangent towards each neighbour (past the borders the grid goes on straight): the curves +/// an envelope mesh without handles of its own runs along between its points. +pub fn smooth_handles(rows: u32, cols: u32, points: &[Point]) -> Vec<[Vec2; 4]> { + let (rows, cols) = (rows as usize, cols as usize); + let at = |r: usize, c: usize| points.get(r * (cols + 1) + c).copied().unwrap_or_default(); + let tangent = |prev: Option, p: Point, next: Option| match (prev, next) { + (Some(a), Some(b)) => (b - a) / 2.0, + (None, Some(b)) => b - p, + (Some(a), None) => p - a, + (None, None) => Vec2::ZERO, + }; + let mut out = Vec::with_capacity(points.len()); + for r in 0..=rows { + for c in 0..=cols { + let p = at(r, c); + let du = tangent(c.checked_sub(1).map(|c| at(r, c)), p, (c < cols).then(|| at(r, c + 1))) / 3.0; + let dv = tangent(r.checked_sub(1).map(|r| at(r, c)), p, (r < rows).then(|| at(r + 1, c))) / 3.0; + // Handles pointing out of the grid are unused: they stay at the point. + let keep = |inside: bool, d: Vec2| if inside { d } else { Vec2::ZERO }; + let mut h = [Vec2::ZERO; 4]; + (h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (keep(c < cols, du), keep(c > 0, -du), keep(r < rows, dv), keep(r > 0, -dv)); + out.push(h); + } + } + out +} + +/// A mesh envelope's grid (`rows`×`cols` patches, `points` and their `handles`; without handles +/// the smooth ones, see [`smooth_handles`]) as a gradient mesh with black points: what the +/// canvas draws and the Mesh tool and Direct Selection edit. `None` for a malformed grid. +pub fn envelope_grid(rows: u32, cols: u32, points: &[Point], handles: &[[Vec2; 4]]) -> Option { + // (Counts come from files: no overflow on 32-bit targets.) + if rows == 0 || cols == 0 || points.len() as u64 != (u64::from(rows) + 1) * (u64::from(cols) + 1) { + return None; + } + let smooth; + let handles = if handles.len() == points.len() { + handles + } else { + smooth = smooth_handles(rows, cols, points); + &smooth + }; + let points = points.iter().zip(handles).map(|(p, h)| MeshPoint { p: *p, color: Color::BLACK, opacity: 1.0, handles: *h }).collect(); + Some(GradientMesh { rows, cols, points }) +} + +/// The point of mesh `m` at (u, v) over the whole mesh (each patch an equal share; past the edges +/// the outer patches go on). +fn mesh_at(m: &GradientMesh, u: f64, v: f64) -> Point { + let (rows, cols) = (m.rows.max(1) as i64, m.cols.max(1) as i64); + let (su, sv) = (u * cols as f64, v * rows as f64); + let (c, r) = ((su.floor() as i64).clamp(0, cols - 1), (sv.floor() as i64).clamp(0, rows - 1)); + m.eval(r as usize, c as usize, su - c as f64, sv - r as f64) +} + +/// The envelope `id` is, or the innermost one it sits in. +pub fn envelope_of(doc: &crate::Document, id: NodeId) -> Option<&Node> { + doc.ancestry(id)?.into_iter().rev().find_map(|a| doc.node(a).filter(|n| matches!(n.kind, NodeKind::Envelope { .. }))) +} + +/// What a selected envelope shows on the canvas: its mesh lines (a warp's surface on a 4×4 grid, +/// a mesh envelope's own grid) or its top object's outline, and for a mesh envelope the grid to +/// edit (its points and handles). +pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option)> { + let NodeKind::Envelope { kind, .. } = &n.kind else { return None }; + match kind { + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let grid = envelope_grid(*rows, *cols, points, handles)?; + Some((grid.lines(), Some(grid))) + } + EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)), + EnvelopeKind::TopObject { path } => Some((path.clone(), None)), + } +} + +/// The frame an envelope keeps after transform `m` (its frame so far composed with the +/// transform): identity when its axes stay square to the page, unflipped, since evaluating on +/// the page axes gives the same result then. +pub fn envelope_frame(m: Affine) -> Affine { + let [a, b, c, d, _, _] = m.as_coeffs(); + if b.abs() < 1e-12 && c.abs() < 1e-12 && a > 0.0 && d > 0.0 { Affine::IDENTITY } else { m } +} + +/// An envelope's point map: content placed in the envelope's own frame maps, by its bounds there, +/// onto the unit square and from there onto the envelope's surface ([`surface_of`]). +pub struct EnvelopeMap<'a> { + src: Rect, + frame: Affine, + to_frame: Affine, + surface: Box Point + 'a>, +} + +impl<'a> EnvelopeMap<'a> { + /// The map of an envelope of `kind` around `content` with `frame`. `None` when the content + /// has no bounds. + pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine) -> Option { + let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; + let to_frame = frame.inverse(); + let src = if frame == Affine::IDENTITY { + nodes_bounds(content)? + } else { + content.iter().try_fold(None, |acc, c| { + let mut m = (**c).clone(); + m.transform(to_frame, false); + Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) + })?? + }; + Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) + } + + /// The map of envelope node `n` (`None` for other nodes). + pub fn of(n: &'a Node) -> Option { + match &n.kind { + NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, *frame), + _ => None, + } + } + + /// The content's bounds in the envelope's frame. + pub fn src(&self) -> Rect { + self.src + } + + /// The surface point at (u, v) ∈ [0,1]². + pub fn at(&self, u: f64, v: f64) -> Point { + (self.surface)(u, v) + } + + /// Where the envelope puts document point `p`. + pub fn map(&self, p: Point) -> Point { + let q = self.to_frame * p; + let (w, h) = (self.src.width().max(1e-9), self.src.height().max(1e-9)); + self.at((q.x - self.src.x0) / w, (q.y - self.src.y0) / h) + } + + /// A flat `(rows+1)×(cols+1)` grid over the content, in the envelope's frame (row-major). + pub fn grid(&self, rows: u32, cols: u32) -> Vec { + grid_points(self.src, rows, cols).into_iter().map(|p| self.frame * p).collect() + } + + /// The surface sampled on a `rows`×`cols` grid as a mesh whose handles follow it and whose + /// points are `color`: Release gives it back as the envelope's shape, Reset with Mesh starts + /// from it. + pub fn surface_mesh(&self, rows: u32, cols: u32, color: Color) -> GradientMesh { + GradientMesh::from_surface(rows, cols, &|u, v| self.at(u, v), &|_, _| color) + } + + /// The surface's bounds (sampled; a mesh's points and a top object's path bound it exactly). + fn bounds(&self) -> Option { + const N: usize = 16; + let mut r: Option = None; + for j in 0..=N { + for i in 0..=N { + let q = self.at(i as f64 / N as f64, j as f64 / N as f64); + r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + } + r + } +} + /// Optional hook that converts nodes the pure evaluation can't map (text) to outlines. pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option>; -fn map_node(n: &Node, f: &dyn Fn(Point) -> Point, piece: f64, outline: Outliner) -> Node { +/// Distort Pattern Fills: the tiles pattern swatch `name` painted with placement `xf` lays over +/// `region` (see [`crate::PatternDef::instances_in`]); `None` without such a swatch. +pub type PatternTiles<'a> = &'a dyn Fn(&str, Affine, Rect) -> Option>; + +/// What live evaluation asks of the crates above this one (`vectorcraft-effects` supplies them). +#[derive(Clone, Copy, Default)] +pub struct Hooks<'a> { + /// Type as its glyph outlines, a symbol instance as its art (see [`Outliner`]). + pub outline: Outliner<'a>, + /// Distort Appearance: a path's strokes and geometry effects baked into filled art (`None` + /// when it has none to bake). + pub appearance: Outliner<'a>, + /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). + pub pattern: Option>, +} + +impl<'a> From> for Hooks<'a> { + fn from(outline: Outliner<'a>) -> Self { + Self { outline, ..Default::default() } + } +} + +/// The affine map that best matches `f` near `p` (finite differences with step `eps`). +pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine { + let o = f(p); + let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps); + let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps); + let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]); + Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) +} + +/// How deep hooks may nest art inside art (symbols in symbols, envelopes in envelopes) before +/// the rest is left as it is: a file can make a symbol contain itself. +const MAX_HOOK_DEPTH: u32 = 16; + +/// How an envelope pushes art through its point map. +#[derive(Clone, Copy)] +struct Warper<'a> { + f: &'a dyn Fn(Point) -> Point, + /// Longest piece a segment is cut into before mapping. + piece: f64, + /// Finite-difference step for the local affine maps gradients follow. + eps: f64, + /// Rows and columns of the pieces an image is cut into. + cells: usize, + hooks: Hooks<'a>, + options: EnvelopeOptions, + /// How many hooks the art being mapped came through. + depth: u32, +} + +impl Warper<'_> { + /// This warper one hook deeper (`None` past [`MAX_HOOK_DEPTH`]). + fn deeper(&self) -> Option { + (self.depth < MAX_HOOK_DEPTH).then_some(Self { depth: self.depth + 1, ..*self }) + } +} + +/// `to` (art standing in for `from`: its outlines, its art) with `from`'s name, transparency, +/// opacity mask and effects. +fn carry_look(from: &Node, mut to: Node) -> Node { + to.name = from.name.clone(); + to.opacity = from.opacity; + to.blend = from.blend; + to.isolate = from.isolate; + to.knockout = from.knockout; + to.knockout_shape = from.knockout_shape; + to.mask = from.mask.clone(); + to.appearance.effects = from.appearance.effects.clone(); + to +} + +fn map_node(n: &Node, w: &Warper) -> Node { + let deeper = w.deeper(); + // Distort Appearance: strokes and geometry effects bend with the art. + if w.options.distort_appearance + && let Some(d) = &deeper + && let Some(o) = w.hooks.appearance.and_then(|h| h(n)) + { + return map_node(&o, d); + } + // Distort Pattern Fills: the tiles bend with the art (not the patterns inside them). + if w.options.patterns() + && let Some(d) = &deeper + && let Some(o) = w.hooks.pattern.and_then(|p| pattern_art(n, p)) + { + let inner = Warper { options: EnvelopeOptions { distort_pattern_fills: false, ..d.options }, ..*d }; + return map_node(&o, &inner); + } let mut out = n.clone(); + // Distort Linear Gradients: the gradient vectors follow the warp where they sit. + if w.options.gradients() + && let Some(b) = n.geometric_bounds() + { + out.appearance.warp_linear_gradients(b, &|p| affine_near(w.f, p, w.eps)); + } + if let Some(m) = &mut out.mask { + m.art = Arc::new(map_node(&m.art, w)); + } match &mut out.kind { NodeKind::Path { path, live, .. } => { - *path = map_nonlinear(path, piece, f); + *path = map_nonlinear(path, w.piece, w.f); *live = None; } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => { for c in children.iter_mut() { - *c = Arc::new(map_node(c, f, piece, outline)); + *c = Arc::new(map_node(c, w)); } } - NodeKind::Mesh(m) => m.map(f), + NodeKind::Mesh(m) => m.map(w.f), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { - let g = expanded_group(n, outline); - return map_node(&g, f, piece, outline); - } - NodeKind::Text(_) => { - if let Some(o) = outline.and_then(|h| h(n)) { - return map_node(&o, f, piece, outline); + if let Some(d) = &deeper { + return map_node(&expanded_group_hooks(n, w.hooks), d); } } - NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {} + NodeKind::Text(_) | NodeKind::SymbolInstance { .. } => { + if let Some(d) = &deeper + && let Some(o) = w.hooks.outline.and_then(|h| h(n)) + { + return map_node(&carry_look(n, o), d); + } + } + NodeKind::Image(im) => { + let im = im.clone(); + return warp_image(&out, &im, w); + } } out } -/// Evaluate an envelope: its content mapped through the envelope. -pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { - let Some(src) = nodes_bounds(content) else { return content.iter().map(|c| (**c).clone()).collect() }; - let f = envelope_mapper(kind, src); - let diag = src.width().hypot(src.height()).max(1e-6); - let piece = diag / (8.0 + fidelity.clamp(0.0, 100.0) / 100.0 * 56.0); - content.iter().map(|c| map_node(c, &*f, piece, outline)).collect() +/// The affine map taking triangle `s` to triangle `d` (`None` when `s` is degenerate). +fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option { + let basis = |p: [Point; 3]| Affine::new([p[1].x - p[0].x, p[1].y - p[0].y, p[2].x - p[0].x, p[2].y - p[0].y, p[0].x, p[0].y]); + let bs = basis(s); + if bs.determinant().abs() < 1e-12 { + return None; + } + let a = basis(d) * bs.inverse(); + a.is_finite().then_some(a) } -/// Envelope bounds (the image of the content box, sampled). -pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind) -> Option { +/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into +/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map +/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its +/// edges (at most half a point) so neighbours overlap and no seams show. +fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node { + let k = w.cells.max(1); + let (pw, ph) = (im.width as f64, im.height as f64); + let at = |i: usize, j: usize| Point::new(pw * i as f64 / k as f64, ph * j as f64 / k as f64); + let mut pieces = Vec::with_capacity(2 * k * k); + for j in 0..k { + for i in 0..k { + let s = [at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1)]; + let d = s.map(|p| (w.f)(im.xf * p)); + for t in [[0, 1, 2], [0, 2, 3]] { + let (st, dt) = (t.map(|v| s[v]), t.map(|v| d[v])); + let Some(xf) = affine_between(st, dt) else { continue }; + let c = Point::new((dt[0].x + dt[1].x + dt[2].x) / 3.0, (dt[0].y + dt[1].y + dt[2].y) / 3.0); + let grow = (dt[0].distance(dt[1]).max(dt[1].distance(dt[2])) * 0.02).min(0.5); + let tri: Vec = dt + .iter() + .map(|p| { + let v = *p - c; + let len = v.hypot(); + if len > 1e-12 { *p + v * (grow / len) } else { *p } + }) + .collect(); + let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let image = Node::new(n.id, NodeKind::Image(ImageObject { xf, ..im.clone() })); + pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }))); + } + } + } + let mut g = n.clone(); + g.kind = NodeKind::Group { children: pieces, clip: false }; + g +} + +/// Distort Pattern Fills: path or compound path `n` with each visible pattern fill turned into the +/// tiles it paints in a clip group shaped like `n` (with the fill's opacity and blend mode), its +/// other fills and strokes on copies of it, in paint order, under `n`'s transparency. `None` +/// without a pattern fill whose definition `pattern` finds. +fn pattern_art(n: &Node, tiles: PatternTiles) -> Option { + let (path, rule, _) = crate::blend::node_path(n)?; + let pattern_fill = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && matches!(f.paint, Paint::Pattern { .. })); + if !n.appearance.items.iter().any(pattern_fill) { + return None; + } + let bounds = path.bounds()?; + let def = |i: &AppearanceItem| match i { + AppearanceItem::Fill(f) if f.visible => match &f.paint { + Paint::Pattern { pattern: name, xf } => tiles(name, *xf, bounds).map(|t| (t, f.opacity, f.blend)), + _ => None, + }, + _ => None, + }; + let plain = |items: Vec| { + Arc::new(Node { + appearance: Appearance { items, ..Default::default() }, + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: Default::default(), + knockout_shape: false, + mask: None, + name: None, + ..n.clone() + }) + }; + let mut pieces = vec![]; + let mut rest = vec![]; + for i in &n.appearance.items { + let Some((tiles, opacity, blend)) = def(i) else { + rest.push(i.clone()); + continue; + }; + if !rest.is_empty() { + pieces.push(plain(std::mem::take(&mut rest))); + } + let mut clip = Node::path(n.id, path.clone(), Appearance::default()); + if let NodeKind::Path { clipping, rule: r, .. } = &mut clip.kind { + (*clipping, *r) = (true, rule); + } + let children = std::iter::once(clip).chain(tiles).map(Arc::new).collect(); + let mut g = Node::new(n.id, NodeKind::Group { children, clip: true }); + g.opacity = opacity; + g.blend = blend; + pieces.push(Arc::new(g)); + } + if !rest.is_empty() { + pieces.push(plain(rest)); + } + Some(Node { + kind: NodeKind::Group { children: pieces, clip: false }, + appearance: Appearance { effects: n.appearance.effects.clone(), ..Default::default() }, + ..n.clone() + }) +} + +/// Evaluate an envelope of `kind` around `content` on the page axes with the options envelopes +/// had before Envelope Options (see [`expand_live_hooks`] for an envelope node). +pub fn envelope_expand(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, outline: Outliner) -> Vec { + expand_envelope(content, kind, fidelity, EnvelopeOptions::default(), Affine::IDENTITY, outline.into()) +} + +/// An envelope's content pushed through its map: Fidelity sets how finely curves are cut (and +/// into how many pieces images are), the options what bends with the art. +fn expand_envelope(content: &[Arc], kind: &EnvelopeKind, fidelity: f64, options: EnvelopeOptions, frame: Affine, hooks: Hooks) -> Vec { + let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() }; + let src = map.src(); + let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 }; + let f = |p: Point| map.map(p); + let w = Warper { + f: &f, + piece: src.width().hypot(src.height()).max(1e-6) / (8.0 + fid * 56.0), + eps: (src.width().max(src.height()) * 0.05).max(0.5), + cells: (4.0 + fid * 12.0).round() as usize, + hooks, + options, + depth: 0, + }; + content.iter().map(|c| map_node(c, &w)).collect() +} + +/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled). +pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine) -> Option { match kind { + EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(), EnvelopeKind::Mesh { points, .. } => { let first = *points.first()?; Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p))) } EnvelopeKind::TopObject { path } => path.bounds(), - EnvelopeKind::Warp { .. } => { - let src = nodes_bounds(content)?; - let f = envelope_mapper(kind, src); - const N: usize = 16; - let mut r: Option = None; - for j in 0..=N { - for i in 0..=N { - let q = f(Point::new(src.x0 + src.width() * i as f64 / N as f64, src.y0 + src.height() * j as f64 / N as f64)); - r = Some(r.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); - } - } - r - } + EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(), } } @@ -1446,9 +1591,14 @@ pub fn expand_live(n: &Node) -> Vec { } pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { + expand_live_hooks(n, outline.into()) +} + +/// [`expand_live_with`] with everything an envelope can ask of the crates above (see [`Hooks`]). +pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { match &n.kind { NodeKind::Blend { children, spec } => blend_expand(children, spec), - NodeKind::Envelope { content, kind, fidelity, .. } => envelope_expand(content, kind, *fidelity, outline), + NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, *frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), NodeKind::Repeat(r) => r.expand(), _ => vec![n.clone()], @@ -1457,7 +1607,12 @@ pub fn expand_live_with(n: &Node, outline: Outliner) -> Vec { /// A live object evaluated into a plain group (keeping the object's id, name and transparency). pub fn expanded_group(n: &Node, outline: Outliner) -> Node { - let children = expand_live_with(n, outline).into_iter().map(Arc::new).collect(); + expanded_group_hooks(n, outline.into()) +} + +/// [`expanded_group`] with [`Hooks`]. +pub fn expanded_group_hooks(n: &Node, hooks: Hooks) -> Node { + let children = expand_live_hooks(n, hooks).into_iter().map(Arc::new).collect(); let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); g.name = n.name.clone(); g.visible = n.visible; @@ -1466,17 +1621,27 @@ pub fn expanded_group(n: &Node, outline: Outliner) -> Node { g.blend = n.blend; g.isolate = n.isolate; g.knockout = n.knockout; + // Knocking out opaque blend steps changes nothing: don't make the outputs work for it. + if matches!(n.kind, NodeKind::Blend { .. }) && g.knockout == crate::Knockout::On && !crate::blend::knockout_shows(&g) { + g.knockout = crate::Knockout::Off; + } g.knockout_shape = n.knockout_shape; + g.mask = n.mask.clone(); g } /// Recursively replace every live object in `n` by plain geometry (for exporters). pub fn expand_deep(n: &Node, outline: Outliner) -> Node { - let mut out = if is_live(n) { expanded_group(n, outline) } else { n.clone() }; + expand_deep_hooks(n, outline.into()) +} + +/// [`expand_deep`] with [`Hooks`]. +pub fn expand_deep_hooks(n: &Node, hooks: Hooks) -> Node { + let mut out = if is_live(n) { expanded_group_hooks(n, hooks) } else { n.clone() }; if let Some(ch) = out.children_mut() { for c in ch.iter_mut() { if subtree_has_live(c) { - *c = Arc::new(expand_deep(c, outline)); + *c = Arc::new(expand_deep_hooks(c, hooks)); } } } @@ -1534,6 +1699,16 @@ mod tests { assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); } + #[test] + fn maps_go_on_past_the_content_box() { + // A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there. + let r = Rect::new(0.0, 0.0, 100.0, 100.0); + let p = grid_eval(2, 2, &grid_points(r, 2, 2), -0.05, 1.1); + assert!((p.x + 5.0).abs() < 1e-6 && (p.y - 110.0).abs() < 1e-6, "{p:?}"); + let c = Coons::from_path(&shapes::rectangle(r)).unwrap().eval(1.05, -0.1); + assert!((c.x - 105.0).abs() < 1e-6 && (c.y + 10.0).abs() < 1e-6, "{c:?}"); + } + #[test] fn grid_identity() { let r = Rect::new(0.0, 0.0, 100.0, 100.0); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index c9125a168..10869b893 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -272,6 +272,14 @@ pub enum NodeKind { /// Edit Contents mode (the content, not the envelope, is edited). #[serde(default)] editing: bool, + /// Envelope Options besides Fidelity. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + options: crate::live::EnvelopeOptions, + /// The envelope's own axes (→ the document): the transforms it took since it was made that + /// the page axes can't stand for (rotation, shear, reflection). The content maps from its + /// bounds in this frame, and a warp bends along it. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + frame: Affine, }, /// Gradient mesh. Mesh(GradientMesh), @@ -335,6 +343,14 @@ pub struct Node { /// these options. #[serde(default, skip_serializing_if = "Option::is_none")] pub slice: Option>, + /// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its + /// bounding box and handles stand at this angle. Transforms turn it with the object (see + /// [`crate::orient`]); Reset Bounding Box sets it back to 0. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub bbox_angle: f64, + /// Object › Perspective: the perspective grid plane the object is attached to. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub perspective: Option>, } /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). @@ -383,6 +399,8 @@ impl Node { graphic_style: None, attrs: None, slice: None, + bbox_angle: 0.0, + perspective: None, } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { @@ -516,14 +534,14 @@ impl Node { .iter() .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())), - NodeKind::Text(t) => t.bounds(), + NodeKind::Text(t) => self.projected(t.bounds()), NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))), - NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))), + NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))), NodeKind::Blend { children, spec } => { let b = crate::live::nodes_bounds(children); vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds())) } - NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind), + NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame), NodeKind::Mesh(m) => m.bounds(), NodeKind::Repeat(r) => r.bounds(), } @@ -572,6 +590,11 @@ impl Node { self.transform_scaled(a, &scaling.into()); } fn transform_scaled(&mut self, a: Affine, sc: &Scaling) { + if !self.is_layer() { + self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a); + } + // Type and symbols in perspective keep looking the same, moved by `a`. + self.transform_projection(a); // Refitting an unplaced gradient only reproduces moves and uniform scales. if !keeps_gradient_fit(a) { self.pin_gradients(); @@ -623,15 +646,22 @@ impl Node { s.transform(a); } } - NodeKind::Envelope { content, kind, .. } => { + NodeKind::Envelope { content, kind, frame, .. } => { for c in content.iter_mut() { Arc::make_mut(c).transform_scaled(a, sc); } + *frame = crate::live::envelope_frame(a * *frame); match kind { - EnvelopeKind::Mesh { points, .. } => { + EnvelopeKind::Mesh { points, handles, .. } => { for p in points.iter_mut() { *p = a * *p; } + // Handles are offsets: they take the linear part. + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]); + for h in handles.iter_mut().flatten() { + *h = (lin * h.to_point()).to_vec2(); + } } EnvelopeKind::TopObject { path } => path.transform(a), EnvelopeKind::Warp { .. } => {} diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs new file mode 100644 index 000000000..013e76496 --- /dev/null +++ b/crates/doc/src/orient.rs @@ -0,0 +1,195 @@ +//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`]) +//! through the transforms applied to it, so after a rotation its bounding box and handles stay +//! square to it: scaling by a side handle stretches it along its own width or height. Path +//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0. + +use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt}; + +use crate::{Document, Node, NodeId, NodeKind}; + +/// Angles closer than this (degrees) are the same. +const EPS_DEG: f64 = 1e-6; + +/// A bounding box square to a frame turned by `angle`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OrientedBox { + /// Counter-clockwise degrees in (-180, 180]; 0: square to the page. + pub angle: f64, + /// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page. + pub rect: Rect, +} + +impl OrientedBox { + /// A box square to the page. + pub fn aligned(rect: Rect) -> Self { + Self { angle: 0.0, rect } + } + /// The turned frame → the page (a rotation about the page origin). + pub fn to_doc(&self) -> Affine { + if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) } + } + /// A page point in the turned frame. + pub fn to_local(&self, p: Point) -> Point { + self.to_doc().inverse() * p + } + /// `a`, a transform of the turned frame, as a page transform. + pub fn conjugate(&self, a: Affine) -> Affine { + if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() } + } + pub fn center(&self) -> Point { + self.to_doc() * self.rect.center() + } + /// One of the 9 reference points (row-major from the box's own top-left), on the page. + pub fn reference_point(&self, index: usize) -> Point { + self.to_doc() * vectorcraft_geom::reference_point(self.rect, index) + } + /// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself. + pub fn corners(&self) -> [Point; 4] { + let r = self.rect; + [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y)) + } +} + +/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis. +/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller +/// angle) is kept, so flipping an upright object keeps it upright. +pub fn transformed_angle(angle: f64, a: Affine) -> f64 { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) { + return angle; + } + let (s, c) = (-angle.to_radians()).sin_cos(); + let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s); + // Degenerate (or non-finite) transforms leave the angle alone. + let len = v.hypot(); + if !len.is_finite() || len <= 1e-12 { + return angle; + } + let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees()); + let dist = |x: f64| normalize_deg(x - angle).abs(); + if a.determinant() < 0.0 { + let alt = normalize_deg(out + 180.0); + let (d0, d1) = (dist(out), dist(alt)); + if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) { + out = alt; + } + } + // Moves and scales along the object's own axes keep its angle exactly. + if dist(out) <= EPS_DEG { + angle + } else if out.abs() <= EPS_DEG { + 0.0 + } else { + out + } +} + +impl Node { + /// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes), + /// without changing it. + pub fn bounds_in(&self, a: Affine, visual: bool) -> Option { + match &self.kind { + NodeKind::Group { children, clip: false } => { + let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual))); + // The group's own strokes paint around its members (as in `visual_bounds`). + let o = if visual { self.appearance.outset() } else { 0.0 }; + b.map(|b| b.inflate(o, o)) + } + NodeKind::Group { children, clip: true } => { + let clip = children.first()?; + let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)); + clip.bounds_in(a, visual && stroked) + } + _ => { + let mut n = self.clone(); + n.transform(a, false); + if visual { n.visual_bounds() } else { n.geometric_bounds() } + } + } + } +} + +impl Document { + /// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0. + pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 { + let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle); + let Some(first) = angles.next() else { return 0.0 }; + if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 } + } + /// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]): + /// geometric bounds, or `visual` with their strokes. + pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option { + let angle = self.bbox_angle(ids); + if angle == 0.0 { + return self.bounds_of(ids, visual).map(OrientedBox::aligned); + } + let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse(); + let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?; + Some(OrientedBox { angle, rect }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Appearance; + use vectorcraft_geom::shapes; + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + fn rot(deg: f64) -> Affine { + Affine::rotate(-deg.to_radians()) + } + + #[test] + fn rotations_add_and_moves_keep_the_angle() { + assert!(close(transformed_angle(0.0, rot(45.0)), 45.0)); + assert!(close(transformed_angle(170.0, rot(20.0)), -170.0)); + assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0); + assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0); + } + + #[test] + fn local_scales_keep_the_angle_exactly() { + let b = OrientedBox { angle: 45.0, rect: Rect::ZERO }; + assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0); + // A flip along the object's own axis keeps the box too. + let b = OrientedBox { angle: 135.0, rect: Rect::ZERO }; + assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0); + } + + #[test] + fn page_flips_mirror_the_angle() { + let flip_h = Affine::scale_non_uniform(-1.0, 1.0); + let flip_v = Affine::scale_non_uniform(1.0, -1.0); + assert_eq!(transformed_angle(0.0, flip_h), 0.0); + assert_eq!(transformed_angle(0.0, flip_v), 0.0); + assert!(close(transformed_angle(30.0, flip_h), -30.0)); + assert!(close(transformed_angle(30.0, flip_v), -30.0)); + // Degenerate transforms leave it alone. + assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0); + } + + #[test] + fn oriented_bounds_hug_a_rotated_rectangle() { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap(); + let c = Point::new(50.0, 25.0); + d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false); + let b = d.oriented_bounds(&[id], false).unwrap(); + assert!(close(b.angle, 30.0)); + assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0)); + assert!(b.center().distance(c) < 1e-9); + // The page box is bigger. + assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0); + // Grouped: the group has no angle of its own, the bounds are square to the page. + let g = d.alloc_id(); + d.insert(Some(l), 1, Node::group(g, vec![])).unwrap(); + d.move_node(id, Some(g), 0).unwrap(); + assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0); + } +} diff --git a/crates/doc/src/perspective.rs b/crates/doc/src/perspective.rs new file mode 100644 index 000000000..4e1e24ef2 --- /dev/null +++ b/crates/doc/src/perspective.rs @@ -0,0 +1,69 @@ +//! Perspective Grid attachments (Object › Perspective). +//! +//! The grid itself is document data (`Document.unknown["perspectiveGrid"]`, modelled by the tools +//! crate). Which plane an object lies on is kept on the object, so copies, duplicates and pastes +//! stay attached and deleted objects leave nothing stale behind. + +use serde::{Deserialize, Serialize}; +use vectorcraft_geom::{Homography, Rect}; + +use crate::{Node, NodeKind}; + +/// The plane an object in perspective lies on. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerspectiveAttachment { + /// `left`, `right` or `ground`. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub plane: String, + /// Where along the plane's normal (points) the parallel plane the object lies on is: 0 is the + /// grid plane in its original place. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub depth: f64, + /// Type and symbol instances: the projective map (row-major 3 × 3) that draws their flat art + /// in perspective. They stay type and symbol instances; the renderer and exporters project + /// their outlines. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub projection: Option<[f64; 9]>, + /// Object › Perspective › Edit Text in progress: the type is drawn flat to be edited (not + /// saved). + #[serde(skip)] + pub editing: bool, +} + +impl PerspectiveAttachment { + pub fn new(plane: &str, depth: f64) -> Self { + Self { plane: plane.to_string(), depth: if depth.is_finite() { depth } else { 0.0 }, ..Self::default() } + } + + /// The projection, when it is a valid one. + pub fn homography(&self) -> Option { + self.projection.and_then(Homography::from_array) + } +} + +impl Node { + /// The projective map type or a symbol instance in perspective is drawn through (not while + /// Edit Text shows it flat). + pub fn projection(&self) -> Option { + let a = self.perspective.as_deref().filter(|a| !a.editing)?; + matches!(self.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }).then(|| a.homography()).flatten() + } + + /// `b` (flat bounds) as drawn: the box of its projection for type and symbols in perspective. + pub(crate) fn projected(&self, b: Option) -> Option { + match (self.projection(), b) { + (Some(h), Some(b)) => h.map_rect_bbox(b).or(Some(b)), + (_, b) => b, + } + } + + /// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). + pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) { + if let Some(p) = self.perspective.as_deref_mut() + && let Some(h) = p.homography().and_then(|h| h.conjugated(a)) + { + p.projection = Some(h.to_array()); + } + } +} diff --git a/crates/doc/src/puppet.rs b/crates/doc/src/puppet.rs new file mode 100644 index 000000000..708460bbb --- /dev/null +++ b/crates/doc/src/puppet.rs @@ -0,0 +1,89 @@ +//! Puppet Warp pins: editing state the Puppet Warp tool and `object.puppetWarp` share while the +//! same artwork stays selected. It lives in the document (never saved) so Undo and Redo take the +//! pins back with the warps they made. + +use std::sync::Arc; + +use vectorcraft_geom::Point; + +use crate::{Document, Node, NodeId}; + +/// One pin: where it sits on the rest shape, where it is now, and the turn it holds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PuppetPin { + pub rest: Point, + pub at: Point, + /// Radians the art is turned around the pin; `None`: free to turn. + pub angle: Option, +} + +/// The pins on some objects and the shape they warp from. +#[derive(Clone, Debug, PartialEq)] +pub struct PuppetPins { + /// The warped objects. + pub ids: Vec, + /// Their rest shape: the objects as they were when pinning began (the warp always starts + /// from it, so warps don't stack and dragging a pin back restores the original). + pub rest: Vec>, + /// The objects as the last warp left them: once anything else edits them, the pins start + /// again from the objects as they are. + pub result: Vec>, + pub pins: Vec, +} + +impl PuppetPins { + /// The pins are still those of `ids` in `doc`: the same objects, as the last warp left them. + pub fn holds(&self, doc: &Document, ids: &[NodeId]) -> bool { + self.ids == ids + && self.ids.len() == self.result.len() + && ids.iter().zip(&self.result).all(|(id, r)| doc.node_arc(*id).is_some_and(|a| Arc::ptr_eq(a, r))) + } +} + +impl Document { + /// The node `id` as the document holds it (shared: comparing pointers says whether it changed). + pub fn node_arc(&self, id: NodeId) -> Option<&Arc> { + fn find(nodes: &[Arc], id: NodeId) -> Option<&Arc> { + for n in nodes { + if n.id == id { + return Some(n); + } + if let Some(f) = n.children().and_then(|ch| find(ch, id)) { + return Some(f); + } + } + None + } + find(&self.layers, id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Appearance; + use vectorcraft_geom::{Rect, shapes}; + + #[test] + fn pins_hold_until_their_objects_change_and_are_never_saved() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + let node = d.node_arc(id).cloned().unwrap(); + let pin = PuppetPin { rest: Point::new(5.0, 5.0), at: Point::new(6.0, 5.0), angle: Some(0.5) }; + d.puppet = Some(Arc::new(PuppetPins { ids: vec![id], rest: vec![node.clone()], result: vec![node], pins: vec![pin] })); + let pins = d.puppet.clone().unwrap(); + assert!(pins.holds(&d, &[id])); + assert!(!pins.holds(&d, &[id, l]), "other objects"); + // Saved and read back without them (editing state). + let json = serde_json::to_string(&d).unwrap(); + assert!(!json.contains("puppet")); + let back: Document = serde_json::from_str(&json).unwrap(); + assert!(back.puppet.is_none()); + assert_eq!(back.node(id), d.node(id)); + // An edit by anything else: the pins no longer hold. + d.node_mut(id).unwrap().name = Some("moved".into()); + assert!(!pins.holds(&d, &[id])); + } +} diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs new file mode 100644 index 000000000..715c1aa9a --- /dev/null +++ b/crates/doc/src/recolor.rs @@ -0,0 +1,111 @@ +//! The colour visitor that recolouring shares: Edit Colors, Recolor Artwork and the live colour +//! adjustment effects walk an object's colours through [`recolor_node`] and decide what each +//! colour, mesh point and embedded image becomes through a [`ColorVisitor`]. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; + +use crate::{AppearanceItem, Node, NodeKind}; + +/// What a recolouring pass does to the colours it meets. +pub trait ColorVisitor { + /// A fill's or stroke's paint (an object's, or a text run's): true when it changed. + fn paint(&mut self, p: &mut Paint) -> bool; + /// One gradient-mesh point's colour. + fn color(&mut self, c: Color) -> Color; + /// The key of embedded image `key` recoloured, `None` when it stays as it is. + fn image(&mut self, key: &str) -> Option; +} + +/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of +/// embedded images. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reach { + pub fill: bool, + pub stroke: bool, + pub images: bool, +} + +impl Reach { + /// Every colour. + pub const ALL: Reach = Reach { fill: true, stroke: true, images: true }; +} + +/// 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. +pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize { + let mut changed = 0; + for it in &mut n.appearance.items { + match it { + AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize, + AppearanceItem::Stroke(l) if reach.stroke => changed += v.paint(&mut l.paint) as usize, + _ => {} + } + } + match &mut n.kind { + NodeKind::Text(t) => { + for r in &mut t.runs { + if reach.fill { + changed += v.paint(&mut r.style.fill) as usize; + } + if reach.stroke { + changed += v.paint(&mut r.style.stroke) as usize; + } + } + } + NodeKind::Mesh(m) if reach.fill => { + let mut ch = false; + for p in &mut m.points { + let c = v.color(p.color); + ch |= c != p.color; + p.color = c; + } + changed += ch as usize; + } + NodeKind::Image(im) if reach.images && im.link.is_none() => { + if let Some(k) = v.image(&im.key) { + im.key = k; + changed += 1; + } + } + _ => { + for c in n.children_mut().into_iter().flatten() { + let mut m = (**c).clone(); + let k = recolor_node(&mut m, reach, v); + if k > 0 { + *c = Arc::new(m); + changed += k; + } + } + } + } + changed +} + +/// Apply `f` to the solid colour or gradient stops of a paint, with their swatch link and tint +/// ([`Paint::map_links`]); a gradient that changes is no longer its gradient swatch's. Returns +/// whether anything changed. +pub fn map_links(p: &mut Paint, f: &dyn Fn(&mut Color, &mut Option, &mut f32) -> bool) -> bool { + let changed = p.map_links(&mut |c, l, t| f(c, l, t)); + if changed && let Paint::Gradient(g) = p { + g.swatch = None; + } + changed +} + +/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether +/// anything changed. +pub fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { + map_links(p, &|c, link, _| { + let n = f(*c); + if n == *c { + return false; + } + *c = n; + *link = None; + true + }) +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 387c14146..7a2ff7042 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -414,6 +414,9 @@ pub enum TextKind { /// A text object. `runs` split into paragraphs at `\n`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextObject { + /// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub vertical: bool, pub kind: TextKind, /// Maps text space (origin = first baseline start for point type) to the document. pub xf: Affine, @@ -437,6 +440,7 @@ pub struct TextObject { impl TextObject { pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { + vertical: false, kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun { text: text.into(), style }], diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index b50e797b3..aad25ac93 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true } vectorcraft-doc = { workspace = true } vectorcraft-pathops = { workspace = true } vectorcraft-text = { workspace = true } +vectorcraft-plugins = { workspace = true } kurbo = { workspace = true } serde_json = { workspace = true } diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs new file mode 100644 index 000000000..85e8a2cd9 --- /dev/null +++ b/crates/effects/src/adjust.rs @@ -0,0 +1,493 @@ +//! Colour adjustment effects (Effect → Color Adjustments): Brightness/Contrast, Curves, Levels, +//! Hue/Saturation, Shift to Color and Temperature/Tint. +//! +//! They recolour what an object paints with, non-destructively: its fills and strokes (solid +//! colours and gradient stops), type, gradient meshes, embedded images and, on a group or layer, +//! every member — through the colour visitor the Edit Colors commands use +//! ([`vectorcraft_doc::recolor`]). Each colour keeps its colour model. An effect on one fill or +//! stroke recolours that paint only. [`adjust`] evaluates them, innermost first and each list in +//! stack order; the renderer draws the result (caching recoloured copies of embedded images) and +//! [`crate::bake_document`] writes it for export, recoloured images as new embedded images. +//! Pattern tiles, and the shadows and glows of raster effects, keep their colours. + +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::{Document, Effect, Node, NodeKind}; + +use crate::merged_params; +use crate::raster::color_param; +use crate::util::{flag, num, text}; + +/// The colour adjustment effects, in menu order. +pub const ADJUSTMENTS: [&str; 6] = + ["adjust.brightnessContrast", "adjust.curves", "adjust.hueSaturation", "adjust.levels", "adjust.shiftToColor", "adjust.temperatureTint"]; + +/// Is `id` a colour adjustment effect? +pub fn is_adjustment(id: &str) -> bool { + ADJUSTMENTS.contains(&id) +} + +fn visible_adjustment(e: &Effect) -> bool { + e.visible && is_adjustment(&e.id) +} + +/// Does `n` carry a visible colour adjustment, on the object or on one of its fills or strokes? +pub fn has_adjustment(n: &Node) -> bool { + n.appearance.effects.iter().any(visible_adjustment) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible_adjustment)) +} + +/// The channels a tone curve (Curves, Levels) applies to. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Channel { + All, + One(usize), +} + +impl Channel { + fn of(p: &Value) -> Self { + match text(p, "channel", "rgb").to_ascii_lowercase().as_str() { + "red" | "r" => Channel::One(0), + "green" | "g" => Channel::One(1), + "blue" | "b" => Channel::One(2), + _ => Channel::All, + } + } +} + +/// Entries of a sampled tone curve. +const LUT: usize = 256; + +/// One adjustment, its parameters normalised. +#[derive(Clone, Debug, PartialEq)] +enum Adjust { + /// Brightness and contrast, -1..1 each. + BrightnessContrast { brightness: f32, contrast: f32 }, + /// A tone curve (Curves, Levels) sampled at [`LUT`] inputs, on `channel`. + Tone { channel: Channel, lut: Arc<[f32]> }, + /// Hue shift in degrees, saturation and lightness -1..1; `colorize` sets the hue instead. + HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool }, + /// Move colours `amount` (0..1) of the way to `target` (sRGB), keeping their lightness when + /// `preserve`. + ShiftToColor { target: [f32; 3], amount: f32, preserve: bool }, + /// Warm/cool and magenta/green shifts, -1..1. + TemperatureTint { temperature: f32, tint: f32 }, +} + +/// The colour adjustments of an effect list, composed in order. +#[derive(Clone, Debug, PartialEq)] +pub struct ColorMap(Vec); + +impl ColorMap { + /// `self`, then `next`. + pub fn then(&self, next: &ColorMap) -> ColorMap { + ColorMap(self.0.iter().chain(&next.0).cloned().collect()) + } + + /// 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 })) + } + + /// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is). + 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; + } + keep_model(c, Color::rgb(out[0], out[1], out[2])) + } + + /// Adjust an 8-bit sRGB pixel. + pub fn apply_rgb8(&self, [r, g, b]: [u8; 3]) -> [u8; 3] { + let out = self.apply_rgb([r, g, b].map(|v| v as f32 / 255.0)); + out.map(|v| (v * 255.0).round() as u8) + } +} + +/// The visible colour adjustments of `effects` as one map, `None` when there are none. +pub fn color_map(effects: &[Effect]) -> Option { + let v: Vec = + effects.iter().filter(|e| visible_adjustment(e)).filter_map(|e| Adjust::parse(&e.id, &merged_params(&e.id, &e.params))).collect(); + (!v.is_empty()).then_some(ColorMap(v)) +} + +/// A parameter in -100..100 as -1..1. +fn amount(p: &Value, key: &str) -> f32 { + (num(p, key, 0.0).clamp(-100.0, 100.0) / 100.0) as f32 +} + +/// A level 0..255 as 0..1. +fn level(p: &Value, key: &str, default: f64) -> f32 { + (num(p, key, default).clamp(0.0, 255.0) / 255.0) as f32 +} + +impl Adjust { + fn parse(id: &str, p: &Value) -> Option { + Some(match id { + "adjust.brightnessContrast" => Adjust::BrightnessContrast { brightness: amount(p, "brightness"), contrast: amount(p, "contrast") }, + "adjust.levels" => { + let (ib, iw) = (level(p, "inputBlack", 0.0), level(p, "inputWhite", 255.0)); + let (ob, ow) = (level(p, "outputBlack", 0.0), level(p, "outputWhite", 255.0)); + let gamma = num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32; + let f = |x: f32| { + let t = if iw > ib { + ((x - ib) / (iw - ib)).clamp(0.0, 1.0) + } else if x >= ib { + 1.0 + } else { + 0.0 + }; + ob + t.powf(1.0 / gamma) * (ow - ob) + }; + Adjust::Tone { channel: Channel::of(p), lut: sample(f) } + } + "adjust.curves" => { + let pts = curve_points(p.get("points")); + Adjust::Tone { channel: Channel::of(p), lut: sample(|x| curve_at(&pts, x)) } + } + "adjust.hueSaturation" => Adjust::HueSaturation { + hue: num(p, "hue", 0.0).clamp(-360.0, 360.0) as f32, + saturation: amount(p, "saturation"), + lightness: amount(p, "lightness"), + colorize: flag(p, "colorize", false), + }, + "adjust.shiftToColor" => Adjust::ShiftToColor { + target: color_param(p, Color::rgb(1.0, 0.5, 0.0)).to_rgb(), + amount: (num(p, "amount", 50.0).clamp(0.0, 100.0) / 100.0) as f32, + preserve: flag(p, "preserveLightness", true), + }, + "adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") }, + _ => return None, + }) + } + + fn apply(&self, [r, g, b]: [f32; 3]) -> [f32; 3] { + match self { + Adjust::BrightnessContrast { brightness, contrast } => { + // Brightness bends the tones (black and white stay), contrast spreads them from the middle. + let gamma = 2f32.powf(-brightness); + let k = if *contrast >= 0.0 { 1.0 / (1.0 - contrast * 0.99) } else { 1.0 + contrast }; + [r, g, b].map(|v| (v.clamp(0.0, 1.0).powf(gamma) - 0.5) * k + 0.5) + } + Adjust::Tone { channel, lut } => { + let f = |v: f32| lookup(lut, v); + match channel { + Channel::All => [f(r), f(g), f(b)], + Channel::One(i) => { + let mut c = [r, g, b]; + if let Some(v) = c.get_mut(*i) { + *v = f(*v); + } + c + } + } + } + Adjust::HueSaturation { hue, saturation, lightness, colorize } => { + let [h, s, l] = to_hsl([r, g, b]); + let (h, s) = if *colorize { + (hue.rem_euclid(360.0), (saturation + 1.0) / 2.0) + } else { + let s = if *saturation >= 0.0 { s / (1.0 - saturation * 0.99) } else { s * (1.0 + saturation) }; + ((h + hue).rem_euclid(360.0), s) + }; + let l = if *lightness >= 0.0 { l + (1.0 - l) * lightness } else { l * (1.0 + lightness) }; + from_hsl([h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)]) + } + Adjust::ShiftToColor { target, amount, preserve } => { + let to = if *preserve { + let [th, ts, _] = to_hsl(*target); + from_hsl([th, ts, to_hsl([r, g, b])[2]]) + } else { + *target + }; + let mix = |a: f32, b: f32| a + (b - a) * amount; + [mix(r, to[0]), mix(g, to[1]), mix(b, to[2])] + } + Adjust::TemperatureTint { temperature, tint } => { + let (t, m) = (temperature * 0.3, tint * 0.3); + [r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)] + } + } + } +} + +/// `f` sampled at [`LUT`] inputs 0..1. +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 { + 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)); + a + (b - a) * (x - i as 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)> { + let mut pts: Vec<(f64, f64)> = match v { + Some(Value::String(s)) => s + .split(|c: char| c.is_whitespace() || c == ';') + .filter_map(|pair| { + let (x, y) = pair.split_once(',')?; + Some((x.trim().parse().ok()?, y.trim().parse().ok()?)) + }) + .collect(), + Some(Value::Array(a)) => a.iter().filter_map(|xy| Some((xy.get(0)?.as_f64()?, xy.get(1)?.as_f64()?))).collect(), + _ => 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.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 { + return vec![(0.0, 0.0), (1.0, 1.0)]; + } + pts +} + +/// Curves' curve through `pts` ([`curve_points`]) at `x` (0..1): a monotone cubic, flat beyond +/// the end points. +pub fn curve_at(pts: &[(f32, f32)], x: f32) -> f32 { + let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return x }; + if x <= first.0 { + return first.1; + } + if x >= last.0 { + return last.1; + } + let n = pts.len(); + let slope = |i: usize| -> f32 { + match (pts.get(i), pts.get(i + 1)) { + (Some(a), Some(b)) if b.0 > a.0 => (b.1 - a.1) / (b.0 - a.0), + _ => 0.0, + } + }; + // Tangents: the harmonic mean of the neighbouring slopes, zero at extrema, so the curve never + // overshoots its points. + let tangent = |i: usize| -> f32 { + if i == 0 { + return slope(0); + } + if i + 1 >= n { + return slope(n - 2); + } + let (a, b) = (slope(i - 1), slope(i)); + if a * b <= 0.0 { 0.0 } else { 2.0 * a * b / (a + b) } + }; + let k = pts.windows(2).position(|w| x >= w[0].0 && x <= w[1].0).unwrap_or(0); + let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(k), pts.get(k + 1)) else { return x }; + let h = x1 - x0; + if h <= 0.0 { + return y0; + } + let t = (x - x0) / h; + let (m0, m1) = (tangent(k) * h, tangent(k + 1) * h); + let (t2, t3) = (t * t, t * t * t); + (2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1 +} + +/// HSL with hue in degrees, saturation and lightness 0..1. +fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] { + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let l = (max + min) / 2.0; + let d = max - min; + if d <= 1e-6 { + return [0.0, 0.0, l]; + } + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if max == r { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + [h * 60.0, s, l] +} + +fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] { + if s <= 0.0 { + return [l; 3]; + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let hue = |t: f32| { + let t = t.rem_euclid(1.0); + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + let h = h / 360.0; + [hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)] +} + +/// 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. +struct Live<'a, 'b> { + map: &'a ColorMap, + images: &'a mut ImageHook<'b>, +} + +impl ColorVisitor for Live<'_, '_> { + fn paint(&mut self, p: &mut Paint) -> bool { + map_paint(p, &|c| self.map.apply(c)) + } + fn color(&mut self, c: Color) -> Color { + self.map.apply(c) + } + fn image(&mut self, key: &str) -> Option { + (self.images)(key, self.map) + } +} + +/// `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 { + 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) + && let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = Arc::new(new); + } + } + } + for (i, item) in n.appearance.items.iter().enumerate() { + 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)); + it.effects_mut().retain(|e| !is_adjustment(&e.id)); + } + } + let map = match (color_map(&n.appearance.effects), outer) { + (Some(own), Some(outer)) => Some(own.then(outer)), + (own, None) => own, + (None, Some(outer)) => Some(outer.clone()), + }; + 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 }); + } + m +} + +/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in +/// `d` (named by their content), and a symbol instance stands for its symbol's art. +pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { + let art; + let src = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { + let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()); + art = crate::reshape::as_art(n, symbol.as_deref())?; + &art + } + _ => n, + }; + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Appearance, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + + fn map(id: &str, params: Value) -> 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 neutral_settings_change_nothing() { + for id in ADJUSTMENTS { + let m = color_map(&[Effect { id: id.into(), params: json!({"amount": 0}), visible: true }]).unwrap(); + for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] { + assert!(close(m.apply_rgb(c), c), "{id} {c:?} → {:?}", m.apply_rgb(c)); + } + } + let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4); + assert_eq!(map("adjust.levels", json!({})).apply(cmyk), cmyk, "an untouched colour keeps its exact values"); + } + + #[test] + fn adjustments_move_colours_the_expected_way() { + let grey = [0.5, 0.5, 0.5]; + assert!(map("adjust.brightnessContrast", json!({"brightness": 50})).apply_rgb(grey)[0] > 0.6); + assert!(map("adjust.brightnessContrast", json!({"contrast": 50})).apply_rgb([0.7; 3])[0] > 0.8); + assert!(close(map("adjust.levels", json!({"inputBlack": 64, "inputWhite": 192})).apply_rgb([0.25, 0.5, 0.75]), [0.0, 0.5, 1.0])); + assert!(close(map("adjust.levels", json!({"outputWhite": 128, "channel": "red"})).apply_rgb([1.0; 3]), [0.502, 1.0, 1.0])); + // An S curve darkens the shadows and lightens the highlights, through its points exactly. + let s = map("adjust.curves", json!({"points": "0,0 64,32 192,224 255,255"})); + assert!(close(s.apply_rgb([64.0 / 255.0; 3]), [32.0 / 255.0; 3])); + assert!(s.apply_rgb([0.85; 3])[0] > 0.9); + let arr = map("adjust.curves", json!({"points": [[0, 255], [255, 0]]})); + assert!(close(arr.apply_rgb([0.0, 1.0, 0.25]), [1.0, 0.0, 0.75]), "an inverting curve"); + assert!(close(map("adjust.hueSaturation", json!({"hue": 120})).apply_rgb([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0])); + assert!(close(map("adjust.hueSaturation", json!({"saturation": -100})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3])); + assert!(close(map("adjust.hueSaturation", json!({"saturation": 100})).apply_rgb(grey), grey), "greys stay grey"); + assert!(close(map("adjust.hueSaturation", json!({"colorize": true, "hue": 240, "saturation": 100})).apply_rgb(grey), [0.0, 0.0, 1.0])); + let shift = map("adjust.shiftToColor", json!({"color": "#0000ff", "amount": 100, "preserveLightness": false})); + assert!(close(shift.apply_rgb([1.0, 1.0, 0.0]), [0.0, 0.0, 1.0])); + let warm = map("adjust.temperatureTint", json!({"temperature": 100})).apply_rgb(grey); + assert!(warm[0] > grey[0] && warm[2] < grey[2]); + // Effects compose in stack order. + let both = map("adjust.levels", json!({"outputWhite": 128})).then(&map("adjust.brightnessContrast", json!({"brightness": 100}))); + assert!(close(both.apply_rgb([1.0; 3]), [(128.0f32 / 255.0).sqrt(); 3])); + } + + #[test] + fn adjust_recolours_paints_members_and_strips_the_effects() { + let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let leaf = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red.clone())); + 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(); + 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()); + 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(); + 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()); + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index afd2edb85..893d21259 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -6,19 +6,21 @@ //! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills, //! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects //! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). +//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images +//! become images of their own). //! //! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an //! object of its own, strokes turned into filled art by the caller (their outlines or brush art). use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use crate::group::{has_own_paint, paints}; use crate::{ - GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline, - reshape, + GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, + has_pathfinder, is_geometry, needs_outline, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -37,10 +39,12 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { - has_crop_marks(n) + has_adjustment(n) + || has_crop_marks(n) || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) + || n.projection().is_some() || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) || n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c))) } @@ -78,12 +82,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if !needs_bake(n) { return None; } + // Colour adjustments: the object recoloured (inside it too), then baked as it is. + if has_adjustment(n) + && let Some(m) = adjust_in_document(d, n) + { + return Some(bake_node(d, &m).unwrap_or(m)); + } // Crop marks: the object and its marks. if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } // Type, images, symbol instances and live objects: reshaped through their outlines. - if needs_outline(n) && has_geometry(&n.appearance.effects) { + if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) { let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, @@ -272,6 +282,48 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) { } } +/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an +/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path +/// painted by that fill and every visible stroke without a brush its filled outline in the +/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as +/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None` +/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing. +pub fn bake_appearance(n: &Node) -> Option { + if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) { + return None; + } + let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none()); + let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))); + if !geometric && !n.appearance.items.iter().any(outlined) { + return None; + } + let (g, rule, ctx, mut m) = leaf_base(n)?; + let piece = |path: PathData, rule: FillRule, item: AppearanceItem| { + let mut p = Node::path(n.id, path, Appearance { items: vec![item], ..Default::default() }); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Arc::new(p) + }; + let mut pieces = vec![]; + for item in n.appearance.items.iter().filter(|i| paints(i)) { + let ig = apply(item_effects(item), &g, &ctx.item(item)); + let mut it = item.clone(); + clear_item_effects(&mut it); + pieces.push(match &it { + AppearanceItem::Stroke(st) if st.brush.is_none() => { + let fill = FillLayer { opacity: st.opacity, blend: st.blend, ..FillLayer::new(st.paint.clone()) }; + piece(crate::stroke::outline_region(&ig, rule, st), FillRule::NonZero, AppearanceItem::Fill(fill)) + } + _ => piece(ig, rule, it), + }); + } + m.appearance.items.clear(); + m.appearance.contents_index = None; + m.kind = NodeKind::Group { children: pieces, clip: false }; + Some(m) +} + /// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the /// document has none (export it as is). pub fn bake_document(doc: &Document) -> Option { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index bcd0645f5..76b33f8c2 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -18,15 +18,23 @@ //! art painting its members (its raster effects then apply to the composite). //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! PDF writers. +//! - **Plug-in effects** (`plugin.`, Effect › Plug-ins) are geometry effects run by an +//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like +//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is. +//! - **Colour adjustments** (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, +//! Temperature/Tint) recolour what an object paints with: [`adjust`] evaluates them for the +//! renderer and the exporters. //! //! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash //! noise (`seed` parameter, default 0). #![forbid(unsafe_code)] +mod adjust; mod bake; mod clip; mod distort; mod group; +mod live; mod marks; mod raster; mod reshape; @@ -44,12 +52,14 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; -pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment}; +pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake}; pub use clip::clip_outline; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints, pathfinder_children, }; +pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use raster::{RasterFx, outset, raster_effects}; pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape}; @@ -125,6 +135,8 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"]; const WARP: &[&str] = &["Effect", "Warp"]; const BLUR: &[&str] = &["Effect", "Blur"]; const PATHFINDER: &[&str] = &["Effect", "Pathfinder"]; +const PLUGINS: &[&str] = &["Effect", "Plug-ins"]; +const ADJUST: &[&str] = &["Effect", "Color Adjustments"]; /// The warp styles in Illustrator's Style menu order: (id suffix, label). pub const WARP_STYLES: [(&str, &str); 15] = [ @@ -254,6 +266,50 @@ pub fn effect_catalog() -> Vec { r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), ]; + v.extend([ + g( + "adjust.brightnessContrast", + "Brightness/Contrast…", + ADJUST, + "{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)", + json!({"brightness": 0.0, "contrast": 0.0}), + ), + g( + "adjust.curves", + "Curves…", + ADJUST, + "{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"points": "0,0 128,128 255,255", "channel": "rgb"}), + ), + g( + "adjust.hueSaturation", + "Hue/Saturation…", + ADJUST, + "{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does", + json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}), + ), + g( + "adjust.levels", + "Levels…", + ADJUST, + "{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does", + json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}), + ), + g( + "adjust.shiftToColor", + "Shift to Color…", + ADJUST, + "{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does", + json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}), + ), + g( + "adjust.temperatureTint", + "Temperature/Tint…", + ADJUST, + "{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does", + json!({"temperature": 0.0, "tint": 0.0}), + ), + ]); for (suffix, label) in WARP_STYLES { let id: &'static str = match suffix { "arc" => "warp.arc", @@ -293,21 +349,47 @@ fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInf INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect()) } -/// Catalogue entry for `id`. +/// Catalogue entry for `id` (built-in, or an installed plug-in effect). pub fn effect_info(id: &str) -> Option { - catalog_index().get(id).cloned() + catalog_index().get(id).cloned().or_else(|| plugin_info(id)) +} + +/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.`). +fn plugin_info(id: &str) -> Option { + use vectorcraft_plugins::registry::intern; + let p = vectorcraft_plugins::effect::installed(id)?; + let m = p.manifest(); + let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" }; + Some(EffectInfo { + id: intern(id), + label: intern(&format!("{}{dots}", m.name)), + menu: PLUGINS, + params: intern(&m.params_doc()), + defaults: Value::Object(m.defaults()), + raster: false, + lengths: Lengths::default(), + }) +} + +/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id. +pub fn plugin_effects() -> Vec { + vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect() } /// Dialog defaults for `id` (`None` for unknown effects). pub fn default_params(id: &str) -> Option { - catalog_index().get(id).map(|e| e.defaults.clone()) + match catalog_index().get(id) { + Some(e) => Some(e.defaults.clone()), + None => plugin_info(id).map(|e| e.defaults), + } } /// `params` layered over the defaults of `id` (unknown keys are kept). pub fn merged_params(id: &str, params: &Value) -> Value { let mut out = match catalog_index().get(id).map(|e| &e.defaults) { Some(Value::Object(m)) => m.clone(), - _ => Map::new(), + Some(_) => Map::new(), + None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(), }; if let Value::Object(p) = params { for (k, v) in p { @@ -319,7 +401,9 @@ pub fn merged_params(id: &str, params: &Value) -> Value { /// A new effect record with default parameters (overridden by `params`). pub fn new_effect(id: &str, params: &Value) -> Option { - catalog_index().get(id)?; + if !catalog_index().contains_key(id) { + vectorcraft_plugins::effect::installed(id)?; + } Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true }) } @@ -346,9 +430,15 @@ pub fn is_raster(id: &str) -> bool { matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") } -/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].) +/// 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 +/// leaves the geometry as it is). pub fn is_geometry(id: &str) -> bool { - !is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id) + !is_raster(id) + && !is_pathfinder(id) + && !is_adjustment(id) + && id != CROP_MARKS + && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } /// Any visible geometry effect in the list? @@ -420,6 +510,9 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { use util::*; + if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { + return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone()); + } match id { "distort.freeDistort" => distort::free_distort(path, b, p), "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs new file mode 100644 index 000000000..7d03068c0 --- /dev/null +++ b/crates/effects/src/live.rs @@ -0,0 +1,57 @@ +//! Live objects (blends, envelopes, gradient meshes, repeats) evaluated alike for the renderer and +//! every exporter, with what the pure evaluation in [`vectorcraft_doc::live`] can't do itself +//! ([`Hooks`]): type inside an envelope distorts as its glyph outlines, run by run +//! ([`outline_text`]); symbol instances as their symbol's art; with Distort Appearance strokes and +//! geometry effects bend with the art ([`bake_appearance`]); with Distort Pattern Fills the pattern +//! tiles do. + +use std::sync::Arc; + +use vectorcraft_doc::live::{self, Hooks, Outliner}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect}; + +use crate::{bake_appearance, outline_text}; + +/// The text outliner live evaluation uses: type as its glyph outlines, one path per run in the +/// run's paint ([`outline_text`]). +pub fn text_outliner() -> Outliner<'static> { + Some(&outline_text) +} + +/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance. +fn outline(doc: Option<&Document>, n: &Node) -> Option { + match &n.kind { + NodeKind::SymbolInstance { symbol, xf } => { + let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?; + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + Some(Node::group(n.id, vec![Arc::new(art)])) + } + _ => outline_text(n), + } +} + +/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a +/// document, symbol instances and pattern tiles stay as they are). +fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { + let outline = |n: &Node| outline(doc, n); + let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); + f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) }) +} + +/// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). +pub fn expand_live(doc: Option<&Document>, n: &Node) -> Vec { + with_hooks(doc, |h| live::expand_live_hooks(n, h)) +} + +/// Live object `n` evaluated into a plain group ([`live::expanded_group_hooks`]): Expand. +pub fn expanded_live_group(doc: Option<&Document>, n: &Node) -> Node { + with_hooks(doc, |h| live::expanded_group_hooks(n, h)) +} + +/// `n` with every live object in it replaced by its evaluated art ([`live::expand_deep_hooks`]): +/// what the exporters write for live objects. +pub fn expand_live_deep(doc: Option<&Document>, n: &Node) -> Node { + with_hooks(doc, |h| live::expand_deep_hooks(n, h)) +} diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index f18ba7e65..ed966f54f 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -64,7 +64,8 @@ pub fn blend_mode(s: &str) -> BlendMode { BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal) } -fn color(p: &Value, default: Color) -> Color { +/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object). +pub(crate) fn color_param(p: &Value, default: Color) -> Color { match p.get("color") { Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default), Some(Value::Array(a)) if a.len() >= 3 => { @@ -95,19 +96,19 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { dx: num(&p, "x", 7.0).clamp(-1e4, 1e4), dy: num(&p, "y", 7.0).clamp(-1e4, 1e4), blur, - color: color(&p, Color::BLACK), + color: color_param(&p, Color::BLACK), }, "stylize.outerGlow" => RasterFx::OuterGlow { mode: blend_mode(text(&p, "mode", "screen")), opacity: opacity(&p), blur, - color: color(&p, Color::rgb(1.0, 1.0, 0.0)), + color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)), }, "stylize.innerGlow" => RasterFx::InnerGlow { mode: blend_mode(text(&p, "mode", "screen")), opacity: opacity(&p), blur, - color: color(&p, Color::WHITE), + color: color_param(&p, Color::WHITE), center: text(&p, "source", "edge").eq_ignore_ascii_case("center"), }, "stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) }, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ad91e8fff..0efcb90e8 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -113,7 +113,7 @@ fn outline_nested_text(n: &mut Node) { /// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` /// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if geometry_effects(n).is_empty() || !needs_outline(n) { + if (geometry_effects(n).is_empty() && n.projection().is_none()) || !needs_outline(n) { return None; } as_art(n, symbol_art) @@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { } /// [`reshape`] without its checks (no geometry effects: just the art). -fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { +pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -146,6 +146,10 @@ fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { _ => outline_art(n, symbol_art)?, }; reshape_leaves(&mut art, &fx); + // Type and symbols in perspective: the art projected onto their plane. + if let Some(h) = n.projection() { + project_leaves(&mut art, &h); + } // The art is a group with `n`'s id (a clip group for an image). let mut out = art; out.name = n.name.clone(); @@ -159,6 +163,33 @@ fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { Some(out) } +/// Map every path under `art` (and its gradients) through the projective map `h`, curves split +/// finely enough to follow it. +fn project_leaves(art: &mut Node, h: &vectorcraft_geom::Homography) { + let f = |p: vectorcraft_geom::Point| h.apply(p).unwrap_or(p); + let piece = art.geometric_bounds().map_or(1.0, |b| (b.width().max(b.height()) / 64.0).max(0.25)); + let project = |path: &mut PathData| *path = vectorcraft_doc::live::map_nonlinear(path, piece, f); + for_each_leaf(art, &mut |leaf| { + leaf.pin_gradients(); + leaf.appearance.warp_gradients(&|p| h.affine_at(p).unwrap_or(vectorcraft_geom::Affine::IDENTITY)); + match &mut leaf.kind { + NodeKind::Path { path, live, .. } => { + project(path); + *live = None; + } + NodeKind::Compound { children, .. } => { + for c in children.iter_mut() { + if let NodeKind::Path { path, live, .. } = &mut Arc::make_mut(c).kind { + project(path); + *live = None; + } + } + } + _ => {} + } + }); +} + /// The visible geometry effects of `n`'s own effect list, in order. pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect() diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index a8ae1adfc..69a493bb6 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,6 +24,7 @@ vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } vectorcraft-metafile = { workspace = true } +vectorcraft-plugins = { workspace = true } image = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } @@ -35,3 +36,4 @@ log = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } proptest = { workspace = true } +wat = { workspace = true } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 9536d00f5..01b07a51f 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -13,7 +13,9 @@ use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation}; use vectorcraft_color::{Color, Paint, keep_model}; use vectorcraft_doc::live::lerp_color; use vectorcraft_doc::pattern::PatternDef; -use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind}; +pub(crate) use vectorcraft_doc::recolor::map_paint; +use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node}; +use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind}; use super::edit::selected_roots; use super::*; @@ -144,30 +146,6 @@ fn gray_level(c: Color) -> f32 { /// gradient stop): `(colour, link, tint)`, true when it changed them. pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option, &mut f32) -> bool + 'a; -/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient -/// that changes is no longer its gradient swatch's. Returns whether anything changed. -fn map_links(p: &mut Paint, f: &LinkMap) -> bool { - let changed = p.map_links(&mut |c, l, t| f(c, l, t)); - if changed && let Paint::Gradient(g) = p { - g.swatch = None; - } - changed -} - -/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether -/// anything changed. -pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool { - map_links(p, &|c, link, _| { - let n = f(*c); - if n == *c { - return false; - } - *c = n; - *link = None; - true - }) -} - /// The identity colour map (a pass that only maps linked colours). fn same(c: Color) -> Color { c @@ -258,68 +236,24 @@ impl<'a> Recolor<'a> { out } - /// `n` recoloured, or `None` when nothing changed. + /// `n` recoloured (through the shared colour visitor), or `None` when nothing changed. fn subtree(&mut self, d: &Document, n: &Node) -> Option { - let before = self.changed; - let mut m = n.clone(); - self.node(d, &mut m); - (self.changed > before).then_some(m) - } - - fn node(&mut self, d: &Document, n: &mut Node) { let Scope { fill, stroke, images, .. } = self.scope; - for it in &mut n.appearance.items { - match it { - AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint), - AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint), - _ => {} - } - } - match &mut n.kind { - NodeKind::Text(t) => { - for r in &mut t.runs { - if fill { - self.paint(d, &mut r.style.fill); - } - if stroke { - self.paint(d, &mut r.style.stroke); - } - } - } - NodeKind::Mesh(m) if fill => { - let mut ch = false; - for p in &mut m.points { - let c = (self.f)(p.color); - ch |= c != p.color; - p.color = c; - } - self.changed += ch as usize; - } - NodeKind::Image(im) if images && im.link.is_none() => { - if let Some(k) = self.image(d, &im.key) { - im.key = k; - self.changed += 1; - } - } - _ => { - for c in n.children_mut().into_iter().flatten() { - if let Some(new) = self.subtree(d, c) { - *c = Arc::new(new); - } - } - } - } + let mut m = n.clone(); + let changed = recolor_node(&mut m, Reach { fill, stroke, images }, &mut Visit { pass: self, d }); + self.changed += changed; + (changed > 0).then_some(m) } - fn paint(&mut self, d: &Document, p: &mut Paint) { - let changed = match p { + /// Recolour one paint: true when it changed. + fn paint(&mut self, d: &Document, p: &mut Paint) -> bool { + match p { Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(), _ => match self.links { Some(f) => map_links(p, f), None => map_paint(p, self.f), }, - }; - self.changed += changed as usize; + } } /// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes. @@ -370,6 +304,24 @@ impl<'a> Recolor<'a> { } } +/// What a pass over document `d` does to each colour the shared visitor meets. +struct Visit<'p, 'a> { + pass: &'p mut Recolor<'a>, + d: &'p Document, +} + +impl ColorVisitor for Visit<'_, '_> { + fn paint(&mut self, p: &mut Paint) -> bool { + self.pass.paint(self.d, p) + } + fn color(&mut self, c: Color) -> Color { + (self.pass.f)(c) + } + fn image(&mut self, key: &str) -> Option { + self.pass.image(self.d, key) + } +} + pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result { run_pass(s, p, label, Recolor::new(Scope::of(p), f)) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 5597406ac..480cf9c1d 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -50,7 +50,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}", + "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}", has_doc, text_create ), @@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { let y = f64_req(p, "y", "text.create")?; let text = str_param(p, "text").unwrap_or(""); let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); if let Some(a) = p.get("area") { let w = f64_or(a, "width", 200.0); let h = f64_or(a, "height", 100.0); diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs new file mode 100644 index 000000000..a0dfe6d2e --- /dev/null +++ b/crates/engine/src/cmd/cut.rs @@ -0,0 +1,397 @@ +//! Cutting tools' commands: Mirror & Cut, Line Cut and Rectangle Cut. +//! +//! They work on the selected paths and compound paths (inside groups too; a compound path is cut +//! as one shape): filled shapes through `vectorcraft-pathops` booleans against a half-plane or a +//! rectangle, open paths by splitting them where they cross the line or the rectangle's edge (the +//! Knife and Path Eraser helpers). Each command is one undo step; the tools in +//! `vectorcraft_tools::cut` preview it live while dragging. + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, Rect, SubPath, Vec2}; +use vectorcraft_pathops as po; +use vectorcraft_pathops::BoolOp; + +use super::draw2::{components, erase_outline, ids_json, replace_with_pieces}; +use super::edit::selected_roots; +use super::pathops::node_path; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "path.mirrorCut", + "Mirror & Cut", + [], + None, + "{axis?: \"vertical\"|\"horizontal\"|\"free\" (vertical), from?: [x,y], to?: [x,y] (free: the axis through both; vertical/horizontal: the axis through `from`, default the selection's centre), keep?: \"left\"|\"right\"|\"top\"|\"bottom\" (the side kept; default left, or top for a horizontal-ish axis)} cut the selected paths and compound paths at the axis, reflect the kept half across it and join both halves into one closed shape (open paths are joined where they meet the axis); art wholly on the other side is deleted, as one undo step → {ids, removed}", + has_selection, + mirror_cut + ), + cmd!( + "path.lineCut", + "Line Cut", + [], + None, + "{from: [x,y], to: [x,y]} split the selected paths and compound paths along the line through both points, extended across the art: filled shapes become their pieces on each side (holes kept), open paths split where they cross; as one undo step → {ids, removed: 0}", + has_selection, + line_cut + ), + cmd!( + "path.rectCut", + "Rectangle Cut", + [], + None, + "{rect: [x, y, width, height]} or {from: [x,y], to: [x,y]} (opposite corners) crop the selected paths and compound paths to the rectangle as real geometry (not a clipping mask): filled shapes are intersected with it, open paths keep their parts inside, art wholly outside is deleted; as one undo step → {ids, removed}", + has_selection, + rect_cut + ), + ] +} + +/// Largest coordinate the cut commands accept (keeps the half-plane polygons finite). +const MAX_COORD: f64 = 1e7; +/// How close an open path's end must be to the axis to be joined to its reflection. +const AXIS_TOL: f64 = 1e-3; + +/// A straight line through `p` along the unit vector `d`. +#[derive(Clone, Copy, Debug)] +struct Axis { + p: Point, + d: Vec2, +} + +impl Axis { + /// The line through `a` and `b` (`None` when they coincide). + fn through(a: Point, b: Point) -> Option { + let v = b - a; + let len = v.hypot(); + (len > 1e-9 && len.is_finite()).then(|| Axis { p: a, d: v / len }) + } + + /// Signed distance of `q` from the line: positive on the side `d` points to when turned a + /// quarter turn clockwise on screen (y pointing down). + fn side(&self, q: Point) -> f64 { + self.d.cross(q - self.p) + } + + /// `q` moved onto the line. + fn project(&self, q: Point) -> Point { + self.p + self.d * self.d.dot(q - self.p) + } + + /// The reflection across the line. + fn reflection(&self) -> Affine { + let (x, y) = (self.d.x, self.d.y); + let (a, b, d) = (2.0 * x * x - 1.0, 2.0 * x * y, 2.0 * y * y - 1.0); + let p = self.p; + Affine::new([a, b, b, d, p.x - (a * p.x + b * p.y), p.y - (b * p.x + d * p.y)]) + } + + /// A polygon covering everything in `around` on one side of the line (`sign` > 0: where + /// [`Axis::side`] is positive). + fn half_plane(&self, sign: f64, around: Rect) -> PathData { + let r = around.width() + around.height() + self.p.distance(around.center()) + 10.0; + let n = Vec2::new(-self.d.y, self.d.x) * sign.signum() * 2.0 * r; + let (a, b) = (self.p - self.d * r, self.p + self.d * r); + polygon(&[a, b, b + n, a + n]) + } +} + +fn polygon(pts: &[Point]) -> PathData { + PathData::single(SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true)) +} + +/// A point param that must be finite and within [`MAX_COORD`]. +fn point_req(p: &Value, key: &str, cmd: &str) -> Result { + let q = point_param(p, key).ok_or_else(|| bad(cmd, format!("missing point `{key}` ([x, y])")))?; + if q.x.abs() > MAX_COORD || q.y.abs() > MAX_COORD { + return Err(bad(cmd, format!("`{key}` is out of range"))); + } + Ok(q) +} + +/// The editable paths and compound paths in the selection (inside groups and layers too; a +/// compound path is one target; guides and clipping paths are left alone). +fn cut_targets(s: &Session) -> Result> { + fn visit(n: &Node, out: &mut Vec) { + match &n.kind { + NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)), + _ => {} + } + } + let roots = selected_roots(s)?; + let doc = &s.doc()?.doc; + let mut out = vec![]; + for id in roots { + if let Some(n) = doc.node(id) { + visit(n, &mut out); + } + } + out.dedup(); + out.retain(|id| doc.is_editable(*id)); + Ok(out) +} + +/// What a cut does to one object: its new pieces (none deletes it). +type Plan = Vec<(NodeId, Vec)>; + +/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step. +fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option>) -> Result { + let targets = cut_targets(s)?; + if targets.is_empty() { + return Err(bad(cmd, "select paths or compound paths")); + } + let doc = &s.doc()?.doc; + let mut plan: Plan = vec![]; + for id in targets { + let Some((pd, rule)) = doc.node(id).and_then(node_path) else { continue }; + if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) { + return Err(bad(cmd, "paths contain invalid coordinates")); + } + if let Some(pieces) = cut(&pd, rule) { + plan.push((id, pieces)); + } + } + apply(s, label, plan) +} + +fn apply(s: &mut Session, label: &str, plan: Plan) -> Result { + if plan.is_empty() { + return Ok(json!({ "ids": [], "removed": 0 })); + } + let (ids, removed) = s.edit(label, |d: &mut Document, sel| { + let (mut all, mut removed) = (vec![], 0); + for (id, pieces) in plan { + removed += pieces.is_empty() as usize; + all.extend(replace_with_pieces(d, id, pieces)?); + } + sel.set(all.iter().copied()); + Ok((all, removed)) + })?; + let mut out = ids_json(&ids); + out["removed"] = json!(removed); + Ok(out) +} + +/// Bounds of `pd` grown a little (the reference box of the half-planes). +fn reach(pd: &PathData) -> Rect { + pd.control_bounds().unwrap_or_default().inflate(1.0, 1.0) +} + +/// `pd ∩ region`, dropping slivers. +fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData { + let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect); + if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r } +} + +// ---------- Line Cut ---------- + +fn line_cut(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.lineCut"; + let axis = Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?; + run_cut(s, C, "Line Cut", &|pd, rule| split_by_line(pd, rule, &axis)) +} + +/// `pd` split by the line: the pieces on both sides, `None` when the line misses it. +fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option> { + if pd.is_closed() { + let around = reach(pd); + let a = intersect(pd, rule, &axis.half_plane(1.0, around)); + let b = intersect(pd, rule, &axis.half_plane(-1.0, around)); + if a.is_empty() || b.is_empty() { + return None; + } + return Some(components(&a).into_iter().chain(components(&b)).collect()); + } + // Open paths: the parts on each side (each pass removes the other side). + let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?; + let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?; + Some(if pd.subpaths.len() == 1 { + a.into_iter().chain(b).collect() + } else { + vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())] + }) +} + +// ---------- Rectangle Cut ---------- + +fn rect_cut(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.rectCut"; + let rect = match p.get("rect") { + Some(v) => { + let a: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) }; + Rect::new(x, y, x + w, y + h).abs() + } + None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?), + }; + let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); + if !finite || rect.width() <= 1e-9 || rect.height() <= 1e-9 { + return Err(bad(C, "the rectangle must have a width and a height")); + } + run_cut(s, C, "Rectangle Cut", &|pd, rule| crop(pd, rule, rect)) +} + +/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside. +fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option> { + let b = pd.bounds()?; + if rect.inflate(1e-9, 1e-9).contains_rect(b) { + return None; + } + if pd.is_closed() { + let r = + intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)])); + return Some(if r.is_empty() { vec![] } else { vec![r] }); + } + let inside = rect.inflate(1e-9, 1e-9); + let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?; + Some(if pd.subpaths.len() == 1 || pieces.is_empty() { + pieces + } else { + vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())] + }) +} + +// ---------- Mirror & Cut ---------- + +fn mirror_cut(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.mirrorCut"; + let kind = str_param(p, "axis").unwrap_or("vertical"); + let at = || -> Result { + match p.get("from") { + Some(_) => point_req(p, "from", C), + None => { + let roots = selected_roots(s)?; + let b = s.doc()?.doc.bounds_of(&roots, false).ok_or_else(|| bad(C, "the selection has no bounds"))?; + Ok(b.center()) + } + } + }; + let axis = match kind { + "vertical" => { + let a = at()?; + Axis { p: a, d: Vec2::new(0.0, 1.0) } + } + "horizontal" => { + let a = at()?; + Axis { p: a, d: Vec2::new(1.0, 0.0) } + } + "free" => Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?, + other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))), + }; + let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?; + run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign))) +} + +/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs +/// more across than down). +fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result { + let keep = keep.unwrap_or(if axis.d.x.abs() > axis.d.y.abs() { "top" } else { "left" }); + let toward = match keep { + "left" => Vec2::new(-1.0, 0.0), + "right" => Vec2::new(1.0, 0.0), + "top" => Vec2::new(0.0, -1.0), + "bottom" => Vec2::new(0.0, 1.0), + other => return Err(format!("unknown side `{other}` (left|right|top|bottom)")), + }; + let s = axis.d.cross(toward); + if s.abs() < 1e-6 { + return Err(format!("`{keep}` is not a side of this axis")); + } + Ok(s.signum()) +} + +/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side). +fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec { + let m = axis.reflection(); + if pd.is_closed() { + let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd))); + if kept.is_empty() { + return vec![]; + } + let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union); + return if joined.is_empty() { vec![] } else { vec![joined] }; + } + let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) { + Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(), + None => pd.subpaths.clone(), + }; + let subs: Vec = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect(); + if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] } +} + +/// An open run and its reflection, joined where its ends touch the axis (closed when both do); +/// the two apart when neither does. +fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec { + let on = |a: Option<&Anchor>| a.is_some_and(|a| axis.side(a.p).abs() <= AXIS_TOL); + let (start_on, end_on) = (on(sp.anchors.first()), on(sp.anchors.last())); + if sp.closed || sp.anchors.len() < 2 || !(start_on || end_on) { + return vec![sp.clone(), SubPath::new(sp.anchors.iter().map(|a| a.transform(m)).collect(), sp.closed)]; + } + // The run ends on the axis; the reflection comes back from there. + let mut run = sp.clone(); + if !end_on { + run.reverse(); + } + let Some((last, body)) = run.anchors.split_last() else { return vec![] }; + let q = axis.project(last.p); + let mut anchors = body.to_vec(); + anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in)); + anchors.extend(body.iter().rev().map(|a| Anchor::with_handles(m * a.p, m * a.h_out, m * a.h_in))); + let closed = start_on && end_on && anchors.len() > 2; + if closed && let (Some(first), Some(back)) = (anchors.first().copied(), anchors.pop()) { + // The start and its reflection meet on the axis: one closed shape. + if let Some(f) = anchors.first_mut() { + *f = Anchor::with_handles(axis.project(first.p), back.h_in, first.h_out); + } + } + vec![SubPath::new(anchors, closed)] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reflection_maps_across_the_axis() { + let a = Axis::through(Point::new(0.0, 0.0), Point::new(10.0, 10.0)).unwrap(); + let q = a.reflection() * Point::new(5.0, 0.0); + assert!((q.x - 0.0).abs() < 1e-9 && (q.y - 5.0).abs() < 1e-9, "{q:?}"); + let v = Axis { p: Point::new(100.0, 0.0), d: Vec2::new(0.0, 1.0) }; + let q = v.reflection() * Point::new(90.0, 7.0); + assert!((q.x - 110.0).abs() < 1e-9 && (q.y - 7.0).abs() < 1e-9); + } + + #[test] + fn keep_names_a_side_of_the_axis() { + let v = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) }; + let left = keep_sign(&v, Some("left")).unwrap(); + assert!(v.side(Point::new(-5.0, 0.0)) * left > 0.0); + assert_eq!(keep_sign(&v, None).unwrap(), left); + assert!(keep_sign(&v, Some("top")).is_err()); + let h = Axis { p: Point::ZERO, d: Vec2::new(1.0, 0.0) }; + let top = keep_sign(&h, None).unwrap(); + assert!(h.side(Point::new(0.0, -5.0)) * top > 0.0); + } + + #[test] + fn open_half_mirrors_into_a_closed_shape() { + // An arch from the axis and back to it: its reflection closes it. + let axis = Axis { p: Point::new(50.0, 0.0), d: Vec2::new(0.0, 1.0) }; + let sp = SubPath::new( + vec![Anchor::corner(Point::new(50.0, 0.0)), Anchor::corner(Point::new(10.0, 50.0)), Anchor::corner(Point::new(50.0, 100.0))], + false, + ); + let out = mirror_open(&sp, &axis, axis.reflection()); + assert_eq!(out.len(), 1); + assert!(out[0].closed); + assert_eq!(out[0].anchors.len(), 4); + assert!(out[0].anchors.iter().any(|a| (a.p.x - 90.0).abs() < 1e-9)); + // One end on the axis: one open path through it. + let sp = SubPath::new(vec![Anchor::corner(Point::new(10.0, 0.0)), Anchor::corner(Point::new(50.0, 40.0))], false); + let out = mirror_open(&sp, &axis, axis.reflection()); + assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3)); + } +} diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index faf9a1c51..6654df578 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -7,13 +7,13 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId, NodeKind, WidthProfile}; -use vectorcraft_geom::Point; -use vectorcraft_tools::distort::liquify::{LiquifyParams, apply_stroke, dabs}; -use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; -use vectorcraft_tools::distort::{arap, collect_points, mesh_for, warp_node_with}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PuppetPin, WidthProfile}; +use vectorcraft_geom::{Affine, Homography, PathData, Point, Rect}; +use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sample, reach_bounds}; +use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; +use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; -use super::edit::selected_roots; +use super::edit::{duplicate_in, selected_roots}; use super::*; use crate::EngineError; @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "Liquify", [], None, - "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y]…] (the brush stroke), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), detail? (1..10), simplify? (0..100), rate? (twirl °), complexity?, horizontal?, vertical?, affectAnchors?, affectIn?, affectOut?, ids? (default: selection, else every path the brush touches)}", + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify? (0..100) and simplifyOn? (default true; warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids? (default: selection, else every path the brush touches)}", has_doc, liquify ), @@ -69,16 +69,25 @@ pub fn specs() -> Vec { "Puppet Warp", [], None, - "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), expand?: pt} as-rigid-as-possible mesh warp of anchors and handles", + "{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)", has_doc, puppet_warp ), + cmd!( + "object.puppetWarp.pins", + "Puppet Warp Pins", + [], + None, + "{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp", + has_doc, + puppet_pins + ), cmd!( "perspective.grid.set", "Define Perspective Grid", [], None, - "{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?} merge into the document's grid", + "{kind?: 1|2|3, origin?: [x,y], horizon?, vpLeft?, vpRight?, vpVertical?: [x,y], distance?, cell?, extent?, height?, visible?, plane?, leftOffset?, rightOffset?, groundOffset?: pt (planes moved along their normals), reproject?: bool} merge into the document's grid. Objects in perspective stay where they are, as in the reference app; with reproject they move with the grid (each keeps its place on its plane)", has_doc, grid_set ), @@ -87,9 +96,9 @@ pub fn specs() -> Vec { "Perspective Grid Preset", [], None, - "{kind: 1|2|3} reset the grid to the one/two/three-point preset for the first artboard (and show it)", + "{kind: 1|2|3 (its normal view) | name: a preset (see perspective.presets.list)} reset the grid to the preset fitted to the first artboard (and show it; attached objects stay attached)", has_doc, - grid_preset + super::perspgrid::grid_preset ), cmd!( "perspective.grid.show", @@ -124,7 +133,7 @@ pub fn specs() -> Vec { "Move in Perspective", [], None, - "{ids?, from: [x,y], to: [x,y], plane?} slide objects within their plane (unattached objects attach to `plane`/the active plane first)", + "{ids?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it", has_doc, persp_move ), @@ -133,10 +142,55 @@ pub fn specs() -> Vec { "Draw in Perspective", [], None, - "{command: shape.* id, params, plane?} run a shape command and attach the result to the plane", + "{command: shape.* id, params, plane?, snap?: bool (default: Snap to Grid)} run a shape command and attach the result to the plane (snapping its corners to gridlines within a quarter cell)", has_doc, persp_draw ), + cmd!( + "perspective.transform", + "Transform in Perspective", + [], + None, + "{ids?, matrix: [a,b,c,d,e,f] (affine map in plane coordinates, points: u along the plane, v up or away), depth?: pt to move along the plane's normal (default 0), copy?: bool, plane?} transform objects within their own planes (Perspective Selection tool handles; unattached objects attach to `plane`/the active plane first); Transform Again repeats it → {ids}", + has_doc, + persp_transform + ), + cmd!( + "perspective.nudge", + "Nudge in Perspective", + [], + None, + "{dx, dy: arrow direction (-1, 0 or 1), big?: bool (×10), copy?: bool} move the selection in perspective by the keyboard increment, as the arrow keys do with the Perspective Selection tool → {ids}", + has_selection, + persp_nudge + ), + cmd!( + "perspective.plane.move", + "Move Plane", + [], + None, + "{plane?: left|right|ground (default: the active plane), offset?: pt (where along its normal; 0 is its place in the grid's definition) | by?: pt, objects?: none|move|copy (default none: the objects on the plane stay; move: they move with it, as Shift-dragging a plane widget does; copy: copies of them do, as Alt-dragging does)} → {plane, offset, ids}", + has_doc, + plane_move + ), + cmd!( + "perspective.plane.matchObject", + "Move Plane to Match Object", + [], + None, + "{id? (default: the first selected object)} move the plane the object is attached to onto the object and make it the active plane → {plane, offset}", + has_doc, + plane_match + ), + cmd!( + "perspective.editText", + "Edit Text", + [], + None, + "{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}", + has_doc, + edit_text + ), ] } @@ -149,10 +203,16 @@ fn uniform() -> WidthProfile { /// Width points closer than this (in t) are at the same place: a discontinuous point. const SAME_T: f64 = 1e-6; -/// The weighted stroke of `id` that width point commands edit. -fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { +/// The stroke of `id` that width point commands edit (a compound path's for its members). +fn stroke_mut(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { + let id = stroke_owner(d, id); let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?; + n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into())) +} + +/// [`stroke_mut`], which must have a weight. +fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc::StrokeLayer> { + let st = stroke_mut(d, id)?; if st.width <= 0.0 { return Err(EngineError::Other("the stroke has no weight".into())); } @@ -271,8 +331,7 @@ fn width_point_remove(s: &mut Session, p: &Value) -> Result { indices.sort_unstable(); indices.dedup(); s.edit("Delete Width Point", |d, _| { - let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; - let st = n.appearance.stroke_mut().ok_or_else(|| EngineError::Other("the object has no stroke".into()))?; + let st = stroke_mut(d, id)?; let fits = |pr: &&mut WidthProfile| indices.last().is_some_and(|i| *i < pr.points.len()); let prof = st.profile.as_mut().filter(fits).ok_or_else(|| bad(C, "no such width point"))?; for i in indices.iter().rev() { @@ -306,7 +365,10 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad(C, "points must be an array or null")), }; - let ids = targets(s, p)?; + let doc = &s.doc()?.doc; + let mut ids: Vec = targets(s, p)?.into_iter().map(|id| stroke_owner(doc, id)).collect(); + ids.sort_unstable(); + ids.dedup(); s.edit("Width Profile", |d, _| { for id in &ids { let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?; @@ -325,90 +387,278 @@ fn points_of(p: &Value, key: &str) -> Option> { p.get(key)?.as_array()?.iter().map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect() } -/// Leaf paths under `roots` that can be edited. -fn leaf_paths(d: &Document, roots: &[NodeId]) -> Vec { - let mut out = vec![]; - for r in roots { - if let Some(n) = d.node(*r) { - n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Path { .. }) && !out.contains(&c.id) { - out.push(c.id); - } - }); +/// A stroke's `[[x, y, pressure?]…]` samples (pressure 0..1, default 1). +fn samples_of(p: &Value, key: &str) -> Option> { + p.get(key)? + .as_array()? + .iter() + .map(|q| Some((Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?), q.get(2).map_or(Some(1.0), Value::as_f64)?))) + .collect() +} + +/// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside +/// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images, +/// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds +/// and what it is. Hidden and locked objects, and guides, are neither. +#[derive(Default)] +struct Targets { + paths: Vec<(NodeId, Rect)>, + blocked: Vec<(NodeId, Rect, &'static str)>, + /// Every object listed (selected roots may hold each other). + seen: std::collections::HashSet, +} + +/// What `n` is when Liquify can't distort it (and doesn't look inside it). +fn not_liquified(n: &Node) -> Option<&'static str> { + Some(match &n.kind { + NodeKind::Group { .. } if n.graph.is_some() => "graphs", + NodeKind::Text(_) => "type", + NodeKind::SymbolInstance { .. } => "symbols", + NodeKind::Image(_) => "images", + NodeKind::Mesh(_) => "meshes", + NodeKind::Envelope { .. } => "envelopes", + NodeKind::Repeat(_) => "repeats", + NodeKind::Blend { .. } => "blends", + _ => return None, + }) +} + +impl Targets { + /// The targets under `roots` (the selection), or with none every visible object. + fn of(d: &Document, roots: &[NodeId]) -> Self { + let mut t = Self::default(); + if roots.is_empty() { + d.layers.iter().for_each(|l| t.visit(l)); + return t; + } + for r in roots { + let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue }; + if !d.is_editable(*r) { + continue; + } + // Inside a graph, an envelope, a repeat or a blend: that object is what is left alone. + match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) { + Some((a, what)) => t.block(a, what), + None => t.visit(n), + } + } + t + } + + fn block(&mut self, n: &Node, what: &'static str) { + if let Some(b) = n.geometric_bounds() + && self.seen.insert(n.id) + { + self.blocked.push((n.id, b, what)); } } - out.retain(|id| d.is_editable(*id)); - out + + fn visit(&mut self, n: &Node) { + if !n.visible || n.locked { + return; + } + if let Some(what) = not_liquified(n) { + return self.block(n, what); + } + match &n.kind { + NodeKind::Path { guide: false, path, .. } => { + if let Some(b) = reach_bounds(path) + && self.seen.insert(n.id) + { + self.paths.push((n.id, b)); + } + } + _ => n.children().into_iter().flatten().for_each(|c| self.visit(c)), + } + } +} + +/// Does a dab with box `bb` reach `b`? +fn reaches(bb: Rect, b: Rect) -> bool { + b.inflate(1e-6, 1e-6).intersect(bb).area() > 0.0 +} + +/// A liquify stroke being applied: its dabs and the paths they reached so far, so a longer stroke +/// (the next sample of a drag) applies only the dabs it adds. It belongs to one document snapshot +/// (`base`, held so the pointer can't be reused) and one set of other parameters (`key`). +pub(crate) struct LiquifyStroke { + base: Arc, + key: Value, + prm: LiquifyParams, + dabs: Dabber, + targets: Targets, + /// The paths reached, by their index in `targets.paths`. + paths: Vec<(usize, PathStroke)>, + /// The objects left alone that the brush passed over, by their index in `targets.blocked`. + skipped: Vec, + /// Which of `targets.paths` and `targets.blocked` the brush reached. + reached: (Vec, Vec), +} + +impl LiquifyStroke { + fn new(base: Arc, key: Value, prm: LiquifyParams, roots: &[NodeId]) -> Self { + let targets = Targets::of(&base, roots); + let reached = (vec![false; targets.paths.len()], vec![false; targets.blocked.len()]); + Self { base, key, prm, dabs: Dabber::new(&prm), targets, paths: vec![], skipped: vec![], reached } + } + + /// Start the targets dab box `bb` reaches. + fn reach(&mut self, bb: Rect) { + for (i, (id, b)) in self.targets.paths.iter().enumerate() { + if let Some(r) = self.reached.0.get_mut(i).filter(|r| !**r && reaches(bb, *b)) { + *r = true; + let Some(path) = self.base.node(*id).and_then(|n| n.path_data()) else { continue }; + self.paths.push((i, PathStroke::new(path.clone(), id.0))); + } + } + for (i, (_, b, _)) in self.targets.blocked.iter().enumerate() { + if let Some(r) = self.reached.1.get_mut(i).filter(|r| !**r && reaches(bb, *b)) { + *r = true; + self.skipped.push(i); + } + } + } + + /// Add `samples` to the stroke and apply the dabs they make. + fn extend(&mut self, samples: &[Sample]) { + let from = self.dabs.dabs.len(); + for s in samples { + self.dabs.push(*s); + } + for i in from..self.dabs.dabs.len() { + if let Some(c) = self.dabs.dabs.get(i).map(|d| d.c) { + self.reach(self.prm.brush_bounds(c)); + } + } + if let Some(t) = self.dabs.tail() { + self.reach(self.prm.brush_bounds(t.c)); + } + for (_, ps) in &mut self.paths { + ps.advance(&self.dabs.dabs, &self.prm); + } + } + + /// The paths the stroke changed (in document order) and their new geometry. + fn results(&self) -> Vec<(NodeId, PathData)> { + let tail = self.dabs.tail(); + let mut out: Vec<(usize, NodeId, PathData)> = + self.paths.iter().filter_map(|(i, ps)| Some((*i, self.targets.paths.get(*i)?.0, ps.finish(&self.dabs.dabs, tail, &self.prm)?))).collect(); + out.sort_by_key(|(i, ..)| *i); + out.into_iter().map(|(_, id, p)| (id, p)).collect() + } + + /// The objects under the brush left as they are, and the status message about them. + fn skipped(&self) -> (Vec, Option) { + let mut ids = vec![]; + let mut kinds: Vec<&str> = vec![]; + for (id, _, what) in self.skipped.iter().filter_map(|i| self.targets.blocked.get(*i)) { + ids.push(id.0); + if !kinds.contains(what) { + kinds.push(what); + } + } + let msg = (!ids.is_empty()).then(|| { + let n = ids.len(); + format!( + "{} left {n} object{} under the brush as {} ({}): it reshapes paths only", + self.prm.kind.label(), + if n == 1 { "" } else { "s" }, + if n == 1 { "it was" } else { "they were" }, + kinds.join(", ") + ) + }); + (ids, msg) + } } fn liquify(s: &mut Session, p: &Value) -> Result { const C: &str = "object.liquify"; let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?; - let pts = points_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y]…] list"))?; - if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite()) { + let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?; + if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) { return Err(bad(C, "points must be finite")); } - let dab_pts = dabs(&pts, prm.dab_spacing()); let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { Some(v) => v, None => s.doc()?.selection.objects.clone(), }; - let doc = &s.doc()?.doc; - let leaves = if roots.is_empty() { - // Nothing selected: everything the brush sweeps over. - // No dabs sweep nothing. - let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap_or(vectorcraft_geom::Rect::ZERO); - let mut all = vec![]; - doc.walk(|n| { - if matches!(n.kind, NodeKind::Path { guide: false, .. }) - && n.geometric_bounds().is_some_and(|b| b.inflate(1e-6, 1e-6).intersect(sweep).area() > 0.0) - { - all.push(n.id); - } - }); - all.retain(|id| doc.is_editable(*id) && doc.is_visible(*id)); - all - } else { - leaf_paths(doc, &roots) + let base = s.doc()?.doc.clone(); + let mut key = p.clone(); + if let Some(o) = key.as_object_mut() { + o.remove("points"); + o.insert("__roots".into(), json!(roots.iter().map(|r| r.0).collect::>())); + } + // A live drag: the same stroke as the last preview with more samples goes on from there. + let mut stroke = match s.liquify_stroke.take() { + Some(st) if Arc::ptr_eq(&st.base, &base) && st.key == key && pts.starts_with(st.dabs.samples()) => st, + _ => Box::new(LiquifyStroke::new(base, key, prm, &roots)), }; + let done = stroke.dabs.samples().len(); + stroke.extend(pts.get(done..).unwrap_or_default()); + let results = stroke.results(); + let (skipped, warning) = stroke.skipped(); let changed = s.edit(&format!("{} Tool", prm.kind.label()), |d, _| { let mut changed = vec![]; - for id in &leaves { - let Some(n) = d.node_mut(*id) else { continue }; - if let NodeKind::Path { path, live, .. } = &mut n.kind - && apply_stroke(path, &dab_pts, &prm, id.0) - { + for (id, new) in results { + let Some(n) = d.node_mut(id) else { continue }; + if let NodeKind::Path { path, live, .. } = &mut n.kind { + *path = new; *live = None; changed.push(id.0); } } Ok(changed) })?; - Ok(json!({ "ids": changed })) + if s.in_interaction() { + s.liquify_stroke = Some(stroke); + } + let mut out = json!({ "ids": changed, "skipped": skipped }); + if let Some(w) = warning { + out["warning"] = json!(w); + } + Ok(out) } // ---------- puppet warp ---------- +/// The objects a Puppet Warp command works on: `id`/`ids`, else `default`. +fn puppet_ids(p: &Value, default: impl FnOnce() -> Result>) -> Result> { + let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { + Some(v) => v, + None => default()?, + }; + if ids.is_empty() { + return Err(EngineError::Other("select the artwork to warp".into())); + } + Ok(ids) +} + fn puppet_warp(s: &mut Session, p: &Value) -> Result { const C: &str = "object.puppetWarp"; let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?; let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?; - if pins.is_empty() || pins.len() != moved.len() { + let rest = bool_or(p, "rest", false); + if (pins.is_empty() && !rest) || pins.len() != moved.len() { return Err(bad(C, "pins and moved must be non-empty lists of the same length")); } if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) { return Err(bad(C, "pins must be finite")); } - let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) { - Some(v) => v, - None => selected_roots(s)?, - }; - if ids.is_empty() { - return Err(EngineError::Other("select the artwork to warp".into())); - } + let ids = if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { puppet_ids(p, || selected_roots(s))? }; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); + let angles: Vec> = match p.get("angles") { + None | Some(Value::Null) => vec![None; pins.len()], + Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(), + Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")), + }; + let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); + if rest { + let pins: Vec = pins.iter().zip(&moved).zip(angles).map(|((r, a), angle)| PuppetPin { rest: *r, at: *a, angle }).collect(); + s.edit("Puppet Warp", |d, _| warp_from_rest(d, &ids, pins, expand).map_err(|e| bad(C, e)))?; + return Ok(ids_json); + } let mesh = mesh_for(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?; - let pins: Vec = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect(); + let pins: Vec = pins.iter().zip(&moved).zip(angles).map(|((a, b), angle)| arap::Pin { angle, ..arap::Pin::new(*a, *b) }).collect(); let deformed = arap::deform(&mesh, &pins); let f = |q: Point| mesh.map(&deformed, q); s.edit("Puppet Warp", |d, _| { @@ -418,7 +668,26 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { } Ok(()) })?; - Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) + Ok(ids_json) +} + +/// Forget the Puppet Warp pins: not an undo step, and a saved document stays saved (pins are +/// never saved). +pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) { + let clean = !st.is_dirty(); + Arc::make_mut(&mut st.doc).puppet = None; + if clean { + st.mark_saved(); + } +} + +fn puppet_pins(s: &mut Session, p: &Value) -> Result { + let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?; + let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); + let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?; + let mut v = set.params(&set.pins, expand); + v["auto"] = json!(set.auto); + Ok(v) } // ---------- perspective grid ---------- @@ -434,7 +703,7 @@ pub(crate) fn store_grid(d: &mut Document, g: &PerspectiveGrid) { } /// Change view state of the grid without an undo step. -fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { +pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result { let st = s.doc_mut()?; let mut g = grid_of(&st.doc); f(&mut g); @@ -447,24 +716,19 @@ fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result Result { - let g = grid_of(&s.doc()?.doc).merged(p).map_err(|e| bad("perspective.grid.set", e))?; - s.edit("Define Perspective Grid", |d, _| { - store_grid(d, &g); - Ok(()) - })?; - Ok(g.definition_json()) -} - -fn grid_preset(s: &mut Session, p: &Value) -> Result { - let kind = p - .get("kind") - .and_then(Value::as_u64) - .filter(|k| (1..=3).contains(k)) - .ok_or_else(|| bad("perspective.grid.preset", "kind must be 1, 2 or 3"))? as u8; let old = grid_of(&s.doc()?.doc); - let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?; - let g = PerspectiveGrid { attached: old.attached, ..PerspectiveGrid::preset(kind, ab) }; - s.edit("Perspective Grid Preset", |d, _| { + let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?; + let reproject = bool_or(p, "reproject", false); + s.edit("Define Perspective Grid", |d, _| { + if reproject { + old.adopt_stored_attachments(d); + for (id, plane, depth) in persp::attached_roots(d) { + let h = old.homography_at(plane, depth).and_then(|h| h.inverse()).zip(g.homography_at(plane, depth)); + let (hi, h) = h.ok_or_else(|| EngineError::Other("an object in perspective would leave the edited grid".into()))?; + let m = h.then_after(&hi); + warp_checked(d, id, &m)?; + } + } store_grid(d, &g); Ok(()) })?; @@ -502,21 +766,27 @@ fn roots(s: &Session, p: &Value) -> Result> { } /// Map `n` with `f`, failing if any point would leave the plane's visible side. -fn warp_checked(d: &mut Document, id: NodeId, f: &dyn Fn(Point) -> Option) -> Result<()> { +/// Map `n` with `h` (type and symbols in perspective keep their art and are drawn through it), +/// failing if any point would leave the plane's visible side. +fn warp_checked(d: &mut Document, id: NodeId, h: &Homography) -> Result<()> { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; let mut pts = vec![]; collect_points(n, &mut pts); - if pts.iter().any(|q| f(*q).is_none()) { + if pts.iter().any(|q| h.apply(*q).is_none()) { return Err(EngineError::Other("the object would cross the horizon of the perspective plane".into())); } - warp_node_with(n, &|q| f(q).unwrap_or(q)); + project_node(n, h); Ok(()) } -fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane) -> Result<()> { +/// Attach `id` to `plane`: projected by `map`, else by the map that keeps its bounds' corners +/// (on the nearest gridlines with `snap`). +fn attach_in(d: &mut Document, g: &mut PerspectiveGrid, id: NodeId, plane: Plane, snap: bool, map: Option) -> Result<()> { let b = d.node(id).ok_or(EngineError::NoNode(id))?.geometric_bounds().ok_or_else(|| EngineError::Other("the object has no geometry".into()))?; - let m = g.attach_map(plane, b).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; + let m = + map.or_else(|| g.attach_homography(plane, b, snap)).ok_or_else(|| EngineError::Other("the object is beyond the plane's horizon".into()))?; warp_checked(d, id, &m)?; + persp::set_attachment(d.node_mut(id).ok_or(EngineError::NoNode(id))?, plane, g.offset(plane)); g.attached.insert(id.0.to_string(), plane); Ok(()) } @@ -527,7 +797,7 @@ fn attach(s: &mut Session, p: &Value) -> Result { let plane = plane_param(&g, p, "perspective.attach")?; s.edit("Attach to Active Plane", |d, _| { for id in &ids { - attach_in(d, &mut g, *id, plane)?; + attach_in(d, &mut g, *id, plane, false, None)?; } store_grid(d, &g); Ok(()) @@ -541,6 +811,9 @@ fn release(s: &mut Session, p: &Value) -> Result { s.edit("Release with Perspective", |d, _| { for id in &ids { g.attached.remove(&id.0.to_string()); + if let Some(n) = d.node_mut(*id) { + persp::release(n); + } } store_grid(d, &g); Ok(()) @@ -548,30 +821,230 @@ fn release(s: &mut Session, p: &Value) -> Result { ok() } +/// One object's share of a perspective edit: the page map for an object on `plane` at `depth`, and +/// the depth it ends at. +type PerspStep<'a> = &'a dyn Fn(&PerspectiveGrid, Plane, f64) -> Result<(Homography, f64)>; + +/// Where an object is in perspective: its plane and depth. +type Attached = (Plane, f64); + +fn beyond_horizon() -> EngineError { + EngineError::Other("the pointer is beyond the plane's horizon".into()) +} + +/// Edit objects in perspective (one undo step `label`): with `copy` their duplicates (which stay +/// attached), each mapped by `step` for its own plane and depth; objects not in perspective attach +/// to `fallback` first. → the edited objects and where the first one was (plane, depth). +fn persp_edit( + s: &mut Session, + label: &str, + ids: &[NodeId], + fallback: Option, + copy: bool, + step: PerspStep, +) -> Result<(Vec, Option)> { + if ids.is_empty() { + return Err(EngineError::Other("select the objects to transform in perspective".into())); + } + let mut g = grid_of(&s.doc()?.doc); + s.edit(label, |d, sel| { + g.adopt_stored_attachments(d); + let targets = if copy { duplicate_in(d, sel, ids, Affine::IDENTITY)? } else { ids.to_vec() }; + let mut first = None; + for id in &targets { + let (plane, depth) = match g.attachment_of(d, *id) { + Some(a) => a, + None => { + let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; + attach_in(d, &mut g, *id, pl, false, None)?; + (pl, g.offset(pl)) + } + }; + first.get_or_insert((plane, depth)); + let (h, depth) = step(&g, plane, depth)?; + warp_checked(d, *id, &h)?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, depth); + } + store_grid(d, &g); + Ok((targets, first)) + }) +} + +/// Remember `again` (`perspective.transform` params) for Object › Transform › Transform Again, once +/// the drag that made it is committed. +fn record_again(s: &mut Session, again: Value) -> Result<()> { + let st = s.doc_mut()?; + match &mut st.interaction { + Some(it) => it.perspective_again = Some(again), + None => st.last_perspective = Some(again), + } + Ok(()) +} + +fn ids_json(ids: &[NodeId]) -> Value { + json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) +} + +/// A plane-space affine map as `perspective.transform`'s `matrix` param. +fn matrix_json(m: Affine) -> Value { + json!(m.as_coeffs()) +} + fn persp_move(s: &mut Session, p: &Value) -> Result { const C: &str = "perspective.move"; let ids = roots(s, p)?; let from = point_param(p, "from").ok_or_else(|| bad(C, "missing from"))?; let to = point_param(p, "to").ok_or_else(|| bad(C, "missing to"))?; - let mut g = grid_of(&s.doc()?.doc); - let fallback = plane_param(&g, p, C).ok(); - s.edit("Move in Perspective", |d, _| { - for id in &ids { - let plane = match g.attached_plane(*id) { - Some(pl) => pl, - None => { - let pl = fallback.ok_or_else(|| EngineError::Other("the object isn't on a perspective plane".into()))?; - attach_in(d, &mut g, *id, pl)?; - pl - } - }; - let m = g.move_map(plane, from, to).ok_or_else(|| EngineError::Other("the pointer is beyond the plane's horizon".into()))?; - warp_checked(d, *id, &m)?; + if ![from.x, from.y, to.x, to.y].iter().all(|v| v.is_finite()) { + return Err(bad(C, "from and to must be finite")); + } + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false)); + // Snap to Grid: a correction for the objects on the first one's plane and depth. + let fix = if perp { None } else { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; + let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { + dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2) + }; + // In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches. + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + if perp { + let to_depth = g.depth_at(pl, depth, from, to).ok_or_else(beyond_horizon)?; + Ok((g.transform_map(pl, depth, Affine::IDENTITY, to_depth - depth).ok_or_else(beyond_horizon)?, to_depth)) + } else { + let dv = snapped(pl, depth, g.plane_delta(pl, depth, from, to).ok_or_else(beyond_horizon)?); + Ok((g.transform_map(pl, depth, Affine::translate(dv), 0.0).ok_or_else(beyond_horizon)?, depth)) } + }; + let label = if copy { "Copy in Perspective" } else { "Move in Perspective" }; + let (targets, first) = persp_edit(s, label, &ids, fallback, copy, &step)?; + // Transform Again repeats the first object's move. + if let Some((pl, depth)) = first { + let g = grid_of(&s.doc()?.doc); + let again = match perp { + true => g.depth_at(pl, depth, from, to).map(|d| json!({"matrix": matrix_json(Affine::IDENTITY), "depth": d - depth, "copy": copy})), + false => { + g.plane_delta(pl, depth, from, to).map(|dv| json!({"matrix": matrix_json(Affine::translate(snapped(pl, depth, dv))), "copy": copy})) + } + }; + if let Some(a) = again { + record_again(s, a)?; + } + } + Ok(ids_json(&targets)) +} + +fn persp_transform(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.transform"; + let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; + let dz = f64_or(p, "depth", 0.0); + if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { + return Err(bad(C, "matrix must be finite and invertible, depth finite")); + } + let ids = roots(s, p)?; + let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); + let copy = bool_or(p, "copy", false); + let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { + let to = depth + dz; + if to.abs() > persp::MAX_DEPTH { + return Err(EngineError::Other("the objects would leave the grid".into())); + } + Ok((g.transform_map(pl, depth, m, dz).ok_or_else(beyond_horizon)?, to)) + }; + let (targets, _) = persp_edit(s, if copy { "Copy in Perspective" } else { "Transform in Perspective" }, &ids, fallback, copy, &step)?; + record_again(s, json!({"matrix": matrix_json(m), "depth": dz, "copy": copy}))?; + Ok(ids_json(&targets)) +} + +/// Object › Transform › Transform Again after a perspective move or scale: the same plane-space +/// transform on the selection. +pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { + let mut p = again.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("ids"); + } + persp_transform(s, &p) +} + +fn plane_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.plane.move"; + let old = grid_of(&s.doc()?.doc); + let plane = plane_param(&old, p, C)?; + let from = old.offset(plane); + let to = match (p.get("offset").and_then(Value::as_f64), p.get("by").and_then(Value::as_f64)) { + (Some(o), _) => o, + (None, Some(b)) => from + b, + (None, None) => return Err(bad(C, "give offset or by")), + }; + let objects = str_param(p, "objects").unwrap_or("none"); + if !matches!(objects, "none" | "move" | "copy") { + return Err(bad(C, "objects must be none, move or copy")); + } + let mut g = old.clone(); + g.set_offset(plane, to); + g.validate().map_err(|e| bad(C, e))?; + let label = match objects { + "copy" => "Move Plane and Copy Objects", + "move" => "Move Plane and Objects", + _ => "Move Plane", + }; + let ids = s.edit(label, |d, sel| { + old.adopt_stored_attachments(d); + let mut ids = vec![]; + if objects != "none" { + // The objects on the plane where it was (not those moved off it along its normal). + let on: Vec = persp::attached_roots(d) + .into_iter() + .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id)) + .map(|(id, ..)| id) + .collect(); + ids = if objects == "copy" { duplicate_in(d, sel, &on, Affine::IDENTITY)? } else { on }; + let m = old.transform_map(plane, from, Affine::IDENTITY, to - from).ok_or_else(beyond_horizon)?; + for id in &ids { + warp_checked(d, *id, &m)?; + persp::set_attachment(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, plane, to); + } + } + store_grid(d, &g); + Ok(ids) + })?; + Ok(json!({ "plane": plane.id(), "offset": to, "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn plane_match(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = id_param(p, "id") + .or_else(|| st.selection.objects.first().copied()) + .ok_or_else(|| EngineError::Other("select an object in perspective".into()))?; + let mut g = grid_of(&st.doc); + let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?; + g.set_offset(plane, depth); + g.plane = plane; + g.validate().map_err(|e| bad("perspective.plane.matchObject", e))?; + s.edit("Move Plane to Match Object", |d, _| { store_grid(d, &g); Ok(()) })?; - ok() + Ok(json!({ "plane": plane.id(), "offset": depth })) +} + +fn persp_nudge(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.nudge"; + let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { + return Err(bad(C, "dx and dy give the arrow's direction")); + } + let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; + let ids = selected_roots(s)?; + // The step is measured on the page at the centre of the selection's perspective box. + let st = s.doc()?; + let g = grid_of(&st.doc); + let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; + let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; + let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; + persp_move( + s, + &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}), + ) } fn persp_draw(s: &mut Session, p: &Value) -> Result { @@ -580,11 +1053,17 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result { let params = p.get("params").cloned().unwrap_or_else(|| json!({})); let mut g = grid_of(&s.doc()?.doc); let plane = plane_param(&g, p, C)?; + let snap = bool_or(p, "snap", g.snap); + // A click-to-size dialog's shape keeps its sizes in plane units from the click. + let map = match point_param(p, "at") { + Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?), + None => None, + }; let undo_before = s.doc()?.history.undo.len(); let r = s.execute(&command, ¶ms)?; let id = r.get("id").and_then(Value::as_u64).map(NodeId).ok_or_else(|| EngineError::Other(format!("{command} didn't create an object")))?; let res = s.edit("Draw in Perspective", |d, _| { - attach_in(d, &mut g, id, plane)?; + attach_in(d, &mut g, id, plane, snap, map)?; store_grid(d, &g); Ok(()) }); @@ -602,10 +1081,85 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result { /// Shape tool previews become `perspective.draw` while the grid is shown with an active plane. pub(crate) fn perspective_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { - const SHAPES: &[&str] = &["shape.rectangle", "shape.ellipse", "shape.polygon", "shape.star", "shape.line", "shape.rectangularGrid", "shape.arc"]; + const SHAPES: &[&str] = &[ + "shape.rectangle", + "shape.ellipse", + "shape.polygon", + "shape.star", + "shape.line", + "shape.rectangularGrid", + "shape.arc", + "shape.flare", + "shape.spiral", + "shape.polarGrid", + ]; if !SHAPES.contains(&cmd) { return None; } - let g = PerspectiveGrid::from_doc(&s.active()?.doc)?; + let g = PerspectiveGrid::current(&s.active()?.doc); (g.visible && g.plane != Plane::None).then(|| ("perspective.draw".to_string(), json!({"command": cmd, "params": params}))) } + +/// A click-to-size shape dialog's command (`cmd` with `params`, clicked at `at`) as it runs: on the +/// active plane while the grid shows (`perspective.draw` keeping the sizes in plane units from the +/// click), else `None` (run it as it is). +pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> Option<(String, Value)> { + let (c, mut p) = perspective_rewrite(s, cmd, params)?; + p["at"] = json!([at.x, at.y]); + Some((c, p)) +} + +/// Object › Perspective › Edit Text: type in perspective shown flat where it is drawn, in isolation +/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]). +fn edit_text(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let id = + id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?; + if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) { + return Err(EngineError::Other("select type in perspective".into())); + } + s.edit("Edit Text", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let h = n.projection().ok_or_else(|| EngineError::Other("select type in perspective".into()))?; + let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("select type in perspective".into())) }; + // Shown flat where it is drawn: the type moves there and the projection moves the other + // way, so the picture stays. + let (Some(flat), Some(drawn)) = (t.bounds(), t.bounds().and_then(|b| h.map_rect_bbox(b))) else { return Ok(()) }; + let shift = vectorcraft_geom::Affine::translate(drawn.center() - flat.center()); + t.transform(shift); + if let Some(rec) = n.perspective.as_deref_mut() { + rec.projection = Some(h.then_after(&Homography::from_affine(shift.inverse())).to_array()); + rec.editing = true; + } + Ok(()) + })?; + let st = s.doc_mut()?; + st.isolation = Some(id); + st.selection.set([id]); + st.revision += 1; + Ok(json!({ "id": id.0 })) +} + +/// Edit Text ends (isolation mode on `id` exits): the type is projected again, in the document and +/// in every undo state (the flat view is never an undo step of its own). +pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) { + let clear = |doc: &mut Arc| { + if doc.node(id).and_then(|n| n.perspective.as_deref()).is_some_and(|p| p.editing) + && let Some(p) = Arc::make_mut(doc).node_mut(id).and_then(|n| n.perspective.as_deref_mut()) + { + p.editing = false; + } + }; + let saved = Arc::ptr_eq(&st.doc, &st.saved_doc); + clear(&mut st.doc); + if saved { + st.saved_doc = st.doc.clone(); + } + for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) { + clear(&mut e.doc); + } + if let Some(it) = &mut st.interaction { + clear(&mut it.doc); + } + st.revision += 1; +} diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 75e489556..721eb6cb6 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -1,6 +1,8 @@ //! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser, //! blob brush, smooth, path eraser, join). +use std::sync::Arc; + use kurbo::{ParamCurve, ParamCurveNearest}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; @@ -224,19 +226,43 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { out } -/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`. +/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids. +fn compound_members(d: &mut Document, pd: PathData) -> Vec> { + pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect() +} + +/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members +/// are rebuilt, one per subpath). +pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> { + if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) { + let members = compound_members(d, pd); + if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + *children = members; + } + return Ok(()); + } + *path_mut(d, id)? = pd; + Ok(()) +} + +/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`. fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result { let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone(); n.id = d.alloc_id(); - if let NodeKind::Path { path, live, .. } = &mut n.kind { - *path = pd; - *live = None; + match &mut n.kind { + NodeKind::Path { path, live, .. } => { + *path = pd; + *live = None; + } + NodeKind::Compound { children, .. } => *children = compound_members(d, pd), + _ => {} } Ok(n) } -/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids. -fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec) -> Result> { +/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above +/// it; none removes it). Returns ids. +pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec) -> Result> { let mut it = pieces.into_iter(); let Some(first) = it.next() else { d.remove(id)?; @@ -248,16 +274,16 @@ fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec) -> R let n = sibling_with(d, id, pd)?; ids.push(d.insert(parent, idx + 1 + k, n)?); } - *path_mut(d, id)? = first; + set_geometry(d, id, first)?; Ok(ids) } -fn ids_json(ids: &[NodeId]) -> Value { +pub(super) fn ids_json(ids: &[NodeId]) -> Value { json!({ "ids": ids.iter().map(|i| i.0).collect::>() }) } /// Split a filled path into connected pieces (each outer contour with its holes). -fn components(pd: &PathData) -> Vec { +pub(super) fn components(pd: &PathData) -> Vec { let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect(); if subs.len() <= 1 { return if pd.is_empty() { vec![] } else { vec![pd.clone()] }; @@ -382,7 +408,7 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option bool) -> Option> { +pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option> { let mut changed = false; let mut subs = vec![]; for sp in &pd.subpaths { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c230b009b..363d56a9b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "file.new", "New…", ["File"], - Some("Cmd+N"), + None, "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new @@ -117,7 +117,9 @@ fn set_units(s: &mut Session, p: &Value) -> Result { } fn undo(s: &mut Session, _: &Value) -> Result { - s.cancel_interaction()?; + // What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes + // back only that and Redo brings it back, instead of dropping it and undoing the step before. + s.commit_interaction()?; let st = s.doc_mut()?; let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?; let label = e.label.clone(); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 8c15b9154..e07ac02fc 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}", has_doc, apply ), @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Effects", [], None, - "{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", + "{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin., menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection", always, list ), @@ -146,6 +146,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { fn list(s: &mut Session, p: &Value) -> Result { let catalog: Vec = effects::effect_catalog() .into_iter() + .chain(effects::plugin_effects()) .map(|e| { json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster, "lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}}) @@ -321,11 +322,25 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects: /// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects /// are baked, raster effects become an embedded image, and the object keeps its transparency. fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { - let Some(n) = d.node(id).cloned() else { return }; - let container = is_container(&n); + let Some(mut n) = d.node(id).cloned() else { return }; if !expandable(&n) { return; } + // Colour adjustments: the colours inside become the adjusted ones (an embedded image a + // recoloured copy), then the rest of the appearance expands. + if effects::has_adjustment(&n) + && let Some(m) = effects::adjust_in_document(d, &n) + && let Some(slot) = d.node_mut(id) + { + *slot = m.clone(); + out.push(id); + n = m; + if !expandable(&n) { + return; + } + } + // (A symbol instance has become its art, a group.) + let container = is_container(&n); // Crop Marks: a group of the object and its marks, then the object expands on its own. if let Some(mut m) = effects::crop_marks_art(&n) { let mut seen = std::collections::HashSet::from([id]); @@ -385,7 +400,9 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush && let Some(slot) = d.node_mut(id) { *slot = m; - out.push(id); + if out.last() != Some(&id) { + out.push(id); + } } if container { let children: Vec = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index 8899a618a..c8db58976 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Expand…", ["Object"], None, - "{object?: true (type → outlines, live shapes → paths, effects baked), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", + "{object?: true (type → outlines, live shapes → paths, effects baked, blends → groups of their steps, envelopes → their distorted content), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}", has_selection, expand ), @@ -84,6 +84,10 @@ struct Expandable { effects: bool, fill: bool, stroke: bool, + /// An envelope (Object expands it into its distorted content). + envelope: bool, + /// Live blends (Object expands them into groups of their steps). + blends: bool, } impl Expandable { @@ -95,6 +99,8 @@ impl Expandable { e.text |= matches!(c.kind, NodeKind::Text(_)); e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. }); e.effects |= !c.appearance.effects.is_empty(); + e.envelope |= matches!(c.kind, NodeKind::Envelope { .. }); + e.blends |= matches!(c.kind, NodeKind::Blend { .. }); e.stroke |= shape && !c.appearance.stroke_paint().is_none(); e.fill |= match &c.kind { NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))), @@ -106,7 +112,7 @@ impl Expandable { } fn object(&self) -> bool { - self.text || self.live || self.effects + self.text || self.live || self.effects || self.envelope || self.blends } } @@ -129,9 +135,23 @@ fn expand(s: &mut Session, p: &Value) -> Result { let from = s.doc()?.history.undo.len(); let rev0 = s.doc()?.revision; let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?); + // Blends first: what their steps hold (type, effects, strokes, gradients) expands below. + if object && e.blends { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| super::live::expand_blends(d, &roots).map(|_| ()))?; + } if object && e.effects { let _ = run_raw(s, "effect.expandAppearance", &json!({})); } + if object && e.envelope { + let roots = selected_roots(s)?; + s.edit("Expand", |d, _| { + for r in &roots { + super::live::expand_envelopes_under(d, *r)?; + } + Ok(()) + })?; + } if object && e.live { let roots = selected_roots(s)?; s.edit("Expand", |d, _| { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 067f2197a..fe66845b1 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -623,6 +623,7 @@ pub fn open_filters() -> impl Iterator = open_filters().collect(); assert_eq!(filters[0], ("All readable files", OPEN_EXTS)); - assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets"); + assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins"); + assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins"); } #[test] diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 5403a9146..424c274f3 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -1,5 +1,6 @@ //! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with -//! another everywhere or in the selection, and selecting the text that uses a font. +//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts +//! available (the bundled and installed ones) and rescanning the installed fonts. use std::collections::BTreeMap; @@ -38,6 +39,24 @@ pub fn specs() -> Vec { has_doc, select_font ), + cmd!( + query "text.fontList", + "Font List", + [], + None, + "{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)", + always, + font_list + ), + cmd!( + "text.rescanFonts", + "Refresh Font List", + [], + None, + "{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families available, installed faces found)", + always, + rescan + ), ] } @@ -166,6 +185,53 @@ fn select_font(s: &mut Session, p: &Value) -> Result { Ok(json!({ "count": n })) } +fn font_list(_: &mut Session, p: &Value) -> Result { + let db = vectorcraft_text::FontDb::global(); + match str_param(p, "family") { + Some(family) => { + if !db.has_family(family) { + return Err(bad("text.fontList", format!("font family `{family}` is not available"))); + } + // The name as the font spells it. + let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string()); + Ok(json!({ "family": name, "styles": db.styles(family) })) + } + None => Ok(json!({ "families": *db.family_list() })), + } +} + +fn rescan(s: &mut Session, _: &Value) -> Result { + let db = vectorcraft_text::FontDb::global(); + #[cfg(not(target_arch = "wasm32"))] + let faces = db.load_system_fonts(); + #[cfg(target_arch = "wasm32")] + let faces = 0; + // The canvas redraws type in the fonts available now (renderers lay it out again when the + // fonts change), and the bounds follow; that isn't an edit to save. + for d in &mut s.docs { + let mut texts = vec![]; + d.doc.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + texts.push(n.id); + } + }); + if !texts.is_empty() { + let saved = !d.is_dirty(); + let doc = std::sync::Arc::make_mut(&mut d.doc); + for id in texts { + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + refresh_bounds(t); + } + } + if saved { + d.mark_saved(); + } + } + d.revision += 1; + } + Ok(json!({ "families": db.family_list().len(), "faces": faces })) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs new file mode 100644 index 000000000..4cf9e4f15 --- /dev/null +++ b/crates/engine/src/cmd/halftone.rs @@ -0,0 +1,298 @@ +//! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or +//! shapes sized by its tone. +//! +//! 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; + +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 super::edit::selected_roots; +use super::menucmds::isolated_doc; +use super::*; + +pub fn specs() -> Vec { + vec![cmd!( + "object.vectorHalftone", + "Vector Halftone…", + ["Object"], + None, + "{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\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}", + has_selection, + halftone + )] +} + +/// 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") { + Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?, + None => DotShape::Circle, + }; + let frequency = f64_or(p, "frequency", 20.0); + if !(1.0..=300.0).contains(&frequency) { + return Err(bad(C, "frequency must be between 1 and 300 lines per inch")); + } + let angle = f64_or(p, "angle", 45.0); + if !angle.is_finite() { + return Err(bad(C, "invalid angle")); + } + let mono = match str_param(p, "mode").unwrap_or("mono") { + "mono" => true, + "cmyk" => false, + other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))), + }; + let color = match p.get("color") { + Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?, + 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 roots = selected_roots(s)?; + let st = s.doc()?; + let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "the selection has no bounds"))?; + 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 nodes: Vec = roots + .iter() + .filter_map(|id| st.doc.node(*id).cloned()) + .map(|mut n| { + n.visible = true; + 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 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() }) + }; + 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)); + } + } + 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 gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some("Vector Halftone".into()); + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + d.insert(par, idx + 1, g)?; + if !keep { + for r in &roots { + d.remove(*r)?; + } + } + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "dots": dots })) +} + +/// 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/live.rs b/crates/engine/src/cmd/live.rs index 591179c86..baa0ffb76 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -9,7 +9,10 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshAppearance, Spine}; +use vectorcraft_doc::live::{ + self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance, + PreserveShape, Spine, +}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Affine, PathData, Point}; @@ -25,7 +28,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool (default smooth colour), orientation?: page|path} blend the selected objects (paint order) into a live blend → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", has_doc, blend_make ), @@ -34,7 +37,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends, keeping the key objects → {ids}", + "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -43,8 +46,8 @@ pub fn specs() -> Vec { "Blend Options…", ["Object", "Blend"], None, - "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path} set the options of the selected blends", - has_blend, + "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}", + always, blend_options ), cmd!( @@ -52,7 +55,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Blend"], None, - "{} replace the selected blends by groups of their steps → {ids}", + "{} replace the selected blends by groups of their keys and steps, each keeping the blend's id, name, transparency (knockout and isolation included), opacity mask and appearance → {ids}", has_blend, blend_expand ), @@ -74,6 +77,42 @@ pub fn specs() -> Vec { has_blend, blend_reverse_spine ), + cmd!( + query "object.blend.info", + "Blend Info", + [], + None, + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + always, + blend_info + ), + cmd!( + "object.blend.spine.moveAnchor", + "Move Spine Point", + [], + None, + "{id? (default: the selected blend), anchor: index, x, y, handle?: in|out, independent?: bool} move a point of the blend's spine (its handles along), or with `handle` place that handle's end at (x, y) (a smooth point keeps the other handle in line unless `independent`). A key object sitting on the point moves with it. The first edit turns the straight spine into a path", + has_doc, + spine_move_anchor + ), + cmd!( + "object.blend.spine.addAnchor", + "Add Spine Point", + [], + None, + "{id? (default: the selected blend), x, y} add a point to the blend's spine where it passes nearest (x, y) → {anchor}", + has_doc, + spine_add_anchor + ), + cmd!( + "object.blend.spine.removeAnchor", + "Delete Spine Point", + [], + None, + "{id? (default: the selected blend), anchor: index} delete a point of the blend's spine that no key object sits on", + has_doc, + spine_remove_anchor + ), cmd!( "object.blend.reverseFrontToBack", "Reverse Front to Back", @@ -89,7 +128,7 @@ pub fn specs() -> Vec { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|…|twist (arc), bend?: % (50), h?: %, v?: %, horizontal?: bool (true)} envelope the selected objects with a warp → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}", has_selection, env_make_warp ), @@ -98,7 +137,7 @@ pub fn specs() -> Vec { "Make with Mesh…", ["Object", "Envelope Distort"], Some("Cmd+Alt+M"), - "{ids?, rows?: n (4), cols?: n (4)} envelope the selected objects with a point mesh → {id}", + "{ids?, rows?: 1..50 (4), cols?: 1..50 (4)} envelope the selected objects with a mesh of rows × cols patches → {id}", has_selection, env_make_mesh ), @@ -111,12 +150,39 @@ pub fn specs() -> Vec { has_multi, env_make_top ), + cmd!( + "object.envelope.resetWithWarp", + "Reset with Warp…", + ["Object", "Envelope Distort"], + None, + "{style?, bend?, h?, v?, horizontal? | orientation? (as makeWithWarp; unset: the envelope's current warp, else arc 50 %)} make the selected envelopes warp envelopes, keeping their content", + has_envelope, + env_reset_warp + ), + cmd!( + "object.envelope.resetWithMesh", + "Reset with Mesh…", + ["Object", "Envelope Distort"], + None, + "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content)} make the selected envelopes mesh envelopes, keeping their content", + has_envelope, + env_reset_mesh + ), + cmd!( + query "object.envelope.info", + "Envelope Info", + [], + None, + "{} the selected envelope's settings, or with none selected the defaults new envelopes get → {id: n|null, type: warp|mesh|topObject, style, bend, h, v, horizontal, rows, cols, fidelity, antiAlias, preserveShape: clippingMask|transparency, distortAppearance, distortLinearGradients, distortPatternFills, editing}", + has_doc, + env_info + ), cmd!( "object.envelope.release", "Release", ["Object", "Envelope Distort"], None, - "{} release the selected envelopes → {ids}", + "{} release the selected envelopes into their content and their shape (a grey gradient mesh for warp and mesh envelopes, the path for a top-object envelope; the content keeps the envelope's opacity, blend mode and opacity mask) → {ids}", has_envelope, env_release ), @@ -125,8 +191,8 @@ pub fn specs() -> Vec { "Envelope Options…", ["Object", "Envelope Distort"], None, - "{fidelity?: 0..100, style?, bend?, h?, v?, horizontal?} set envelope options (warp params for warp envelopes)", - has_envelope, + "{fidelity?: 0..100, antiAlias?: bool, preserveShape?: clippingMask|transparency, distortAppearance?: bool, distortLinearGradients?: bool, distortPatternFills?: bool (the last two work with distortAppearance), style?, bend?, h?, v?, horizontal? (warp envelopes)} set the selected envelopes' options; with no envelope selected, the options new envelopes get (see object.envelope.info)", + has_doc, env_options ), cmd!( @@ -134,7 +200,7 @@ pub fn specs() -> Vec { "Expand", ["Object", "Envelope Distort"], None, - "{} replace the selected envelopes by their distorted content → {ids}", + "{} replace the selected envelopes by groups of their distorted content (type outlined), keeping name, opacity, blend mode, isolation, knockout and opacity mask → {ids}", has_envelope, env_expand ), @@ -142,7 +208,7 @@ pub fn specs() -> Vec { "object.envelope.editContents", "Edit Contents", ["Object", "Envelope Distort"], - Some("Cmd+Shift+V"), + None, "{editing?: bool} toggle Edit Contents / Edit Envelope → {editing}", has_envelope, env_edit_contents @@ -152,7 +218,7 @@ pub fn specs() -> Vec { "Move Envelope Mesh Point", [], None, - "{id, index, x, y} move one point of a mesh envelope", + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a mesh envelope (its handles follow), or with `handle` place that handle end at (x, y); object.mesh.movePoint, addLine and deletePoint edit mesh envelopes too", has_doc, env_set_mesh_point ), @@ -180,7 +246,7 @@ pub fn specs() -> Vec { "Move Mesh Point", [], None, - "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a mesh point (its handles follow), or with `handle` place that handle end at (x, y)", + "{id, index, x, y, handle?: 0 right|1 left|2 down|3 up} move a point of a gradient mesh or mesh envelope (its handles follow), or with `handle` place that handle end at (x, y)", has_doc, mesh_move_point ), @@ -189,7 +255,7 @@ pub fn specs() -> Vec { "Add Mesh Line", [], None, - "{id, x, y, color?} add a mesh row and column through (x, y) → {index}", + "{id, x, y, color?} add a row and a column through (x, y) to a gradient mesh (new point in `color`) or a mesh envelope → {index}", has_doc, mesh_add_line ), @@ -198,7 +264,7 @@ pub fn specs() -> Vec { "Delete Mesh Point", [], None, - "{id, index} delete the mesh lines through a point", + "{id, index} delete the mesh lines through a point of a gradient mesh or mesh envelope", has_doc, mesh_delete_point ), @@ -323,14 +389,18 @@ fn fix_ids(d: &mut Document, n: &mut Node) { // ---------- Blend ---------- +/// The Specified Steps and Specified Distance a spacing mode starts with. +const DEFAULT_STEPS: u32 = 5; +const DEFAULT_DISTANCE: f64 = 10.0; + fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { let num = |k: &str| p.get(k).and_then(Value::as_f64); let mode = str_param(p, "spacing"); let value = num("value"); let sp = match mode { Some("smooth") | Some("smoothColor") => BlendSpacing::SmoothColor, - Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(5.0) as u32), - Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(10.0)), + Some("steps") => BlendSpacing::Steps(value.or(num("steps")).unwrap_or(DEFAULT_STEPS as f64) as u32), + Some("distance") => BlendSpacing::Distance(value.or(num("distance")).unwrap_or(DEFAULT_DISTANCE)), Some(o) => return Err(bad(cmd, format!("unknown spacing `{o}` (smooth|steps|distance)"))), None => { if let Some(n) = num("steps") { @@ -359,33 +429,251 @@ fn orientation_param(p: &Value, current: BlendOrientation) -> BlendOrientation { } } +/// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. +fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { + const C: &str = "object.blend.make"; + let Some(v) = p.get("starts").filter(|v| !v.is_null()) else { return Ok(vec![]) }; + let list = v.as_array().ok_or_else(|| bad(C, "`starts` must be an array of anchor indices (or nulls)"))?; + let owners = ids_param(p, "ids").unwrap_or_else(|| roots.to_vec()); + let mut out = vec![]; + for (id, a) in owners.iter().zip(list) { + if a.is_null() { + continue; + } + let i = a.as_u64().and_then(|i| u32::try_from(i).ok()).ok_or_else(|| bad(C, "a start must be an anchor index ≥ 0 or null"))?; + out.push((*id, i)); + } + Ok(out) +} + +/// The first blend among `roots`. +fn first_blend(d: &Document, roots: &[NodeId]) -> Option { + roots.iter().filter_map(|r| d.node(*r)).find(|n| is_blend(n)).cloned() +} + +/// Key objects of a blend made of `nodes` (paint order) and their start points: a blend among +/// them gives its keys (with theirs), the other objects are keys themselves. +fn merge_keys(nodes: Vec>, starts: &[(NodeId, u32)]) -> (Vec>, Vec>) { + let (mut keys, mut st) = (vec![], vec![]); + for n in nodes { + match &n.kind { + NodeKind::Blend { children, spec } => { + st.extend((0..children.len()).map(|i| spec.starts.get(i).copied().flatten())); + keys.extend(children.iter().cloned()); + } + _ => { + st.push(starts.iter().find(|(id, _)| *id == n.id).map(|(_, a)| *a)); + keys.push(n); + } + } + } + if st.iter().all(Option::is_none) { + st.clear(); + } + (keys, st) +} + +/// The spine of a blend of `keys` made by giving blend `old` (spec `old_spec`) more keys: its +/// spine pinned, with a straight segment to each new key before or after its own (a closed spine +/// gives way to the straight lines between the keys). +fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, keys: &[Arc]) { + if old_spec.spine.is_none() { + return; + } + let Some((mut path, anchors)) = live::pin_spine(old, old_spec) else { return }; + let Some(first) = old.first().and_then(|f| keys.iter().position(|k| k.id == f.id)) else { return }; + let Some(sp) = path.subpaths.first_mut().filter(|sp| !sp.closed) else { return }; + let center = |k: &Arc| vectorcraft_geom::Anchor::corner(k.geometric_bounds().map(|b| b.center()).unwrap_or_default()); + let before: Vec<_> = keys.iter().take(first).map(center).collect(); + let after: Vec<_> = keys.iter().skip(first + old.len()).map(center).collect(); + let shift = before.len(); + sp.anchors.splice(0..0, before); + let n = sp.anchors.len(); + sp.anchors.extend(after); + let mut ka: Vec = (0..shift).collect(); + ka.extend(anchors.iter().map(|a| a + shift)); + ka.extend(n..sp.anchors.len()); + spec.key_anchors = ka.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); +} + fn blend_make(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.make"; - let spacing = spacing_param(p, C, BlendSpacing::SmoothColor)?; - let orientation = orientation_param(p, BlendOrientation::AlignToPage); let roots = roots_param(s, p)?; if roots.len() < 2 { return Err(bad(C, "select at least two objects")); } + let starts = starts_param(p, &roots)?; + let base = first_blend(&s.doc()?.doc, &roots); + let cur = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, + _ => s.prefs.blend_options.unwrap_or_default(), + }; + let spacing = spacing_param(p, C, cur.spacing)?; + let orientation = orientation_param(p, cur.orientation); let id = s.edit("Make Blend", |d, sel| { - wrap(d, sel, &roots, |id, children| Node::new(id, NodeKind::Blend { children, spec: BlendSpec { spacing, orientation, spine: None } })) + wrap(d, sel, &roots, |id, nodes| { + let (children, starts) = merge_keys(nodes, &starts); + // A blend taking more keys keeps its name, transparency and spine; a new one is a + // knockout group, so translucent steps don't show through each other. + let mut n = base.unwrap_or_else(|| { + let mut n = Node::new(id, NodeKind::Group { children: vec![], clip: false }); + n.knockout = vectorcraft_doc::Knockout::On; + n + }); + let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; + if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { + extend_spine(&mut spec, old, old_spec, &children); + } + n.id = id; + n.kind = NodeKind::Blend { children, spec }; + n + }) })?; Ok(json!({ "id": id.0 })) } fn blend_release(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); - let ids = s.edit("Release Blend", |d, sel| { - let mut out = vec![]; + let (ids, spines, groups) = s.edit("Release Blend", |d, sel| { + let (mut keys, mut spines, mut groups) = (vec![], vec![], vec![]); for b in &blends { let Some(n) = d.node(*b).cloned() else { continue }; - let keys: Vec = n.children().into_iter().flatten().map(|c| (**c).clone()).collect(); - out.extend(replace_with(d, *b, keys)?); + let NodeKind::Blend { children, spec } = &n.kind else { continue }; + // The spine stays behind as a path that paints nothing, below the keys. + let mut nodes = vec![]; + if let Some((path, _)) = live::blend_spine(children, spec) { + let id = d.alloc_id(); + spines.push(id); + nodes.push(Node::path(id, path, Appearance::basic(Paint::None, Paint::None, 0.0))); + } + keys.extend(children.iter().map(|c| c.id)); + nodes.extend(children.iter().map(|c| (**c).clone())); + // What the blend itself carries stays on a group around what it gave back. + if n.name.is_some() + || n.opacity < 1.0 + || n.blend != vectorcraft_color::BlendMode::Normal + || n.isolate + || n.mask.is_some() + || !n.appearance.items.is_empty() + || !n.appearance.effects.is_empty() + { + let mut g = n.clone(); + g.kind = NodeKind::Group { children: nodes.into_iter().map(Arc::new).collect(), clip: false }; + groups.push(g.id); + nodes = vec![g]; + } + replace_with(d, *b, nodes)?; } - sel.set(out.iter().copied()); - Ok(out) + if groups.is_empty() { + sel.set(spines.iter().chain(&keys).copied()); + } else { + sel.set(groups.iter().copied()); + } + Ok((keys, spines, groups)) })?; - Ok(ids_json(&ids)) + let mut r = ids_json(&ids); + r["spines"] = json!(spines.iter().map(|i| i.0).collect::>()); + r["groups"] = json!(groups.iter().map(|i| i.0).collect::>()); + Ok(r) +} + +/// The blend `id` (default: the first selected), its spine pinned to its keys (see +/// [`live::pin_spine`]) and given to `f` with the key anchors and the keys; the result is stored +/// as the blend's spine, one undo step. +fn edit_spine( + s: &mut Session, + p: &Value, + cmd: &str, + label: &str, + f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, +) -> Result { + let id = match id_param(p, "id") { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?, + }; + let c = cmd.to_string(); + s.edit(label, move |d, _| { + let Some(NodeKind::Blend { children, spec }) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(bad(&c, "not a blend")) }; + let (mut path, mut anchors) = live::pin_spine(children, spec).ok_or_else(|| bad(&c, "the blend has no spine"))?; + let sp = path.subpaths.first_mut().ok_or_else(|| bad(&c, "the blend has no spine"))?; + let out = f(sp, &mut anchors, children)?; + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + spec.spine = Some(path); + Ok(out) + }) +} + +/// A point parameter that must be finite. +fn finite_point(p: &Value, cmd: &str) -> Result { + let q = Point::new(f64_req(p, "x", cmd)?, f64_req(p, "y", cmd)?); + if q.x.is_finite() && q.y.is_finite() && q.x.abs() < 1e9 && q.y.abs() < 1e9 { Ok(q) } else { Err(bad(cmd, "x and y must be finite")) } +} + +fn spine_move_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.moveAnchor"; + let index = index_param(p, "anchor", C)?; + let q = finite_point(p, C)?; + let handle = match str_param(p, "handle") { + None => None, + Some("out") => Some(true), + Some("in") => Some(false), + Some(_) => return Err(bad(C, "handle must be in or out")), + }; + let independent = bool_or(p, "independent", false); + edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| { + let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; + if let Some(out) = handle { + a.set_handle(out, q, independent); + return Ok(()); + } + let delta = q - a.p; + a.translate(delta); + // The key objects on the point move with it. + for (k, _) in anchors.iter().enumerate().filter(|(_, a)| **a == index) { + if let Some(key) = keys.get_mut(k) { + Arc::make_mut(key).transform(Affine::translate(delta), false); + } + } + Ok(()) + })?; + ok() +} + +fn spine_add_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.addAnchor"; + let q = finite_point(p, C)?; + let index = edit_spine(s, p, C, "Add Spine Point", |sp, anchors, _| { + let (_, seg, t, _, _) = PathData::single(sp.clone()).nearest(q).ok_or_else(|| bad(C, "the spine has no segments"))?; + let i = sp.insert_anchor(seg, t.clamp(1e-3, 1.0 - 1e-3)); + for a in anchors.iter_mut().filter(|a| **a >= i) { + *a += 1; + } + Ok(i) + })?; + Ok(json!({ "anchor": index })) +} + +fn spine_remove_anchor(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.spine.removeAnchor"; + let index = index_param(p, "anchor", C)?; + edit_spine(s, p, C, "Delete Spine Point", |sp, anchors, _| { + if index >= sp.anchors.len() { + return Err(bad(C, "no such spine point")); + } + if anchors.contains(&index) { + return Err(bad(C, "a key object sits on that point")); + } + if sp.anchors.len() <= 2 { + return Err(bad(C, "a spine keeps at least two points")); + } + sp.anchors.remove(index); + for a in anchors.iter_mut().filter(|a| **a > index) { + *a -= 1; + } + Ok(()) + })?; + ok() } fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mut BlendSpec)) -> Result { @@ -401,12 +689,23 @@ fn edit_blends(s: &mut Session, label: &str, f: impl Fn(&mut Vec>, &mu ok() } +/// The first selected blend: its id, spec and key ids. +fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { + let b = *selected_of(s, is_blend).first()?; + match &s.doc().ok()?.doc.node(b)?.kind { + NodeKind::Blend { spec, children } => Some((b, spec.clone(), children.iter().map(|c| c.id).collect())), + _ => None, + } +} + fn blend_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.options"; - let blends = selected_of(s, is_blend); - let cur = match blends.first().and_then(|b| s.doc().ok()?.doc.node(*b)).map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => spec.clone(), - _ => BlendSpec::default(), + let Some((_, cur, _)) = selected_spec(s) else { + // Nothing to set them on: the options new blends start with. + let d = s.prefs.blend_options.unwrap_or_default(); + let spacing = spacing_param(p, C, d.spacing)?; + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + return Ok(json!({ "defaults": true })); }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); @@ -416,39 +715,130 @@ fn blend_options(s: &mut Session, p: &Value) -> Result { }) } +/// Spacing and orientation as `object.blend.options` takes them; the step count and distance +/// not in use show their defaults. +fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { + let (mode, steps, distance) = match spacing { + BlendSpacing::SmoothColor => ("smooth", DEFAULT_STEPS, DEFAULT_DISTANCE), + BlendSpacing::Steps(n) => ("steps", n, DEFAULT_DISTANCE), + BlendSpacing::Distance(d) => ("distance", DEFAULT_STEPS, d), + }; + let orientation = if orientation == BlendOrientation::AlignToPath { "path" } else { "page" }; + json!({ "spacing": mode, "steps": steps, "distance": distance, "orientation": orientation }) +} + +fn blend_info(s: &mut Session, _: &Value) -> Result { + Ok(match selected_spec(s) { + Some((id, spec, keys)) => { + let mut v = blend_options_json(spec.spacing, spec.orientation); + v["target"] = json!("blend"); + v["id"] = json!(id.0); + v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); + v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) + && let Some((path, anchors)) = live::blend_spine(children, &spec) + && let Some(sp) = path.subpaths.first() + { + let pt = |p: Point| json!([p.x, p.y]); + let points: Vec = sp.anchors.iter().map(|a| json!({"x": a.p.x, "y": a.p.y, "in": pt(a.h_in), "out": pt(a.h_out)})).collect(); + v["spine"] = json!({"anchors": points, "closed": sp.closed, "keyAnchors": anchors, "explicit": spec.spine.is_some()}); + } + v + } + None => { + let d = s.prefs.blend_options.unwrap_or_default(); + let mut v = blend_options_json(d.spacing, d.orientation); + v["target"] = json!("defaults"); + v + } + }) +} + fn blend_expand(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { - let mut out = vec![]; for b in &blends { - let Some(n) = d.node(*b).cloned() else { continue }; - let children = vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); - g.opacity = n.opacity; - g.blend = n.blend; - g.visible = n.visible; - fix_ids(d, &mut g); - out.extend(replace_with(d, *b, vec![g])?); + expand_blend(d, *b)?; } - sel.set(out.iter().copied()); - Ok(out) + sel.set(blends.iter().copied()); + Ok(blends.clone()) })?; Ok(ids_json(&ids)) } -/// Move the keys onto their spine positions (so what's stored matches what's drawn). +/// Replace blend `id` by a group of its keys and steps in place, keeping everything about the +/// blend itself: id, name, visibility, lock, transparency (knockout and isolation included), +/// opacity mask and appearance. +fn expand_blend(d: &mut Document, id: NodeId) -> Result<()> { + let Some(n) = d.node(id).filter(|n| is_blend(n)).cloned() else { return Ok(()) }; + let mut g = n.clone(); + g.kind = NodeKind::Group { children: vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(), clip: false }; + fix_ids(d, &mut g); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object › Expand: every blend in the subtrees of `roots` (outer ones first) becomes a group of +/// its steps ([`expand_blend`]). Returns how many. +pub(crate) fn expand_blends(d: &mut Document, roots: &[NodeId]) -> Result { + let mut blends = vec![]; + for r in roots { + if let Some(n) = d.node(*r) { + n.walk(&mut |c| { + if is_blend(c) { + blends.push(c.id); + } + }); + } + } + for b in &blends { + expand_blend(d, *b)?; + } + Ok(blends.len()) +} + +/// Move the keys onto their spine positions (so what's stored matches what's drawn): onto their +/// anchors, or spread evenly by arc length along a spine from Replace Spine. fn keys_to_spine(children: &mut [Arc], spec: &BlendSpec) { - let Some(sp) = spec.spine.as_ref().and_then(Spine::new) else { return }; + let Some(path) = &spec.spine else { return }; let k = children.len(); + let pinned = spec.key_anchors.len() == k; + let sp = Spine::new(path); for (i, c) in children.iter_mut().enumerate() { - let f = if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }; - let (pt, _) = sp.at(f); - if let Some(b) = c.geometric_bounds() { + let pt = if pinned { + spec.key_anchors.get(i).and_then(|a| path.subpaths.first()?.anchors.get(*a as usize)).map(|a| a.p) + } else { + sp.as_ref().map(|sp| sp.at(if k > 1 { i as f64 / (k - 1) as f64 } else { 0.0 }).0) + }; + if let (Some(pt), Some(b)) = (pt, c.geometric_bounds()) { Arc::make_mut(c).transform(Affine::translate(pt - b.center()), false); } } } +/// Store the blend's spine pinned to its keys (see [`live::pin_spine`]); false when it has none. +fn pin(children: &[Arc], spec: &mut BlendSpec) -> bool { + match live::pin_spine(children, spec) { + Some((path, anchors)) => { + spec.spine = Some(path); + spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect(); + true + } + None => false, + } +} + +/// `spec`'s spine reversed: the key anchors count from the other end. +fn reverse_spine(spec: &mut BlendSpec) { + if let Some(sp) = &mut spec.spine { + sp.reverse(); + let n = sp.subpaths.first().map_or(0, |s| s.anchors.len() as u32); + for a in &mut spec.key_anchors { + *a = n.saturating_sub(1).saturating_sub(*a); + } + } +} + fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let st = s.doc()?; @@ -469,6 +859,7 @@ fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { spec.spine = Some(spine); + spec.key_anchors.clear(); keys_to_spine(children, spec); } sel.set([b]); @@ -479,8 +870,11 @@ fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { edit_blends(s, "Reverse Spine", |children, spec| { - if let Some(sp) = &mut spec.spine { - sp.reverse(); + if spec.spine.is_some() && pin(children, spec) { + // The keys keep their anchors, which now count from the other end. + if let Some(sp) = &mut spec.spine { + sp.reverse(); + } keys_to_spine(children, spec); } else { let centers: Vec = children.iter().map(|k| k.geometric_bounds().map(|b| b.center()).unwrap_or_default()).collect(); @@ -495,9 +889,14 @@ fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { fn blend_reverse_stack(s: &mut Session, _: &Value) -> Result { edit_blends(s, "Reverse Front to Back", |children, spec| { children.reverse(); - if let Some(sp) = &mut spec.spine { - sp.reverse(); + // Each key keeps its start point. + if !spec.starts.is_empty() { + spec.starts.resize(children.len(), None); + spec.starts.reverse(); } + // Each key keeps its place: on its anchor, or (spread evenly) with the spine reversed. + spec.key_anchors.reverse(); + reverse_spine(spec); }) } @@ -526,8 +925,23 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result>, kind: EnvelopeKind) -> Node { - Node::new(id, NodeKind::Envelope { content, kind, fidelity: live::default_fidelity(), editing: false }) +/// The options and fidelity new envelopes get (Envelope Options with no envelope selected). +fn envelope_defaults(s: &Session) -> (EnvelopeOptions, f64) { + s.envelope_defaults.unwrap_or((EnvelopeOptions::NEW, live::default_fidelity())) +} + +fn envelope(id: NodeId, content: Vec>, kind: EnvelopeKind, (options, fidelity): (EnvelopeOptions, f64)) -> Node { + Node::new(id, NodeKind::Envelope { content, kind, fidelity, editing: false, options, frame: Affine::IDENTITY }) +} + +/// `rows` and `cols` (or `columns`), 1..50 each, defaulting to `default`. +fn rows_cols(p: &Value, cmd: &str, default: (u32, u32)) -> Result<(u32, u32)> { + let rows = f64_or(p, "rows", default.0 as f64); + let cols = f64_or(p, "cols", f64_or(p, "columns", default.1 as f64)); + if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { + return Err(bad(cmd, "rows and cols must be 1..50")); + } + Ok((rows as u32, cols as u32)) } fn env_make_warp(s: &mut Session, p: &Value) -> Result { @@ -536,26 +950,32 @@ fn env_make_warp(s: &mut Session, p: &Value) -> Result { if roots.is_empty() { return Err(EngineError::Other("nothing selected".into())); } - let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind)))?; + let defaults = envelope_defaults(s); + let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; Ok(json!({ "id": id.0 })) } +/// A mesh envelope of `rows`×`cols` patches for an envelope with map `map`: following its +/// current surface (`maintain`), or a flat grid over its content. +fn mesh_kind(map: &EnvelopeMap, maintain: bool, rows: u32, cols: u32) -> EnvelopeKind { + if !maintain { + return EnvelopeKind::Mesh { rows, cols, points: map.grid(rows, cols), handles: vec![] }; + } + // The surface's points with handles along it: the grid lines keep the current shape. + let m = map.surface_mesh(rows, cols, Color::BLACK); + EnvelopeKind::Mesh { rows, cols, points: m.points.iter().map(|q| q.p).collect(), handles: m.points.iter().map(|q| q.handles).collect() } +} + fn env_make_mesh(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.makeWithMesh"; - let rows = f64_or(p, "rows", 4.0); - let cols = f64_or(p, "cols", f64_or(p, "columns", 4.0)); - if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { - return Err(bad(C, "rows and cols must be 1..50")); - } + let (rows, cols) = rows_cols(p, C, (4, 4))?; let roots = roots_param(s, p)?; let st = s.doc()?; - let src = roots - .iter() - .filter_map(|id| st.doc.node(*id)) - .fold(None, |a, n| vectorcraft_geom::union_opt(a, n.geometric_bounds())) - .ok_or_else(|| bad(C, "selection has no bounds"))?; - let kind = EnvelopeKind::Mesh { rows: rows as u32, cols: cols as u32, points: live::grid_points(src, rows as u32, cols as u32) }; - let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind)))?; + let nodes: Vec> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); + let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; + let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; + let defaults = envelope_defaults(s); + let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; Ok(json!({ "id": id.0 })) } @@ -579,14 +999,153 @@ fn env_make_top(s: &mut Session, p: &Value) -> Result { let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) }; let content = content.to_vec(); let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?; + let defaults = envelope_defaults(s); let id = s.edit("Make Envelope", |d, sel| { - let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }))?; + let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }, defaults))?; d.remove(top)?; Ok(id) })?; Ok(json!({ "id": id.0 })) } +/// The kind `f` makes of each selected envelope (from the envelope and its current kind). +fn reshaped(s: &Session, f: impl Fn(&Node, &EnvelopeKind) -> Result) -> Result> { + let doc = &s.doc()?.doc; + let mut out = vec![]; + for e in selected_of(s, is_envelope) { + if let Some(n) = doc.node(e) + && let NodeKind::Envelope { kind, .. } = &n.kind + { + out.push((e, f(n, kind)?)); + } + } + Ok(out) +} + +/// Give each envelope of `kinds` its new kind (as one undo step `label`). +fn set_kinds(s: &mut Session, label: &str, kinds: Vec<(NodeId, EnvelopeKind)>) -> Result { + s.edit(label, |d, _| { + for (e, k) in kinds { + if let Some(NodeKind::Envelope { kind, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + *kind = k; + } + } + Ok(()) + })?; + ok() +} + +fn env_reset_warp(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.resetWithWarp"; + let kinds = reshaped(s, |_, k| warp_kind(p, C, Some(k)))?; + set_kinds(s, "Reset with Warp", kinds) +} + +fn env_reset_mesh(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.resetWithMesh"; + let maintain = bool_or(p, "maintainShape", true); + let kinds = reshaped(s, |env, kind| { + let own = match kind { + EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols), + _ => (4, 4), + }; + let (rows, cols) = rows_cols(p, C, own)?; + let map = EnvelopeMap::of(env).ok_or_else(|| bad(C, "the envelope's content has no bounds"))?; + Ok(mesh_kind(&map, maintain, rows, cols)) + })?; + set_kinds(s, "Reset with Mesh", kinds) +} + +/// `v` with an envelope's options and fidelity added. +fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Value { + v["fidelity"] = json!(fidelity); + v["antiAlias"] = json!(o.anti_alias); + v["preserveShape"] = json!(o.preserve_shape.id()); + v["distortAppearance"] = json!(o.distort_appearance); + v["distortLinearGradients"] = json!(o.distort_linear_gradients); + v["distortPatternFills"] = json!(o.distort_pattern_fills); + v +} + +/// `cur` with the options `p` sets. +fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result { + let preserve_shape = match str_param(p, "preserveShape") { + Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?, + None => cur.preserve_shape, + }; + Ok(EnvelopeOptions { + anti_alias: bool_or(p, "antiAlias", cur.anti_alias), + preserve_shape, + distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance), + distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients), + distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills), + }) +} + +fn env_info(s: &mut Session, _: &Value) -> Result { + let doc = &s.doc()?.doc; + let env = selected_of(s, is_envelope).into_iter().find_map(|e| Some((e, &doc.node(e)?.kind))); + let Some((id, NodeKind::Envelope { kind, fidelity, editing, options, .. })) = env else { + let (options, fidelity) = envelope_defaults(s); + return Ok(envelope_options_json(json!({ "id": null }), &options, fidelity)); + }; + let v = match kind { + EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + json!({"type": "warp", "style": style, "bend": bend, "h": h, "v": v, "horizontal": horizontal}) + } + EnvelopeKind::Mesh { rows, cols, .. } => json!({"type": "mesh", "rows": rows, "cols": cols}), + EnvelopeKind::TopObject { .. } => json!({"type": "topObject"}), + }; + let mut v = envelope_options_json(v, options, *fidelity); + v["id"] = json!(id.0); + v["editing"] = json!(editing); + Ok(v) +} + +/// What Release gives back as the shape of envelope `env` (of `kind`): a top object's path, or the +/// surface of a warp or mesh envelope as a gradient mesh, painted grey (id 0). +fn envelope_shape(env: &Node, kind: &EnvelopeKind) -> Option { + let grey = Color::gray(0.25); + let (rows, cols) = match kind { + EnvelopeKind::TopObject { path } => { + return Some(Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(grey), Paint::None, 0.0))); + } + // The mesh itself, handles and all. + EnvelopeKind::Mesh { rows, cols, points, handles } => { + let mut m = live::envelope_grid(*rows, *cols, points, handles)?; + m.points.iter_mut().for_each(|q| q.color = grey); + return Some(Node::new(NodeId(0), NodeKind::Mesh(m))); + } + EnvelopeKind::Warp { .. } => (4, 4), + }; + EnvelopeMap::of(env).map(|map| Node::new(NodeId(0), NodeKind::Mesh(map.surface_mesh(rows, cols, grey)))) +} + +/// Released content keeps envelope `env`'s opacity, blend mode, isolation, knockout and opacity +/// mask: a single object without its own takes them, else a new group around the content does. +fn carry_transparency(d: &mut Document, env: &Node, mut nodes: Vec) -> Vec { + if env.has_default_transparency() { + return nodes; + } + let carry = |n: &mut Node| { + n.opacity = env.opacity; + n.blend = env.blend; + n.isolate = env.isolate; + n.knockout = env.knockout; + n.knockout_shape = env.knockout_shape; + n.mask = env.mask.clone(); + }; + if let [one] = nodes.as_mut_slice() + && one.has_default_transparency() + { + carry(one); + return nodes; + } + let mut g = Node::group(d.alloc_id(), nodes.into_iter().map(Arc::new).collect()); + carry(&mut g); + vec![g] +} + fn env_release(s: &mut Session, _: &Value) -> Result { let envs = selected_of(s, is_envelope); let ids = s.edit("Release Envelope", |d, sel| { @@ -594,15 +1153,10 @@ fn env_release(s: &mut Session, _: &Value) -> Result { for e in &envs { let Some(n) = d.node(*e).cloned() else { continue }; let NodeKind::Envelope { content, kind, .. } = &n.kind else { continue }; - let mut nodes: Vec = content.iter().map(|c| (**c).clone()).collect(); - // The envelope shape comes back as a grey path above the content. - let shape = match kind { - EnvelopeKind::TopObject { path } => Some(path.clone()), - EnvelopeKind::Mesh { .. } | EnvelopeKind::Warp { .. } => None, - }; - if let Some(path) = shape { - let id = d.alloc_id(); - nodes.push(Node::path(id, path, Appearance::basic(Paint::solid(Color::gray(0.25)), Paint::None, 0.0))); + let mut nodes = carry_transparency(d, &n, content.iter().map(|c| (**c).clone()).collect()); + if let Some(mut shape) = envelope_shape(&n, kind) { + shape.id = d.alloc_id(); + nodes.push(shape); } out.extend(replace_with(d, *e, nodes)?); } @@ -614,28 +1168,33 @@ fn env_release(s: &mut Session, _: &Value) -> Result { fn env_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.options"; - let envs = selected_of(s, is_envelope); let fid = p.get("fidelity").and_then(Value::as_f64); if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { return Err(bad(C, "fidelity must be 0..100")); } - // Validate warp params up front. + let envs = selected_of(s, is_envelope); + if envs.is_empty() { + // No envelope selected: the options new envelopes get. + let (o, f) = envelope_defaults(s); + s.envelope_defaults = Some((options_param(p, C, o)?, fid.unwrap_or(f))); + return ok(); + } + // Validate everything up front. let doc = &s.doc()?.doc; - let mut kinds = vec![]; + let mut updates = vec![]; for e in &envs { - if let Some(NodeKind::Envelope { kind: k @ EnvelopeKind::Warp { .. }, .. }) = doc.node(*e).map(|n| &n.kind) { - kinds.push((*e, warp_kind(p, C, Some(k))?)); + if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = doc.node(*e).map(|n| &n.kind) { + let kind = match kind { + EnvelopeKind::Warp { .. } => warp_kind(p, C, Some(kind))?, + k => k.clone(), + }; + updates.push((*e, kind, options_param(p, C, *options)?, fid.unwrap_or(*fidelity))); } } s.edit("Envelope Options", |d, _| { - for e in &envs { - if let Some(NodeKind::Envelope { fidelity, kind, .. }) = d.node_mut(*e).map(|n| &mut n.kind) { - if let Some(f) = fid { - *fidelity = f; - } - if let Some((_, k)) = kinds.iter().find(|(id, _)| id == e) { - *kind = k.clone(); - } + for (e, k, o, f) in updates { + if let Some(NodeKind::Envelope { kind, options, fidelity, .. }) = d.node_mut(e).map(|n| &mut n.kind) { + (*kind, *options, *fidelity) = (k, o, f); } } Ok(()) @@ -643,21 +1202,48 @@ fn env_options(s: &mut Session, p: &Value) -> Result { ok() } +/// Replace envelope `id` by a group of its distorted content (type outlined) with its id, name, +/// transparency and opacity mask; the generated pieces get ids of their own. +fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> { + let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n); + fix_ids(d, &mut g); + // Outlined type repeats its object's id on its pieces. + vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default()); + *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; + Ok(()) +} + +/// Object → Expand: every envelope in `root`'s subtree (itself included) expanded +/// ([`expand_envelope`]; envelopes inside envelopes go with them). Returns how many. +pub(super) fn expand_envelopes_under(d: &mut Document, root: NodeId) -> Result { + fn collect(n: &Node, out: &mut Vec) { + if is_envelope(n) { + out.push(n.id); + } else { + for c in n.children().into_iter().flatten() { + collect(c, out); + } + } + } + let mut envs = vec![]; + if let Some(n) = d.node(root) { + collect(n, &mut envs); + } + for e in &envs { + expand_envelope(d, *e)?; + } + Ok(envs.len()) +} + fn env_expand(s: &mut Session, _: &Value) -> Result { let envs = selected_of(s, is_envelope); let ids = s.edit("Expand Envelope", |d, sel| { - let mut out = vec![]; for e in &envs { - let Some(n) = d.node(*e).cloned() else { continue }; - let children = vectorcraft_render::expand_live(&n).into_iter().map(Arc::new).collect(); - let mut g = Node::new(n.id, NodeKind::Group { children, clip: false }); - g.opacity = n.opacity; - g.blend = n.blend; - fix_ids(d, &mut g); - out.extend(replace_with(d, *e, vec![g])?); + expand_envelope(d, *e)?; } - sel.set(out.iter().copied()); - Ok(out) + sel.set(envs.iter().copied()); + Ok(envs.clone()) })?; Ok(ids_json(&ids)) } @@ -691,10 +1277,24 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; let index = f64_req(p, "index", C)? as usize; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + let handle = handle_param(p, C)?; s.edit("Move Envelope Point", |d, _| { - match d.node_mut(id).map(|n| &mut n.kind) { - Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { points, .. }, .. }) if index < points.len() => points[index] = q, - _ => return Err(bad(C, "not a mesh envelope point")), + let Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { + return Err(bad(C, "not a mesh envelope point")); + }; + let at = *points.get(index).ok_or_else(|| bad(C, "not a mesh envelope point"))?; + match handle { + // The handles are offsets: they follow. + None => points.get_mut(index).into_iter().for_each(|p| *p = q), + Some(h) => { + // The first handle edited fills in the smooth mesh's handles. + if handles.len() != points.len() { + *handles = live::smooth_handles(*rows, *cols, points); + } + if let Some(hs) = handles.get_mut(index) { + hs[h] = q - at; + } + } } Ok(()) })?; @@ -705,18 +1305,14 @@ fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { fn mesh_create(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.create"; - let rows = f64_or(p, "rows", 4.0); - let cols = f64_or(p, "cols", f64_or(p, "columns", 4.0)); - if !(1.0..=50.0).contains(&rows) || !(1.0..=50.0).contains(&cols) { - return Err(bad(C, "rows and cols must be 1..50")); - } + let (rows, cols) = rows_cols(p, C, (4, 4))?; let app = match str_param(p, "appearance") { Some(a) => MeshAppearance::parse(a).ok_or_else(|| bad(C, "appearance must be flat|center|edge"))?, None => MeshAppearance::Flat, }; let highlight = f64_or(p, "highlight", 100.0).clamp(0.0, 100.0); let at = point_param(p, "at"); - let (rows, cols) = if at.is_some() { (1, 1) } else { (rows as u32, cols as u32) }; + let (rows, cols) = if at.is_some() { (1, 1) } else { (rows, cols) }; let roots = roots_param(s, p)?; let (ids, index) = s.edit("Create Gradient Mesh", |d, sel| { let mut out = vec![]; @@ -760,18 +1356,39 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result { Ok(r) } -fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { +/// 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). +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) { Some(NodeKind::Mesh(m)) => f(m), - Some(_) => Err(bad(&c, "not a gradient mesh")), + Some(NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. }) if envelopes => { + let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "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" })), None => Err(EngineError::NoNode(id)), }) } -fn index_param(p: &Value, cmd: &str) -> Result { - let i = f64_req(p, "index", cmd)?; +/// The `handle` parameter (0 right, 1 left, 2 down, 3 up), if any. +fn handle_param(p: &Value, cmd: &str) -> Result> { + match p.get("handle") { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(h) if h <= 3 => Ok(Some(h as usize)), + _ => Err(bad(cmd, "handle must be 0..3")), + }, + } +} + +fn index_param(p: &Value, key: &str, cmd: &str) -> Result { + let i = f64_req(p, key, cmd)?; if i < 0.0 { return Err(bad(cmd, "index must be ≥ 0")); } @@ -780,13 +1397,13 @@ fn index_param(p: &Value, cmd: &str) -> Result { fn mesh_set_color(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.setPointColor"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; let color = p.get("color").and_then(color_value); 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", |m| { + 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"))?; if let Some(c) = color { pt.color = c; @@ -801,13 +1418,10 @@ fn mesh_set_color(s: &mut Session, p: &Value) -> Result { fn mesh_move_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.movePoint"; - let index = index_param(p, C)?; + let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); - let handle = p.get("handle").and_then(Value::as_u64).map(|h| h as usize); - if handle.is_some_and(|h| h > 3) { - return Err(bad(C, "handle must be 0..3")); - } - with_mesh(s, p, C, "Move Mesh Point", |m| { + let handle = handle_param(p, C)?; + with_mesh(s, p, C, "Move Mesh Point", true, |m| { let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?; match handle { Some(h) => pt.handles[h] = q - pt.p, @@ -822,7 +1436,7 @@ 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)?); let color = p.get("color").and_then(color_value); - let index = with_mesh(s, p, C, "Add Mesh Line", |m| { + let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| { let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?; if let Some(c) = color { m.points[i].color = c; @@ -834,8 +1448,8 @@ fn mesh_add_line(s: &mut Session, p: &Value) -> Result { fn mesh_delete_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.deletePoint"; - let index = index_param(p, C)?; - with_mesh(s, p, C, "Delete Mesh Point", |m| { + let index = index_param(p, "index", C)?; + with_mesh(s, p, C, "Delete Mesh Point", true, |m| { if m.remove_point_lines(index) { Ok(()) } else { Err(bad(C, "only interior mesh lines can be deleted")) } })?; ok() diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index d1be61343..cdf6f1559 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -86,9 +86,9 @@ pub fn specs() -> Vec { "Reset Bounding Box", ["Object", "Transform"], None, - "{} no-op: VectorCraft bounding boxes are always axis-aligned to the document → {changed: 0}", + "{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}", has_selection, - |_, _| Ok(json!({ "changed": 0 })) + reset_bounding_box ), cmd!( "object.rasterize", @@ -177,6 +177,25 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) { } } +// ---------- Reset Bounding Box ---------- + +fn reset_bounding_box(s: &mut Session, _: &Value) -> Result { + let ids = selected_roots(s)?; + let d = &s.doc()?.doc; + let turned: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect(); + if !turned.is_empty() { + s.edit("Reset Bounding Box", |d, _| { + for id in &turned { + if let Some(n) = d.node_mut(*id) { + n.bbox_angle = 0.0; + } + } + Ok(()) + })?; + } + Ok(json!({ "changed": turned.len() })) +} + // ---------- Lock / Hide ---------- fn above(s: &mut Session, lock: bool) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 875cd2950..d61365d61 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ pub mod colormgmt; pub mod colortheme; mod create; mod css; +mod cut; pub(crate) mod distortcmds; mod docinfo; mod docmenu; @@ -26,6 +27,7 @@ mod fonts; pub(crate) mod freeform; pub(crate) mod gradient; pub(crate) mod graph; +mod halftone; pub mod help; mod layer; pub mod links; @@ -44,7 +46,9 @@ mod path; mod pathops; mod patterncmds; pub mod pdfcmds; +pub mod perspgrid; mod place; +pub mod plugin; pub mod prefscmds; pub mod print; pub(crate) mod printadvanced; @@ -244,6 +248,10 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(css::specs()); v.extend(printpresets::specs()); v.extend(printtiling::specs()); + v.extend(plugin::specs()); + v.extend(cut::specs()); + v.extend(halftone::specs()); + v.extend(perspgrid::specs()); v }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 1c08f1c1d..0b80ea938 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, Scaling}; +use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling}; use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2}; use super::edit::{duplicate_in, selected_roots}; @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Rotate…", ["Object", "Transform"], None, - "{angle: deg (counter-clockwise), origin?: [x,y], copy?}", + "{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}", has_selection, rotate ), @@ -175,7 +175,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", has_selection, set_bounds ), @@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { if let Some(o) = point_param(p, "origin") { return Ok(o); } - s.transform_bounds(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) + s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into())) } /// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from @@ -219,6 +219,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf let st = s.doc_mut()?; if st.interaction.is_none() { st.last_transform = Some((xf, copy)); + st.last_perspective = None; } Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } @@ -255,8 +256,12 @@ fn about(o: Point, a: Affine) -> Affine { fn rotate(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let o = origin_of(s, p, &ids)?; - // Illustrator angles are counter-clockwise; y is down, so negate. - let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians())); + let mut angle = f64_or(p, "angle", 0.0); + if bool_or(p, "absolute", false) { + angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids)); + } + // Angles are counter-clockwise; y is down, so negate. + let a = about(o, Affine::rotate(-angle.to_radians())); apply_transform(s, "Rotate", ids, a, p) } @@ -295,6 +300,9 @@ fn shear(s: &mut Session, p: &Value) -> Result { } fn transform_again(s: &mut Session, _: &Value) -> Result { + if let Some(again) = s.doc()?.last_perspective.clone() { + return super::distortcmds::transform_again(s, &again); + } let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?; let ids = selected_roots(s)?; apply_transform(s, "Transform Again", ids, m, &json!({ "copy": copy })) @@ -657,6 +665,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result { fn exit_isolation(s: &mut Session, _: &Value) -> Result { let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { + super::distortcmds::finish_edit_text(st, i); st.selection.set([i]); } st.revision += 1; @@ -716,6 +725,11 @@ impl Session { pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds) } + /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding + /// box, see [`Document::oriented_bounds`]). + pub fn transform_box(&self, ids: &[NodeId]) -> Option { + self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds) + } } /// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds. @@ -876,10 +890,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { fn set_bounds(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let d = &s.doc()?.doc; - let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + // Sizes are measured square to the bounding box, which a rotation turns with the objects: + // the math below runs in its frame. + let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; + let g = frame.rect; // With Use Preview Bounds the values measure the visual box, whose margins around the // geometric box (left, top, right, bottom) scale with the strokes or stay. - let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g }; + let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g }; let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1]; let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; let rp = vectorcraft_geom::reference_point(v, refi); @@ -906,9 +923,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { // box's reference point moves with the art, the margin from it to the visual one by `f`. let gp = vectorcraft_geom::reference_point(g, refi); let new_rp = scale * gp + (rp - gp) * f(sx, sy); - let tx = f64_or(p, "x", rp.x) - new_rp.x; - let ty = f64_or(p, "y", rp.y) - new_rp.y; - apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p) + // x, y place the reference point on the page. + let page = frame.to_doc() * rp; + let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); + apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p) } fn expand_shape(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index e4faf3b9b..9486117bd 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -197,20 +197,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; - if independent { - a.kind = AnchorKind::Corner; - } - let (moved, other) = if out { (&mut a.h_out, &mut a.h_in) } else { (&mut a.h_in, &mut a.h_out) }; - *moved = pos; - if a.kind == AnchorKind::Smooth { - // Keep the opposite handle collinear, preserving its length. - let len = (*other - a.p).hypot(); - let dir = a.p - pos; - let l = dir.hypot(); - if l > 1e-9 { - *other = a.p + dir * (len / l); - } - } + a.set_handle(out, pos, independent); Ok(()) })?; ok() diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index a060826be..adf6afe73 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -757,11 +757,7 @@ fn clean_up(s: &mut Session, p: &Value) -> Result { visit(c, true, false, false, v, opts); } } - NodeKind::Text(t) => { - if empty_text && t.plain_text().trim().is_empty() { - v.push(n.id); - } - } + NodeKind::Text(t) if empty_text && t.plain_text().trim().is_empty() => v.push(n.id), NodeKind::Layer { children, .. } => { for c in children { visit(c, false, false, false, v, opts); diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs new file mode 100644 index 000000000..e272dce41 --- /dev/null +++ b/crates/engine/src/cmd/perspgrid.rs @@ -0,0 +1,481 @@ +//! View → Perspective Grid → Define Grid: `perspective.grid.get` reads the grid (the model, the +//! Define Grid fields and the station point) and `perspective.grid.define` sets it from those +//! fields. The model and the camera maths live in `vectorcraft_tools::distort::perspective`. +//! +//! Presets: the built-in views (generated in code, fitted to the first artboard, protected) and +//! the user's, saved with the preferences ([`crate::Prefs::perspective_presets`]) as Define Grid +//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them. +//! +//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on +//! the grid: they toggle without an undo step, as Show Grid does. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_tools::distort::perspective::Plane; +use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin}; +use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace}; +use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid}; +use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey}; + +use super::distortcmds::{grid_of, silent, store_grid}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "perspective.grid.get", + "Perspective Grid", + [], + None, + "{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}", + has_doc, + grid_get + ), + cmd!( + "perspective.grid.define", + "Define Perspective Grid", + [], + None, + "{name?: preset name (\"\" = custom), kind?: 1|2|3, units?: unit name (points, pixels, inches, centimeters, millimeters…), scale?: [artboard, real world], gridline?: gridline every, angle?: viewing angle (0–90°), distance?: viewing distance, horizonHeight?: above the ground level, thirdVp?: [x right, y up] from the centre of vision (3-point), leftColor?, rightColor?, groundColor?: \"#rrggbb\", opacity?: 0–100} lengths are real-world lengths in `units` at `scale`; missing fields keep the grid's (see perspective.grid.get define). Only what changes changes: a new type, angle or distance moves the vanishing points around the station point. One undo step → the new define fields", + has_doc, + grid_define + ), + cmd!( + query "perspective.presets.list", + "Perspective Grid Presets", + [], + None, + "{} → {presets: [{builtIn, name, kind, …the perspective.grid.define fields}]} the built-in views (fitted to the first artboard, protected) first, then the saved presets", + always, + presets_list + ), + cmd!( + query "perspective.presets.save", + "Save Perspective Grid Preset", + [], + None, + "{name?: (default: a new \"Perspective Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else the document's grid), …the perspective.grid.define fields (over it)} create or change a saved preset (Save Grid as Preset: just `name`); built-in ones are protected → {name, created, definition}", + always, + presets_save + ), + cmd!( + query "perspective.presets.delete", + "Delete Perspective Grid Preset", + [], + None, + "{name} delete a saved preset (built-in ones stay) → {deleted: name}", + always, + presets_delete + ), + cmd!( + query "perspective.presets.export", + "Export Perspective Grid Presets", + [], + None, + "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcperspective file (JSON) → {path, count}; without path → {data: the file's text, count}", + always, + presets_export + ), + cmd!( + query "perspective.presets.import", + "Import Perspective Grid Presets", + [], + None, + "{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcperspective file (as perspective.presets.export writes) to the saved ones → {imported: [names]}", + always, + presets_import + ), + cmd!( + query "perspective.widget.options", + "Perspective Grid Options", + [], + None, + "{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}", + always, + widget_options + ), + cmd!( + "perspective.grid.lock", + "Lock Grid", + [], + None, + "{on?: bool} (no param toggles) View › Perspective Grid › Lock Grid: the grid's widgets can't be dragged (the Plane Switching Widget still works). View state: not an undo step → {on}", + has_doc, + lock + ), + cmd!( + "perspective.grid.lockStation", + "Lock Station Point", + [], + None, + "{on?: bool} (no param toggles) dragging one vanishing point moves the other around the station point (the viewing angle turns, the viewer stays). View state: not an undo step → {on}", + has_doc, + lock_station + ), + cmd!( + "perspective.grid.snap", + "Snap to Grid", + [], + None, + "{on?: bool} (no param toggles) art drawn or moved in perspective lands on gridlines within a quarter cell (on by default). View state: not an undo step → {on}", + has_doc, + snap + ), + cmd!( + "perspective.grid.rulers", + "Show Rulers", + [], + None, + "{on?: bool} (no param toggles) a ruler up the line where the planes meet, in the grid's units at its scale. View state: not an undo step → {on}", + has_doc, + rulers + ), + ] +} + +/// The `format` of a perspective grid presets file, also its extension. +pub const PRESET_FORMAT: &str = "vcperspective"; + +/// The extensions of perspective grid presets files (opening one imports it). +pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT]; + +/// The most presets one file imports. +const MAX_IMPORT: usize = 1000; + +/// What `perspective.presets.export` writes and `perspective.presets.import` reads. +#[derive(Serialize, Deserialize)] +struct PresetFile { + format: String, + #[serde(default)] + presets: Vec, +} + +impl Session { + /// The artboard presets are fitted to: the active document's first (a Letter page without). + fn preset_artboard(&self) -> Rect { + self.active().map_or(LETTER, |d| first_artboard(&d.doc)) + } + + /// Every perspective grid preset: the built-in views, then the saved ones. + pub fn perspective_presets(&self) -> Vec { + let ab = self.preset_artboard(); + let builtins = BUILTINS.iter().filter_map(|b| PerspectiveGrid::builtin(b.0, ab)).map(|g| g.definition()); + builtins.chain(self.prefs.perspective_presets.iter().cloned()).collect() + } + + /// The preset `name` (any case): a built-in view or a saved one. + pub fn perspective_preset(&self, name: &str) -> Option { + match PerspectiveGrid::builtin(name, self.preset_artboard()) { + Some(g) => Some(g.definition()), + None => self.persp_saved_index(name).and_then(|i| self.prefs.perspective_presets.get(i)).cloned(), + } + } + + /// The grid preset `name` makes on `ab`: a built-in view, or a saved preset's fields over the + /// normal view of its type. + pub fn perspective_preset_grid(&self, name: &str, ab: Rect) -> Option { + if let Some(g) = PerspectiveGrid::builtin(name, ab) { + return Some(g); + } + let def = self.perspective_preset(name)?; + PerspectiveGrid::normal(def.kind, ab).with_definition(&def).ok() + } + + /// The first free "Perspective Preset N": the name a new preset gets. + pub fn new_perspective_preset_name(&self) -> String { + (1..).map(|i| format!("Perspective Preset {i}")).find(|n| !self.persp_preset_taken(n, None)).unwrap_or_default() + } + + /// The index of the saved preset `name` (any case). + fn persp_saved_index(&self, name: &str) -> Option { + self.prefs.perspective_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim())) + } + + /// Whether `name` is a built-in view's or a saved preset's other than the one at `except`. + fn persp_preset_taken(&self, name: &str, except: Option) -> bool { + is_builtin(name) || self.persp_saved_index(name).is_some_and(|i| Some(i) != except) + } + + /// The name of the first preset among `candidates` whose fields `def` has ("" for none). + fn preset_named(&self, def: &GridDefinition, candidates: &[&str]) -> String { + candidates + .iter() + .filter(|n| !n.is_empty()) + .find_map(|n| self.perspective_preset(n).filter(|p| p.same(def))) + .map(|p| p.name) + .unwrap_or_default() + } +} + +/// What `perspective.grid.get` reports for `g`. +pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) -> Value { + let st = g.station(); + json!({ + "grid": g.definition_json(), + "define": g.definition(), + "station": {"x": st.x, "y": g.horizon, "distance": st.distance}, + "defined": PerspectiveGrid::from_doc(doc).is_some(), + }) +} + +fn grid_get(s: &mut Session, _: &Value) -> Result { + let doc = &s.doc()?.doc; + Ok(grid_info(doc, &grid_of(doc))) +} + +fn grid_define(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.define"; + let old = grid_of(&s.doc()?.doc); + let def: GridDefinition = old.definition().merged(p).map_err(|e| bad(C, e))?; + let mut g = old.with_definition(&def).map_err(|e| bad(C, e))?; + // The grid is the preset it was given or came from while it has that preset's fields. + g.name = s.preset_named(&g.definition(), &[&def.name, &old.name]); + if g != old { + s.edit("Define Perspective Grid", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + } + Ok(json!(g.definition())) +} + +/// `perspective.grid.preset`: reset the grid to a preset fitted to the first artboard (shown; +/// the attached objects stay attached). +pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.preset"; + let name = match (str_param(p, "name"), p.get("kind").and_then(Value::as_u64)) { + (Some(n), _) => n.to_string(), + (None, Some(k @ 1..=3)) => PerspectiveGrid::normal(k as u8, LETTER).name, + _ => return Err(bad(C, "kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list)")), + }; + let old = grid_of(&s.doc()?.doc); + let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?; + let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?; + // The objects stay attached and the view options stay as they were. + let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g }; + s.edit("Perspective Grid Preset", |d, _| { + store_grid(d, &g); + Ok(()) + })?; + Ok(g.definition_json()) +} + +fn presets_list(s: &mut Session, _: &Value) -> Result { + let rows: Vec = s + .perspective_presets() + .into_iter() + .map(|d| { + let mut v = json!(d); + v["builtIn"] = json!(is_builtin(&d.name)); + v + }) + .collect(); + Ok(json!({ "presets": rows })) +} + +fn presets_save(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.presets.save"; + let name = match str_param(p, "name").map(str::trim) { + Some("") => return Err(bad(C, "`name` is empty")), + Some(n) => n.to_string(), + None => s.new_perspective_preset_name(), + }; + if is_builtin(&name) { + return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name"))); + } + let at = s.persp_saved_index(&name); + let saved = at.and_then(|i| s.prefs.perspective_presets.get(i)).cloned(); + // A saved preset changes from its own fields unless `preset` says where to start. + let base = match (str_param(p, "preset"), &saved) { + (Some(b), _) => { + s.perspective_preset(b).ok_or_else(|| bad(C, format!("no perspective grid preset named `{b}` (see perspective.presets.list)")))? + } + (None, Some(old)) => old.clone(), + (None, None) => s.active().map(|d| grid_of(&d.doc).definition()).unwrap_or_default(), + }; + let mut def = base.merged(p).map_err(|e| bad(C, e))?; + def.name = match str_param(p, "newName").map(str::trim) { + Some("") => return Err(bad(C, "`newName` is empty")), + Some(n) if s.persp_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))), + Some(n) => n.to_string(), + None => saved.map_or(name, |old| old.name), + }; + let r = json!({ "name": def.name, "created": at.is_none(), "definition": def }); + match at.and_then(|i| s.prefs.perspective_presets.get_mut(i)) { + Some(slot) => *slot = def, + None => s.prefs.perspective_presets.push(def), + } + Ok(r) +} + +fn presets_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.presets.delete"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + if is_builtin(name) { + return Err(bad(C, format!("`{name}` is a built-in preset and stays"))); + } + let i = s.persp_saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?; + Ok(json!({ "deleted": s.prefs.perspective_presets.remove(i).name })) +} + +fn presets_export(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.presets.export"; + let presets = match p.get("names").and_then(Value::as_array) { + Some(names) => { + let find = |n: &Value| { + let n = n.as_str().unwrap_or_default(); + s.perspective_preset(n).ok_or_else(|| bad(C, format!("no perspective grid preset named `{n}`"))) + }; + names.iter().map(find).collect::>>()? + } + None => s.prefs.perspective_presets.clone(), + }; + if presets.is_empty() { + return Err(bad(C, "no saved presets to export (name built-in ones in `names`)")); + } + let count = presets.len(); + let presets = presets.iter().map(|d| json!(d)).collect(); + let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?; + match str_param(p, "path") { + Some(path) => { + super::fileio::write_file(path, text.as_bytes())?; + Ok(json!({"path": path, "count": count})) + } + None => Ok(json!({"data": text, "count": count})), + } +} + +fn presets_import(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.presets.import"; + let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => super::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?, + _ => return Err(bad(C, "give `path`, `data` or `dataBase64`")), + }; + let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a perspective grid presets file: {e}")))?; + if file.format != PRESET_FORMAT { + return Err(bad(C, format!("not a perspective grid presets file (format `{}`)", file.format))); + } + if file.presets.len() > MAX_IMPORT { + return Err(bad(C, format!("more than {MAX_IMPORT} presets"))); + } + // Every preset is checked as Define Grid checks its fields before any is added. + let base = GridDefinition::default(); + let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?")))); + let presets = file.presets.iter().map(check).collect::>>()?; + let replace = bool_or(p, "replace", false); + let mut imported = vec![]; + for mut preset in presets { + // A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays. + let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim(); + let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Perspective Preset").to_string(); + match s.persp_saved_index(&base).filter(|_| replace) { + Some(i) => { + if let Some(slot) = s.prefs.perspective_presets.get_mut(i) { + preset.name = slot.name.clone(); + imported.push(preset.name.clone()); + *slot = preset; + } + } + None => { + preset.name = unique_name(&base, |n| s.persp_preset_taken(n, None)); + imported.push(preset.name.clone()); + s.prefs.perspective_presets.push(preset); + } + } + } + Ok(json!({ "imported": imported })) +} + +/// Toggle (or set, with `on`) a view option of the grid. +fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result { + let want = p.get("on").and_then(Value::as_bool); + let mut g = silent(s, |g| { + let f = field(g); + *f = want.unwrap_or(!*f); + })?; + Ok(json!({ "on": *field(&mut g) })) +} + +fn lock(s: &mut Session, p: &Value) -> Result { + view_option(s, p, |g| &mut g.locked) +} + +fn lock_station(s: &mut Session, p: &Value) -> Result { + view_option(s, p, |g| &mut g.lock_station) +} + +fn snap(s: &mut Session, p: &Value) -> Result { + view_option(s, p, |g| &mut g.snap) +} + +fn rulers(s: &mut Session, p: &Value) -> Result { + view_option(s, p, |g| &mut g.rulers) +} + +/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`): +/// the plane and depth of the first one and the plane-space correction that lands the edge of the +/// joint bounds of the objects there nearer a gridline on it. +pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> { + let g = grid_of(doc); + if !bool_or(p, "snap", g.snap) { + return None; + } + let (plane, depth, b) = g.plane_bounds(doc, ids)?; + let dv = g.plane_delta(plane, depth, from, to)?; + Some((plane, depth, g.snap_offset(b, dv) - dv)) +} + +fn widget_options(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.widget.options"; + let mut o = s.prefs.perspective_widget; + if let Some(v) = p.get("show") { + o.show = v.as_bool().ok_or_else(|| bad(C, "show must be true or false"))?; + } + if let Some(v) = p.get("position") { + o.position = + v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?; + } + s.prefs.perspective_widget = o; + Ok(json!(o)) +} + +impl Session { + /// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its + /// drag and release go nowhere); None when the event isn't the widget's. + pub(crate) fn plane_widget_pointer(&mut self, ev: &PointerEvent, view: crate::ViewInfo) -> Option>> { + match ev.kind { + PointerKind::Down => { + let w = self.prefs.perspective_widget; + let st = self.active().filter(|_| w.show)?; + let g = PerspectiveGrid::current(&st.doc); + if !g.shown(self.tool_id()) { + return None; + } + let place = WidgetPlace { screen: view.screen.as_ref(), corner: w.position }; + let plane = g.widget_hit_at(&st.doc, 1.0 / view.zoom.max(1e-9), place, ev.pos)?; + self.plane_widget_press = true; + Some(self.execute("perspective.plane.set", &json!({ "plane": plane.id() })).map(|_| vec![])) + } + PointerKind::Drag if self.plane_widget_press => Some(Ok(vec![])), + PointerKind::Up if std::mem::take(&mut self.plane_widget_press) => Some(Ok(vec![])), + _ => None, + } + } + + /// The plane digit key `key` picks while the grid is shown: 1 left, 2 horizontal (ground), + /// 3 right, 4 none. + pub(crate) fn plane_key(&self, key: ToolKey) -> Option { + let plane = match key { + ToolKey::Digit(1) => Plane::Left, + ToolKey::Digit(2) => Plane::Ground, + ToolKey::Digit(3) => Plane::Right, + ToolKey::Digit(4) => Plane::None, + _ => return None, + }; + PerspectiveGrid::current(&self.active()?.doc).shown(self.tool_id()).then_some(plane) + } +} diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs new file mode 100644 index 000000000..e307f9d0d --- /dev/null +++ b/crates/engine/src/cmd/plugin.rs @@ -0,0 +1,238 @@ +//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`). +//! +//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional +//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run +//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a +//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any +//! effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`. + +use std::sync::{Arc, Mutex}; + +use serde_json::{Map, Value, json}; +use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry}; + +use super::*; + +/// What a plug-in error looks like to the user. +fn err(e: vectorcraft_plugins::Error) -> EngineError { + EngineError::Other(e.to_string()) +} + +/// JSON description of an installed plug-in. +pub fn describe(p: &Plugin) -> Value { + let m = p.manifest(); + let mut v = json!({ + "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, + "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), + "size": p.size(), "source": p.source(), + }); + if m.kind == Kind::Effect { + v["effect"] = json!(effect::effect_id(&m.id)); + } + v +} + +/// The plug-in's own parameters from command params: `params` if given, else the top-level keys +/// minus the ones the command itself uses. +fn plugin_params(p: &Value) -> Value { + if let Some(v) = p.get("params") { + return v.clone(); + } + match p { + Value::Object(m) => { + Value::Object(m.iter().filter(|(k, _)| !matches!(k.as_str(), "id" | "ids")).map(|(k, v)| (k.clone(), v.clone())).collect()) + } + _ => Value::Object(Map::new()), + } +} + +fn plugin_by_id(cmd: &str, p: &Value) -> Result> { + let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?; + registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into()))) +} + +fn run(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.run"; + let plugin = plugin_by_id(C, p)?; + let m = plugin.manifest(); + if m.kind != Kind::Filter { + return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id)))); + } + // Bad parameters fail before anything runs. + let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + let st = s.doc()?; + let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + let inputs = objects::filter_targets(&st.doc, &selected); + let parent = st.insertion_parent(); + let bounds = st.doc.bounds_of(&inputs, false); + let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); + let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect))}); + let input = objects::encode_input(&st.doc, &inputs); + let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + let out = objects::decode(&plugin.run(&input, ¶ms).map_err(err)?).map_err(err)?; + let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?; + sel.set(ids.iter().copied()); + Ok(ids) + })?; + Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()})) +} + +/// Largest base64 payload `plugin.install` accepts (the module limit, encoded). +const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4; + +/// The `.wasm` extension plug-in files have (File › Open installs them). +pub const EXTS: &[&str] = &["wasm"]; + +fn install(_: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.install"; + let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) { + (_, Some(data)) => { + if data.len() > MAX_DATA_CHARS { + return Err(bad(C, "`dataBase64` is larger than the module limit")); + } + let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?; + let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?; + match str_param(p, "name") { + Some(name) => plugin.with_source(name), + None => plugin, + } + } + (Some(path), None) => load_path(path)?, + (None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")), + }; + if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() { + return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); + } + Ok(describe(®istry::install(plugin))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_path(path: &str) -> Result { + registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err) +} + +#[cfg(target_arch = "wasm32")] +fn load_path(_: &str) -> Result { + Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into())) +} + +/// The plug-in folder last loaded from the preferences, and what happened. +static FOLDER: Mutex> = Mutex::new(None); + +/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since +/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only. +pub(crate) fn sync_prefs(s: &Session) { + let folder = s.prefs.plugins_folder.trim(); + if folder.is_empty() { + return; + } + let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + if last.as_ref().is_some_and(|(f, _)| f == folder) { + return; + } + *last = Some((folder.to_string(), load_folder(folder))); +} + +#[cfg(not(target_arch = "wasm32"))] +fn load_folder(folder: &str) -> Value { + match registry::load_folder(std::path::Path::new(folder)) { + Ok(r) => { + json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) + } + Err(e) => json!({"folder": folder, "error": e.to_string()}), + } +} + +#[cfg(target_arch = "wasm32")] +fn load_folder(folder: &str) -> Value { + json!({"folder": folder, "error": "plug-in folders are not available on the web"}) +} + +fn reload(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.reload"; + let folder = match p.get("path") { + Some(v) => v.as_str().map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "`path` must be a folder"))?.to_string(), + None => s.prefs.plugins_folder.trim().to_string(), + }; + if folder.is_empty() { + return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder")); + } + let report = load_folder(&folder); + if let Some(e) = report.get("error").and_then(Value::as_str) { + return Err(EngineError::Other(e.to_string())); + } + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone())); + Ok(report) +} + +fn list(_: &mut Session, _: &Value) -> Result { + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone()); + Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::>(), "folder": folder})) +} + +fn info(_: &mut Session, p: &Value) -> Result { + let plugin = plugin_by_id("plugin.info", p)?; + let mut v = describe(&plugin); + v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null); + v["defaults"] = Value::Object(plugin.manifest().defaults()); + v["lastError"] = json!(effect::last_error(plugin.id())); + Ok(v) +} + +fn remove(_: &mut Session, p: &Value) -> Result { + let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?; + if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "plugin.list", + "List Plug-ins", + [], + None, + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + always, + list + ), + cmd!( + query "plugin.info", + "Plug-in Info", + [], + None, + "{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}", + always, + info + ), + cmd!( + "plugin.run", + "Run Plug-in", + [], + None, + "{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection)} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}", + has_doc, + run + ), + cmd!( + query "plugin.install", + "Install Plug-in", + [], + None, + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + always, + install + ), + cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), + cmd!( + query "plugin.reload", + "Reload Plug-ins", + ["Object", "Plug-ins"], + None, + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}", + always, + reload + ), + ] +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 5919afd99..80cde8688 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -320,6 +320,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool), // Native saves (`document.save {compress}`) p!("useCompression", "File Handling", "Files", "Use Compression", bool), + // The W/H link of the Transform panel, the Properties panel and the Control bar + p!("constrainProportions", "General", "Transform Panel", "Constrain Width and Height Proportions", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { @@ -327,10 +329,11 @@ pub fn spec(key: &str) -> Option<&'static PrefSpec> { } /// Preferences kept as one object with a command of their own instead of [`PREF_SPECS`] rows (they -/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`). `prefs.get` +/// aren't in the Preferences dialog): the Eyedropper Options (`eyedropper.setOptions`) and the +/// Perspective Grid Options (`perspective.widget.options`). `prefs.get` /// and `prefs.set` take them by key (a partial object updates what it names) and `prefs.reset` /// without a category resets them. -pub const PREF_GROUPS: &[&str] = &["eyedropper"]; +pub const PREF_GROUPS: &[&str] = &["eyedropper", "perspectiveWidget"]; /// Validate a value for preference group `key` against `current`. fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result { @@ -339,6 +342,15 @@ fn validate_group(key: &str, current: &Value, v: &Value) -> std::result::Result< let cur: super::EyedropperOptions = serde_json::from_value(current.clone()).unwrap_or_default(); cur.merged(v).map(|o| json!(o)) } + "perspectiveWidget" => { + let mut merged = current.clone(); + if let (Some(o), Some(p)) = (merged.as_object_mut(), v.as_object()) { + o.extend(p.clone()); + } + serde_json::from_value::(merged) + .map(|o| json!(o)) + .map_err(|e| e.to_string()) + } _ => Err(format!("unknown preference `{key}`")), } } @@ -453,6 +465,8 @@ impl Session { st.revision += 1; } } + // Plug-ins in a newly set Additional Plug-ins Folder are installed. + super::plugin::sync_prefs(self); } } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index 88ccc4a18..25df95aac 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -273,7 +273,9 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) { let mut inner = m.clone(); inner.id = d.alloc_id(); (inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None); - inner.knockout = Default::default(); + // Knockout is between the object's own parts (a blend's steps): it stays with them, + // so the image doesn't knock out the art over it. + m.knockout = Default::default(); inner.knockout_shape = false; m.appearance = Default::default(); m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false }; diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index aa3cd500f..68195b5b6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}", has_doc, create_in_path ), @@ -58,6 +58,15 @@ pub fn specs() -> Vec { has_selection, fit_headline ), + cmd!( + "text.discardEmpty", + "Discard Empty Type", + [], + None, + "{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}", + has_doc, + discard_empty + ), ] } @@ -359,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { }; let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } }; let mut t = TextObject { + vertical: p.get("vertical").and_then(Value::as_bool).unwrap_or(false), kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], @@ -395,6 +405,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { let head = edit::slice_runs(&t.runs, 0, end); let measure = |tr: f64| { let mut h = TextObject { + vertical: t.vertical, kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), @@ -449,3 +460,31 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "tracking": applied})) } + +fn discard_empty(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.discardEmpty"; + let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + if edit::runs_len(&text_ref(s, id)?.runs) > 0 { + return Ok(json!({"removed": false})); + } + // The newest step whose document lacks the text created it: when the document now is that one + // plus the text (and its id), roll back to it without a trace. + let st = s.doc_mut()?; + if st.interaction.is_none() + && let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none()) + && let Some(created) = st.history.undo.get(k) + { + let mut now = (*st.doc).clone(); + let mut before = (*created.doc).clone(); + before.alloc_id(); + if now.remove(id).is_ok() && now == before { + st.doc = created.doc.clone(); + st.history.undo.truncate(k); + st.selection.prune(&st.doc); + st.revision += 1; + return Ok(json!({"removed": true})); + } + } + s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?; + Ok(json!({"removed": true})) +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 7eaf908e9..5cc62b58b 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -13,6 +13,18 @@ use super::*; pub fn specs() -> Vec { vec![ + cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| { + orientation(s, p, true) + }), + cmd!( + "type.orientation.horizontal", + "Horizontal", + ["Type", "Orientation"], + None, + "{ids?|id?} set horizontal writing", + has_selection, + |s, p| orientation(s, p, false) + ), cmd!( "type.createOutlines", "Create Outlines", @@ -61,6 +73,21 @@ pub fn specs() -> Vec { ] } +fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result { + let ids = text_targets(s, p, "Text Orientation")?; + s.edit("Text Orientation", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { + return Err(EngineError::NoNode(*id)); + }; + t.vertical = vertical; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::>()})) +} + /// Recompute the layout bounds cache after a text edit. pub(crate) fn refresh_bounds(t: &mut TextObject) { let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 704af969c..f7ebd27ff 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -25,6 +25,10 @@ pub fn node_summary(n: &Node) -> Value { if n.opacity < 1.0 { v["opacity"] = json!(n.opacity); } + // The angle of its rotated bounding box (counter-clockwise degrees). + if n.bbox_angle != 0.0 { + v["rotation"] = json!(n.bbox_angle); + } if !n.is_container() { v["fill"] = json!(n.appearance.fill_paint().label()); v["stroke"] = json!(n.appearance.stroke_paint().label()); @@ -64,6 +68,8 @@ pub fn document(s: &Session) -> Value { "isolation": st.isolation.map(|l| l.0), "selection": st.selection.objects.iter().map(|i| i.0).collect::>(), "selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)), + // The angle the selection's bounding box stands at (counter-clockwise degrees). + "selectionRotation": d.bbox_angle(&st.selection.objects), // The layer, group or object targeted through the Layers panel (`layer.target`). "target": st.selection.target.map(|t| t.0), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d95e3e57..8095cbf23 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::EyedropperOptions; pub use cmd::clipboard::Clipboard; +pub use cmd::distortcmds::perspective_click; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use tooling::{UiRequest, ViewInfo}; @@ -81,6 +82,9 @@ pub struct Interaction { /// Per-document state restored on cancel (current layer, isolation). pub active_layer: Option, pub isolation: Option, + /// The perspective transform the previews make (`perspective.transform` params): Transform + /// Again repeats it once the drag is committed. + pub perspective_again: Option, } /// Per-document editing state. @@ -131,6 +135,9 @@ pub struct DocState { pub recovery: Option, /// View → Show Print Tiling, per document (view state: not saved, not undoable). pub print_tiling: bool, + /// Transform Again after a perspective move or scale (Perspective Selection tool): the + /// `perspective.transform` params it repeats. `None` once an ordinary transform follows. + pub last_perspective: Option, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -163,6 +170,7 @@ impl DocState { recovered: false, recovery: None, print_tiling: false, + last_perspective: None, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). @@ -472,6 +480,30 @@ pub struct Prefs { /// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub print_presets: Vec, + /// Constrain Width and Height Proportions: the link between W and H in the Transform panel, + /// the Properties panel and the Control bar (their size fields pass `proportional` to + /// `object.setBounds`). + pub constrain_proportions: bool, + /// The tools' persistent options by store (a tool id, or a store a family shares: `liquify` + /// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]: + /// kept across tool switches and saved with the preferences. Not a Preferences dialog field: + /// resetting the preferences keeps them; `tool.setOption` edits them. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub tool_settings: std::collections::BTreeMap>, + /// View → Perspective Grid presets: the user's (the built-in ones aren't stored). A local + /// library, not a Preferences dialog field: resetting the preferences keeps it; + /// `perspective.presets.*` edit it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub perspective_presets: Vec, + /// Blend Options set with no blend selected: what new blends start with + /// (`object.blend.options`; none: Smooth Color, Align to Page). A tool setting, not a + /// Preferences dialog field: it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting the + /// preferences keeps it. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend_options: Option, + /// Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane + /// Switching Widget shows and where (`perspective.widget.options`). + pub perspective_widget: vectorcraft_tools::distort::perspective::widget::WidgetOptions, } impl Default for Prefs { @@ -604,6 +636,11 @@ impl Default for Prefs { web_export_presets: vec![], web_export_settings: None, print_presets: vec![], + constrain_proportions: false, + tool_settings: Default::default(), + perspective_presets: vec![], + blend_options: None, + perspective_widget: Default::default(), } } } @@ -668,6 +705,17 @@ pub struct Session { pub(crate) batch_stash: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, + /// While the active tool's actions run: their commands aren't "a command from outside the + /// tool" ([`Session::after_command`]). + pub(crate) in_tool_actions: bool, + /// Envelope Options with no envelope selected: the options and fidelity new envelopes get + /// (`None`: the reference app's defaults, fidelity 50); not saved. + pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, + /// The Liquify stroke the last live preview applied, which the next sample of the drag goes on + /// from ([`cmd::distortcmds::LiquifyStroke`]). + pub(crate) liquify_stroke: Option>, + /// A press on the Plane Switching Widget is under way: its drag and release are the widget's. + pub(crate) plane_widget_press: bool, } impl Default for Session { @@ -708,6 +756,10 @@ impl Session { note_depth: 1, batch_stash: None, recovery: Default::default(), + in_tool_actions: false, + envelope_defaults: None, + liquify_stroke: None, + plane_widget_press: false, } } @@ -747,7 +799,8 @@ impl Session { if self.batch_stash.is_none() { let _ = self.cancel_interaction(); } - self.tool = vectorcraft_tools::create(self.tool.id()); + self.keep_tool_settings(); + self.tool = self.make_tool(self.tool.id()); } pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { @@ -861,6 +914,9 @@ impl Session { let p = self.noted(params); self.journal.push((id.to_string(), p)); } + if self.depth == 0 { + self.after_command(); + } Ok(r) } @@ -1005,6 +1061,10 @@ impl Session { f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); st.revision += 1; + // Puppet Warp pins belong to the art they were placed on: another selection starts afresh. + if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) { + cmd::distortcmds::drop_puppet_pins(st); + } Ok(()) } @@ -1022,6 +1082,7 @@ impl Session { preview: None, active_layer: st.active_layer, isolation: st.isolation, + perspective_again: None, }); Ok(()) } @@ -1050,14 +1111,19 @@ impl Session { let st = self.doc_mut()?; let Some(mut it) = st.interaction.take() else { return Ok(()) }; let Some(preview) = it.preview.take() else { return Ok(()) }; + let perspective_again = it.perspective_again.take(); st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); if let Some(m) = m { st.last_transform = Some((m, copy)); + st.last_perspective = None; } } + if perspective_again.is_some() { + st.last_perspective = perspective_again; + } self.journal.push(preview); Ok(()) } @@ -1088,12 +1154,22 @@ impl Session { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_assets; #[cfg(test)] mod tests_attributes; #[cfg(test)] +mod tests_bboxrotate; +#[cfg(test)] +mod tests_blendfidelity; +#[cfg(test)] +mod tests_blendopts; +#[cfg(test)] +mod tests_blendspine; +#[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; @@ -1124,6 +1200,8 @@ mod tests_containers; #[cfg(test)] mod tests_css; #[cfg(test)] +mod tests_cut; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_distort; @@ -1136,6 +1214,14 @@ mod tests_editcolors; #[cfg(test)] mod tests_effectedit; #[cfg(test)] +mod tests_emptytype; +#[cfg(test)] +mod tests_envelope; +#[cfg(test)] +mod tests_envelope_distort; +#[cfg(test)] +mod tests_envelope_edit; +#[cfg(test)] mod tests_expand; #[cfg(test)] mod tests_eyedropper; @@ -1152,12 +1238,16 @@ mod tests_flatten; #[cfg(test)] mod tests_focal; #[cfg(test)] +mod tests_fontlist; +#[cfg(test)] mod tests_freeform; #[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; #[cfg(test)] +mod tests_halftone; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_knockout; @@ -1172,6 +1262,8 @@ mod tests_links; #[cfg(test)] mod tests_linkspanel; #[cfg(test)] +mod tests_liquify; +#[cfg(test)] mod tests_live; #[cfg(test)] mod tests_maskview; @@ -1210,8 +1302,18 @@ mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; #[cfg(test)] +mod tests_persp_planes; +#[cfg(test)] +mod tests_persp_select; +#[cfg(test)] +mod tests_persp_text; +#[cfg(test)] +mod tests_perspgrid; +#[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_plugins; +#[cfg(test)] mod tests_prefs; #[cfg(test)] mod tests_previewbounds; @@ -1230,6 +1332,8 @@ mod tests_printtiling; #[cfg(test)] mod tests_proxyitems; #[cfg(test)] +mod tests_puppetwarp; +#[cfg(test)] mod tests_rastersettings; #[cfg(test)] mod tests_recolor; @@ -1276,6 +1380,8 @@ mod tests_tileedge; #[cfg(test)] mod tests_tints; #[cfg(test)] +mod tests_toolsettings; +#[cfg(test)] mod tests_transparencygrid; #[cfg(test)] mod tests_units; @@ -1286,4 +1392,6 @@ mod tests_widthpoints; #[cfg(test)] mod tests_widthprofiles; #[cfg(test)] +mod tests_widthtool; +#[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs new file mode 100644 index 000000000..040a4924d --- /dev/null +++ b/crates/engine/src/tests_adjust.rs @@ -0,0 +1,137 @@ +//! Live colour adjustments (Effect → Color Adjustments) through the commands: applied, listed, +//! exported, expanded and kept in the native file. + +use serde_json::json; +use vectorcraft_doc::{Node, NodeKind}; + +use super::*; + +pub(crate) fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +pub(crate) fn red_rect(s: &mut Session) -> NodeId { + 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(); + id +} + +pub(crate) fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +pub(crate) fn fill_hex(n: &Node) -> String { + n.appearance.fill_paint().color().unwrap().to_hex() +} + +/// A selected embedded 4×4 red image. +fn red_image(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let im = vectorcraft_doc::ImageObject { + key: "pic".into(), + width: 4, + height: 4, + xf: vectorcraft_geom::Affine::scale(10.0), + link: None, + placement: Default::default(), + }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn the_effects_are_in_the_catalog_under_color_adjustments() { + let mut s = session(); + let v = s.execute("effect.list", &json!({})).unwrap(); + let ids: Vec<&str> = + v["catalog"].as_array().unwrap().iter().filter(|e| e["menu"][1] == "Color Adjustments").map(|e| e["id"].as_str().unwrap()).collect(); + for id in vectorcraft_render::effects::ADJUSTMENTS { + assert!(ids.contains(&id), "{id} in {ids:?}"); + } +} + +#[test] +fn an_adjustment_is_live_and_one_undo_step() { + let mut s = session(); + let id = red_rect(&mut s); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Non-destructive: the fill stays red, the effect sits on the object. + let n = node(&s, id); + assert_eq!(fill_hex(&n), "#ff0000"); + assert_eq!(n.appearance.effects.len(), 1); + // Drawn green. + let img = + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + assert_eq!(img.pixel(50, 50), [0, 255, 0, 255]); + // Editing the parameters changes it live. + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"hue": -120}})).unwrap(); + let img = + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true); + assert_eq!(img.pixel(50, 50), [0, 0, 255, 255]); +} + +#[test] +fn export_and_expand_write_the_adjusted_colours() { + let mut s = session(); + let id = red_rect(&mut s); + s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"outputWhite": 128, "channel": "red"}})).unwrap(); + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + assert!(svg.contains("#800000") && !svg.contains("#ff0000"), "{svg}"); + // Expand Appearance makes it permanent. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let n = node(&s, id); + assert!(n.appearance.effects.is_empty()); + assert_eq!(fill_hex(&n), "#800000"); +} + +#[test] +fn an_embedded_image_is_adjusted_for_export_without_changing_its_pixels() { + let mut s = session(); + let id = red_image(&mut s); + s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let baked = vectorcraft_render::effects::bake_document(&doc).unwrap(); + let NodeKind::Image(im) = &baked.node(id).unwrap().kind else { panic!("not an image") }; + assert_ne!(im.key, "pic", "a recoloured copy"); + let px = image::load_from_memory(&baked.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(1, 1).0, [0, 255, 0, 255]); + assert_eq!(doc.images.len(), 1, "the document keeps its one image"); + // Through Expand Appearance too. + s.execute("effect.expandAppearance", &json!({})).unwrap(); + let NodeKind::Image(im) = &node(&s, id).kind else { panic!("not an image") }; + let px = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(0, 0).0, [0, 255, 0, 255]); +} + +#[test] +fn adjustments_survive_the_native_file() { + let mut s = session(); + red_rect(&mut s); + s.execute("effect.apply", &json!({"effect": "adjust.curves", "params": {"points": "0,0 64,32 255,255", "channel": "green"}})).unwrap(); + let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap(); + let mut found = None; + o.doc().unwrap().doc.walk(|n| { + if let Some(e) = n.appearance.effects.first() { + found = Some(e.clone()); + } + }); + let e = found.expect("the effect is kept"); + assert_eq!(e.id, "adjust.curves"); + assert_eq!(e.params["points"], "0,0 64,32 255,255"); +} diff --git a/crates/engine/src/tests_bboxrotate.rs b/crates/engine/src/tests_bboxrotate.rs new file mode 100644 index 000000000..334e7b452 --- /dev/null +++ b/crates/engine/src/tests_bboxrotate.rs @@ -0,0 +1,211 @@ +//! Rotated bounding boxes (#39): a rotation keeps the objects' angle, the bounding box and its +//! handles turn with them, sizes are measured along their own axes, and Reset Bounding Box squares +//! the box to the page again. + +use serde_json::{Value, json}; +use vectorcraft_doc::OrientedBox; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::bbox::Handle; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +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()) +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn angle(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.node(id).unwrap().bbox_angle +} + +fn selection_box(s: &Session) -> OrientedBox { + s.transform_box(&s.doc().unwrap().selection.objects).unwrap() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// A 100 × 50 rectangle centred on (200, 200), selected and turned to 45°. +fn turned_rect(s: &mut Session) -> NodeId { + let a = rect(s, 150.0, 175.0, 100.0, 50.0); + sel(s, &[a]); + s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap(); + a +} + +#[test] +fn rotating_to_45_keeps_45() { + let mut s = session(); + let n = undo_len(&s); + let a = turned_rect(&mut s); + assert_eq!(undo_len(&s), n + 2, "the rectangle, then one step for the rotation"); + // The panel's angle and the inspector's. + let b = selection_box(&s); + assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}"); + assert!(near(s.execute("document.inspect", &json!({})).unwrap()["selectionRotation"].as_f64().unwrap(), 45.0)); + // The box hugs the rectangle: its own sides, its own centre. + assert!(near(b.rect.width(), 100.0) && near(b.rect.height(), 50.0), "{b:?}"); + assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6); + // Absolute angles: entering 45 again changes nothing, 90 turns it by 45 more. + s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap(); + assert!(near(angle(&s, a), 45.0)); + s.execute("object.rotate", &json!({"angle": 90, "absolute": true})).unwrap(); + assert!(near(angle(&s, a), 90.0)); + let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + assert!(near(b.width(), 50.0) && near(b.height(), 100.0), "{b:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(angle(&s, a), 45.0)); +} + +#[test] +fn handles_turn_with_the_object_and_scale_along_its_axes() { + let mut s = session(); + let a = turned_rect(&mut s); + let b = selection_box(&s); + // The Right handle sits on the turned box, 50 pt from the centre along 45° (up and right). + let right = b.to_doc() * Handle::Right.pos(b.rect); + let d = std::f64::consts::FRAC_1_SQRT_2 * 50.0; + assert!(right.distance(Point::new(200.0 + d, 200.0 - d)) < 1e-6, "{right:?}"); + // Drag it 50 pt further out along the object's own x axis. + let to = Point::new(right.x + d, right.y - d); + let n = undo_len(&s); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (kind, p) in [(PointerKind::Down, right), (PointerKind::Drag, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap(); + } + assert_eq!(undo_len(&s), n + 1); + // 150 × 50 along its own axes, still at exactly 45°, the left side where it was. + assert_eq!(angle(&s, a), b.angle); + let nb = selection_box(&s); + assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}"); + let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect); + assert!(left(&nb).distance(left(&b)) < 1e-6); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(selection_box(&s).rect.width(), 100.0)); +} + +#[test] +fn transform_panel_sizes_follow_the_turned_box() { + let mut s = session(); + let a = turned_rect(&mut s); + let n = undo_len(&s); + s.execute("object.setBounds", &json!({"width": 200, "reference": 4})).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let b = selection_box(&s); + assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0) && near(b.angle, 45.0), "{b:?}"); + assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6); + // X/Y place the box's own reference point on the page. + s.execute("object.setBounds", &json!({"x": 300, "y": 100, "reference": 0})).unwrap(); + let b = selection_box(&s); + assert!(b.reference_point(0).distance(Point::new(300.0, 100.0)) < 1e-6, "{b:?}"); + assert!(near(angle(&s, a), 45.0)); +} + +#[test] +fn reset_bounding_box_squares_the_box_in_one_step() { + let mut s = session(); + let a = turned_rect(&mut s); + let geometry = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + let n = undo_len(&s); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(angle(&s, a), 0.0); + // The art stays; the box is square to the page around it. + let b = selection_box(&s); + assert_eq!(b.angle, 0.0); + assert_eq!(b.rect, geometry); + // Nothing left to reset: no step. + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(angle(&s, a), 45.0)); +} + +#[test] +fn page_flips_keep_an_upright_box_upright() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0); + sel(&mut s, &[a]); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(angle(&s, a), 0.0); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + assert!(near(angle(&s, a), -30.0)); +} + +#[test] +fn groups_type_and_multiple_selections_keep_their_angle() { + let mut s = session(); + // Objects turned together share the box. + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + assert!(near(selection_box(&s).angle, 20.0) && near(angle(&s, a), 20.0) && near(angle(&s, b), 20.0)); + // Mixed angles: the box is square to the page. + let c = rect(&mut s, 100.0, 300.0, 50.0, 50.0); + sel(&mut s, &[a, c]); + assert_eq!(selection_box(&s).angle, 0.0); + // A group turns as one; its members keep their own angles inside it. + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + assert_eq!(selection_box(&s).angle, 0.0); + s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap(); + assert!(near(angle(&s, g), 30.0) && near(angle(&s, a), 50.0) && near(angle(&s, c), 30.0)); + s.execute("object.ungroup", &json!({})).unwrap(); + sel(&mut s, &[c]); + let cb = selection_box(&s); + assert!(near(cb.angle, 30.0) && near(cb.rect.width(), 50.0) && near(cb.rect.height(), 50.0), "{cb:?}"); + // Type keeps its angle too. + let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Turn"})).unwrap()); + sel(&mut s, &[t]); + let before = selection_box(&s).rect; + s.execute("object.rotate", &json!({"angle": 60, "absolute": true})).unwrap(); + let tb = selection_box(&s); + assert!(near(tb.angle, 60.0), "{tb:?}"); + assert!(near(tb.rect.width(), before.width()) && near(tb.rect.height(), before.height()), "{tb:?} vs {before:?}"); +} + +#[test] +fn native_files_keep_the_angle() { + let mut s = session(); + let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let a = turned_rect(&mut s); + let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let text = String::from_utf8(vectorcraft_format::base64_decode(&data).unwrap()).unwrap(); + // Only turned objects write it. + assert_eq!(text.matches("bbox_angle").count(), 1); + s.execute("document.open", &json!({"name": "turned.vectorcraft", "dataBase64": data})).unwrap(); + assert!(near(angle(&s, a), 45.0)); + assert_eq!(angle(&s, plain), 0.0); + sel(&mut s, &[a]); + assert!(near(selection_box(&s).rect.width(), 100.0)); +} + +#[test] +fn rotated_box_rect_matches_page_bounds_when_upright() { + let mut s = session(); + let a = rect(&mut s, 10.0, 20.0, 30.0, 40.0); + sel(&mut s, &[a]); + assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0))); +} diff --git a/crates/engine/src/tests_blendfidelity.rs b/crates/engine/src/tests_blendfidelity.rs new file mode 100644 index 000000000..b71f6eb66 --- /dev/null +++ b/crates/engine/src/tests_blendfidelity.rs @@ -0,0 +1,206 @@ +//! Blend fidelity (M8.20): start points that don't twist, strokes, groups, compound paths, text +//! and symbols interpolating, knockout on the canvas, and Expand / Release keeping what the +//! blend carries. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::{Knockout, Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +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()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn steps(s: &Session, g: NodeId) -> Vec { + vectorcraft_doc::live::expand_live(&node(s, g)) +} + +fn make(s: &mut Session, ids: &[NodeId], steps: u32) -> NodeId { + id_of(&s.execute("object.blend.make", &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "steps": steps})).unwrap()) +} + +#[test] +fn closed_shapes_start_where_they_dont_twist() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + // The same square drawn from its bottom right corner. + let b = id_of(&s.execute("path.create", &json!({"d": "M240 40 L200 40 L200 0 L240 0 Z"})).unwrap()); + let g = make(&mut s, &[a, b], 1); + let mid = steps(&s, g)[1].geometric_bounds().unwrap(); + assert!((mid.width() - 40.0).abs() < 1e-6 && (mid.height() - 40.0).abs() < 1e-6, "no twist: {mid:?}"); +} + +#[test] +fn dashes_caps_and_width_profiles_interpolate() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 2, "dash": [10, 10], "cap": "butt"})).unwrap(); + let b = rect(&mut s, 200.0, 0.0, 40.0, 40.0); + s.execute("stroke.set", &json!({"ids": [b.0], "weight": 6, "cap": "round", "profile": "lens"})).unwrap(); + let g = make(&mut s, &[a, b], 3); + let st = steps(&s, g); + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap(); + let (first, last) = (stroke(&st[1]), stroke(&st[3])); + assert_eq!(first.dash.as_ref().unwrap().pattern, vec![12.5, 7.5], "the gaps close towards the solid key"); + assert!(last.dash.as_ref().unwrap().pattern[1] < first.dash.as_ref().unwrap().pattern[1]); + assert_eq!((first.cap, last.cap), (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::LineCap::Round), "caps switch halfway"); + assert_eq!(stroke(&st[2]).profile.unwrap().at(0.0), (0.5, 0.5), "the lens profile comes in gradually"); +} + +#[test] +fn groups_with_different_member_counts_interpolate_member_by_member() { + let mut s = session(); + let a1 = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let a2 = rect(&mut s, 0.0, 40.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [a1.0, a2.0]})).unwrap(); + let ga = id_of(&s.execute("object.group", &json!({})).unwrap()); + let b1 = rect(&mut s, 200.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [b1.0]})).unwrap(); + let gb = id_of(&s.execute("object.group", &json!({})).unwrap()); + let g = make(&mut s, &[ga, gb], 1); + let mid = &steps(&s, g)[1]; + let NodeKind::Group { children, .. } = &mid.kind else { panic!("a group step") }; + assert_eq!(children.len(), 2, "the member only one group has grows out of the other's centre"); + let c = children[0].geometric_bounds().unwrap(); + assert!((c.x0 - 100.0).abs() < 1e-6 && (c.width() - 20.0).abs() < 1e-6, "{c:?}"); + assert!(children[1].geometric_bounds().unwrap().width() < 15.0); +} + +#[test] +fn compound_steps_stay_compound_paths() { + let mut s = session(); + let mk = |s: &mut Session, x: f64| { + let o = rect(s, x, 0.0, 40.0, 40.0); + let i = rect(s, x + 10.0, 10.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [o.0, i.0]})).unwrap(); + id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()) + }; + let (a, b) = (mk(&mut s, 0.0), mk(&mut s, 200.0)); + let g = make(&mut s, &[a, b], 1); + let mid = &steps(&s, g)[1]; + assert!(matches!(&mid.kind, NodeKind::Compound { children, .. } if children.len() == 2), "{}", mid.kind_label()); +} + +#[test] +fn text_moves_and_recolours_while_its_words_switch_halfway() { + let mut s = session(); + let a = id_of(&s.execute("text.create", &json!({"x": 0, "y": 100, "text": "Hi", "size": 20, "color": "#000000"})).unwrap()); + let b = id_of(&s.execute("text.create", &json!({"x": 200, "y": 100, "text": "Yo", "size": 40, "color": "#ffffff"})).unwrap()); + let g = make(&mut s, &[a, b], 3); + let st = steps(&s, g); + let NodeKind::Text(t) = &st[2].kind else { panic!("a text step") }; + assert!((t.xf.as_coeffs()[4] - 100.0).abs() < 1e-6, "halfway along"); + let run = &t.runs[0]; + assert!((run.style.size - 30.0).abs() < 1e-6); + assert!((run.style.fill.color().unwrap().to_rgb()[0] - 0.5).abs() < 1e-3, "colour halfway"); + let NodeKind::Text(t1) = &st[1].kind else { panic!() }; + assert_eq!(t1.plain_text(), "Hi", "the words switch only halfway"); +} + +#[test] +fn symbol_instances_interpolate_their_transforms() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let a = id_of(&s.execute("symbol.new", &json!({"name": "Dot"})).unwrap()); + let b = id_of(&s.execute("symbol.place", &json!({"name": "Dot", "x": 210, "y": 10})).unwrap()); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + let g = make(&mut s, &[a, b], 1); + let mid = &steps(&s, g)[1]; + let NodeKind::SymbolInstance { xf, .. } = &mid.kind else { panic!("{}", mid.kind_label()) }; + let c = xf.as_coeffs(); + assert!((c[1].atan2(c[0]).to_degrees().abs() - 45.0).abs() < 1e-6, "turned halfway: {c:?}"); + assert!((mid.geometric_bounds().unwrap().center().x - 110.0).abs() < 1e-6); +} + +#[test] +fn knockout_blends_draw_as_they_export() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let b = rect(&mut s, 20.0, 0.0, 40.0, 40.0); + for id in [a, b] { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": null})).ok(); + s.execute("object.setProps", &json!({"ids": [id.0], "opacity": 50})).unwrap(); + } + let g = make(&mut s, &[a, b], 3); + assert_eq!(node(&s, g).knockout, Knockout::On, "blends knock out by default"); + let px = |s: &Session| { + vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 60.0, 40.0), 1.0, true).pixel(30, 20) + }; + let on = px(&s); + assert!((i32::from(on[1]) - 128).abs() <= 3, "one layer of 50 % red over white: {on:?}"); + s.execute("object.setProps", &json!({"ids": [g.0], "knockout": "off"})).unwrap(); + let off = px(&s); + assert!(off[1] < 100, "the steps build up without knockout: {off:?}"); + // Opaque steps don't need a knockout group in the outputs. + let mut t = session(); + let (c, d) = (rect(&mut t, 0.0, 0.0, 10.0, 10.0), rect(&mut t, 100.0, 0.0, 10.0, 10.0)); + let h = make(&mut t, &[c, d], 3); + assert_eq!(vectorcraft_doc::live::expanded_group(&node(&t, h), None).knockout, Knockout::Off); +} + +#[test] +fn expand_and_release_keep_what_the_blend_carries() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); + let g = make(&mut s, &[a, b], 2); + s.execute("object.setProps", &json!({"ids": [g.0], "name": "Fade", "opacity": 40, "isolate": true})).unwrap(); + s.edit("mask", |d, _| { + let art = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 10.0)), Default::default()); + let mask = vectorcraft_doc::OpacityMask::new(art, false); + d.node_mut(g).unwrap().mask = Some(Box::new(mask)); + Ok(()) + }) + .unwrap(); + let before = node(&s, g); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("object.blend.expand", &json!({})).unwrap(); + let e = node(&s, g); + assert!(matches!(e.kind, NodeKind::Group { .. })); + assert_eq!((e.name.as_deref(), e.opacity, e.isolate, e.knockout), (Some("Fade"), before.opacity, true, Knockout::On)); + assert_eq!(e.mask, before.mask); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + let grp = NodeId(r["groups"][0].as_u64().unwrap()); + let n = node(&s, grp); + assert_eq!((n.name.as_deref(), n.opacity, n.isolate), (Some("Fade"), before.opacity, true)); + assert_eq!(n.mask, before.mask); + assert_eq!(n.children().unwrap().len(), 3, "spine and keys inside"); +} + +#[test] +fn object_expand_expands_blends_inside_the_selection() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); + let g = make(&mut s, &[a, b], 2); + let c = rect(&mut s, 0.0, 100.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap(); + let outer = id_of(&s.execute("object.group", &json!({})).unwrap()); + assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true)); + s.execute("object.expand", &json!({"object": true, "fill": false, "stroke": false})).unwrap(); + let inner = node(&s, g); + assert!(matches!(inner.kind, NodeKind::Group { .. }), "the blend inside the group expanded"); + assert_eq!(inner.children().unwrap().len(), 4); + assert!(s.doc().unwrap().doc.node(outer).is_some()); + let _ = Paint::None; + let _ = Point::ZERO; +} diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs new file mode 100644 index 000000000..7e08912c6 --- /dev/null +++ b/crates/engine/src/tests_blendopts.rs @@ -0,0 +1,155 @@ +//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool: +//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18). + +use serde_json::{Value, json}; +use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn spec(s: &Session, id: NodeId) -> (Vec, BlendSpec) { + match node(s, id).kind { + NodeKind::Blend { children, spec } => (children.iter().map(|c| c.id).collect(), spec), + _ => panic!("not a blend"), + } +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +#[test] +fn options_with_nothing_selected_set_what_new_blends_start_with() { + let mut s = session(); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((info["target"].as_str(), info["spacing"].as_str()), (Some("defaults"), Some("smooth"))); + let depth = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.blend.options", &json!({"spacing": "distance", "value": 20, "orientation": "path"})).unwrap(); + assert_eq!(r["defaults"], json!(true)); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step"); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((info["spacing"].as_str(), info["distance"].as_f64(), info["orientation"].as_str()), (Some("distance"), Some(20.0), Some("path"))); + // New blends start with them; explicit params still win. + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + let (_, sp) = spec(&s, g); + assert_eq!((sp.spacing, sp.orientation), (BlendSpacing::Distance(20.0), BlendOrientation::AlignToPath)); + s.execute("edit.undo", &json!({})).unwrap(); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); + assert_eq!(spec(&s, g).1.spacing, BlendSpacing::Steps(2)); + // The preference survives a round trip through the preferences file. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.blend_options, s.prefs.blend_options); + assert!(Prefs::default().to_json().get("blendOptions").is_none()); +} + +#[test] +fn info_reads_the_selected_blend() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 4})).unwrap()); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["target"], "blend"); + assert_eq!(info["id"], json!(g.0)); + assert_eq!((info["spacing"].as_str(), info["steps"].as_u64()), (Some("steps"), Some(4))); + assert_eq!(info["keys"], json!([a.0, b.0])); +} + +#[test] +fn start_points_pair_the_clicked_anchors() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + // a starts at its top left, b at its bottom right: corners meet their opposites. + let g = id_of(&s.execute("object.blend.make", &json!({"ids": [b.0, a.0], "starts": [2, 0], "steps": 1})).unwrap()); + let (keys, sp) = spec(&s, g); + assert_eq!(keys, vec![a, b], "keys in paint order"); + assert_eq!(sp.starts, vec![Some(0), Some(2)], "each start stays with its object"); + let steps = vectorcraft_doc::live::expand_live(&node(&s, g)); + let mid = steps[1].geometric_bounds().unwrap(); + assert!(mid.height() < 1e-6, "the middle step collapses where opposite corners meet: {mid:?}"); + assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": "x"})).is_err()); + assert!(s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [-1, 0]})).is_err()); + // Reverse Front to Back keeps each key's start. + sel(&mut s, &[g]); + s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.starts, vec![Some(2), Some(0)]); +} + +#[test] +fn a_blend_and_an_object_make_one_blend_with_more_keys() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0), rect(&mut s, 200.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + s.edit("name", |d, _| { + d.node_mut(g).unwrap().name = Some("Ribbon".into()); + Ok(()) + }) + .unwrap(); + let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap()); + let n = node(&s, g2); + let (keys, sp) = spec(&s, g2); + assert_eq!(keys, vec![a, b, c], "no blend inside the blend"); + assert_eq!(sp.spacing, BlendSpacing::Steps(3), "the blend's options are kept"); + assert_eq!(n.name.as_deref(), Some("Ribbon")); + assert!(s.doc().unwrap().doc.node(g).is_none()); + assert_eq!(vectorcraft_doc::live::expand_live(&n).len(), 3 + 2 * 3); +} + +#[test] +fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() { + let mut s = session(); + let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0)); + s.select_tool("blend", ViewInfo::default()).unwrap(); + let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap(); + // a's top right anchor, then b's interior. + click(&mut s, 40.0, 0.0); + click(&mut s, 220.0, 20.0); + let g = s.doc().unwrap().selection.objects[0]; + let (keys, sp) = spec(&s, g); + assert_eq!(keys, vec![a, b]); + assert_eq!(sp.starts, vec![Some(1), None]); + // The next object joins the same blend. + click(&mut s, 420.0, 20.0); + let g2 = s.doc().unwrap().selection.objects[0]; + assert_eq!(spec(&s, g2).0, vec![a, b, c]); + // Alt-click asks for Blend Options. + let r = s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 500.0).with_mods(Mods { alt: true, ..Default::default() }), ViewInfo::default()); + assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions")); +} + +#[test] +fn start_points_round_trip_and_old_blends_load() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "starts": [null, 3]})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + // A blend saved before start points has none. + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}, "orientation": "alignToPage"})).unwrap(); + assert!(old.starts.is_empty() && old.start(0).is_none()); + assert!(serde_json::to_value(&old).unwrap().get("starts").is_none()); +} diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs new file mode 100644 index 000000000..30c4504e5 --- /dev/null +++ b/crates/engine/src/tests_blendspine.rs @@ -0,0 +1,189 @@ +//! A blend's spine and keys on the canvas (M8.19): spine points moved, added and deleted (the +//! straight spine becomes a path on the first edit), keys picked and edited live, Release keeping +//! the spine as a path. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_doc::live::BlendSpec; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Point, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn spec(s: &Session, id: NodeId) -> BlendSpec { + match node(s, id).kind { + NodeKind::Blend { spec, .. } => spec, + _ => panic!("not a blend"), + } +} + +fn center(n: &Node) -> Point { + n.geometric_bounds().unwrap().center() +} + +/// Two 10 pt squares centred on (5, 5) and (105, 5), blended with one step. +fn blend(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 10, "height": 10})).unwrap()); + let g = id_of(&s.execute("object.blend.make", &json!({"ids": [a.0, b.0], "steps": 1})).unwrap()); + (a, b, g) +} + +fn steps(s: &Session, g: NodeId) -> Vec { + vectorcraft_doc::live::expand_live(&node(s, g)) +} + +#[test] +fn a_spine_point_added_and_moved_bends_the_blend() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + assert!(spec(&s, g).spine.is_none(), "straight until edited"); + let r = s.execute("object.blend.spine.addAnchor", &json!({"x": 55, "y": 8})).unwrap(); + assert_eq!(r["anchor"], json!(1)); + let sp = spec(&s, g); + assert_eq!(sp.key_anchors, vec![0, 2], "the keys keep their points"); + assert!(sp.spine.unwrap().subpaths[0].anchors[1].p.distance(Point::new(55.0, 5.0)) < 1e-9, "on the spine, nearest the click"); + s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 1, "x": 55, "y": 105})).unwrap(); + let st = steps(&s, g); + assert!(center(&st[1]).distance(Point::new(55.0, 105.0)) < 1e-6, "the step follows the spine: {:?}", center(&st[1])); + assert_eq!(center(&st[0]), Point::new(5.0, 5.0), "the keys stay put"); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 5.0)) < 1e-6); +} + +#[test] +fn moving_a_key_point_moves_the_key_and_the_other_way_round() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "x": 5, "y": 105})).unwrap(); + assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0)); + // A handle drag curves the spine without moving the key. + s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "handle": "out", "x": 5, "y": 300})).unwrap(); + assert_eq!(center(&node(&s, a)), Point::new(5.0, 105.0)); + assert!(center(&steps(&s, g)[1]).y > 100.0, "the step bows out"); + // Editing a key on the canvas (it is selectable inside the blend) drags its point along. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 0, "dy": 50})).unwrap(); + let n = node(&s, g); + let NodeKind::Blend { children, spec } = &n.kind else { panic!() }; + let (path, _) = vectorcraft_doc::live::blend_spine(children, spec).unwrap(); + assert_eq!(path.subpaths[0].anchors[1].p, Point::new(105.0, 55.0)); + assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 9, "x": 0, "y": 0})).is_err()); + assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err()); +} + +#[test] +fn only_points_without_a_key_can_be_deleted() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + assert!(s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 0})).is_err()); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 30, "y": 5})).unwrap(); + s.execute("object.blend.spine.removeAnchor", &json!({"id": g.0, "anchor": 1})).unwrap(); + let sp = spec(&s, g); + assert_eq!((sp.spine.unwrap().subpaths[0].anchors.len(), sp.key_anchors), (2, vec![0, 1])); +} + +#[test] +fn info_lists_the_spine() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["spine"]["explicit"], json!(false)); + assert_eq!(info["spine"]["keyAnchors"], json!([0, 1])); + assert_eq!(info["spine"]["anchors"][1]["x"], json!(105.0)); + let _ = g; +} + +#[test] +fn release_keeps_the_spine_as_a_path() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap(); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0])); + let spine = NodeId(r["spines"][0].as_u64().unwrap()); + let n = node(&s, spine); + assert!(n.appearance.fill_paint().is_none() && n.appearance.stroke_paint() == Paint::None, "paints nothing"); + let pts: Vec = n.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect(); + assert_eq!(pts, vec![Point::new(5.0, 5.0), Point::new(55.0, 60.0), Point::new(105.0, 5.0)]); + let d = &s.doc().unwrap().doc; + let layer = d.layers[0].children().unwrap().iter().map(|c| c.id).collect::>(); + assert_eq!(layer, vec![spine, a, b], "the spine below the keys"); +} + +#[test] +fn reverse_spine_and_front_to_back_keep_an_edited_spine() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap(); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + assert_eq!((center(&node(&s, a)), center(&node(&s, b))), (Point::new(105.0, 5.0), Point::new(5.0, 5.0)), "the keys swap ends"); + assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6, "the spine keeps its shape"); + s.execute("object.blend.reverseFrontToBack", &json!({})).unwrap(); + assert_eq!(center(&node(&s, a)), Point::new(105.0, 5.0), "stacking changes, places don't"); + assert!(center(&steps(&s, g)[1]).distance(Point::new(55.0, 60.0)) < 1e-6); +} + +#[test] +fn a_blend_with_an_edited_spine_takes_more_keys_along_it() { + let mut s = session(); + let (a, b, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 60})).unwrap(); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + let g2 = id_of(&s.execute("object.blend.make", &json!({"ids": [g.0, c.0]})).unwrap()); + let sp = spec(&s, g2); + assert_eq!(sp.key_anchors, vec![0, 2, 3]); + let pts: Vec = sp.spine.unwrap().subpaths[0].anchors.iter().map(|a| a.p).collect(); + assert_eq!(pts[1], Point::new(55.0, 60.0), "the bend is kept"); + assert_eq!(pts[3], Point::new(205.0, 5.0)); + let _ = (a, b); +} + +#[test] +fn direct_selection_picks_keys_and_edits_update_the_blend_live() { + let mut s = session(); + let (a, _, g) = blend(&mut s); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#000000"})).ok(); + let opt = vectorcraft_doc::hit::HitOptions::default(); + let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(3.0, 3.0), opt).unwrap(); + assert_eq!((h.leaf, h.top_object(None)), (a, g), "the key is hit; the Selection tool still takes the blend"); + let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, Point::new(55.0, 5.0), opt).unwrap(); + assert_eq!(h.leaf, g, "a step hits as the blend"); + let mut r = vectorcraft_render::Renderer::new(); + let before = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + let after = r.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 120.0, 20.0), 1.0, true).pixel(55, 5); + assert_ne!(before, after, "the step redraws with the key's new colour"); +} + +#[test] +fn key_anchors_round_trip() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + let old: BlendSpec = serde_json::from_value(json!({"spine": {"subpaths": []}})).unwrap(); + assert!(old.key_anchors.is_empty()); +} diff --git a/crates/engine/src/tests_cut.rs b/crates/engine/src/tests_cut.rs new file mode 100644 index 000000000..7bda31d8d --- /dev/null +++ b/crates/engine/src/tests_cut.rs @@ -0,0 +1,268 @@ +//! Tests for the cutting commands: Mirror & Cut, Line Cut and Rectangle Cut. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +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()) +} + +fn path(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()) +} + +fn select(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn geometry(s: &Session, id: NodeId) -> (PathData, FillRule) { + let n = s.doc().unwrap().doc.node(id).cloned().unwrap(); + match &n.kind { + NodeKind::Path { path, rule, .. } => (path.clone(), *rule), + NodeKind::Compound { children, rule } => { + (PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule) + } + _ => panic!("not a path"), + } +} + +fn area(s: &Session, id: NodeId) -> f64 { + let (p, r) = geometry(s, id); + vectorcraft_pathops::area(&p, r) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + geometry(s, id).0.bounds().unwrap() +} + +fn near(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.05) +} + +fn undo_depth(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn line_cut_splits_a_shape_in_one_undo_step() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + select(&mut s, &[r]); + let before = undo_depth(&s); + let out = s.execute("path.lineCut", &json!({"from": [30, -10], "to": [30, 10]})).unwrap(); + let pieces = ids(&out); + assert_eq!(pieces.len(), 2); + let mut areas: Vec = pieces.iter().map(|id| area(&s, *id)).collect(); + areas.sort_by(f64::total_cmp); + assert!((areas[0] - 3000.0).abs() < 1.0 && (areas[1] - 7000.0).abs() < 1.0, "{areas:?}"); + // The pieces are selected, and the cut is one undo step. + assert_eq!(s.doc().unwrap().selection.len(), 2); + assert_eq!(undo_depth(&s), before + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((area(&s, r) - 10_000.0).abs() < 1.0); +} + +#[test] +fn line_cut_keeps_compound_holes_and_splits_open_paths() { + let mut s = session(); + let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + // Wound against the outer rectangle, so the non-zero compound has a hole. + let hole = path(&mut s, &[(40.0, 40.0), (40.0, 60.0), (60.0, 60.0), (60.0, 40.0)], true); + select(&mut s, &[outer, hole]); + let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + assert!((area(&s, c) - 9600.0).abs() < 1.0); + let line = path(&mut s, &[(0.0, 200.0), (100.0, 200.0)], false); + select(&mut s, &[c, line]); + // A vertical line at x = 20 misses the hole: the right piece keeps it. + let out = s.execute("path.lineCut", &json!({"from": [20, 0], "to": [20, 300]})).unwrap(); + let pieces = ids(&out); + assert_eq!(pieces.len(), 4, "two compound pieces and two line pieces"); + let doc = &s.doc().unwrap().doc; + let compounds: Vec = pieces.iter().copied().filter(|id| matches!(doc.node(*id).unwrap().kind, NodeKind::Compound { .. })).collect(); + assert_eq!(compounds.len(), 2, "both pieces of a compound path stay compound paths"); + let mut areas: Vec = compounds.iter().map(|id| area(&s, *id)).collect(); + areas.sort_by(f64::total_cmp); + assert!((areas[0] - 2000.0).abs() < 1.0 && (areas[1] - 7600.0).abs() < 1.0, "{areas:?}"); + let open: Vec = pieces.iter().filter(|id| !compounds.contains(id)).map(|id| bounds(&s, *id)).collect(); + assert!(open.iter().any(|b| (b.x1 - 20.0).abs() < 0.01) && open.iter().any(|b| (b.x0 - 20.0).abs() < 0.01), "{open:?}"); +} + +#[test] +fn line_cut_that_misses_changes_nothing() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + select(&mut s, &[r]); + let before = undo_depth(&s); + let out = s.execute("path.lineCut", &json!({"from": [200, 0], "to": [200, 10]})).unwrap(); + assert!(ids(&out).is_empty()); + assert_eq!(undo_depth(&s), before); + assert!(s.execute("path.lineCut", &json!({"from": [5, 5], "to": [5, 5]})).is_err()); + assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err()); +} + +#[test] +fn rect_cut_crops_to_real_geometry() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let outside = rect(&mut s, 300.0, 300.0, 10.0, 10.0); + let inside = rect(&mut s, 60.0, 20.0, 10.0, 10.0); + let line = path(&mut s, &[(0.0, 50.0), (200.0, 50.0)], false); + select(&mut s, &[a, outside, inside, line]); + let out = s.execute("path.rectCut", &json!({"rect": [50, 0, 100, 200]})).unwrap(); + assert_eq!(out["removed"], 1); + let doc = &s.doc().unwrap().doc; + assert!(doc.node(outside).is_none(), "art wholly outside is deleted"); + assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Path { .. }), "no clipping mask"); + assert!(near(bounds(&s, a), Rect::new(50.0, 0.0, 100.0, 100.0))); + assert!((area(&s, a) - 5000.0).abs() < 1.0); + assert!(near(bounds(&s, inside), Rect::new(60.0, 20.0, 70.0, 30.0)), "art inside is untouched"); + assert!(near(bounds(&s, line), Rect::new(50.0, 50.0, 150.0, 50.0))); + // Two corners work too. + select(&mut s, &[a]); + s.execute("path.rectCut", &json!({"from": [100, 100], "to": [75, 50]})).unwrap(); + assert!(near(bounds(&s, a), Rect::new(75.0, 50.0, 100.0, 100.0))); + assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 0, 10]})).is_err()); + assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err()); +} + +#[test] +fn mirror_cut_makes_a_symmetric_shape() { + let mut s = session(); + // A right triangle: mirrored at x = 50 keeping the left, it becomes a house-like pentagon. + let t = path(&mut s, &[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true); + select(&mut s, &[t]); + let out = s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap(); + assert_eq!(ids(&out), vec![t]); + let (pd, _) = geometry(&s, t); + assert_eq!(pd.subpaths.len(), 1, "both halves are joined into one closed path"); + assert!(pd.subpaths[0].closed); + assert!((area(&s, t) - 7500.0).abs() < 1.0); + assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 100.0))); + // Keeping the right half instead. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0], "keep": "right"})).unwrap(); + assert!((area(&s, t) - 2500.0).abs() < 1.0); + assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 50.0))); +} + +#[test] +fn mirror_cut_axes_and_sides() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0); + let gone = rect(&mut s, 300.0, 0.0, 10.0, 10.0); + select(&mut s, &[a, gone]); + // A free axis at x = 50 (drawn upward): art on the discarded side is deleted. + let out = s.execute("path.mirrorCut", &json!({"axis": "free", "from": [50, 100], "to": [50, 0], "keep": "left"})).unwrap(); + assert_eq!(out["removed"], 1); + assert!(s.doc().unwrap().doc.node(gone).is_none()); + // Wholly on the kept side: the shape and its reflection, one object. + let (pd, _) = geometry(&s, a); + assert_eq!(pd.subpaths.len(), 2); + assert!(near(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0))); + // A horizontal axis through the selection's centre keeps the top by default. + let b = rect(&mut s, 0.0, 200.0, 100.0, 60.0); + select(&mut s, &[b]); + s.execute("path.mirrorCut", &json!({"axis": "horizontal"})).unwrap(); + assert!(near(bounds(&s, b), Rect::new(0.0, 200.0, 100.0, 260.0))); + assert!(s.execute("path.mirrorCut", &json!({"axis": "vertical", "keep": "top"})).is_err()); + assert!(s.execute("path.mirrorCut", &json!({"axis": "diagonal"})).is_err()); + assert!(s.execute("path.mirrorCut", &json!({"axis": "free", "from": [1, 1], "to": [1, 1]})).is_err()); +} + +#[test] +fn mirror_cut_closes_an_open_half() { + let mut s = session(); + let p = path(&mut s, &[(50.0, 0.0), (0.0, 50.0), (50.0, 100.0)], false); + select(&mut s, &[p]); + s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap(); + let (pd, _) = geometry(&s, p); + assert_eq!(pd.subpaths.len(), 1); + assert!(pd.subpaths[0].closed); + assert_eq!(pd.subpaths[0].anchors.len(), 4); + assert!(near(bounds(&s, p), Rect::new(0.0, 0.0, 100.0, 100.0))); +} + +#[test] +fn cut_commands_need_paths() { + let mut s = session(); + assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err(), "nothing selected"); + let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A"})).unwrap()); + select(&mut s, &[t]); + assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err()); +} + +mod tools { + use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + + use super::*; + use crate::tooling::ViewInfo; + + fn view() -> ViewInfo { + ViewInfo { smart_guides: false, ..Default::default() } + } + + fn press(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), view()).unwrap(); + } + + #[test] + fn line_cut_tool_previews_live_and_commits_one_step() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + select(&mut s, &[r]); + let before = undo_depth(&s); + s.select_tool("lineCut", view()).unwrap(); + press(&mut s, PointerKind::Down, 50.0, -20.0); + press(&mut s, PointerKind::Drag, 50.0, 40.0); + // The preview already shows the cut. + assert!(s.in_interaction()); + assert_eq!(s.doc().unwrap().selection.len(), 2); + press(&mut s, PointerKind::Drag, 50.0, 120.0); + press(&mut s, PointerKind::Up, 50.0, 120.0); + assert!(!s.in_interaction()); + assert_eq!(undo_depth(&s), before + 1); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.lineCut")); + assert!((area(&s, r) - 5000.0).abs() < 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!((area(&s, r) - 10_000.0).abs() < 1.0); + } + + #[test] + fn rect_and_mirror_tools_cut_and_escape_restores() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + select(&mut s, &[r]); + s.select_tool("rectCut", view()).unwrap(); + press(&mut s, PointerKind::Down, 50.0, 50.0); + press(&mut s, PointerKind::Drag, 150.0, 150.0); + assert!(near(bounds(&s, r), Rect::new(50.0, 50.0, 100.0, 100.0))); + s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap(); + assert!(!s.in_interaction()); + assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 100.0, 100.0))); + // Mirror & Cut with a vertical axis placed by a click at x = 80. + s.select_tool("mirrorCut", view()).unwrap(); + s.set_tool_option("axis", &json!("vertical")); + press(&mut s, PointerKind::Down, 80.0, 50.0); + press(&mut s, PointerKind::Up, 80.0, 50.0); + assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 160.0, 100.0))); + assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.mirrorCut")); + } +} diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests_distort.rs index 832073a48..e7f8e836a 100644 --- a/crates/engine/src/tests_distort.rs +++ b/crates/engine/src/tests_distort.rs @@ -207,13 +207,19 @@ fn puppet_tool_drag_warps_selection() { s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); let v = ViewInfo::default(); s.select_tool("puppetWarp", v).unwrap(); - // First click seeds pins and adds one at the right end, then drag it. - s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 150.0), v).unwrap(); + // The automatic pins show at once, one in the middle of each end: drag the right end's up. + let pins = s.execute("object.puppetWarp.pins", &json!({})).unwrap()["moved"].clone(); + let right = pins + .as_array() + .unwrap() + .iter() + .map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())) + .fold((0.0, 0.0), |a, q| if q.0 > a.0 { q } else { a }); + assert!(right.0 > 350.0, "{pins}"); let n = undo_len(&s); - s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Drag, 385.0, 120.0), v).unwrap(); - s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 120.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, right.0, right.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, right.0, right.1 - 30.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, right.0, right.1 - 30.0), v).unwrap(); assert_eq!(undo_len(&s), n + 1); assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp"); assert!(path(&s, a).bounds().unwrap().y0 < 95.0); @@ -290,6 +296,8 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() { let mut s = session(); s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + // Snap to Grid (on by default) would move the dragged corners onto gridlines. + s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); let n = undo_len(&s); gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default()); assert_eq!(undo_len(&s), n + 1); @@ -313,6 +321,8 @@ fn perspective_selection_moves_within_the_plane() { s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); + // Snap to Grid (on by default) would land the moved edges on gridlines. + s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); let g = grid(&s); let before: Vec = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect(); let centre = before.iter().fold(vectorcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64; diff --git a/crates/engine/src/tests_emptytype.rs b/crates/engine/src/tests_emptytype.rs new file mode 100644 index 000000000..71261fd0a --- /dev/null +++ b/crates/engine/src/tests_emptytype.rs @@ -0,0 +1,161 @@ +//! Point type placed with the Type tool and left empty is discarded when editing ends, without a +//! trace in the history; frames (dragged area type, type in or on a path) stay. + +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind, TextKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + if from != to { + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); +} + +fn click(s: &mut Session, x: f64, y: f64) { + drag(s, (x, y), (x, y)); +} + +fn key(s: &mut Session, k: ToolKey) { + s.tool_key(k, Mods::default(), ViewInfo::default()).unwrap(); +} + +/// The text objects in the document: (id, kind is point type, plain text). +fn texts(s: &Session) -> Vec<(NodeId, bool, String)> { + let mut v = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push((n.id, matches!(t.kind, TextKind::Point), t.plain_text())); + } + }); + v +} + +fn undo_labels(s: &Session) -> Vec { + s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect() +} + +#[test] +fn clicking_without_typing_leaves_no_text_and_no_history() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + // Click, then Escape. + click(&mut s, 100.0, 100.0); + assert_eq!(texts(&s).len(), 1, "the click places point type to edit"); + assert!(s.tool_wants_text()); + key(&mut s, ToolKey::Escape); + assert!(texts(&s).is_empty()); + // Click, then switch tools. + click(&mut s, 150.0, 100.0); + s.select_tool("rectangle", v).unwrap(); + assert!(texts(&s).is_empty()); + // Click in two places: the first is gone when the second is placed. + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 300.0); + click(&mut s, 400.0, 300.0); + assert_eq!(texts(&s).len(), 1); + s.select_tool("selection", v).unwrap(); + assert!(texts(&s).is_empty()); + assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s)); + let st = s.doc().unwrap(); + assert!(!st.is_dirty() && st.selection.is_empty()); +} + +#[test] +fn typed_text_stays_and_typing_it_away_leaves_nothing() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("Hi", v).unwrap(); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["Hi"]); + let kept = undo_labels(&s); + assert_eq!(kept, ["Type", "Typing"]); + // Typed, the caret moved (a step of its own), then deleted: all of it goes. + click(&mut s, 300.0, 300.0); + s.tool_text("a", v).unwrap(); + key(&mut s, ToolKey::Left); + key(&mut s, ToolKey::Delete); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).len(), 1); + assert_eq!(undo_labels(&s), kept); +} + +#[test] +fn frames_stay_when_left_empty() { + let mut s = session(); + let v = ViewInfo::default(); + // A dragged area-type frame. + s.select_tool("type", v).unwrap(); + drag(&mut s, (100.0, 100.0), (300.0, 200.0)); + key(&mut s, ToolKey::Escape); + assert!(matches!(texts(&s).as_slice(), [(_, false, t)] if t.is_empty())); + // Area type made from a path. + s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 200, "height": 100})).unwrap(); + s.select_tool("areaType", v).unwrap(); + click(&mut s, 500.0, 300.0); + s.select_tool("selection", v).unwrap(); + assert_eq!(texts(&s).len(), 2); + assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Area Type"]); +} + +#[test] +fn a_command_that_deselects_the_text_ends_the_edit() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + // Commands on the edited text (the Character panel) keep editing it. + let id = texts(&s)[0].0; + s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap(); + s.execute("document.inspect", &json!({})).unwrap(); + assert!(s.tool_wants_text()); + s.execute("select.none", &json!({})).unwrap(); + assert!(!s.tool_wants_text()); + assert!(texts(&s).is_empty()); + assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s)); + // A command that edits something else as well: the text goes in a step of its own. + click(&mut s, 100.0, 100.0); + s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 20, "height": 10})).unwrap(); + assert!(!s.tool_wants_text()); + assert!(texts(&s).is_empty()); + assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Discard Empty Type"]); +} + +#[test] +fn switching_documents_discards_it_in_the_document_left() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s.set_active(0); + assert!(texts(&s).is_empty() && undo_labels(&s).is_empty()); +} + +#[test] +fn discard_empty_command() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + assert_eq!(s.execute("text.discardEmpty", &json!({"id": r["id"]})).unwrap(), json!({"removed": false})); + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("text.discardEmpty", &json!({"id": rect["id"]})).is_err(), "not text"); + assert!(s.execute("text.discardEmpty", &json!({})).is_err(), "id required"); + // Created by the newest step: that step goes. + let e = s.execute("text.create", &json!({"x": 10, "y": 80, "text": ""})).unwrap(); + assert_eq!(s.execute("text.discardEmpty", &json!({"id": e["id"]})).unwrap(), json!({"removed": true})); + assert_eq!(undo_labels(&s), ["Type", "Rectangle"]); + assert_eq!(texts(&s).len(), 1); +} diff --git a/crates/engine/src/tests_envelope.rs b/crates/engine/src/tests_envelope.rs new file mode 100644 index 000000000..78006942b --- /dev/null +++ b/crates/engine/src/tests_envelope.rs @@ -0,0 +1,276 @@ +//! Envelope Distort: Reset with Warp / Mesh, Envelope Options, Release, Expand and Object → Expand, +//! driven through `Session::execute`. + +use serde_json::{Value, json}; +use vectorcraft_color::BlendMode; +use vectorcraft_doc::live::{self, EnvelopeKind, EnvelopeOptions, PreserveShape}; +use vectorcraft_doc::{Knockout, Node, NodeKind, OpacityMask}; +use vectorcraft_geom::Rect; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +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()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +/// A 200×100 rectangle at (100, 100) enveloped by `cmd` with `params` → (rectangle, envelope). +fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { + let a = rect(s, 100.0, 100.0, 200.0, 100.0); + sel(s, &[a]); + (a, id_of(&s.execute(cmd, ¶ms).unwrap())) +} + +fn kind(s: &Session, id: NodeId) -> EnvelopeKind { + match node(s, id).kind { + NodeKind::Envelope { kind, .. } => kind, + k => panic!("{k:?}"), + } +} + +fn options(s: &Session, id: NodeId) -> (EnvelopeOptions, f64) { + match node(s, id).kind { + NodeKind::Envelope { options, fidelity, .. } => (options, fidelity), + k => panic!("{k:?}"), + } +} + +#[test] +fn new_envelopes_take_the_reference_defaults_and_options_change_them() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({})); + assert_eq!(options(&s, e), (EnvelopeOptions::NEW, 50.0)); + s.execute( + "object.envelope.options", + &json!({"antiAlias": false, "preserveShape": "transparency", "distortAppearance": true, "distortLinearGradients": true, "distortPatternFills": true, "fidelity": 75}), + ) + .unwrap(); + let (o, f) = options(&s, e); + assert_eq!((o.anti_alias, o.preserve_shape, o.gradients(), o.patterns(), f), (false, PreserveShape::Transparency, true, true, 75.0)); + let info = s.execute("object.envelope.info", &json!({})).unwrap(); + assert_eq!(info["id"], json!(e.0)); + assert_eq!((info["type"].clone(), info["style"].clone(), info["preserveShape"].clone()), (json!("warp"), json!("arc"), json!("transparency"))); + assert!(s.execute("object.envelope.options", &json!({"preserveShape": "alpha"})).is_err()); + assert!(s.execute("object.envelope.options", &json!({"fidelity": -1})).is_err()); +} + +#[test] +fn options_with_nothing_selected_are_the_defaults_for_new_envelopes() { + let mut s = session(); + s.execute("object.envelope.options", &json!({"distortAppearance": false, "fidelity": 20})).unwrap(); + let info = s.execute("object.envelope.info", &json!({})).unwrap(); + assert_eq!((info["id"].clone(), info["distortAppearance"].clone(), info["fidelity"].clone()), (Value::Null, json!(false), json!(20.0))); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})); + let (o, f) = options(&s, e); + assert_eq!((o.distort_appearance, f), (false, 20.0)); + assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 2, cols: 3, .. })); +} + +#[test] +fn reset_with_warp_and_mesh_switch_the_kind_and_keep_the_content() { + let mut s = session(); + let (a, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2})); + s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 60, "orientation": "vertical"})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { ref style, bend, horizontal: false, .. } if style == "arch" && bend == 60.0)); + assert_eq!(node(&s, e).children().unwrap()[0].id, a); + // Unset values keep the current warp's. + s.execute("object.envelope.resetWithWarp", &json!({"bend": -20})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Warp { ref style, bend, horizontal: false, .. } if style == "arch" && bend == -20.0)); + assert!(s.execute("object.envelope.resetWithWarp", &json!({"style": "nope"})).is_err()); + // Back to a mesh that keeps the arch's shape: the top middle point is where the warp put it. + s.execute("object.envelope.resetWithWarp", &json!({"style": "arch", "bend": 50, "horizontal": true})).unwrap(); + let top = live::EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK); + s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap(); + let EnvelopeKind::Mesh { rows: 2, cols: 2, points, .. } = kind(&s, e) else { panic!("{:?}", kind(&s, e)) }; + assert!(points[1].distance(top.points[1].p) < 1e-9 && points[1].y < 99.0, "{:?}", points[1]); + // Without Maintain Envelope Shape: a flat grid over the content. + s.execute("object.envelope.resetWithMesh", &json!({"rows": 1, "cols": 1, "maintainShape": false})).unwrap(); + assert_eq!( + kind(&s, e), + EnvelopeKind::Mesh { rows: 1, cols: 1, points: live::grid_points(Rect::new(100.0, 100.0, 300.0, 200.0), 1, 1), handles: vec![] } + ); + assert!(s.execute("object.envelope.resetWithMesh", &json!({"rows": 0})).is_err()); + // One undo step per reset. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(kind(&s, e), EnvelopeKind::Mesh { rows: 2, cols: 2, .. })); + // Nothing to reset without an envelope. + sel(&mut s, &[]); + assert!(s.execute("object.envelope.resetWithMesh", &json!({})).is_err()); +} + +#[test] +fn release_gives_back_the_warp_shape_as_a_mesh_and_keeps_the_transparency() { + let mut s = session(); + let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 50})); + s.execute("transparency.set", &json!({"opacity": 40, "blend": "multiply"})).unwrap(); + let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default()); + s.edit("mask", |d, _| { + d.node_mut(e).unwrap().mask = Some(Box::new(OpacityMask::new(mask, true))); + Ok(()) + }) + .unwrap(); + let r = s.execute("object.envelope.release", &json!({})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], a); + let content = node(&s, a); + assert_eq!((content.opacity, content.blend, content.mask.is_some()), (0.4, BlendMode::Multiply, true), "the content keeps the look"); + let NodeKind::Mesh(m) = node(&s, ids[1]).kind else { panic!("the warp's shape comes back as a mesh") }; + // The mesh follows the arc: its outline reaches below the content's bottom edge at the sides. + let b = m.outline().bounds().unwrap(); + assert!(b.y1 > 201.0, "{b:?}"); + assert_eq!(s.doc().unwrap().selection.objects, ids); +} + +#[test] +fn release_groups_several_objects_under_the_transparency() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0); + let b = rect(&mut s, 200.0, 100.0, 50.0, 50.0); + sel(&mut s, &[a, b]); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 2})).unwrap()); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + let r = s.execute("object.envelope.release", &json!({})).unwrap(); + let g = node(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert_eq!(g.opacity, 0.5); + assert_eq!(g.children().unwrap().iter().map(|c| c.id).collect::>(), vec![a, b]); + assert!(matches!(node(&s, NodeId(r["ids"][1].as_u64().unwrap())).kind, NodeKind::Mesh(ref m) if (m.rows, m.cols) == (1, 2))); + assert!(s.doc().unwrap().doc.node(e).is_none()); +} + +#[test] +fn expand_keeps_name_transparency_knockout_and_mask() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "bulge", "bend": 40})); + s.execute("transparency.set", &json!({"opacity": 70, "isolate": true, "knockout": "on"})).unwrap(); + let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default()); + s.edit("name and mask", |d, _| { + let n = d.node_mut(e).unwrap(); + n.name = Some("Banner".into()); + n.mask = Some(Box::new(OpacityMask::new(mask, true))); + Ok(()) + }) + .unwrap(); + s.execute("object.envelope.expand", &json!({})).unwrap(); + let g = node(&s, e); + assert!(matches!(g.kind, NodeKind::Group { .. })); + assert_eq!((g.name.as_deref(), g.opacity, g.isolate, g.knockout, g.mask.is_some()), (Some("Banner"), 0.7, true, Knockout::On, true)); +} + +#[test] +fn object_expand_expands_envelopes_inside_the_selection() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50})); + let other = rect(&mut s, 400.0, 400.0, 20.0, 20.0); + sel(&mut s, &[e, other]); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + let info = s.execute("object.expand.info", &json!({})).unwrap(); + assert_eq!(info["object"], json!(true)); + s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap(); + let mut envelopes = 0; + node(&s, g).walk(&mut |n| envelopes += matches!(n.kind, NodeKind::Envelope { .. }) as usize); + assert_eq!(envelopes, 0); + // The arch lifted the expanded art's middle above the rectangle's top edge. + let b = node(&s, e).geometric_bounds().unwrap(); + assert!(b.y0 < 99.0, "{b:?}"); +} + +#[test] +fn envelope_options_round_trip_natively_and_old_files_load() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({})); + s.execute("object.envelope.options", &json!({"preserveShape": "transparency", "distortPatternFills": true})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(back.node(e), d.node(e)); + // An envelope saved before the options existed loads with its old behaviour. + let mut old = serde_json::to_value(node(&s, e)).unwrap(); + old["kind"].as_object_mut().unwrap().remove("options"); + let n: Node = serde_json::from_value(old).unwrap(); + assert!(matches!(n.kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::default() && !options.distort_appearance)); + // The old behaviour's options aren't written. + let mut legacy = node(&s, e); + if let NodeKind::Envelope { options, .. } = &mut legacy.kind { + *options = EnvelopeOptions::default(); + } + assert!(serde_json::to_value(&legacy).unwrap()["kind"].get("options").is_none()); +} + +/// Regression: every exporter wrote type inside an envelope undistorted (no text outliner), so a +/// warped line of type exported the same whatever its bend. +#[test] +fn enveloped_type_exports_distorted_in_every_format() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap()); + sel(&mut s, &[t]); + s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 0})).unwrap(); + // The export's bytes without its time stamps (EPS and PDF date their files to the second). + let export = |s: &mut Session, format: &str| -> Vec { + let data = s.execute("document.export", &json!({"format": format})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let bytes = vectorcraft_format::base64_decode(&data).unwrap(); + let dated = |line: &&[u8]| line.windows(12).any(|w| w == b"CreationDate") || line.windows(7).any(|w| w == b"ModDate"); + bytes.split(|b| *b == b'\n').filter(|l| !dated(l)).flat_map(|l| l.iter().copied().chain(*b"\n")).collect() + }; + for format in ["svg", "pdf", "eps", "emf", "dxf"] { + s.execute("object.envelope.options", &json!({"bend": 0})).unwrap(); + let flat = export(&mut s, format); + assert_eq!(export(&mut s, format), flat, "{format} exports are repeatable"); + s.execute("object.envelope.options", &json!({"bend": 80})).unwrap(); + assert_ne!(export(&mut s, format), flat, "{format}: the bend shows in the export"); + } + let svg = String::from_utf8(export(&mut s, "svg")).unwrap(); + assert!(!svg.contains(" 200 && p[1] < 60 { + assert!(left, "red at ({x}, {y})"); + red += 1; + } else if p[0] < 60 && p[1] < 60 && p[3] > 200 { + assert!(!left, "black at ({x}, {y})"); + black += 1; + } + } + } + assert!(red > 100 && black > 100, "{red} red, {black} black"); +} diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests_envelope_distort.rs new file mode 100644 index 000000000..246b13ef8 --- /dev/null +++ b/crates/engine/src/tests_envelope_distort.rs @@ -0,0 +1,298 @@ +//! What envelopes distort: the envelope's own frame under transforms, images (a raster mesh warp), +//! symbols, linear gradients, pattern fills, strokes and effects (Distort Appearance). + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_doc::live::EnvelopeOptions; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind, PatternDef, Symbol}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_render::effects; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +/// A 200×100 rectangle at (100, 100), filled red, no stroke. +fn rect(s: &mut Session) -> NodeId { + let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()); + s.edit("red", |d, _| { + d.node_mut(a).unwrap().appearance = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + Ok(()) + }) + .unwrap(); + a +} + +/// Envelope the selection with an arch warp (`bend` %) and `options` → the envelope. +fn arch(s: &mut Session, bend: f64, options: Value) -> NodeId { + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": bend})).unwrap()); + s.execute("object.envelope.options", &options).unwrap(); + e +} + +/// The envelope evaluated as the canvas and the exporters do. +fn evaluated(s: &Session, e: NodeId) -> Vec { + effects::expand_live(Some(&s.doc().unwrap().doc), &node(s, e)) +} + +fn anchors(nodes: &[Node]) -> Vec { + let mut v = vec![]; + for n in nodes { + n.walk(&mut |c| v.extend(c.path_data().into_iter().flat_map(|p| p.anchors().map(|(_, _, a)| a.p)))); + } + v +} + +fn frame(s: &Session, e: NodeId) -> Affine { + match node(s, e).kind { + NodeKind::Envelope { frame, .. } => frame, + k => panic!("{k:?}"), + } +} + +/// Regression: rotating a warp envelope rotated its content and re-warped it along the page axes. +#[test] +fn a_rotated_warp_envelope_turns_with_its_warp() { + let mut s = session(); + let a = rect(&mut s); + sel(&mut s, &[a]); + let e = arch(&mut s, 50.0, json!({})); + let before = anchors(&evaluated(&s, e)); + let c = Point::new(200.0, 150.0); + s.execute("object.rotate", &json!({"angle": 90, "origin": [c.x, c.y]})).unwrap(); + let after = anchors(&evaluated(&s, e)); + let turn = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, c); + assert_eq!(before.len(), after.len()); + for (p, q) in before.iter().zip(&after) { + assert!((turn * *p).distance(*q) < 1e-6, "{:?} vs {q:?}", turn * *p); + } + assert_ne!(frame(&s, e), Affine::IDENTITY); + // Turning back squares the frame with the page again; a plain move or scale never tilts it. + s.execute("object.rotate", &json!({"angle": -90, "origin": [c.x, c.y]})).unwrap(); + assert_eq!(frame(&s, e), Affine::IDENTITY); + s.execute("object.scale", &json!({"sx": 150, "sy": 50})).unwrap(); + assert_eq!(frame(&s, e), Affine::IDENTITY); + // A reflection keeps a frame: the arch flips with the art. + s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap(); + let flipped = anchors(&evaluated(&s, e)); + let b = node(&s, e).geometric_bounds().unwrap(); + assert!(flipped.iter().any(|p| p.y > b.y1 - 1.0) && frame(&s, e) != Affine::IDENTITY); +} + +#[test] +fn the_frame_round_trips_natively() { + let mut s = session(); + let a = rect(&mut s); + sel(&mut s, &[a]); + let e = arch(&mut s, 50.0, json!({})); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(back.node(e), d.node(e)); + let mut old = serde_json::to_value(node(&s, e)).unwrap(); + old["kind"].as_object_mut().unwrap().remove("frame"); + let n: Node = serde_json::from_value(old).unwrap(); + assert!(matches!(n.kind, NodeKind::Envelope { frame, .. } if frame == Affine::IDENTITY), "older envelopes stand square to the page"); +} + +/// A selected embedded 40×40 pt red image at (100, 100). +fn red_image(s: &mut Session) -> NodeId { + let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.edit("Place", |d, sel| { + d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); + let id = d.alloc_id(); + let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() }; + d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?; + sel.set([id]); + Ok(id) + }) + .unwrap() +} + +#[test] +fn images_bend_as_a_raster_mesh_warp() { + let mut s = session(); + let im = red_image(&mut s); + sel(&mut s, &[im]); + let e = arch(&mut s, 80.0, json!({})); + let out = evaluated(&s, e); + let mut pieces = 0; + out[0].walk(&mut |c| pieces += matches!(c.kind, NodeKind::Image(_)) as usize); + assert!(pieces >= 32, "{pieces} pieces"); + // The arch lifts the image's middle above its top edge, not its corners. + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + let b = node(&s, e).geometric_bounds().unwrap(); + assert!(b.y0 < 95.0, "{b:?}"); + let mid = img.pixel(120, 97); + assert!(mid[0] > 200 && mid[1] < 60, "the middle rose: {mid:?}"); + assert!(img.pixel(101, 95)[1] > 200, "the corner stayed: {:?}", img.pixel(101, 95)); + // The pieces export as clipped images, bent in the SVG too. + let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let svg = String::from_utf8(vectorcraft_format::base64_decode(&svg).unwrap()).unwrap(); + assert!(svg.matches("= 32 && svg.contains("clip"), "{}", &svg[..svg.len().min(400)]); + assert_eq!(svg.matches(" 200); +} + +fn strokes(nodes: &[Node]) -> usize { + let mut n = 0; + for c in nodes { + c.walk(&mut |c| n += c.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count()); + } + n +} + +#[test] +fn distort_appearance_bends_strokes_and_effects_with_the_art() { + let mut s = session(); + let a = rect(&mut s); + s.execute("paint.setStroke", &json!({"ids": [a.0], "color": "#000000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0], "weight": 8})).unwrap(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); + let out = evaluated(&s, e); + assert_eq!(strokes(&out), 0, "the stroke became its bent outline"); + // Off: the stroke paints along the bent path at its own weight. + s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap(); + assert_eq!(strokes(&evaluated(&s, e)), 1); + // Geometry effects bake into the art first too. + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.zigZag", "params": {"size": 5, "ridges": 10}})).unwrap(); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + s.execute("object.envelope.options", &json!({"distortAppearance": true})).unwrap(); + let out = evaluated(&s, e); + let mut effects_left = 0; + out[0].walk(&mut |c| effects_left += c.appearance.effects.len()); + assert_eq!(effects_left, 0); + assert!(anchors(&out).len() > anchors(&[node(&s, a)]).len()); +} + +fn gradient_geom(nodes: &[Node]) -> Option { + let mut g = None; + for n in nodes { + n.walk(&mut |c| { + if let Paint::Gradient(gp) = c.appearance.fill_paint() { + g = g.or(gp.geom); + } + }); + } + g +} + +#[test] +fn linear_gradients_bend_when_asked() { + let mut s = session(); + let a = rect(&mut s); + s.edit("gradient", |d, _| { + d.node_mut(a).unwrap().appearance.set_fill(Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))); + Ok(()) + }) + .unwrap(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); + assert!(gradient_geom(&evaluated(&s, e)).is_none(), "off: the gradient fits the bent shape"); + s.execute("object.envelope.options", &json!({"distortLinearGradients": true})).unwrap(); + let g = gradient_geom(&evaluated(&s, e)).expect("on: the gradient is placed and follows the warp"); + // Arch lifts the middle: the vector (left to right through the middle) moved up. + assert!(g.start.midpoint(g.end).y < 150.0 - 1.0, "{g:?}"); + // It needs Distort Appearance. + s.execute("object.envelope.options", &json!({"distortAppearance": false})).unwrap(); + assert!(gradient_geom(&evaluated(&s, e)).is_none()); +} + +#[test] +fn pattern_fills_bend_when_asked() { + let mut s = session(); + let a = rect(&mut s); + s.edit("pattern", |d, _| { + let dot = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), + vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + let mut def = PatternDef::new("Dots", vec![Arc::new(dot)]); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(def); + d.node_mut(a).unwrap().appearance.set_fill(vectorcraft_doc::pattern::pattern_paint("Dots")); + Ok(()) + }) + .unwrap(); + sel(&mut s, &[a]); + let e = arch(&mut s, 60.0, json!({"distortAppearance": true})); + let clip_groups = |out: &[Node]| { + let mut n = 0; + out[0].walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize); + n + }; + assert_eq!(clip_groups(&evaluated(&s, e)), 0, "off: the pattern fills the bent shape unbent"); + s.execute("object.envelope.options", &json!({"distortPatternFills": true})).unwrap(); + let out = evaluated(&s, e); + assert_eq!(clip_groups(&out), 1); + assert!(anchors(&out).len() > 50 * 4, "the tiles are art"); + assert!(!matches!(out[0].appearance.fill_paint(), Paint::Pattern { .. })); +} + +#[test] +fn new_envelopes_distort_appearance_and_old_ones_keep_their_look() { + let mut s = session(); + let a = rect(&mut s); + sel(&mut s, &[a]); + let e = arch(&mut s, 50.0, json!({})); + assert!(matches!(node(&s, e).kind, NodeKind::Envelope { options, .. } if options == EnvelopeOptions::NEW)); +} diff --git a/crates/engine/src/tests_envelope_edit.rs b/crates/engine/src/tests_envelope_edit.rs new file mode 100644 index 000000000..1cccb9b8d --- /dev/null +++ b/crates/engine/src/tests_envelope_edit.rs @@ -0,0 +1,136 @@ +//! Editing envelopes on the canvas: Edit Contents hit-testing, mesh points and handles. + +use serde_json::{Value, json}; +use vectorcraft_doc::hit::{HitOptions, hit_test}; +use vectorcraft_doc::live::{self, EnvelopeMap}; +use vectorcraft_doc::{EnvelopeKind, Node, NodeKind}; +use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// A 200×100 rectangle at (100, 100) enveloped by `cmd` → (rectangle, envelope). +fn enveloped(s: &mut Session, cmd: &str, params: Value) -> (NodeId, NodeId) { + let a = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + (a, id_of(&s.execute(cmd, ¶ms).unwrap())) +} + +fn mesh(s: &Session, e: NodeId) -> (u32, u32, Vec, Vec<[Vec2; 4]>) { + match node(s, e).kind { + NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, .. } => (rows, cols, points, handles), + k => panic!("{k:?}"), + } +} + +#[test] +fn edit_contents_clicks_reach_the_content_where_it_is() { + let mut s = session(); + let (a, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arch", "bend": 50})); + let doc = |s: &Session| s.doc().unwrap().doc.clone(); + // Inside the rectangle near its bottom middle, which the arch lifted away. + let below = Point::new(200.0, 195.0); + let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap(); + assert_eq!((h.leaf, h.top_object(None), h.contents_of), (e, e, None), "the envelope is one object"); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + let h = hit_test(&doc(&s), below, HitOptions::default()).unwrap(); + assert_eq!((h.leaf, h.top_object(None), h.contents_of), (a, a, Some(e)), "Edit Contents: the content is the object clicked"); + // The Selection tool picks it. + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 200.0, 195.0), ViewInfo::default()).unwrap(); + } + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); +} + +#[test] +fn mesh_handles_start_from_the_smooth_mesh() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2})); + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 210, "y": 130})).unwrap(); + assert!(mesh(&s, e).3.is_empty(), "moving a point keeps the smooth mesh"); + let smooth = |s: &Session, u: f64, v: f64| EnvelopeMap::of(&node(s, e)).unwrap().at(u, v); + let before: Vec = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect(); + // Placing a handle where it already is fills in the handles: the grid lines don't move. + let (rows, cols, points, _) = mesh(&s, e); + let h = live::smooth_handles(rows, cols, &points)[4][live::H_RIGHT]; + let end = points[4] + h; + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y, "handle": 0})).unwrap(); + assert_eq!(mesh(&s, e).3.len(), 9); + let after: Vec = (0..=8).flat_map(|i| [(i as f64 / 8.0, 0.5), (0.5, i as f64 / 8.0)]).map(|(u, v)| smooth(&s, u, v)).collect(); + for (p, q) in before.iter().zip(&after) { + assert!(p.distance(*q) < 1e-6, "{p:?} vs {q:?}"); + } + // Now the handle bends the row line up right of the point. + let y0 = smooth(&s, 0.62, 0.5).y; + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": end.x, "y": end.y - 40.0, "handle": 0})).unwrap(); + assert!(smooth(&s, 0.62, 0.5).y < y0 - 1.0, "{} vs {y0}", smooth(&s, 0.62, 0.5).y); + assert!(s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 4, "x": 0, "y": 0, "handle": 7})).is_err()); +} + +#[test] +fn mesh_commands_edit_mesh_envelopes() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})); + let r = s.execute("object.mesh.addLine", &json!({"id": e.0, "x": 150, "y": 175})).unwrap(); + let (rows, cols, points, handles) = mesh(&s, e); + assert_eq!((rows, cols, handles.len()), (2, 2, 9)); + let i = r["index"].as_u64().unwrap() as usize; + assert!(points[i].distance(Point::new(150.0, 175.0)) < 0.5); + s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 160, "y": 160})).unwrap(); + assert!(mesh(&s, e).2[i].distance(Point::new(160.0, 160.0)) < 1e-9); + s.execute("object.mesh.movePoint", &json!({"id": e.0, "index": i, "x": 200, "y": 160, "handle": 0})).unwrap(); + assert!((mesh(&s, e).3[i][0] - Vec2::new(40.0, 0.0)).hypot() < 1e-9); + s.execute("object.mesh.deletePoint", &json!({"id": e.0, "index": i})).unwrap(); + assert_eq!(mesh(&s, e).0, 1); + assert!(s.execute("object.mesh.setPointColor", &json!({"id": e.0, "index": 0, "color": "#ff0000"})).is_err(), "envelope points have no colour"); + // One undo step per edit. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(mesh(&s, e).0, 2); +} + +#[test] +fn mesh_handles_turn_with_the_envelope_and_round_trip() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1})); + s.execute("object.envelope.setMeshPoint", &json!({"id": e.0, "index": 0, "x": 150, "y": 100, "handle": 0})).unwrap(); + s.execute("object.rotate", &json!({"angle": 90, "origin": [200, 150]})).unwrap(); + let h = mesh(&s, e).3[0][0]; + let want = Affine::rotate(-std::f64::consts::FRAC_PI_2) * Point::new(50.0, 0.0); + assert!((h - want.to_vec2()).hypot() < 1e-9, "{h:?}"); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(back.node(e), d.node(e)); + // Mesh envelopes saved before handles existed load as smooth meshes. + let mut old = serde_json::to_value(node(&s, e)).unwrap(); + old["kind"]["kind"].as_object_mut().unwrap().remove("handles"); + let n: Node = serde_json::from_value(old).unwrap(); + assert!(matches!(n.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { handles, .. }, .. } if handles.is_empty())); +} + +#[test] +fn reset_with_mesh_keeps_the_warp_shape_along_the_grid() { + let mut s = session(); + let (_, e) = enveloped(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 60})); + let warp = EnvelopeMap::of(&node(&s, e)).unwrap().surface_mesh(2, 2, vectorcraft_color::Color::BLACK); + s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2})).unwrap(); + let (_, _, points, handles) = mesh(&s, e); + for (q, (p, h)) in warp.points.iter().zip(points.iter().zip(&handles)) { + assert!(q.p.distance(*p) < 1e-9 && q.handles == *h); + } +} diff --git a/crates/engine/src/tests_fontlist.rs b/crates/engine/src/tests_fontlist.rs new file mode 100644 index 000000000..01aaecfd4 --- /dev/null +++ b/crates/engine/src/tests_fontlist.rs @@ -0,0 +1,46 @@ +//! The fonts available (`text.fontList`), the installed ones listed without asking for them (#36), +//! and Refresh Font List (`text.rescanFonts`). + +use serde_json::{Value, json}; +use vectorcraft_text::{FontDb, system_font_dirs}; + +use super::*; + +fn names(v: &Value) -> Vec { + serde_json::from_value(v.clone()).unwrap() +} + +#[test] +fn the_font_list_names_every_available_family_with_its_styles() { + let mut s = Session::new(); + let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]); + assert_eq!(fams, FontDb::global().families()); + for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { + assert!(fams.iter().any(|x| x == f), "{f}"); + } + // Every installed family, though nothing asked for system fonts. + let installed = FontDb::with_font_dirs(system_font_dirs()); + installed.load_system_fonts(); + let missing: Vec = installed.families().into_iter().filter(|f| !fams.contains(f)).collect(); + assert!(missing.is_empty(), "{missing:?}"); + let r = s.execute("text.fontList", &json!({"family": "source sans 3"})).unwrap(); + assert_eq!(r["family"], "Source Sans 3"); + let styles = names(&r["styles"]); + assert!(["Regular", "Semibold", "Bold", "Italic"].iter().all(|x| styles.iter().any(|s| s == x)), "{styles:?}"); + assert!(s.execute("text.fontList", &json!({"family": "No Such Font"})).is_err()); +} + +#[test] +fn refreshing_the_font_list_redraws_type_without_editing_the_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Type", "font": "No Such Font"})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let revision = s.doc().unwrap().revision; + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert_eq!(r["families"], json!(FontDb::global().families().len())); + assert!(r["faces"].is_u64()); + let d = s.doc().unwrap(); + assert!(d.revision > revision, "the canvas redraws"); + assert!(!d.is_dirty(), "nothing to save"); +} diff --git a/crates/engine/src/tests_halftone.rs b/crates/engine/src/tests_halftone.rs new file mode 100644 index 000000000..25ccf4cc9 --- /dev/null +++ b/crates/engine/src/tests_halftone.rs @@ -0,0 +1,53 @@ +//! Object → Vector Halftone. + +use serde_json::json; + +use super::*; +use crate::tests_adjust::{fill_hex, node, red_rect, session}; + +#[test] +fn halftone_turns_art_into_dots_by_tone() { + let mut s = session(); + let id = red_rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.vectorHalftone", &json!({"frequency": 18})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(s.doc().unwrap().doc.node(id).is_none(), "the original is replaced"); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let dots = r["dots"].as_u64().unwrap(); + // 60 pt at 18 lpi (4 pt cells) turned 45°: about 225 cells, all black. + assert!((180..=320).contains(&dots), "{dots}"); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 1); + let (pd, b) = (ch[0].path_data().unwrap(), ch[0].geometric_bounds().unwrap()); + assert!(pd.subpaths.len() as u64 >= dots / 2); + // Clipped to the art. + assert!(b.x0 >= 19.9 && b.y0 >= 19.9 && b.x1 <= 80.1 && b.y1 <= 80.1, "{b:?}"); +} + +#[test] +fn halftone_options() { + let mut s = session(); + let id = red_rect(&mut s); + // Red art in CMYK: magenta and yellow screens, no cyan or black. + let r = s.execute("object.vectorHalftone", &json!({"mode": "cmyk", "shape": "square", "keepOriginal": true})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_some(), "the original is kept"); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let inks: Vec = g.children().unwrap().iter().map(|c| fill_hex(c)).collect(); + assert_eq!(inks.len(), 2, "{inks:?}"); + assert!(g.children().unwrap().iter().all(|c| c.blend == vectorcraft_color::BlendMode::Multiply)); + // Light art gets no dots; inverted, it does. + let mut s = session(); + let id = red_rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + assert!(s.execute("object.vectorHalftone", &json!({})).is_err()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + assert!(s.execute("object.vectorHalftone", &json!({"invert": true, "shape": "line"})).is_ok()); + // Bad input is refused. + for bad in [json!({"frequency": 0}), json!({"shape": "star"}), json!({"mode": "rgb"}), json!({"color": "nope"})] { + let mut s = session(); + red_rect(&mut s); + assert!(s.execute("object.vectorHalftone", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/engine/src/tests_liquify.rs b/crates/engine/src/tests_liquify.rs new file mode 100644 index 000000000..d868d61c4 --- /dev/null +++ b/crates/engine/src/tests_liquify.rs @@ -0,0 +1,351 @@ +//! The Liquify tools: their options (Brush Affects, Simplify, Use Pressure Pen, Show Brush Size), +//! Alt-drag brush sizing, holding still, what they leave alone and incremental strokes. + +use serde_json::{Value, json}; +use vectorcraft_geom::{PathData, Point}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn path(s: &Session, id: NodeId) -> PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + PointerEvent::new(kind, x, y) +} + +/// Down at the first point, drags through the middle ones, up at the last. +fn stroke(s: &mut Session, pts: &[(f64, f64)], pressure: f32) { + let v = ViewInfo::default(); + for (i, &(x, y)) in pts.iter().enumerate() { + let kind = match i { + 0 => PointerKind::Down, + _ if i + 1 == pts.len() => PointerKind::Up, + _ => PointerKind::Drag, + }; + s.pointer(&PointerEvent { pressure, ..ev(kind, x, y) }, v).unwrap(); + } +} + +fn last_journal(s: &Session) -> (String, Value) { + s.journal.last().unwrap().clone() +} + +#[test] +fn brush_affects_reach_the_command() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.select_tool("scallop", ViewInfo::default()).unwrap(); + s.set_tool_option("affectAnchors", &json!(false)); + s.set_tool_option("affectOut", &json!(false)); + stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 250.0)], 1.0); + let (cmd, p) = last_journal(&s); + assert_eq!((cmd.as_str(), &p["affectAnchors"], &p["affectIn"], &p["affectOut"]), ("object.liquify", &json!(false), &json!(true), &json!(false))); + // Replaying it gives the same path, and the boxes change the result. + let mut t = session(); + let b = rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + t.execute(&cmd, &p).unwrap(); + assert_eq!(path(&t, b), path(&s, a)); + let mut all = p.clone(); + for k in ["affectAnchors", "affectIn", "affectOut"] { + all[k] = json!(true); + } + t.execute("edit.undo", &json!({})).unwrap(); + t.execute(&cmd, &all).unwrap(); + assert_ne!(path(&t, b), path(&s, a)); +} + +#[test] +fn simplify_can_be_turned_off() { + let run = |on: bool| { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + // Pucker centred on an edge pulls its new anchors along the edge: they stay flat. + let p = json!({"tool": "pucker", "points": [[300, 200]], "intensity": 1, "detail": 10, "simplify": 100, "simplifyOn": on}); + s.execute("object.liquify", &p).unwrap(); + path(&s, a).anchor_count() + }; + assert!(run(false) > run(true), "{} vs {}", run(false), run(true)); +} + +#[test] +fn pen_pressure_is_the_intensity_with_use_pressure_pen() { + let run = |pressures: [f64; 2], use_pressure: bool| { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let pts = json!([[260, 150, pressures[0]], [260, 250, pressures[1]]]); + s.execute("object.liquify", &json!({"tool": "pucker", "points": pts, "width": 120, "height": 120, "usePressure": use_pressure})).unwrap(); + path(&s, a) + }; + let square = { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + path(&s, a) + }; + // No pressure: nothing moves; more pressure moves more; pressure is ignored without the option. + assert_eq!(run([0.0, 0.0], true), square); + let x = |p: &PathData| p.anchors().map(|(_, _, an)| an.p.x).fold(f64::MAX, |m, v| if v > 200.0 { m.min(v) } else { m }); + assert!(x(&run([1.0, 1.0], true)) < x(&run([0.3, 0.3], true))); + assert_eq!(run([0.0, 0.0], false), run([1.0, 1.0], false)); + + // The tool sends each sample's pressure while the option is on. + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.select_tool("bloat", ViewInfo::default()).unwrap(); + stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 210.0)], 0.4); + assert_eq!(last_journal(&s).1["points"][0].as_array().unwrap().len(), 2); + s.set_tool_option("usePressure", &json!(true)); + stroke(&mut s, &[(300.0, 150.0), (300.0, 200.0), (300.0, 210.0)], 0.4); + let (_, p) = last_journal(&s); + assert_eq!((p["usePressure"].as_bool(), p["points"][1][2].as_f64().map(|f| (f * 1e6).round() / 1e6)), (Some(true), Some(0.4))); +} + +#[test] +fn alt_drag_sizes_the_brush_from_its_size() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("warp", v).unwrap(); + s.set_tool_option("height", &json!(60)); + let alt = Mods { alt: true, ..Mods::default() }; + let shift_alt = Mods { shift: true, ..alt }; + let size = |s: &Session| (s.tool_options()["width"].as_f64().unwrap(), s.tool_options()["height"].as_f64().unwrap()); + s.pointer(&ev(PointerKind::Down, 50.0, 50.0).with_mods(alt), v).unwrap(); + // The first sample barely moves: the brush stays about as it was (it used to collapse to 1 pt). + s.pointer(&ev(PointerKind::Drag, 51.0, 50.0).with_mods(alt), v).unwrap(); + assert_eq!(size(&s), (102.0, 60.0)); + s.pointer(&ev(PointerKind::Drag, 60.0, 45.0).with_mods(alt), v).unwrap(); + assert_eq!(size(&s), (120.0, 50.0)); + s.pointer(&ev(PointerKind::Up, 60.0, 45.0).with_mods(alt), v).unwrap(); + assert_eq!(s.journal.iter().filter(|(c, _)| c == "object.liquify").count(), 0, "sizing doesn't liquify"); + // Shift keeps the proportions. + s.pointer(&ev(PointerKind::Down, 0.0, 0.0).with_mods(shift_alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, 60.0, 5.0).with_mods(shift_alt), v).unwrap(); + assert_eq!(size(&s), (240.0, 100.0)); + s.pointer(&ev(PointerKind::Up, 60.0, 5.0).with_mods(shift_alt), v).unwrap(); + // The size is kept: the other Liquify tools share it. + s.select_tool("pucker", v).unwrap(); + assert_eq!(size(&s), (240.0, 100.0)); +} + +#[test] +fn show_brush_size_hides_the_outline() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("twirl", v).unwrap(); + s.pointer(&ev(PointerKind::Move, 200.0, 200.0), v).unwrap(); + assert_eq!(s.overlays(v).len(), 1); + s.set_tool_option("showBrush", &json!(false)); + assert!(s.overlays(v).is_empty()); + assert_eq!(s.tool_options()["showBrush"], json!(false)); + // Sizing the brush shows it anyway. + s.pointer(&ev(PointerKind::Down, 200.0, 200.0).with_mods(Mods { alt: true, ..Mods::default() }), v).unwrap(); + assert_eq!(s.overlays(v).len(), 1); +} + +// ---------- holding still ---------- + +/// Twirl pressed at (300, 200) on the square's right edge, held for `holds` (seconds per tick), +/// released. +fn held_twirl(holds: &[f64]) -> (Session, NodeId) { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let v = ViewInfo::default(); + s.select_tool("twirl", v).unwrap(); + s.pointer(&ev(PointerKind::Down, 300.0, 200.0), v).unwrap(); + for dt in holds { + assert!(s.tool_wants_ticks()); + s.tool_tick(*dt, v).unwrap(); + } + s.pointer(&ev(PointerKind::Up, 300.0, 200.0), v).unwrap(); + (s, a) +} + +#[test] +fn holding_still_keeps_twirling_scaled_by_time() { + let (once, a) = held_twirl(&[]); + let (held, _) = held_twirl(&[0.35]); + let (ticked, _) = held_twirl(&[0.1, 0.1, 0.1, 0.05]); + // A tenth of a second held is one more dab where the brush is: 3 after 0.35 s. + let (_, p) = last_journal(&held); + assert_eq!(p["points"], json!([[300.0, 200.0], [300.0, 200.0], [300.0, 200.0], [300.0, 200.0]])); + assert_eq!(path(&held, a), path(&ticked, a), "the same time held in other ticks"); + // Longer holds twirl further: the edge's anchors swing further off x = 300. + let c = Point::new(300.0, 200.0); + let turned = + |s: &Session| path(s, a).anchors().filter(|(_, _, an)| an.p.distance(c) < 50.0).map(|(_, _, an)| (an.p.x - 300.0).abs()).fold(0.0, f64::max); + let (longer, _) = held_twirl(&[1.0]); + assert!(turned(&longer) > turned(&held) && turned(&held) > turned(&once), "{} {} {}", turned(&longer), turned(&held), turned(&once)); + // One undo step whose journal entry replays the same. + let mut t = session(); + rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("object.liquify", &p).unwrap(); + assert_eq!(path(&t, a), path(&held, a)); +} + +#[test] +fn only_twirl_pucker_and_bloat_keep_going() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let v = ViewInfo::default(); + for (tool, holds) in [("warp", false), ("scallop", false), ("crystallize", false), ("wrinkle", false), ("pucker", true), ("bloat", true)] { + s.select_tool(tool, v).unwrap(); + assert!(!s.tool_wants_ticks(), "{tool} idle"); + s.pointer(&ev(PointerKind::Down, 300.0, 200.0), v).unwrap(); + assert_eq!(s.tool_wants_ticks(), holds, "{tool}"); + s.tool_tick(0.5, v).unwrap(); + s.pointer(&ev(PointerKind::Up, 300.0, 200.0), v).unwrap(); + let n = last_journal(&s).1["points"].as_array().unwrap().len(); + assert_eq!(n, if holds { 6 } else { 1 }, "{tool}"); + } + // Repeated points are no extra dabs for the tools that don't keep going. + let warp = |pts: Value| { + let mut t = session(); + let a = rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("object.liquify", &json!({"tool": "warp", "points": pts})).unwrap(); + path(&t, a) + }; + assert_eq!(warp(json!([[300, 200], [300, 200], [320, 200]])), warp(json!([[300, 200], [320, 200]]))); +} + +// ---------- what Liquify leaves alone ---------- + +fn node_json(s: &Session, id: u64) -> Value { + serde_json::to_value(s.doc().unwrap().doc.node(NodeId(id)).unwrap()).unwrap() +} + +#[test] +fn type_symbols_graphs_and_live_contents_are_left_alone_and_reported() { + let mut s = session(); + let id = |v: Value| v["id"].as_u64().unwrap(); + let text = id(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Liquid"})).unwrap()); + let graph = id(s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 60, "width": 120, "height": 80, "rows": [[1, 2]]})).unwrap()); + let sq = rect(&mut s, 450.0, 60.0, 60.0, 60.0); + s.execute("select.set", &json!({"ids": [sq.0]})).unwrap(); + let inst = id(s.execute("symbol.new", &json!({"name": "Dot"})).unwrap()); + let a = rect(&mut s, 100.0, 300.0, 60.0, 60.0); + let b = rect(&mut s, 200.0, 300.0, 60.0, 60.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let blend = id(s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); + let c = rect(&mut s, 400.0, 300.0, 80.0, 60.0); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + let env = id(s.execute("object.envelope.makeWithWarp", &json!({})).unwrap()); + let r = rect(&mut s, 560.0, 300.0, 40.0, 40.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let rep = id(s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 60})).unwrap()); + let plain = rect(&mut s, 100.0, 450.0, 600.0, 60.0); + let all = [text, graph, inst, blend, env, rep]; + let before: Vec = all.iter().map(|i| node_json(&s, *i)).collect(); + // Nothing selected: the brush sweeps the whole drawing. + s.execute("select.set", &json!({"ids": []})).unwrap(); + let pts: Vec = (0..=16).flat_map(|i| [json!([80 + i * 40, 100]), json!([80 + i * 40, 330]), json!([80 + i * 40, 480])]).collect(); + let out = s.execute("object.liquify", &json!({"tool": "bloat", "points": pts, "width": 160, "height": 160, "intensity": 1})).unwrap(); + for (i, id) in all.iter().enumerate() { + assert_eq!(node_json(&s, *id), before[i], "object {id} unchanged"); + } + assert!(out["ids"].as_array().unwrap().contains(&json!(plain.0))); + let mut skipped: Vec = out["skipped"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + skipped.sort(); + let mut want = all.to_vec(); + want.sort(); + assert_eq!(skipped, want); + let w = out["warning"].as_str().unwrap(); + for kind in ["type", "symbols", "graphs", "blends", "envelopes", "repeats"] { + assert!(w.contains(kind), "{kind} in {w}"); + } + // Selected, they are left alone too (and a path inside a blend is the blend's). + s.execute("select.set", &json!({"ids": [text, blend, plain.0]})).unwrap(); + let out = + s.execute("object.liquify", &json!({"tool": "pucker", "points": [[130, 100], [130, 330], [130, 480]], "width": 200, "height": 200})).unwrap(); + assert_eq!(out["ids"], json!([plain.0])); + assert_eq!(node_json(&s, blend), before[3]); + let inside = s.execute("object.liquify", &json!({"tool": "pucker", "ids": [a.0], "points": [[130, 330]]})).unwrap(); + assert_eq!((&inside["ids"], &inside["skipped"]), (&json!([]), &json!([blend]))); +} + +#[test] +fn guides_are_left_alone_in_selection_mode_and_the_tool_reports_skips() { + let mut s = session(); + let g = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + let guide = path(&s, g); + let a = rect(&mut s, 120.0, 120.0, 160.0, 160.0); + let t = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [g.0, a.0, t]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("bloat", v).unwrap(); + let ui = s.pointer(&ev(PointerKind::Down, 290.0, 200.0), v).unwrap(); + assert!(matches!(ui.first(), Some(UiRequest::Status(m)) if m.contains("type")), "{ui:?}"); + s.pointer(&ev(PointerKind::Up, 290.0, 200.0), v).unwrap(); + assert_eq!(path(&s, g), guide); + assert_ne!(path(&s, a).bounds().unwrap(), vectorcraft_geom::Rect::new(120.0, 120.0, 280.0, 280.0)); +} + +// ---------- incremental strokes ---------- + +/// Every prefix of `pts` previewed in one interaction gives what the whole prefix gives at once. +fn previews_match_whole_strokes(params: Value, pts: &[Value], select: bool) { + let setup = || { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + let b = rect(&mut s, 330.0, 120.0, 120.0, 160.0); + if select { + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + } else { + s.execute("select.set", &json!({"ids": []})).unwrap(); + } + (s, a, b) + }; + let (mut live, a, b) = setup(); + live.begin_interaction("Liquify").unwrap(); + for k in 1..=pts.len() { + let mut p = params.clone(); + p["points"] = json!(pts[..k]); + let r = live.preview("object.liquify", &p).unwrap(); + let (mut whole, ..) = setup(); + let w = whole.execute("object.liquify", &p).unwrap(); + assert_eq!(r["ids"], w["ids"], "prefix {k}"); + assert_eq!((path(&live, a), path(&live, b)), (path(&whole, a), path(&whole, b)), "prefix {k} of {params}"); + } + assert!(live.liquify_stroke.is_some(), "the drag goes on from the last preview"); + live.commit_interaction().unwrap(); +} + +#[test] +fn a_stroke_applied_as_it_grows_equals_the_whole_stroke() { + let path_pts: Vec = (0..14).map(|i| json!([250 + i * 12, 130 + (i * 37) % 90])).collect(); + previews_match_whole_strokes(json!({"tool": "warp", "width": 70, "height": 50, "angle": 20, "intensity": 0.8}), &path_pts, false); + previews_match_whole_strokes(json!({"tool": "crystallize", "width": 60, "height": 60, "complexity": 4}), &path_pts, true); + // Holds (repeated points) and pressure. + let mut held: Vec = path_pts.iter().take(5).cloned().collect(); + held.extend([json!([298, 200, 0.4]), json!([298, 200, 0.4]), json!([298, 200, 0.9]), json!([310, 210, 1])]); + previews_match_whole_strokes(json!({"tool": "pucker", "usePressure": true}), &held, false); + previews_match_whole_strokes(json!({"tool": "twirl", "rate": 120}), &held, true); +} + +#[test] +fn a_changed_brush_or_another_stroke_starts_afresh() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.begin_interaction("Liquify").unwrap(); + s.preview("object.liquify", &json!({"tool": "bloat", "points": [[300, 150], [300, 200]]})).unwrap(); + // Not a continuation (other points, another size): applied to the snapshot, as a fresh call is. + s.preview("object.liquify", &json!({"tool": "bloat", "points": [[300, 250]], "width": 60})).unwrap(); + let mut t = session(); + rect(&mut t, 100.0, 100.0, 200.0, 200.0); + t.execute("object.liquify", &json!({"tool": "bloat", "points": [[300, 250]], "width": 60})).unwrap(); + assert_eq!(path(&s, a), path(&t, a)); +} diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index a570bb60d..204afa4d6 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -399,8 +399,10 @@ fn envelope_release_and_expand() { s.execute("object.envelope.expand", &json!({})).unwrap(); let g = node(&s, e); assert!(matches!(g.kind, NodeKind::Group { .. })); - let p = g.children().unwrap()[0].path_data().unwrap().anchor_count(); - assert!(p > 4); + // (Distort Appearance, on for new envelopes, expands the stroke too: the fill comes first.) + let mut fill = None; + g.walk(&mut |c| fill = fill.or_else(|| c.path_data().map(|p| p.anchor_count()))); + assert!(fill.unwrap() > 4); } #[test] diff --git a/crates/engine/src/tests_persp_planes.rs b/crates/engine/src/tests_persp_planes.rs new file mode 100644 index 000000000..669fdf81e --- /dev/null +++ b/crates/engine/src/tests_persp_planes.rs @@ -0,0 +1,184 @@ +//! Perspective planes: offsets, plane widgets (alone, Shift with the objects, Alt with copies), +//! Move Plane to Match Object, and grid edits with attached art. + +use serde_json::json; +use vectorcraft_geom::{Point, Vec2}; +use vectorcraft_tools::Mods; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; + +use super::tests_persp_select::{attached, drag, grid, persp_session, plane_pts, undo_len}; +use super::*; + +fn path(s: &Session, id: NodeId) -> vectorcraft_geom::PathData { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone() +} + +#[test] +fn moving_a_plane_alone_leaves_its_objects() { + let (mut s, id) = persp_session(); + let before = path(&s, id); + let n = undo_len(&s); + let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 50})).unwrap(); + assert_eq!((r["plane"].as_str(), r["offset"].as_f64()), (Some("right"), Some(50.0))); + assert_eq!(undo_len(&s), n + 1); + let g = grid(&s); + assert_eq!(g.right_offset, 50.0); + // The plane is drawn where it was moved: its origin corner lies 50 pt along its normal. + let o = g.homography(Plane::Right).unwrap().apply(Point::ZERO).unwrap(); + assert!((o - g.homography_at(Plane::Right, 50.0).unwrap().apply(Point::ZERO).unwrap()).hypot() < 1e-9); + assert!((o - Point::new(g.origin[0], g.origin[1])).hypot() > 1.0); + assert_eq!(path(&s, id), before); + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); + // Relative moves; new objects attach where the plane is. + s.execute("perspective.plane.move", &json!({"plane": "right", "by": -20})).unwrap(); + assert_eq!(grid(&s).right_offset, 30.0); + let r = s.execute("shape.rectangle", &json!({"x": 470, "y": 300, "width": 30, "height": 30})).unwrap(); + let b = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [b.0], "plane": "right"})).unwrap(); + assert_eq!(attached(&s, b), Some((Plane::Right, 30.0))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(grid(&s).right_offset, 50.0); + assert!(s.execute("perspective.plane.move", &json!({"plane": "right"})).is_err()); + assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 1, "objects": "all"})).is_err()); + assert!(s.execute("perspective.plane.move", &json!({"plane": "right", "offset": -1e9})).is_err(), "behind the viewer"); +} + +#[test] +fn moving_a_plane_with_its_objects_or_copies() { + let (mut s, id) = persp_session(); + // A second object moved off the plane along its normal stays put. + let r = s.execute("shape.rectangle", &json!({"x": 560, "y": 330, "width": 30, "height": 30})).unwrap(); + let off = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [off.0], "plane": "right"})).unwrap(); + s.execute("perspective.transform", &json!({"ids": [off.0], "matrix": [1, 0, 0, 1, 0, 0], "depth": 20})).unwrap(); + let off_path = path(&s, off); + let q = plane_pts(&s, id, Plane::Right, 0.0); + let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 35, "objects": "move"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(attached(&s, id), Some((Plane::Right, 35.0))); + for (a, b) in q.iter().zip(plane_pts(&s, id, Plane::Right, 35.0)) { + assert!((*a - b).hypot() < 1e-6, "keeps its place on the plane"); + } + assert_eq!(path(&s, off), off_path); + // Copies: the originals stay, the copies go with the plane. + let r = s.execute("perspective.plane.move", &json!({"plane": "right", "offset": 60, "objects": "copy"})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + assert_ne!(copy, id); + assert_eq!(attached(&s, id), Some((Plane::Right, 35.0))); + assert_eq!(attached(&s, copy), Some((Plane::Right, 60.0))); + for (a, b) in q.iter().zip(plane_pts(&s, copy, Plane::Right, 60.0)) { + assert!((*a - b).hypot() < 1e-6); + } +} + +#[test] +fn plane_widgets_drag_with_shift_and_alt() { + let (mut s, id) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + for tool in ["perspectiveGrid", "perspectiveSelection"] { + s.select_tool(tool, ViewInfo::default()).unwrap(); + let g = grid(&s); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + let from = g.offset(Plane::Right); + // Where the widget lands 15 pt further along the plane's normal. + let to = g + .homography_at(Plane::Right, from + 15.0) + .unwrap() + .apply(g.homography(Plane::Right).unwrap().inverse().unwrap().apply(w).unwrap()) + .unwrap(); + let n = undo_len(&s); + drag(&mut s, &[w, to, to], Mods { shift: true, ..Default::default() }); + assert_eq!(undo_len(&s), n + 1, "{tool}"); + let (cmd, p) = s.journal.last().unwrap().clone(); + assert_eq!((cmd.as_str(), p["objects"].as_str()), ("perspective.plane.move", Some("move"))); + assert!((grid(&s).right_offset - from - 15.0).abs() < 1e-6, "{tool}: {}", grid(&s).right_offset); + assert!((attached(&s, id).unwrap().1 - from - 15.0).abs() < 1e-6, "{tool}: the object went along"); + } + // Alt copies the objects; a plain drag moves the plane alone. + let g = grid(&s); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len(); + let k = count(&s); + drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods { alt: true, ..Default::default() }); + assert_eq!(count(&s), k + 1); + let g = grid(&s); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + let depth = attached(&s, id).unwrap().1; + drag(&mut s, &[w, w + Vec2::new(-12.0, 0.0), w + Vec2::new(-12.0, 0.0)], Mods::default()); + assert_eq!(attached(&s, id).unwrap().1, depth); + assert_eq!(count(&s), k + 1); +} + +#[test] +fn double_clicking_a_plane_widget_asks_for_its_options() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + let v = ViewInfo::default(); + let (_, w) = grid(&s).plane_widgets().into_iter().find(|(p, _)| *p == Plane::Ground).unwrap(); + for tool in ["perspectiveGrid", "perspectiveSelection"] { + s.select_tool(tool, v).unwrap(); + let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, w.x, w.y), v).unwrap(); + assert_eq!(r, vec![crate::UiRequest::Dialog("perspectivePlane".into(), json!({"plane": "ground"}))], "{tool}"); + } +} + +#[test] +fn move_plane_to_match_object() { + let (mut s, id) = persp_session(); + let q0 = plane_pts(&s, id, Plane::Right, 0.0); + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -25})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "ground"})).unwrap(); + let r = s.execute("perspective.plane.matchObject", &json!({})).unwrap(); + assert_eq!(r, json!({"plane": "right", "offset": -25.0})); + let g = grid(&s); + assert_eq!((g.right_offset, g.plane), (-25.0, Plane::Right)); + // The object now lies on the plane where it is drawn. + let h = g.homography(Plane::Right).unwrap(); + for (q, a) in q0.iter().zip(s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors()) { + assert!((h.apply(*q).unwrap() - a.2.p).hypot() < 1e-6); + } + s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap(); + assert!(s.execute("perspective.plane.matchObject", &json!({"id": id.0})).is_err()); +} + +#[test] +fn grid_edits_keep_attached_art_in_place_unless_reprojected() { + let (mut s, id) = persp_session(); + let before = path(&s, id); + let q = plane_pts(&s, id, Plane::Right, 0.0); + let vr = grid(&s).vp_right; + s.execute("perspective.grid.set", &json!({"vpRight": vr + 150.0})).unwrap(); + assert_eq!(path(&s, id), before, "as in the reference app, art stays where it is"); + s.execute("perspective.grid.set", &json!({"vpRight": vr - 100.0, "reproject": true})).unwrap(); + assert_ne!(path(&s, id), before); + // Re-projected from the previous grid: the same place on the plane of the new one. + let old = { + let mut g = grid(&s); + g.vp_right = vr + 150.0; + g + }; + let hi = old.homography(Plane::Right).unwrap().inverse().unwrap(); + let back: Vec = before.anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect(); + for (a, b) in back.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}"); + } + assert!(q.len() == back.len()); +} + +#[test] +fn plane_offsets_round_trip_and_old_grids_have_none() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.set", &json!({"leftOffset": 12, "groundOffset": -8})).unwrap(); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let g = PerspectiveGrid::from_doc(&doc).unwrap(); + assert_eq!((g.left_offset, g.right_offset, g.ground_offset), (12.0, 0.0, -8.0)); + let mut stored = doc.unknown["perspectiveGrid"].clone(); + assert!(stored.get("rightOffset").is_none(), "zero offsets aren't written"); + for k in ["leftOffset", "groundOffset"] { + stored.as_object_mut().unwrap().remove(k); + } + let old: PerspectiveGrid = serde_json::from_value(stored).unwrap(); + assert_eq!((old.left_offset, old.ground_offset), (0.0, 0.0)); +} diff --git a/crates/engine/src/tests_persp_select.rs b/crates/engine/src/tests_persp_select.rs new file mode 100644 index 000000000..88031ba2c --- /dev/null +++ b/crates/engine/src/tests_persp_select.rs @@ -0,0 +1,255 @@ +//! Perspective Selection tool: scaling in perspective, Alt-drag copies, moving perpendicular to the +//! plane (5), Transform Again and arrow nudges in perspective. + +use serde_json::json; +use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use super::*; + +/// An 800 × 600 document with a two-point grid and a rectangle attached to the right plane. +pub(crate) fn persp_session() -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + // Snap to Grid (on by default) would land moved edges on gridlines: these tests measure exact moves. + s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + (s, id) +} + +pub(crate) fn grid(s: &Session) -> PerspectiveGrid { + PerspectiveGrid::from_doc(&s.doc().unwrap().doc).unwrap() +} + +pub(crate) fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Where `id` is attached (plane, depth). +pub(crate) fn attached(s: &Session, id: NodeId) -> Option<(Plane, f64)> { + grid(s).attachment_of(&s.doc().unwrap().doc, id) +} + +/// The anchors of path `id` in the plane coordinates of `plane` at `depth`. +pub(crate) fn plane_pts(s: &Session, id: NodeId, plane: Plane, depth: f64) -> Vec { + let g = grid(s); + let hi = g.homography_at(plane, depth).unwrap().inverse().unwrap(); + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect() +} + +pub(crate) fn bounds_of(pts: &[Point]) -> Rect { + pts.iter().skip(1).fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)) +} + +/// Pointer events with the active tool: down at the first point, drags, up at the last. +pub(crate) fn drag(s: &mut Session, pts: &[Point], mods: Mods) { + let v = ViewInfo::default(); + for (i, p) in pts.iter().enumerate() { + let kind = if i == 0 { + PointerKind::Down + } else if i == pts.len() - 1 { + PointerKind::Up + } else { + PointerKind::Drag + }; + s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn handles_scale_in_perspective_about_the_opposite_handle() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let g = grid(&s); + let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + // The box's handles are drawn in perspective: the far corner of the plane-space box. + let h = g.homography(Plane::Right).unwrap(); + let corner = h.apply(Point::new(before.x1, before.y1)).unwrap(); + let anchors = s.overlays(ViewInfo::default()).into_iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count(); + assert!(anchors >= 8, "{anchors} handles"); + // Drag that handle to twice the size in plane space. + let target = h.apply(Point::new(before.x0 + 2.0 * before.width(), before.y0 + 2.0 * before.height())).unwrap(); + let n = undo_len(&s); + drag(&mut s, &[corner, target, target], Mods::default()); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + assert_eq!(s.journal.last().unwrap().0, "perspective.transform"); + let after = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "the opposite corner stays: {before:?} → {after:?}"); + assert!((after.width() - 2.0 * before.width()).abs() < 1e-6 && (after.height() - 2.0 * before.height()).abs() < 1e-6, "{after:?}"); + // Still a rectangle on the plane. + for q in plane_pts(&s, id, Plane::Right, 0.0) { + assert!(((q.x - after.x0).abs() < 1e-6 || (q.x - after.x1).abs() < 1e-6) && ((q.y - after.y0).abs() < 1e-6 || (q.y - after.y1).abs() < 1e-6)); + } + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); +} + +#[test] +fn alt_drag_copies_and_the_copy_stays_attached() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let c = bounds_of(&before).center(); + let h = grid(&s).homography(Plane::Right).unwrap(); + let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(80.0, 0.0)).unwrap()); + let n = undo_len(&s); + drag(&mut s, &[from, to, to], Mods { alt: true, ..Default::default() }); + assert_eq!(undo_len(&s), n + 1); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(copy, id, "the copy is selected"); + assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before, "the original stays"); + assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)), "the copy is attached"); + for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(copy).is_none()); +} + +#[test] +fn pressing_5_while_dragging_moves_perpendicular_to_the_plane() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let c = bounds_of(&before).center(); + let g = grid(&s); + let from = g.homography(Plane::Right).unwrap().apply(c).unwrap(); + // The point 40 pt in front of the plane (along its normal, toward the viewer's left wall side). + let to = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap(); + let v = ViewInfo::default(); + let n = undo_len(&s); + s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap(); + s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let (plane, depth) = attached(&s, id).unwrap(); + assert_eq!(plane, Plane::Right); + assert!((depth - 40.0).abs() < 1e-6, "{depth}"); + // Same place within the plane, on the parallel plane 40 pt along the normal. + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) { + assert!((*a - b).hypot() < 1e-6, "{a:?} {b:?}"); + } + // Pressing 5 again goes back to moving within the plane. + let c2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c).unwrap(); + let to2 = g.homography_at(Plane::Right, 40.0).unwrap().apply(c + Vec2::new(30.0, 0.0)).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, c2.x, c2.y), v).unwrap(); + s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Digit(5), Mods::default(), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to2.x, to2.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to2.x, to2.y), v).unwrap(); + assert!((attached(&s, id).unwrap().1 - 40.0).abs() < 1e-6); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 40.0)) { + assert!((b - *a - Vec2::new(30.0, 0.0)).hypot() < 1e-6); + } +} + +#[test] +fn transform_again_repeats_the_last_perspective_move() { + let (mut s, id) = persp_session(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let c = bounds_of(&before).center(); + let h = grid(&s).homography(Plane::Right).unwrap(); + let (from, to) = (h.apply(c).unwrap(), h.apply(c + Vec2::new(25.0, 10.0)).unwrap()); + drag(&mut s, &[from, to, to], Mods::default()); + // Twice the plane-space move, not twice the page move (which foreshortening would distort). + s.execute("object.transformAgain", &json!({})).unwrap(); + for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) { + assert!((b - *a - Vec2::new(50.0, 20.0)).hypot() < 1e-6, "{a:?} → {b:?}"); + } + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Transform in Perspective"); + // An ordinary move takes over Transform Again. + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + let p0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + s.execute("object.transformAgain", &json!({})).unwrap(); + let p1 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert!((p1.x0 - p0.x0 - 5.0).abs() < 1e-9 && (p1.y0 - p0.y0).abs() < 1e-9); + // A cancelled drag records nothing. + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 7, 0]})).unwrap(); + let v = ViewInfo::default(); + let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center(); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 20.0, c.y), v).unwrap(); + assert!(s.in_interaction()); + s.cancel_interaction().unwrap(); + assert_eq!(s.doc().unwrap().last_perspective.as_ref().unwrap()["matrix"][4], json!(7.0)); +} + +#[test] +fn transform_again_repeats_a_perspective_scale_and_copies() { + let (mut s, id) = persp_session(); + let before = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0)); + let r = s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 100, 0], "copy": true})).unwrap(); + let copy = NodeId(r["ids"][0].as_u64().unwrap()); + s.execute("object.transformAgain", &json!({})).unwrap(); + let third = *s.doc().unwrap().selection.objects.first().unwrap(); + assert!(third != copy && third != id); + let b = bounds_of(&plane_pts(&s, third, Plane::Right, 0.0)); + assert!((b.x0 - before.x0 - 200.0).abs() < 1e-6, "{before:?} {b:?}"); + assert_eq!(attached(&s, third), Some((Plane::Right, 0.0))); + assert!(s.execute("perspective.transform", &json!({"matrix": [0, 0, 0, 0, 0, 0]})).is_err()); + assert!(s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": f64::MAX})).is_err()); +} + +#[test] +fn arrow_keys_nudge_in_perspective() { + let (mut s, id) = persp_session(); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + assert!(s.tool_claims_key(ToolKey::Right, v), "the tool takes the arrows for a selection in perspective"); + let before = plane_pts(&s, id, Plane::Right, 0.0); + let n = undo_len(&s); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(undo_len(&s), n + 1); + let after = plane_pts(&s, id, Plane::Right, 0.0); + let d = after[0] - before[0]; + for (a, b) in before.iter().zip(&after) { + assert!((*b - *a - d).hypot() < 1e-6, "moves within the plane"); + } + // The page step at the centre is the keyboard increment. + let h = grid(&s).homography(Plane::Right).unwrap(); + let c = bounds_of(&before).center(); + let step = h.apply(c + d).unwrap() - h.apply(c).unwrap(); + assert!((step - Vec2::new(s.prefs.keyboard_increment, 0.0)).hypot() < 1e-6, "{step:?}"); + // Alt copies; Shift takes ten steps. + s.tool_key(ToolKey::Left, Mods { alt: true, shift: true, ..Default::default() }, v).unwrap(); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(copy, id); + assert_eq!(attached(&s, copy).map(|a| a.0), Some(Plane::Right)); + // Nothing attached: the arrows nudge as usual. + s.execute("perspective.release", &json!({"ids": [id.0, copy.0]})).unwrap(); + assert!(!s.tool_claims_key(ToolKey::Right, v)); + assert!(s.execute("perspective.nudge", &json!({"dx": 1, "dy": 0})).is_err()); + assert!(s.execute("perspective.nudge", &json!({"dx": 0, "dy": 0})).is_err()); +} + +#[test] +fn selecting_an_attached_object_switches_to_its_plane() { + let (mut s, id) = persp_session(); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap(); + let c = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center(); + drag(&mut s, &[c, c], Mods::default()); + assert_eq!(grid(&s).plane, Plane::Right); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + // A click is no edit. + assert_eq!(persp::attachment(s.doc().unwrap().doc.node(id).unwrap()), Some((Plane::Right, 0.0))); +} + +#[test] +fn attachments_round_trip_through_native_files() { + let (mut s, id) = persp_session(); + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": 12.5})).unwrap(); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(persp::attachment(doc.node(id).unwrap()), Some((Plane::Right, 12.5))); + assert_eq!(*doc.node(id).unwrap(), *s.doc().unwrap().doc.node(id).unwrap()); + // Objects outside perspective write nothing for it. + let v = serde_json::to_value(vectorcraft_doc::Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![], clip: false })).unwrap(); + assert!(v.get("perspective").is_none()); +} diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs new file mode 100644 index 000000000..96fe97c20 --- /dev/null +++ b/crates/engine/src/tests_persp_text.rs @@ -0,0 +1,271 @@ +//! Type and symbols in perspective (projected, still type and symbol instances, Edit Text), +//! attachment bookkeeping (copy, paste, duplicate, Selection-tool moves, release) and drawing in +//! perspective (flare, spiral, polar grid, click-to-size dialogs). + +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::Mods; +use vectorcraft_tools::distort::perspective::{self as persp, Plane}; + +use super::tests_persp_select::{attached, drag, grid, persp_session, undo_len}; +use super::*; + +/// Point type "HELLO" (48 pt) at (430, 400), attached to the right plane. +fn persp_text(s: &mut Session) -> NodeId { + let r = s.execute("text.create", &json!({"x": 430, "y": 400, "text": "HELLO", "size": 48})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap(); + id +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// The art the renderer and exporters draw for `id` (its outlines, projected). +fn drawn_bounds(s: &Session, id: NodeId) -> Rect { + let n = node(s, id); + vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap() +} + +#[test] +fn type_in_perspective_is_projected_and_stays_type() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::Text(_)), "still type"); + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); + let h = n.projection().expect("drawn through a projection"); + // Really foreshortened: the right plane recedes to the right, so the letters' far end is + // shorter than their near end. + let out = vectorcraft_render::effects::reshape(&n, None).unwrap(); + let mut pts = vec![]; + out.walk(&mut |c| { + if let Some(p) = c.path_data() { + pts.extend(p.anchors().map(|(_, _, a)| a.p)); + } + }); + let b = drawn_bounds(&s, id); + let near: Vec<&Point> = pts.iter().filter(|p| p.x < b.x0 + 0.2 * b.width()).collect(); + let far: Vec<&Point> = pts.iter().filter(|p| p.x > b.x1 - 0.2 * b.width()).collect(); + let span = |v: &[&Point]| v.iter().map(|p| p.y).fold(f64::MIN, f64::max) - v.iter().map(|p| p.y).fold(f64::MAX, f64::min); + assert!(span(&far) < span(&near) * 0.97, "far {} near {}", span(&far), span(&near)); + // Bounds, hit testing and the selection box follow the drawn type. + let gb = n.geometric_bounds().unwrap(); + assert!((gb.x0 - b.x0).abs() < 12.0 && (gb.x1 - b.x1).abs() < 12.0, "{gb:?} {b:?}"); + let hit = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, b.center(), Default::default()).unwrap(); + assert_eq!(hit.leaf, id); + let _ = h; +} + +#[test] +fn type_in_perspective_exports_projected_outlines() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let b = drawn_bounds(&s, id); + let doc = s.doc().unwrap().doc.clone(); + let svg = vectorcraft_svg::export(&doc, &Default::default()); + assert!(!svg.contains("HELLO"), "no flat text in the SVG"); + let pdf = vectorcraft_render::effects::bake_document(&doc).unwrap(); + let baked = pdf.node(id).unwrap(); + assert!(!matches!(baked.kind, NodeKind::Text(_))); + let bb = baked.geometric_bounds().unwrap(); + assert!((bb.x0 - b.x0).abs() < 1e-6 && (bb.y1 - b.y1).abs() < 1e-6, "{bb:?} {b:?}"); +} + +#[test] +fn moving_type_in_perspective_moves_its_projection() { + let (mut s, rect) = persp_session(); + let id = persp_text(&mut s); + let NodeKind::Text(t) = &node(&s, id).kind else { panic!("type") }; + let flat = t.xf; + s.execute("perspective.transform", &json!({"ids": [id.0], "matrix": [1, 0, 0, 1, 40, 0]})).unwrap(); + let n = node(&s, id); + let NodeKind::Text(t) = &n.kind else { panic!() }; + assert_eq!(t.xf, flat, "the type itself is untouched"); + // Its drawn corner moved 40 pt along the plane. + let g = grid(&s); + let hi = g.homography(Plane::Right).unwrap().inverse().unwrap(); + let b0 = { + s.execute("edit.undo", &json!({})).unwrap(); + drawn_bounds(&s, id) + }; + s.execute("edit.redo", &json!({})).unwrap(); + let b1 = drawn_bounds(&s, id); + let d = hi.apply(Point::new(b1.x0, b1.y1)).unwrap() - hi.apply(Point::new(b0.x0, b0.y1)).unwrap(); + assert!((d.x - 40.0).abs() < 1.0 && d.y.abs() < 1.0, "{d:?}"); + let _ = rect; +} + +#[test] +fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let drawn = node(&s, id).geometric_bounds().unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("perspective.editText", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.isolation, Some(id)); + let n = node(&s, id); + assert!(n.projection().is_none(), "flat while editing"); + // Shown where it is drawn in perspective. + assert!(n.geometric_bounds().unwrap().center().distance(drawn.center()) < 1e-6); + s.execute("text.setText", &json!({"id": id.0, "text": "HELLO WORLD"})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let n = node(&s, id); + assert!(n.projection().is_some(), "projected again"); + // Nothing left flat anywhere in the history. + for e in &s.doc().unwrap().history.undo { + if let Some(p) = e.doc.node(id).and_then(|n| n.perspective.as_deref()) { + assert!(!p.editing); + } + } + // Not type in perspective: refused. + let (mut s2, rect) = persp_session(); + assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err()); +} + +#[test] +fn symbols_in_perspective_stay_instances() { + let (mut s, rect) = persp_session(); + s.execute("perspective.release", &json!({"ids": [rect.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [rect.0]})).unwrap(); + let sym = s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + let _ = sym; + let r = s.execute("symbol.place", &json!({"name": "Box", "x": 470, "y": 380})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("perspective.attach", &json!({"ids": [id.0], "plane": "right"})).unwrap(); + let n = node(&s, id); + assert!(matches!(n.kind, NodeKind::SymbolInstance { .. })); + assert!(n.projection().is_some()); + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + assert!(!matches!(baked.node(id).unwrap().kind, NodeKind::SymbolInstance { .. }), "exports draw the projected art"); +} + +#[test] +fn copies_pastes_and_duplicates_stay_attached() { + let (mut s, id) = persp_session(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_ne!(pasted, id); + assert_eq!(attached(&s, pasted), Some((Plane::Right, 0.0))); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 30, 0], "copy": true})).unwrap(); + let copy = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0))); + // Deleting leaves nothing stale behind. + s.execute("edit.clear", &json!({})).unwrap(); + assert!(grid(&s).attached_plane(copy).is_none()); +} + +#[test] +fn a_selection_tool_move_keeps_the_attachment_and_type_follows() { + let (mut s, rect) = persp_session(); + let id = persp_text(&mut s); + let before = drawn_bounds(&s, id); + s.execute("select.set", &json!({"ids": [id.0, rect.0]})).unwrap(); + let c = node(&s, rect).geometric_bounds().unwrap().center(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + drag(&mut s, &[c, Point::new(c.x + 25.0, c.y + 10.0), Point::new(c.x + 25.0, c.y + 10.0)], Mods::default()); + assert_eq!(attached(&s, rect), Some((Plane::Right, 0.0))); + assert_eq!(attached(&s, id), Some((Plane::Right, 0.0))); + let after = drawn_bounds(&s, id); + assert!((after.x0 - before.x0 - 25.0).abs() < 1e-6 && (after.y0 - before.y0 - 10.0).abs() < 1e-6, "{before:?} {after:?}"); +} + +#[test] +fn release_keeps_the_look_of_type_in_perspective() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + let b = drawn_bounds(&s, id); + s.execute("perspective.release", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(attached(&s, id), None); + assert_eq!(drawn_bounds(&s, id), b); + assert!(persp::attachment(&node(&s, id)).is_none()); +} + +#[test] +fn flare_spiral_and_polar_grid_draw_in_perspective() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + for (tool, pts) in [("spiral", [(300.0, 380.0), (320.0, 390.0), (330.0, 400.0)]), ("polarGrid", [(250.0, 350.0), (280.0, 380.0), (300.0, 410.0)])] + { + let n = undo_len(&s); + s.select_tool(tool, ViewInfo::default()).unwrap(); + drag(&mut s, &pts.map(|(x, y)| Point::new(x, y)), Mods::default()); + assert_eq!(undo_len(&s), n + 1, "{tool}"); + assert_eq!(s.journal.last().unwrap().0, "perspective.draw", "{tool}"); + let id = *s.doc().unwrap().selection.objects.first().unwrap(); + assert_eq!(attached(&s, id).map(|a| a.0), Some(Plane::Left), "{tool}"); + } + let r = s.execute("perspective.draw", &json!({"command": "shape.flare", "params": {"cx": 300, "cy": 380, "diameter": 40}})).unwrap(); + assert_eq!(attached(&s, NodeId(r["id"].as_u64().unwrap())).map(|a| a.0), Some(Plane::Left)); +} + +#[test] +fn click_to_size_dialogs_draw_on_the_active_plane_in_plane_units() { + let (mut s, _) = persp_session(); + s.execute("perspective.grid.show", &json!({"visible": true})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap(); + let at = Point::new(470.0, 300.0); + let (cmd, p) = crate::perspective_click(&s, "shape.rectangle", &json!({"x": at.x, "y": at.y, "width": 50, "height": 30}), at).unwrap(); + assert_eq!(cmd, "perspective.draw"); + let r = s.execute(&cmd, &p).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let g = grid(&s); + let hi = g.homography(Plane::Right).unwrap().inverse().unwrap(); + let q: Vec = node(&s, id).path_data().unwrap().anchors().map(|(_, _, a)| hi.apply(a.p).unwrap()).collect(); + let b = q.iter().skip(1).fold(Rect::from_points(q[0], q[0]), |r, p| r.union_pt(*p)); + assert!((b.width() - 50.0).abs() < 1e-6 && (b.height() - 30.0).abs() < 1e-6, "{b:?}"); + assert!(q.iter().any(|p| (g.homography(Plane::Right).unwrap().apply(*p).unwrap() - at).hypot() < 1e-6), "the click stays a corner"); + // Without a shown grid (or with no active plane) nothing changes. + s.execute("perspective.plane.set", &json!({"plane": "none"})).unwrap(); + assert!(crate::perspective_click(&s, "shape.rectangle", &json!({}), at).is_none()); +} + +#[test] +fn the_canvas_draws_type_in_perspective_foreshortened() { + let (mut s, rect) = persp_session(); + s.execute("edit.clear", &json!({"ids": [rect.0]})).ok(); + let id = persp_text(&mut s); + let b = drawn_bounds(&s, id); + let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, b, 2.0, true); + // The inked height of a strip of columns near each end of the line. + let height = |x0: f64, x1: f64| { + let (mut lo, mut hi) = (u32::MAX, 0); + for x in (x0 * img.width as f64) as u32..(x1 * img.width as f64) as u32 { + for y in 0..img.height { + if img.pixel(x, y)[0] < 128 { + lo = lo.min(y); + hi = hi.max(y); + } + } + } + hi.saturating_sub(lo) + }; + let (near, far) = (height(0.0, 0.25), height(0.75, 1.0)); + assert!(near > 10 && far * 100 < near * 97, "near {near} far {far}"); +} + +#[test] +fn double_clicking_type_in_perspective_edits_it_and_projections_round_trip() { + let (mut s, _) = persp_session(); + let id = persp_text(&mut s); + // Saved and opened again: still type, still projected. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(doc.node(id).unwrap().projection(), node(&s, id).projection()); + let v = ViewInfo::default(); + s.select_tool("perspectiveSelection", v).unwrap(); + let c = node(&s, id).geometric_bounds().unwrap().center(); + let r = s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, c.x, c.y), v).unwrap(); + assert_eq!(r, vec![crate::UiRequest::SwitchTool("type".into())]); + assert_eq!(s.doc().unwrap().isolation, Some(id)); + // Saved while editing: the file has it projected. + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(doc.node(id).unwrap().projection().is_some()); + s.execute("object.exitIsolation", &json!({})).unwrap(); + assert!(node(&s, id).projection().is_some()); +} diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests_perspgrid.rs new file mode 100644 index 000000000..8061b137f --- /dev/null +++ b/crates/engine/src/tests_perspgrid.rs @@ -0,0 +1,526 @@ +//! Perspective Grid definition, presets and view options (`perspective.grid.get` / `define`, +//! `perspective.presets.*`, View → Perspective Grid toggles, the grid widgets). + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Rgb}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn grid(s: &Session) -> PerspectiveGrid { + PerspectiveGrid::effective(&s.doc().unwrap().doc) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +// ---------- M8.8: Define Grid ---------- + +#[test] +fn grid_get_reports_the_model_the_define_fields_and_the_station_point() { + let mut s = session(); + let r = run(&mut s, "perspective.grid.get", json!({})); + assert_eq!(r["defined"], json!(false), "the default grid until one is set"); + assert_eq!(r["grid"]["kind"], json!(2)); + assert_eq!(r["define"]["name"], json!("[2P-Normal View]")); + assert_eq!((r["define"]["angle"].clone(), r["define"]["units"].clone()), (json!(45.0), json!("points"))); + let g = grid(&s); + assert!(near(r["station"]["x"].as_f64().unwrap(), g.origin[0]), "the normal view looks at the corner"); + assert!(near(r["station"]["distance"].as_f64().unwrap(), (g.vp_right - g.vp_left) / 2.0)); + // A query: nothing to undo. + assert_eq!(undo_len(&s), 0); + run(&mut s, "perspective.grid.preset", json!({"kind": 1})); + assert_eq!(run(&mut s, "perspective.grid.get", json!({}))["defined"], json!(true)); +} + +#[test] +fn define_sets_the_grid_from_the_dialog_fields_in_one_undo_step() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let before = grid(&s); + let n = undo_len(&s); + let r = run( + &mut s, + "perspective.grid.define", + json!({"units": "inches", "gridline": 0.5, "angle": 30, "distance": 4, "horizonHeight": 2, "leftColor": "#00ff00", "opacity": 75}), + ); + assert_eq!(undo_len(&s), n + 1); + assert_eq!(r["units"], json!("inches")); + let g = grid(&s); + assert!(near(g.cell, 36.0) && near(g.origin[1] - g.horizon, 144.0)); + let st = g.station(); + assert!(near(st.distance, 288.0) && near(st.x, before.station().x), "the viewer stays put"); + assert!(near(g.viewing_angle(), 30.0)); + assert_eq!((g.left_color, g.opacity, g.name.as_str()), (Rgb([0, 255, 0]), 75.0, ""), "no longer the preset"); + // Unchanged fields leave the grid alone: no undo step. + let same = run(&mut s, "perspective.grid.get", json!({}))["define"].clone(); + run(&mut s, "perspective.grid.define", same); + assert_eq!(undo_len(&s), n + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(grid(&s), before); + // Bad values are refused and change nothing. + for bad in [json!({"angle": 0}), json!({"distance": -1}), json!({"units": "parsecs"}), json!({"kind": 5}), json!({"rightColor": "red"})] { + assert!(s.execute("perspective.grid.define", &bad).is_err(), "{bad}"); + } + assert_eq!(grid(&s), before); +} + +#[test] +fn define_switches_the_type_around_the_station_point() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let st = grid(&s).station(); + run(&mut s, "perspective.grid.define", json!({"kind": 1})); + let g = grid(&s); + assert_eq!(g.kind, 1); + assert!(near(g.vp_left, st.x) && near(g.distance, st.distance), "one-point looks straight at the station's centre of vision"); + run(&mut s, "perspective.grid.define", json!({"kind": 3, "thirdVp": [0, 500]})); + let g = grid(&s); + assert!(near(g.vp_vertical[0], g.station().x) && near(g.horizon - g.vp_vertical[1], 500.0)); +} + +#[test] +fn grid_definition_fields_survive_a_native_round_trip() { + let mut s = session(); + run(&mut s, "perspective.grid.define", json!({"units": "cm", "scale": [1, 4], "angle": 40, "groundColor": "#123456", "opacity": 20})); + let doc = s.doc().unwrap().doc.clone(); + vectorcraft_testkit::invariants::check_native_roundtrip_exact(&doc).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(PerspectiveGrid::from_doc(&back), PerspectiveGrid::from_doc(&doc)); + let g = PerspectiveGrid::from_doc(&back).unwrap(); + assert_eq!((g.units.as_str(), g.scale, g.ground_color, g.opacity), ("centimeters", [1.0, 4.0], Rgb([0x12, 0x34, 0x56]), 20.0)); + // A grid saved before these fields loads with their defaults. + let mut old = serde_json::to_value(&g).unwrap(); + for k in ["name", "angle", "units", "scale", "leftColor", "rightColor", "groundColor", "opacity"] { + old.as_object_mut().unwrap().remove(k); + } + let g: PerspectiveGrid = serde_json::from_value(old).unwrap(); + assert_eq!((g.angle, g.units.as_str(), g.scale, g.opacity), (None, "points", [1.0, 1.0], 50.0)); +} + +// ---------- M8.9: presets ---------- + +fn preset_names(s: &mut Session) -> Vec { + let r = run(s, "perspective.presets.list", json!({})); + r["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn built_in_views_apply_by_name_or_type() { + let mut s = session(); + let names = preset_names(&mut s); + assert_eq!(&names[..3], ["[1P-Normal View]", "[1P-Low View]", "[1P-High View]"]); + assert!(names.contains(&"[3P-Normal View]".to_string())); + let r = run(&mut s, "perspective.presets.list", json!({})); + assert!(r["presets"].as_array().unwrap().iter().all(|p| p["builtIn"] == json!(true))); + run(&mut s, "perspective.grid.preset", json!({"name": "[2P-low view]"})); + let low = grid(&s); + assert_eq!((low.name.as_str(), low.kind, low.visible), ("[2P-Low View]", 2, true)); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let normal = grid(&s); + assert_eq!(normal.name, "[2P-Normal View]"); + assert!(low.origin[1] - low.horizon < normal.origin[1] - normal.horizon, "the low view's horizon is nearer the ground"); + assert!(s.execute("perspective.grid.preset", &json!({"name": "[9P-Fisheye]"})).is_err()); + assert!(s.execute("perspective.grid.preset", &json!({})).is_err()); +} + +#[test] +fn presets_save_rename_delete_and_protect_the_built_in_ones() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 3})); + run(&mut s, "perspective.grid.define", json!({"angle": 35, "opacity": 30})); + // Save Grid as Preset: the document's grid under a name, not an undo step. + let n = undo_len(&s); + let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower"})); + assert_eq!((r["name"].clone(), r["created"].clone(), r["definition"]["kind"].clone()), (json!("Tower"), json!(true), json!(3))); + assert_eq!(undo_len(&s), n); + let saved = &s.prefs.perspective_presets[0]; + assert!((saved.angle - 35.0).abs() < 1e-9 && saved.opacity == 30.0); + // A grid with its fields is that preset; changing one makes it custom again. + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + run(&mut s, "perspective.grid.preset", json!({"name": "tower"})); + assert_eq!(grid(&s).name, "Tower"); + assert!((grid(&s).viewing_angle() - 35.0).abs() < 1e-9); + run(&mut s, "perspective.grid.define", json!({"opacity": 31})); + assert_eq!(grid(&s).name, ""); + run(&mut s, "perspective.grid.define", json!({"name": "Tower", "opacity": 30})); + assert_eq!(grid(&s).name, "Tower"); + // Edit, rename, start from another preset. + let r = run(&mut s, "perspective.presets.save", json!({"name": "Tower", "newName": "Spire", "gridline": 12})); + assert_eq!((r["name"].clone(), r["created"].clone()), (json!("Spire"), json!(false))); + assert_eq!(s.prefs.perspective_presets[0].gridline, 12.0); + assert_eq!(run(&mut s, "perspective.presets.save", json!({"preset": "[1P-Normal View]"}))["name"], json!("Perspective Preset 1")); + assert_eq!(s.prefs.perspective_presets[1].kind, 1); + // Built-in names are protected and names stay unique. + assert!(s.execute("perspective.presets.save", &json!({"name": "[2P-Normal View]"})).is_err()); + assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "[1P-Low View]"})).is_err()); + assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "newName": "perspective preset 1"})).is_err()); + assert!(s.execute("perspective.presets.save", &json!({"name": "Spire", "angle": 95})).is_err()); + assert!(s.execute("perspective.presets.delete", &json!({"name": "[3P-Low View]"})).is_err()); + assert_eq!(run(&mut s, "perspective.presets.delete", json!({"name": "spire"}))["deleted"], json!("Spire")); + assert!(s.execute("perspective.presets.delete", &json!({"name": "Spire"})).is_err()); + assert_eq!(preset_names(&mut s).last().unwrap(), "Perspective Preset 1"); +} + +#[test] +fn presets_export_import_and_live_in_the_preferences() { + let mut s = session(); + run(&mut s, "perspective.presets.save", json!({"name": "Street", "units": "inches", "gridline": 1, "distance": 8, "horizonHeight": 4})); + let text = run(&mut s, "perspective.presets.export", json!({"names": ["Street", "[2P-High View]"]}))["data"].as_str().unwrap().to_string(); + assert!(text.contains("vcperspective")); + let mut t = session(); + let r = run(&mut t, "perspective.presets.import", json!({"data": text})); + assert_eq!(r["imported"], json!(["Street", "2P-High View"]), "a built-in view comes in as a saved copy"); + assert_eq!(t.prefs.perspective_presets[0], s.prefs.perspective_presets[0]); + // Again: names in use get a number, or replace. + assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text}))["imported"], json!(["Street 2", "2P-High View 2"])); + assert_eq!(run(&mut t, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street", "2P-High View"])); + assert_eq!(t.prefs.perspective_presets.len(), 4); + // Bad files are refused whole. + for bad in [ + json!({"data": "{}"}), + json!({"data": "{\"format\": \"vcprintpresets\", \"presets\": []}"}), + json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"ok\"}, {\"name\": \"x\", \"angle\": 120}]}"}), + json!({"data": "{\"format\": \"vcperspective\", \"presets\": [{\"kind\": \"two\"}]}"}), + json!({"dataBase64": "%%%"}), + json!({}), + ] { + assert!(t.execute("perspective.presets.import", &bad).is_err(), "{bad}"); + } + assert_eq!(t.prefs.perspective_presets.len(), 4); + assert!(Session::new().execute("perspective.presets.export", &json!({})).is_err(), "nothing saved to export"); + // Saved with the preferences; older preferences have none. + let back: Prefs = serde_json::from_value(serde_json::to_value(&t.prefs).unwrap()).unwrap(); + assert_eq!(back.perspective_presets, t.prefs.perspective_presets); + assert!(serde_json::from_value::(json!({})).unwrap().perspective_presets.is_empty()); +} + +// ---------- M8.10: view options ---------- + +use vectorcraft_geom::Point; +use vectorcraft_tools::distort::perspective::Plane; +use vectorcraft_tools::{Overlay, PointerEvent, PointerKind}; + +fn drag(s: &mut Session, tool: &str, from: Point, to: Point) { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent::new(k, p.x, p.y), v).unwrap(); + } +} + +#[test] +fn view_options_toggle_without_undo_steps_and_survive_presets() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let n = undo_len(&s); + let g = grid(&s); + assert!(g.snap && !g.locked && !g.lock_station && !g.rulers, "Snap to Grid is on by default"); + for (id, on) in + [("perspective.grid.lock", true), ("perspective.grid.lockStation", true), ("perspective.grid.snap", false), ("perspective.grid.rulers", true)] + { + assert_eq!(run(&mut s, id, json!({})), json!({"on": on}), "{id} toggles"); + } + assert_eq!(run(&mut s, "perspective.grid.rulers", json!({"on": true})), json!({"on": true})); + assert_eq!(undo_len(&s), n); + run(&mut s, "perspective.grid.preset", json!({"name": "[2P-High View]"})); + let g = grid(&s); + assert!(g.locked && g.lock_station && !g.snap && g.rulers, "a preset keeps the view options"); + // Saved with the document. + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(PerspectiveGrid::from_doc(&back), Some(g)); +} + +#[test] +fn lock_grid_keeps_the_widgets_still_but_planes_switch() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + run(&mut s, "perspective.grid.lock", json!({"on": true})); + let g = grid(&s); + let n = undo_len(&s); + drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon)); + assert_eq!((grid(&s).vp_left, undo_len(&s)), (g.vp_left, n)); + let (faces, _) = g.widget(&s.doc().unwrap().doc, 1.0); + let q = faces[2].1; + let c = Point::new((q[0].x + q[2].x) / 2.0, (q[0].y + q[2].y) / 2.0); + drag(&mut s, "perspectiveGrid", c, c); + assert_eq!(grid(&s).plane, Plane::Ground); + run(&mut s, "perspective.grid.lock", json!({"on": false})); + drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 50.0, g.horizon)); + assert!((grid(&s).vp_left - (g.vp_left - 50.0)).abs() < 1e-6); +} + +#[test] +fn lock_station_point_swings_the_other_vanishing_point() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let g = grid(&s); + let st = g.station(); + // Unlocked: only the dragged point moves (the viewing angle stays). + drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon)); + assert_eq!(grid(&s).vp_right, g.vp_right); + s.execute("edit.undo", &json!({})).unwrap(); + run(&mut s, "perspective.grid.lockStation", json!({"on": true})); + drag(&mut s, "perspectiveGrid", Point::new(g.vp_left, g.horizon), Point::new(g.vp_left - 60.0, g.horizon)); + let n = grid(&s); + assert!((n.vp_left - (g.vp_left - 60.0)).abs() < 1e-6); + assert!(n.vp_right < g.vp_right, "the right vanishing point comes in"); + let st2 = n.station(); + assert!((st2.x - st.x).abs() < 1e-6 && (st2.distance - st.distance).abs() < 1e-6, "the viewer stays put"); + assert!(n.viewing_angle() < 45.0); +} + +#[test] +fn snap_to_grid_lands_drawn_and_moved_art_on_gridlines() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let g = grid(&s); + let on_line = |v: f64| (v / g.cell - (v / g.cell).round()).abs() < 1e-6; + // A corner a little off the gridlines (within a quarter cell) is drawn on them. + let a = g.to_page(Plane::Left, Point::new(2.0 * g.cell + 3.0, g.cell - 2.0)).unwrap(); + let b = g.to_page(Plane::Left, Point::new(4.0 * g.cell - 1.0, 3.0 * g.cell + 4.0)).unwrap(); + let rect = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()}); + let r = run(&mut s, "perspective.draw", json!({"command": "shape.rectangle", "params": rect, "plane": "left"})); + let id = NodeId(r["id"].as_u64().unwrap()); + let corners = |s: &Session| -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone(); + n.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap()).collect() + }; + assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s)); + // Moved a cell and a bit: it lands a whole cell over. + let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap(); + let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 2.0, 2.0 * g.cell)).unwrap(); + run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y]})); + assert!(corners(&s).iter().all(|q| on_line(q.x) && on_line(q.y)), "{:?}", corners(&s)); + // Without snapping it moves exactly. + let before = corners(&s); + run(&mut s, "perspective.move", json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false})); + for (p, q) in before.iter().zip(corners(&s)) { + assert!((q.x - p.x - (g.cell + 2.0)).abs() < 1e-6 && (q.y - p.y).abs() < 1e-6); + } +} + +#[test] +fn grid_overlays_use_the_colours_opacity_and_rulers() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + run(&mut s, "perspective.grid.define", json!({"leftColor": "#102030", "opacity": 20})); + let ov = s.overlays(ViewInfo::default()); + assert!(ov.iter().any(|o| matches!(o, Overlay::GridLine { color: [0x10, 0x20, 0x30, 51], .. }))); + let labels = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Label { .. })).count(); + assert_eq!(labels(&ov), 0); + run(&mut s, "perspective.grid.rulers", json!({"on": true})); + assert!(labels(&s.overlays(ViewInfo::default())) > 2); +} + +#[test] +fn snapped_moves_keep_objects_on_a_plane_together() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let g = grid(&s); + let rect_at = |s: &mut Session, u: f64, v: f64| { + let (a, b) = (g.to_page(Plane::Left, Point::new(u, v)).unwrap(), g.to_page(Plane::Left, Point::new(u + g.cell, v + g.cell)).unwrap()); + let p = json!({"x": a.x.min(b.x), "y": a.y.min(b.y), "width": (a.x - b.x).abs(), "height": (a.y - b.y).abs()}); + NodeId(run(s, "perspective.draw", json!({"command": "shape.rectangle", "params": p, "plane": "left"}))["id"].as_u64().unwrap()) + }; + let (a, b) = (rect_at(&mut s, 2.0 * g.cell, g.cell), rect_at(&mut s, 4.0 * g.cell + 7.0, g.cell)); + let left = |s: &Session, id: NodeId| { + let p = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone(); + p.anchors().map(|(_, _, an)| g.to_plane(Plane::Left, an.p).unwrap().x).fold(f64::MAX, f64::min) + }; + let gap = left(&s, b) - left(&s, a); + let from = g.to_page(Plane::Left, Point::new(3.0 * g.cell, 2.0 * g.cell)).unwrap(); + let to = g.to_page(Plane::Left, Point::new(4.0 * g.cell + 3.0, 2.0 * g.cell)).unwrap(); + run(&mut s, "perspective.move", json!({"ids": [a.0, b.0], "from": [from.x, from.y], "to": [to.x, to.y]})); + assert!(near(left(&s, b) - left(&s, a), gap), "they move by the same amount"); + assert!(near(left(&s, a), 3.0 * g.cell), "the joint bounds land on a gridline"); +} + +// ---------- M8.11: grid widgets and the Plane Switching Widget ---------- + +use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace}; +use vectorcraft_tools::{Mods, ScreenFrame, ToolKey}; + +/// A 1000 × 700 window at zoom 2 showing the document from (100, 50). +fn screen() -> ScreenFrame { + ScreenFrame { + origin: Point::new(100.0, 50.0), + right: vectorcraft_geom::Vec2::new(0.5, 0.0), + down: vectorcraft_geom::Vec2::new(0.0, 0.5), + size: (1000.0, 700.0), + } +} + +fn windowed() -> ViewInfo { + ViewInfo { zoom: 2.0, screen: Some(screen()), ..Default::default() } +} + +/// The centre of the widget's `plane` face in `view`. +fn face(s: &Session, view: ViewInfo, corner: WidgetCorner, plane: Plane) -> Point { + let d = &s.doc().unwrap().doc; + let (faces, (c, _)) = grid(s).widget_at(d, 1.0 / view.zoom, WidgetPlace { screen: view.screen.as_ref(), corner }); + match faces.into_iter().find(|f| f.0 == plane) { + Some((_, q)) => Point::new((q[0].x + q[1].x + q[2].x + q[3].x) / 4.0, (q[0].y + q[1].y + q[2].y + q[3].y) / 4.0), + None => c, + } +} + +fn click(s: &mut Session, p: Point, view: ViewInfo) { + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, p.x, p.y), view).unwrap(); + } +} + +#[test] +fn the_plane_widget_stays_on_screen_and_takes_clicks_with_any_tool() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let view = windowed(); + // In the window's top-left corner, whatever part of the document shows. + let p = face(&s, view, WidgetCorner::TopLeft, Plane::Right); + assert!(p.x > 100.0 && p.x < 100.0 + 40.0 && p.y > 50.0 && p.y < 50.0 + 40.0, "{p:?}"); + // The Selection tool's click on it picks the plane and selects nothing. + s.select_tool("selection", view).unwrap(); + let n = undo_len(&s); + click(&mut s, p, view); + assert_eq!(grid(&s).plane, Plane::Right); + assert!(s.doc().unwrap().selection.is_empty()); + assert_eq!(undo_len(&s), n); + // While the grid is hidden only the perspective tools show it (and take its clicks). + run(&mut s, "perspective.grid.show", json!({"visible": false})); + let q = face(&s, view, WidgetCorner::TopLeft, Plane::Ground); + click(&mut s, q, view); + assert_eq!(grid(&s).plane, Plane::Right); + s.select_tool("perspectiveSelection", view).unwrap(); + click(&mut s, q, view); + assert_eq!(grid(&s).plane, Plane::Ground); + // Perspective Grid Options move it to another corner or hide it. + assert_eq!(run(&mut s, "perspective.widget.options", json!({"position": "bottomRight"})), json!({"show": true, "position": "bottomRight"})); + let r = face(&s, view, WidgetCorner::BottomRight, Plane::Left); + assert!(r.x > 100.0 + 450.0 && r.y > 50.0 + 300.0, "{r:?}"); + click(&mut s, r, view); + assert_eq!(grid(&s).plane, Plane::Left); + run(&mut s, "perspective.widget.options", json!({"show": false})); + let hidden = face(&s, view, WidgetCorner::BottomRight, Plane::Right); + click(&mut s, hidden, view); + assert_eq!(grid(&s).plane, Plane::Left, "a hidden widget takes no clicks"); + assert!(s.execute("perspective.widget.options", &json!({"position": "middle"})).is_err()); + assert!(s.execute("perspective.widget.options", &json!({"show": "yes"})).is_err()); +} + +#[test] +fn digit_keys_pick_the_plane_while_the_grid_shows() { + let mut s = session(); + let view = ViewInfo::default(); + assert!(!s.tool_claims_key(ToolKey::Digit(2), view), "no grid shown: the digit is free"); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + for (n, plane) in [(1, Plane::Left), (2, Plane::Ground), (3, Plane::Right), (4, Plane::None)] { + assert!(s.tool_claims_key(ToolKey::Digit(n), view)); + s.tool_key(ToolKey::Digit(n), Mods::default(), view).unwrap(); + assert_eq!(grid(&s).plane, plane, "key {n}"); + } + assert!(!s.tool_claims_key(ToolKey::Digit(5), view)); +} + +#[test] +fn ground_level_points_move_the_whole_grid() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let g = grid(&s); + let o = Point::new(g.origin[0], g.origin[1]); + // The left ground-level point sits 24 px from the origin along the left wall's ground line. + let towards = g.to_page(Plane::Left, Point::new(g.cell, 0.0)).unwrap(); + let gl = o + (towards - o).normalize() * 24.0; + drag(&mut s, "perspectiveGrid", gl, gl + vectorcraft_geom::Vec2::new(30.0, -20.0)); + let n = grid(&s); + let moved = [n.origin[0] - o.x, n.origin[1] - o.y, n.horizon - g.horizon, n.vp_left - g.vp_left, n.vp_right - g.vp_right]; + for (got, want) in moved.into_iter().zip([30.0, -20.0, -20.0, 30.0, 30.0]) { + assert!(near(got, want), "{moved:?}"); + } + assert_eq!((n.cell, n.viewing_angle()), (g.cell, g.viewing_angle()), "the same grid, elsewhere"); + // Shift keeps it on one axis. + s.execute("edit.undo", &json!({})).unwrap(); + gesture_mods(&mut s, gl, gl + vectorcraft_geom::Vec2::new(30.0, -8.0), Mods { shift: true, ..Default::default() }); + let n = grid(&s); + assert!(near(n.origin[0], o.x + 30.0) && n.origin[1] == o.y && n.horizon == g.horizon); +} + +/// A Perspective Grid tool drag with modifiers held. +fn gesture_mods(s: &mut Session, from: Point, to: Point, mods: Mods) { + let v = ViewInfo::default(); + s.select_tool("perspectiveGrid", v).unwrap(); + for (k, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent::new(k, p.x, p.y).with_mods(mods), v).unwrap(); + } +} + +#[test] +fn extents_cell_size_and_vanishing_points_follow_their_widgets() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + let g = grid(&s); + // The right extent alone. + let er = g.to_page(Plane::Right, Point::new(g.extent, 0.0)).unwrap(); + let to = g.to_page(Plane::Right, Point::new(g.extent * 0.5, 0.0)).unwrap(); + drag(&mut s, "perspectiveGrid", er, to); + let n = grid(&s); + assert!((n.right_extent() - g.extent * 0.5).abs() < 1e-6 && n.extent == g.extent, "{:?} {}", n.extent_right, n.extent); + assert_eq!(n.domain(Plane::Right).width(), n.right_extent()); + // Alt drags both; Shift goes by whole cells. + let el = g.to_page(Plane::Left, Point::new(g.extent, 0.0)).unwrap(); + let to = g.to_page(Plane::Left, Point::new(5.3 * g.cell, 0.0)).unwrap(); + gesture_mods(&mut s, el, to, Mods { alt: true, shift: true, ..Default::default() }); + let n = grid(&s); + assert!((n.extent - 5.0 * g.cell).abs() < 1e-6 && n.extent_right == Some(n.extent)); + // The cell widget: a cell up the line where the planes meet (more cells when they're small). + let c = g.to_page(Plane::Left, Point::new(0.0, g.cell)).unwrap(); + drag(&mut s, "perspectiveGrid", c, g.to_page(Plane::Left, Point::new(0.0, 40.0)).unwrap()); + assert!((grid(&s).cell - 40.0).abs() < 1e-6, "{}", grid(&s).cell); + // Shift-dragging a vanishing point keeps the horizon. + gesture_mods( + &mut s, + Point::new(g.vp_right, g.horizon), + Point::new(g.vp_right + 20.0, g.horizon + 30.0), + Mods { shift: true, ..Default::default() }, + ); + assert_eq!((grid(&s).vp_right, grid(&s).horizon), (g.vp_right + 20.0, g.horizon)); +} + +#[test] +fn widget_options_and_the_right_extent_are_saved() { + let mut s = session(); + run(&mut s, "perspective.widget.options", json!({"position": "Top Right"})); + let back: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert_eq!(back.perspective_widget.position, WidgetCorner::TopRight); + run(&mut s, "perspective.grid.set", json!({"extentRight": 123})); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(PerspectiveGrid::from_doc(&back).unwrap().extent_right, Some(123.0)); + assert!(s.execute("perspective.grid.set", &json!({"extentRight": -1})).is_err()); +} + +#[test] +fn perspective_grid_options_are_a_preference_group() { + let mut s = session(); + run(&mut s, "prefs.set", json!({"key": "perspectiveWidget", "value": {"position": "bottomLeft"}})); + assert_eq!(s.prefs.perspective_widget.position, WidgetCorner::BottomLeft); + assert!(s.prefs.perspective_widget.show, "a partial object keeps the rest"); + assert!(s.execute("prefs.set", &json!({"key": "perspectiveWidget", "value": {"position": "nowhere"}})).is_err()); + run(&mut s, "prefs.reset", json!({})); + assert_eq!(s.prefs.perspective_widget, Default::default()); +} diff --git a/crates/engine/src/tests_plugins.rs b/crates/engine/src/tests_plugins.rs new file mode 100644 index 000000000..aa72c0a0c --- /dev/null +++ b/crates/engine/src/tests_plugins.rs @@ -0,0 +1,214 @@ +//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step, +//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management +//! commands. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_geom::{PathData, Point, SubPath}; +use vectorcraft_plugins::wat as tpl; + +use super::*; + +/// The example Desaturate plug-in (built from `crates/plugins/example`). +const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn install(s: &mut Session, wasm: &[u8]) -> Value { + run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})) +} + +fn install_wat(s: &mut Session, src: &str) -> Value { + install(s, &wat::parse_str(src).unwrap()) +} + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap(); + s +} + +/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke. +fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"ids": [id.0], "color": fill})); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn triangle() -> PathData { + PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true)) +} + +#[test] +fn object_filter_recolours_the_selection_as_one_undo_step() { + let mut s = session(); + let info = install(&mut s, DESATURATE); + assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter"))); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#0000ff"); + run(&mut s, "select.all", json!({})); + let undo = s.doc().unwrap().history.undo.len(); + let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}})); + assert_eq!(r["ids"], json!([a.0, b.0])); + for id in [a, b] { + let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated(); + assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}"); + } + let st = s.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Desaturate"); + assert_eq!(st.selection.objects, [a, b]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + // Plug-in parameters may also be top-level keys; wrong types are refused before it runs. + run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0})); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. }))); +} + +#[test] +fn object_filters_add_replace_and_generate_objects() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + let b = rect(&mut s, 40.0, "#00ff00"); + run(&mut s, "select.all", json!({})); + // Keeps A, drops B and adds a triangle above A. + let out = + json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]}); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string())); + let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"})); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids[0], a); + let st = s.doc().unwrap(); + assert!(st.doc.node(b).is_none()); + let layer: Vec = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [a, ids[1]]); + assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri")); + assert_eq!(st.selection.objects, ids); + // A generator with nothing selected adds to the current layer. + let generated = json!({"objects": [{"path": triangle()}]}); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string())); + run(&mut s, "select.none", json!({})); + let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"})); + let made = NodeId(r["ids"][0].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made); +} + +#[test] +fn failing_plugins_leave_the_document_and_history_alone() { + let mut s = session(); + rect(&mut s, 10.0, "#ff0000"); + run(&mut s, "select.all", json!({})); + let man = |id: &str| tpl::manifest(id, "filter", "{}"); + install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1)); + install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#)); + install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#)); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] { + let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err(); + if id == "test.engine.declines" { + assert!(e.to_string().contains("Select two paths."), "{e}"); + } + assert_eq!(s.doc().unwrap().doc, before, "{id}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}"); + } +} + +#[test] +fn live_effect_plugins_reshape_editable_art_and_expand() { + let mut s = session(); + let a = rect(&mut s, 10.0, "#ff0000"); + // An effect that draws a triangle, and one that hands the geometry back. + let tri = json!({"objects": [{"path": triangle()}]}); + install_wat( + &mut s, + &tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()), + ); + install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1)); + let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone(); + let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed"); + assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"]))); + assert_eq!(entry["defaults"], json!({"size": 3.0})); + // A filter can't be applied as an effect, nor an effect run as a filter. + install(&mut s, DESATURATE); + assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err()); + assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err()); + + let effected = |s: &Session| { + let n = node(s, a); + let path = n.path_data().unwrap().clone(); + let b = path.bounds().unwrap(); + vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b) + }; + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"})); + assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity"); + // Re-evaluated when the object changes (non-destructive). + run(&mut s, "select.all", json!({})); + run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); + assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}})); + assert_eq!(effected(&s), triangle()); + assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4})); + // Uninstalled: the geometry is drawn as it is. + run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"})); + assert_eq!(effected(&s), *node(&s, a).path_data().unwrap()); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string())); + // Expand Appearance bakes it. + run(&mut s, "select.all", json!({})); + run(&mut s, "effect.expandAppearance", json!({})); + let mut baked = None; + s.doc().unwrap().doc.walk(|n| { + if let Some(p) = n.path_data() { + baked = Some(p.clone()); + } + }); + assert_eq!(baked.unwrap().bounds(), triangle().bounds()); +} + +#[test] +fn plugin_management_commands() { + let mut s = session(); + install(&mut s, DESATURATE); + let list = run(&mut s, "plugin.list", json!({})); + assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID)); + let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID})); + assert_eq!(info["defaults"], json!({"amount": 100.0})); + assert_eq!(info["params"], "{amount: 0..100 (100)}"); + assert_eq!(info["manifest"]["kind"], "filter"); + // Refused installs. + for bad in [ + json!({}), + json!({"dataBase64": "!!"}), + json!({"dataBase64": "AGFzbQ=="}), + json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}), + ] { + assert!(s.execute("plugin.install", &bad).is_err(), "{bad}"); + } + assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err()); + assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err()); + assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set"); + + // The Additional Plug-ins Folder preference installs what's in it. + let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap(); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()})); + assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some()); + run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"})); + let r = run(&mut s, "plugin.reload", json!({})); + assert_eq!(r["loaded"], json!(["test.engine.folder"])); + assert_eq!(r["failed"].as_array().unwrap().len(), 1); + assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"])); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/engine/src/tests_puppetwarp.rs b/crates/engine/src/tests_puppetwarp.rs new file mode 100644 index 000000000..fab70c7ae --- /dev/null +++ b/crates/engine/src/tests_puppetwarp.rs @@ -0,0 +1,263 @@ +//! Puppet Warp: turning the art around a pin (`object.puppetWarp {angles}`) and the tool's pins. + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +#[test] +fn a_pin_with_an_angle_turns_the_art_around_it() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + // One pin at the centre, held at a quarter turn: the 200 × 100 rectangle stands up. + s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": [90]})).unwrap(); + let b = bounds(&s, a); + assert!((b.width() - 100.0).abs() < 4.0 && (b.height() - 200.0).abs() < 4.0, "{b:?}"); + assert!(b.center().distance(Point::new(200.0, 150.0)) < 2.0, "{b:?}"); + // null leaves a pin free; the list must be as long as the pins. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[210, 150]], "angles": [null]})).unwrap(); + assert!((bounds(&s, a).x0 - 110.0).abs() < 0.5, "a free single pin only moves the art"); + for bad in [json!([90, 0]), json!("90"), json!({})] { + assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[200, 150]], "moved": [[200, 150]], "angles": bad})).is_err(), "{bad}"); + } +} + +#[test] +fn alt_drag_with_the_tool_turns_the_art_in_one_undo_step() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("puppetWarp", v).unwrap(); + s.set_tool_option("selectAllPins", &json!(false)); + // Select the pin in the middle, then Alt-drag about a quarter turn around it, 20 pt out. + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap(); + let n = s.doc().unwrap().history.undo.len(); + let alt = Mods { alt: true, ..Default::default() }; + for (k, x, y) in + [(PointerKind::Down, 220.0, 150.0), (PointerKind::Drag, 214.0, 164.0), (PointerKind::Drag, 200.0, 170.0), (PointerKind::Up, 200.0, 170.0)] + { + s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), v).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let (cmd, p) = s.journal.last().unwrap(); + assert_eq!(cmd, "object.puppetWarp"); + // The turn is the angle the pointer swept around the pin. + let i = p["angles"].as_array().unwrap().iter().position(|a| a.is_number()).unwrap(); + let (cx, cy) = (p["moved"][i][0].as_f64().unwrap(), p["moved"][i][1].as_f64().unwrap()); + let swept = ((170.0 - cy).atan2(200.0 - cx) - (150.0 - cy).atan2(220.0 - cx)).to_degrees(); + assert!((p["angles"][i].as_f64().unwrap() - swept).abs() < 1e-6 && swept > 60.0, "{p}"); + let b = bounds(&s, a); + assert!(b.height() > 110.0, "the art turned: {b:?}"); +} + +// ---------- the pins' session (M8.6) ---------- + +fn pins(s: &mut Session) -> Value { + s.execute("object.puppetWarp.pins", &json!({})).unwrap() +} + +fn anchors(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| { + if let Some(p) = n.path_data() { + out.extend(p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))); + } + }); + out +} + +fn worst(a: &[Point], b: &[Point]) -> f64 { + assert_eq!(a.len(), b.len()); + a.iter().zip(b).map(|(x, y)| x.distance(*y)).fold(0.0, f64::max) +} + +fn click(s: &mut Session, x: f64, y: f64) { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap(); +} + +fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) { + let v = ViewInfo::default(); + s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap(); +} + +fn pin_overlays(s: &mut Session) -> usize { + s.overlays(ViewInfo::default()).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Anchor { .. })).count() +} + +#[test] +fn pins_appear_when_the_tool_is_chosen_or_art_selected() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + // Art selected, then the tool: the automatic pins show at once (the centre and both ends). + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + assert_eq!(pin_overlays(&mut s), 3); + let p = pins(&mut s); + assert_eq!((p["auto"].clone(), p["ids"].clone(), p["rest"].clone()), (json!(true), json!([a.0]), json!(true))); + assert_eq!(p["pins"], p["moved"], "automatic pins sit where they started"); + // The tool first, then art selected. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(pin_overlays(&mut s), 0); + assert!(s.execute("object.puppetWarp.pins", &json!({})).is_err(), "nothing selected"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(pin_overlays(&mut s), 3); +} + +#[test] +fn warps_start_from_the_rest_shape_and_dont_stack() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + let rest = anchors(&s, a); + let p = pins(&mut s); + // Move the right-end pin up, then back: the original comes back. + let right = p["pins"].as_array().unwrap().iter().position(|q| q[0].as_f64().unwrap() > 300.0).unwrap(); + let mut up = p.clone(); + up["moved"][right][1] = json!(up["moved"][right][1].as_f64().unwrap() - 60.0); + s.execute("object.puppetWarp", &up).unwrap(); + let once = anchors(&s, a); + assert!(worst(&rest, &once) > 30.0); + // The same pins again: the same warp (it doesn't stack on the last one). + s.execute("object.puppetWarp", &up).unwrap(); + assert!(worst(&once, &anchors(&s, a)) < 1e-9); + s.execute("object.puppetWarp", &p).unwrap(); + assert!(worst(&rest, &anchors(&s, a)) < 0.01, "back to the original"); + // The query reads the pins back: where they started, where they are. + let now = pins(&mut s); + assert_eq!((now["pins"].clone(), now["auto"].clone()), (p["pins"].clone(), json!(false))); + // A pin off the mesh is refused. + let mut off = p.clone(); + off["pins"][0] = json!([900, 900]); + let e = s.execute("object.puppetWarp", &off).unwrap_err().to_string(); + assert!(e.contains("off the artwork"), "{e}"); +} + +#[test] +fn pins_follow_undo_and_redo() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + let rest = anchors(&s, a); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + // Adding a pin is one undo step; dragging it is another. + click(&mut s, 250.0, 150.0); + let added = pins(&mut s); + assert_eq!(added["pins"].as_array().unwrap().len(), 4); + drag(&mut s, (250.0, 150.0), (250.0, 110.0)); + let warped = anchors(&s, a); + let moved = pins(&mut s); + assert_eq!(moved["moved"][3], json!([250.0, 110.0])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pins(&mut s), added, "the pin went back with the art"); + assert!(worst(&rest, &anchors(&s, a)) < 1e-9); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(pins(&mut s)["auto"], json!(true), "no pin placed"); + assert_eq!(pin_overlays(&mut s), 3); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(pins(&mut s), moved); + assert!(worst(&warped, &anchors(&s, a)) < 1e-9); + // Pins persist across tool switches while the selection stays. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + assert_eq!(pins(&mut s), moved); + assert_eq!(pin_overlays(&mut s), 4); +} + +#[test] +fn a_new_selection_clears_the_pins() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 60.0); + let b = rect(&mut s, 100.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + click(&mut s, 250.0, 150.0); + drag(&mut s, (250.0, 150.0), (250.0, 120.0)); + let warped = anchors(&s, a); + assert_eq!(pins(&mut s)["auto"], json!(false)); + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let dirty = s.doc().unwrap().is_dirty(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + // Fresh automatic pins on the art as it is now (the warp stays). + let p = pins(&mut s); + assert_eq!(p["auto"], json!(true)); + assert!(worst(&warped, &anchors(&s, a)) < 1e-9); + assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().is_dirty()), (undo, dirty), "not an edit"); + // Something else editing the art keeps the pins, starting again from the art as it is. + click(&mut s, 200.0, 140.0); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + let p2 = pins(&mut s); + assert_eq!(p2["auto"], json!(false)); + assert_eq!(p2["pins"], p2["moved"]); +} + +#[test] +fn puppet_warp_works_on_groups_compound_paths_and_open_strokes() { + for kind in ["group", "compound", "open"] { + let mut s = session(); + let id = match kind { + "open" => { + let r = s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 400, "y2": 200})).unwrap(); + s.execute("stroke.set", &json!({"weight": 8})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + } + _ => { + let a = rect(&mut s, 100.0, 170.0, 140.0, 60.0); + let b = rect(&mut s, 260.0, 170.0, 140.0, 60.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let cmd = if kind == "group" { "object.group" } else { "object.compoundPath.make" }; + let r = s.execute(cmd, &json!({})).unwrap(); + r["id"].as_u64().map(NodeId).unwrap_or_else(|| s.doc().unwrap().selection.objects[0]) + } + }; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let rest = anchors(&s, id); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + // Pin both ends, lift the right one with the tool, then put it back. + click(&mut s, 104.0, 200.0); + click(&mut s, 396.0, 200.0); + drag(&mut s, (396.0, 200.0), (396.0, 140.0)); + assert!(worst(&rest, &anchors(&s, id)) > 20.0, "{kind}: warped"); + let n = s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len); + drag(&mut s, (396.0, 140.0), (396.0, 200.0)); + assert!(worst(&rest, &anchors(&s, id)) < 0.05, "{kind}: back to the original"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().children().map(Vec::len), n, "{kind}: the structure stays"); + } +} + +#[test] +fn pins_are_never_saved() { + let mut s = session(); + rect(&mut s, 100.0, 100.0, 300.0, 60.0); + s.select_tool("puppetWarp", ViewInfo::default()).unwrap(); + click(&mut s, 250.0, 150.0); + assert!(s.doc().unwrap().doc.puppet.is_some()); + let r = s.execute("document.serialize", &json!({"format": "vectorcraft"})).unwrap(); + let data = r["dataBase64"].as_str().unwrap().to_string(); + s.execute("document.open", &json!({"name": "pins.vectorcraft", "dataBase64": data})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.puppet.is_none()); + assert_eq!(st.doc.node_count(), 2, "layer + rectangle"); +} diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 07b908c2e..cd98697b5 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -119,6 +119,44 @@ fn type_tool_typing_is_one_undo_step() { assert_eq!(obj(&s, id).plain_text(), ""); } +#[test] +fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("abc", v).unwrap(); + key(&mut s, ToolKey::Left, Mods::default()); + key(&mut s, ToolKey::Right, Mods::default()); + s.tool_text("def", v).unwrap(); + assert!(s.in_interaction(), "still typing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "abc", "only the typing in progress is undone"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "abcdef", "and Redo brings it back"); + // Typing on after an undo starts a new session from the restored text. + s.execute("edit.undo", &json!({})).unwrap(); + s.tool_text("!", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "abc!"); +} + +#[test] +fn undo_during_a_drag_takes_back_only_the_drag() { + let mut s = session(); + let v = ViewInfo::default(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.select_tool("selection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 225.0, 125.0), v).unwrap(); + assert!(s.in_interaction(), "dragging"); + s.execute("edit.undo", &json!({})).unwrap(); + let doc = &s.doc().unwrap().doc; + let b = doc.bounds_of(&[id], false).expect("the rectangle is still there"); + assert_eq!(b.x0, 100.0, "back where it was: {b:?}"); +} + #[test] fn type_tool_selection_replace_and_delete() { let mut s = session(); @@ -340,3 +378,22 @@ fn type_tool_state_does_not_leak_across_documents() { assert_eq!(obj(&s, id).plain_text(), "First"); assert!(!s.in_interaction()); } + +#[test] +fn vertical_text_creation_orientation_and_persistence() { + let mut s = session(); + let r = s.execute("text.create", &json!({"x":100,"y":100,"text":"日本語", "vertical":true})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let t = obj(&s, id); + assert!(t.vertical); + let round: TextObject = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap(); + assert!(round.vertical); + s.execute("type.orientation.horizontal", &json!({"id":id.0})).unwrap(); + assert!(!obj(&s, id).vertical); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(obj(&s, id).vertical); + let old = serde_json::to_value(TextObject::point(Point::ZERO, "old", Default::default())).unwrap(); + assert!(old.get("vertical").is_none()); + let old: TextObject = serde_json::from_value(old).unwrap(); + assert!(!old.vertical); +} diff --git a/crates/engine/src/tests_toolsettings.rs b/crates/engine/src/tests_toolsettings.rs new file mode 100644 index 000000000..49b930af4 --- /dev/null +++ b/crates/engine/src/tests_toolsettings.rs @@ -0,0 +1,98 @@ +//! Tool options that persist across tool switches, documents and launches (`Prefs::tool_settings`). + +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn select(s: &mut Session, tool: &str) { + s.select_tool(tool, ViewInfo::default()).unwrap(); +} + +#[test] +fn options_survive_a_tool_switch() { + let mut s = session(); + select(&mut s, "mirrorCut"); + s.set_tool_option("axis", &json!("horizontal")); + s.set_tool_option("keep", &json!("bottom")); + select(&mut s, "selection"); + select(&mut s, "mirrorCut"); + assert_eq!(s.tool_options(), json!({"axis": "horizontal", "keep": "bottom"})); + // Interaction state isn't kept: the Rotate tool's reference point starts afresh. + select(&mut s, "rotate"); + s.set_tool_option("origin", &json!([10, 20])); + select(&mut s, "selection"); + select(&mut s, "rotate"); + assert_eq!(s.tool_options(), json!({"origin": null})); +} + +#[test] +fn liquify_tools_share_the_global_brush_but_not_their_own_options() { + let mut s = session(); + select(&mut s, "warp"); + s.set_tool_option("width", &json!(60)); + s.set_tool_option("intensity", &json!(80)); + s.set_tool_option("detail", &json!(7)); + select(&mut s, "twirl"); + let o = s.tool_options(); + assert_eq!((o["width"].as_f64(), o["intensity"].as_f64(), o["detail"].as_f64()), (Some(60.0), Some(0.8), Some(2.0))); + s.set_tool_option("rate", &json!(-90)); + select(&mut s, "warp"); + assert_eq!(s.tool_options()["detail"], json!(7.0)); + select(&mut s, "twirl"); + assert_eq!(s.tool_options()["rate"], json!(-90.0)); +} + +#[test] +fn another_tools_options_are_stored_and_shared_ones_reach_the_active_tool() { + let mut s = session(); + select(&mut s, "warp"); + let o = s.set_tool_option_cmd(&json!({"tool": "pucker", "values": {"width": 40, "height": 30, "detail": 5}})).unwrap(); + assert_eq!((o["tool"].as_str(), o["detail"].as_f64()), (Some("pucker"), Some(5.0))); + assert_eq!((s.tool_id(), s.tool_options()["width"].as_f64(), s.tool_options()["detail"].as_f64()), ("warp", Some(40.0), Some(2.0))); + assert_eq!(s.tool_options_of("pucker")["height"], json!(30.0)); + select(&mut s, "pucker"); + assert_eq!(s.tool_options()["detail"], json!(5.0)); + // `{}` reads the options; an unknown tool is an error. + assert_eq!(s.set_tool_option_cmd(&json!({})).unwrap(), s.tool_options()); + assert!(s.set_tool_option_cmd(&json!({"tool": "nope", "key": "width", "value": 1})).is_err()); +} + +#[test] +fn a_gesture_keeps_what_it_changed_and_a_document_switch_keeps_options() { + let mut s = session(); + select(&mut s, "spiral"); + s.set_tool_option("segments", &json!(14)); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + assert_eq!(s.tool_id(), "spiral"); + assert_eq!(s.tool_options()["segments"], json!(14)); + s.set_active(0); + assert_eq!(s.tool_options()["segments"], json!(14)); +} + +#[test] +fn options_are_saved_with_the_preferences() { + let mut s = session(); + select(&mut s, "polygon"); + s.set_tool_option("sides", &json!(9)); + select(&mut s, "selection"); + let saved = serde_json::to_string(&s.prefs).unwrap(); + assert!(saved.contains("toolSettings"), "{saved}"); + // Next launch. + let mut t = session(); + t.apply_prefs(serde_json::from_str(&saved).unwrap()); + select(&mut t, "polygon"); + assert_eq!(t.tool_options()["sides"], json!(9)); + // Older preference files have none; none are written until a tool keeps something. + assert!(Prefs::default().to_json().get("toolSettings").is_none()); + let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); + assert!(old.tool_settings.is_empty()); + // Resetting the preferences keeps them. + t.execute("prefs.reset", &json!({})).unwrap(); + assert_eq!(t.prefs.tool_settings["polygon"]["sides"], json!(9)); +} diff --git a/crates/engine/src/tests_widthtool.rs b/crates/engine/src/tests_widthtool.rs new file mode 100644 index 000000000..53be26b35 --- /dev/null +++ b/crates/engine/src/tests_widthtool.rs @@ -0,0 +1,52 @@ +//! The Width tool on compound paths: the stroke is the compound's, so its members are edited +//! through it, by the tool and by the width point commands given a member. + +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::*; + +/// Two 200 pt lines made into a compound path with a 10 pt stroke → (compound, members). +fn compound() -> (Session, NodeId, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap(); + let members: Vec = [200, 300] + .iter() + .map(|y| NodeId(s.execute("shape.line", &json!({"x1": 100, "y1": y, "x2": 300, "y2": y})).unwrap()["id"].as_u64().unwrap())) + .collect(); + s.execute("select.set", &json!({"ids": [members[0].0, members[1].0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 10})).unwrap(); + (s, c, members) +} + +fn profile(s: &Session, id: NodeId) -> Option> { + s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().and_then(|st| st.profile.as_ref().map(|p| p.points.clone())) +} + +#[test] +fn the_width_tool_edits_a_compound_paths_stroke() { + let (mut s, c, members) = compound(); + assert!(s.doc().unwrap().doc.node(members[1]).is_some(), "members keep their ids"); + let v = ViewInfo::default(); + s.select_tool("width", v).unwrap(); + // Drag out from the second member's middle: 20 pt either side. + for (k, y) in [(PointerKind::Down, 301.0), (PointerKind::Drag, 310.0), (PointerKind::Drag, 320.0), (PointerKind::Up, 320.0)] { + s.pointer(&PointerEvent::new(k, 200.0, y), v).unwrap(); + } + let p = profile(&s, c).expect("the compound's stroke has the width point"); + assert!(p.iter().any(|q| (q.0 - 0.5).abs() < 1e-3 && (q.1 - 4.0).abs() < 1e-6), "{p:?}"); + assert_eq!(s.journal.last().unwrap().1["id"], json!(c.0)); +} + +#[test] +fn width_point_commands_given_a_member_edit_the_compound() { + let (mut s, c, members) = compound(); + s.execute("stroke.widthPoint.set", &json!({"id": members[0].0, "t": 0.25, "left": 10, "right": 10})).unwrap(); + assert_eq!(profile(&s, c).unwrap().len(), 3); + s.execute("stroke.widthPoint.remove", &json!({"id": members[1].0, "index": 1})).unwrap(); + assert_eq!(profile(&s, c).unwrap().len(), 2); + s.execute("stroke.widthProfile.set", &json!({"ids": [members[0].0, members[1].0], "points": [[0, 0, 0], [1, 2, 2]]})).unwrap(); + assert_eq!(profile(&s, c).unwrap(), vec![(0.0, 0.0, 0.0), (1.0, 2.0, 2.0)]); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 0f0b2ee4d..738929de8 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1,10 +1,11 @@ //! Hosting the active tool: pointer/key events → actions → commands. -use serde_json::Value; +use serde_json::{Map, Value}; use vectorcraft_geom::Point; -use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey}; +use vectorcraft_tools::distort::perspective::widget::WidgetPlace; +use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings}; -use crate::{EngineError, Result, Session}; +use crate::{EngineError, Prefs, Result, Session}; /// View state the tools need from the frontend. #[derive(Clone, Copy, Debug)] @@ -20,6 +21,8 @@ pub struct ViewInfo { pub snap_to_point: bool, /// View → Show Corner Widget. pub corner_widgets: bool, + /// The document window (none headless): screen-fixed widgets sit in it. + pub screen: Option, } impl Default for ViewInfo { @@ -33,6 +36,7 @@ impl Default for ViewInfo { snap_to_pixel: false, snap_to_point: true, corner_widgets: true, + screen: None, } } } @@ -42,6 +46,9 @@ impl Default for ViewInfo { pub enum UiRequest { Dialog(String, Value), SwitchTool(String), + /// A message for the status bar: the `warning` of a command the tool ran (Liquify skipping + /// type under the brush). + Status(String), } impl Session { @@ -56,10 +63,50 @@ impl Session { } let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); self.apply_actions(acts)?; - self.tool = vectorcraft_tools::create(id); + self.keep_tool_settings(); + self.tool = self.make_tool(id); Ok(()) } + /// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this + /// session or a saved one. + pub(crate) fn make_tool(&self, id: &str) -> Box { + let mut t = vectorcraft_tools::create(id); + Self::restore_tool_settings(&self.prefs, t.as_mut()); + t + } + + /// Give `t` the persistent options stored for it in `prefs`. + fn restore_tool_settings(prefs: &Prefs, t: &mut dyn Tool) { + for (store, keys) in settings::stores(t.id()) { + let Some(saved) = prefs.tool_settings.get(store) else { continue }; + for k in keys { + if let Some(v) = saved.get(*k) { + t.set_option(k, v); + } + } + } + } + + /// Store the persistent options of `t` in `prefs` (so they are saved with them too). + fn store_tool_settings(prefs: &mut Prefs, t: &dyn Tool) { + let opts = t.options(); + for (store, keys) in settings::stores(t.id()) { + let saved = prefs.tool_settings.entry(store.to_string()).or_default(); + for k in keys { + if let Some(v) = opts.get(*k) { + saved.insert(k.to_string(), v.clone()); + } + } + } + } + + /// Keep the active tool's persistent options for the next time it, or a tool sharing them, is + /// made. + pub(crate) fn keep_tool_settings(&mut self) { + Self::store_tool_settings(&mut self.prefs, self.tool.as_ref()); + } + pub(crate) fn with_tool_cx(&mut self, view: ViewInfo, f: impl FnOnce(&mut dyn vectorcraft_tools::Tool, &ToolContext) -> R) -> R where R: Default, @@ -91,6 +138,8 @@ impl Session { paste_plain_text: self.prefs.paste_text_formatting == "plain", slices_hidden: self.menu.slices_hidden, slices_locked: self.menu.slices_locked, + screen: view.screen, + plane_widget: self.prefs.perspective_widget.show.then_some(self.prefs.perspective_widget.position), }; let tool = &mut self.tool; match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) { @@ -98,7 +147,7 @@ impl Session { Err(msg) => { // A bug in the tool: start it afresh and drop its half-done drag. let id = self.tool.id(); - self.tool = vectorcraft_tools::create(id); + self.tool = self.make_tool(id); let _ = self.cancel_interaction(); self.tool_panic = Some(EngineError::Internal { cmd: format!("tool `{id}`"), msg }); R::default() @@ -114,12 +163,41 @@ impl Session { /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { self.last_view = view; + // The Plane Switching Widget takes its clicks whatever the tool. + if let Some(r) = self.plane_widget_pointer(ev, view) { + return r; + } let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev)); self.take_tool_panic()?; + // A gesture may change options (Alt-drag sizes a Liquify brush): keep them. + if ev.kind == PointerKind::Up { + self.keep_tool_settings(); + } + self.apply_actions(acts) + } + + /// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the + /// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event. + pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result> { + if !self.tool.wants_ticks() { + return Ok(vec![]); + } + let acts = self.with_tool_cx(view, |t, cx| t.tick(cx, dt)); + self.take_tool_panic()?; self.apply_actions(acts) } + /// Does the active tool want [`Session::tool_tick`]s now? + pub fn tool_wants_ticks(&self) -> bool { + self.tool.wants_ticks() + } + pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result> { + // 1–4 pick the active perspective plane while the grid is shown. + if let Some(plane) = self.plane_key(key) { + self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?; + return Ok(vec![]); + } let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods)); self.take_tool_panic()?; self.apply_actions(acts) @@ -142,13 +220,15 @@ impl Session { /// Does the active tool take `key` ahead of the shortcuts bound to it (see `Tool::claims_key`)? pub fn tool_claims_key(&mut self, key: ToolKey, view: ViewInfo) -> bool { - self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) + self.plane_key(key).is_some() || self.with_tool_cx(view, |t, cx| t.claims_key(cx, key)) } pub fn overlays(&mut self, view: ViewInfo) -> Vec { let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); if let Some(d) = self.active() { - v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id())); + let w = self.prefs.perspective_widget; + let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); + v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), self.tool.id(), place)); } v } @@ -162,21 +242,94 @@ impl Session { } pub fn set_tool_option(&mut self, key: &str, v: &Value) { self.tool.set_option(key, v); + self.keep_tool_settings(); + } + + /// The options of tool `id`: the active tool's, or those it would have if chosen now. + pub fn tool_options_of(&self, id: &str) -> Value { + if id == self.tool.id() { self.tool.options() } else { self.make_tool(id).options() } + } + + /// Set options of tool `id` (default: the active tool). Another tool's persistent options are + /// stored for when it is chosen, and the active tool takes those it shares with it (the + /// Liquify tools' Global Brush Dimensions). Returns the tool's options. + pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map) -> Value { + let active = id.is_none_or(|id| id == self.tool.id()); + let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default())); + let t = other.as_deref_mut().unwrap_or(self.tool.as_mut()); + for (k, v) in values { + t.set_option(k, v); + } + Self::store_tool_settings(&mut self.prefs, t); + let out = t.options(); + if other.is_some() { + Self::restore_tool_settings(&self.prefs, self.tool.as_mut()); + } + out + } + + /// `tool.setOption {tool?, key?, value?, values?}` (a command of the desktop app and of the + /// headless host): set one option (`key`, `value`) or several (`values`) of `tool` (default: + /// the active tool) → its options. + pub fn set_tool_option_cmd(&mut self, p: &Value) -> std::result::Result { + let tool = p.get("tool").and_then(Value::as_str); + if let Some(t) = tool + && vectorcraft_tools::tool_info(t).is_none() + { + return Err(format!("unknown tool `{t}`")); + } + let mut values = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default(); + if let Some(k) = p.get("key").and_then(Value::as_str) { + values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); + } + Ok(self.set_tool_options(tool, &values)) + } + + /// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]): + /// the Type tool stops editing text that is no longer selected. Only while it edits text. + pub(crate) fn after_command(&mut self) { + if self.in_tool_actions || !self.tool.wants_text() { + return; + } + let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx)); + // The command itself succeeded: a failure here only leaves the edit as it was. + if let Err(e) = self.apply_actions(acts) { + log::warn!("ending the text edit: {e}"); + } } /// Apply tool actions. Errors from previews are reported but keep the interaction alive. pub fn apply_actions(&mut self, acts: Vec) -> Result> { + let outer = std::mem::replace(&mut self.in_tool_actions, true); + let r = self.apply_tool_actions(acts); + self.in_tool_actions = outer; + r + } + + fn apply_tool_actions(&mut self, acts: Vec) -> Result> { let mut ui = vec![]; for a in acts { match a { Action::Begin(label) => self.begin_interaction(&label)?, - Action::Preview(cmd, p) => { - if let Err(e) = self.preview(&cmd, &p) { - log::warn!("preview {cmd}: {e}"); + Action::Preview(cmd, p) => match self.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) + && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) + { + ui.push(UiRequest::Status(w.to_string())); + } } + Err(e) => log::warn!("preview {cmd}: {e}"), + }, + // The drag is over: the dabs of a Liquify stroke have no further use. + Action::Commit => { + self.liquify_stroke = None; + self.commit_interaction()? + } + Action::Cancel => { + self.liquify_stroke = None; + self.cancel_interaction()? } - Action::Commit => self.commit_interaction()?, - Action::Cancel => self.cancel_interaction()?, Action::Exec(cmd, p) => { self.execute(&cmd, &p)?; } diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index e28c28761..6505e416b 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -224,6 +224,24 @@ fn structured_junk() { ("stroke.set", json!({"dash": [1e-12]})), ("transparency.set", json!({"opacity": -50})), ("transparency.set", json!({"opacity": 1e308, "blend": "NoSuchBlend"})), + ("perspective.move", json!({"from": [0, 0], "to": [1e308, -1e308], "perpendicular": true, "copy": true, "plane": "ground"})), + ("perspective.move", json!({"from": [f64::MAX, 0], "to": [0, 0], "plane": "left"})), + ("perspective.transform", json!({"matrix": [1e308, 0, 0, 1e308, 0, 0], "plane": "right"})), + ("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": -1e9, "plane": "left", "copy": true})), + ("perspective.transform", json!({"matrix": [1, 0, 0, 1, 1e300, 0], "plane": "left"})), + ("perspective.nudge", json!({"dx": 1e308, "dy": -1e308, "big": true, "copy": true})), + ("perspective.plane.move", json!({"plane": "ground", "offset": 1e308, "objects": "copy"})), + ("perspective.plane.move", json!({"plane": "left", "by": -1e7, "objects": "move"})), + ("perspective.plane.move", json!({"plane": "none", "offset": 0})), + ("perspective.plane.matchObject", json!({"id": u64::MAX})), + ("perspective.grid.set", json!({"leftOffset": -1e12, "reproject": true})), + ("perspective.grid.set", json!({"vpRight": -1e6, "reproject": true})), + ("perspective.editText", json!({"id": u64::MAX})), + ( + "perspective.draw", + json!({"command": "shape.rectangle", "params": {"x": 1, "y": 1, "width": 10, "height": 10}, "at": [1e308, -1e308], "plane": "left"}), + ), + ("perspective.draw", json!({"command": "shape.flare", "params": {"cx": 0, "cy": 0}, "plane": "ground"})), ("object.setProps", json!({"opacity": -1, "blend": 5})), ("effect.apply", json!({"effect": "distort.roughen", "params": {"size": 100, "detail": 100, "relative": false}})), ("effect.apply", json!({"effect": "distort.zigZag", "params": {"size": 1e308, "ridges": 100}})), @@ -262,6 +280,21 @@ fn structured_junk() { ("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})), ("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})), ("command.batch", json!({"commands": "nope"})), + ("object.puppetWarp", json!({"pins": [[0, 0]], "moved": [[0, 0]], "angles": [1e308]})), + ("object.puppetWarp", json!({"pins": [[0, 0], [5, 5]], "moved": [[0, 0], [1e9, 1e9]], "angles": [-1e308, "x"]})), + ("object.liquify", json!({"tool": "pucker", "points": [[0, 0, 1e308], [1, 1, -5], [2, 2, "x"]], "usePressure": true})), + ("object.liquify", json!({"tool": "twirl", "points": vec![json!([50, 50, 0.5]); 400], "usePressure": "yes", "simplifyOn": 3})), + ("object.liquify", json!({"tool": "wrinkle", "points": [[0, 0], [1e4, 1e4]], "width": 1e308, "complexity": -1, "affectIn": null})), + ("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})), + ("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})), + ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), + ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), + ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), + ("object.blend.spine.removeAnchor", json!({"anchor": -1})), + ("object.puppetWarp", json!({"rest": true, "pins": [], "moved": []})), + ("object.puppetWarp", json!({"rest": true, "pins": [[1e308, 0]], "moved": [[0, 0]], "expand": -5})), + ("object.puppetWarp", json!({"rest": true, "ids": [u64::MAX], "pins": [[0, 0]], "moved": [[0, 0]]})), + ("object.puppetWarp.pins", json!({"ids": [0, 1, 2], "expand": 1e308})), ]; let mut failures = vec![]; for fx in Fixture::ALL { @@ -363,3 +396,47 @@ fn recovery_commands_with_a_damaged_store_and_junk_params() { } } } + +/// Envelope Distort's commands on warp, mesh and top-object envelopes (selected, and with their +/// content selected in Edit Contents) with `{}` and junk params: never a panic, an open +/// interaction or a broken tree. +#[test] +fn envelope_commands_with_junk_params_on_envelopes() { + let path = safe_path(); + let makes: [(&str, Value); 3] = [ + ("object.envelope.makeWithWarp", json!({"style": "twist", "bend": 80})), + ("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 3})), + ("object.envelope.makeWithTopObject", json!({})), + ]; + let mut failures = vec![]; + for (make, mp) in &makes { + for editing in [false, true] { + let ids: Vec<&str> = + command_specs().iter().map(|c| c.id).filter(|id| id.starts_with("object.envelope.") || id.starts_with("object.mesh.")).collect(); + for id in ids { + let spec = command_specs().iter().find(|c| c.id == id).unwrap(); + for p in std::iter::once(json!({})).chain(junk_params(spec.params, &path)) { + let mut s = Fixture::Multi.session(); + if s.execute(make, mp).is_err() { + failures.push(format!("{make} failed on the Multi fixture")); + continue; + } + if editing { + let _ = s.execute("object.envelope.editContents", &json!({"editing": true})); + } + let r = catch_quiet(|| s.execute(id, &p)); + if r.is_err() { + failures.push(format!("PANIC {id} {p} [{make}, editing {editing}]")); + } else if s.in_interaction() { + failures.push(format!("{id} {p} [{make}]: left an interaction open")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("{id} {p} [{make}]: {e}")); + } + } + } + } + } + failures.sort(); + failures.dedup(); + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.iter().take(40).cloned().collect::>().join("\n")); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b7c71a92d..6c58f17f1 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1097,7 +1097,7 @@ fn emf_of(records: &[(u32, Vec)]) -> Vec { /// A placeable WMF of a 1 × 1 inch box holding `records`. fn wmf_of(records: &[(u16, Vec)]) -> Vec { let mut b: Vec = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0, 0, 0, 0, 0, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, 0, 0, 0, 0]; - let sum = b.chunks_exact(2).fold(0u16, |a, w| a ^ u16::from_le_bytes([w[0], w[1]])); + let sum = b.as_chunks::<2>().0.iter().fold(0u16, |a, w| a ^ u16::from_le_bytes(*w)); b.extend(sum.to_le_bytes()); b.extend([1, 0, 9, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); for (function, body) in records { @@ -1204,3 +1204,34 @@ proptest! { print_presets("mutated print presets", &mutate_text(&text, cut, &edits))?; } } + +// ---------- perspective grid presets files ---------- + +/// Import perspective grid presets from `data`, then apply each one and draw on its grid. +fn perspective_presets(what: &str, data: &str) -> Result<(), TestCaseError> { + survive_library(what, "perspective.presets.import", data, |s, r| { + for name in r["imported"].as_array().into_iter().flatten() { + let _ = s.execute("perspective.grid.preset", &json!({"name": name})); + let _ = s.execute("perspective.grid.define", &json!({"name": name, "gridline": 3})); + let _ = s.execute("perspective.draw", &json!({"command": "shape.rectangle", "params": {"x": 300, "y": 400, "width": 40, "height": 30}})); + } + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn perspective_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcperspective\", ", "{\"format\": \"vcperspective\", \"presets\": [{\"name\": \"x\", \"kind\": 3, \"scale\": "])) { + perspective_presets("perspective presets garbage", &format!("{head}{s}"))?; + } + + #[test] + fn mutated_perspective_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + let mut s = rich_session(); + s.execute("perspective.presets.save", &json!({"name": "Tall", "kind": 3, "units": "inches", "scale": [1, 4], "angle": 25, "thirdVp": [1, 30]})).unwrap(); + s.execute("perspective.presets.save", &json!({"name": "Flat", "preset": "[1P-Low View]", "gridline": 4, "groundColor": "#00ff00"})).unwrap(); + let text = s.execute("perspective.presets.export", &json!({"names": ["Tall", "Flat", "[2P-High View]"]})).unwrap()["data"].as_str().unwrap().to_string(); + perspective_presets("mutated perspective presets", &mutate_text(&text, cut, &edits))?; + } +} diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs new file mode 100644 index 000000000..ac982a20e --- /dev/null +++ b/crates/engine/tests/plugin_fuzz.rs @@ -0,0 +1,271 @@ +//! Untrusted plug-ins never crash the app: mutated and garbage modules handed to `plugin.install`, +//! and plug-ins returning hostile output (random JSON in the object model's shape: huge or +//! malformed coordinates, unknown ids, broken paints and gradients, junk of every type) as object +//! filters (`plugin.run`) or live effects (`effect.apply`). Each run must end as an error that +//! leaves the document as it was, or as a document that then renders and exports, without a +//! panic. +//! +//! `PROPTEST_CASES=2000 cargo test -p vectorcraft-engine --test plugin_fuzz` runs a deeper search. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use proptest::prelude::*; +use serde_json::{Map, Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; +use vectorcraft_testkit::catch_quiet; +use vectorcraft_testkit::fixtures::rich_session; + +const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); + +/// 64 cases each, unless `PROPTEST_CASES` asks for more. +fn config() -> ProptestConfig { + let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() }; + if std::env::var_os("PROPTEST_CASES").is_none() { + c.cases = 64; + } + c +} + +fn install(s: &mut Session, wasm: &[u8]) -> Result { + s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string()) +} + +/// The document renders and exports without panicking. +fn renders(s: &Session, what: &str) -> Result<(), TestCaseError> { + let d = &s.doc().unwrap().doc; + let r = catch_quiet(|| { + let mut r = vectorcraft_render::Renderer::new(); + if let Some(ab) = d.artboards.first() { + let _ = r.render_region(d, ab.rect, 0.25, true).to_png(); + } + let _ = vectorcraft_svg::export(d, &vectorcraft_svg::ExportOptions::default()); + let _ = vectorcraft_engine::export_pdf(d, &vectorcraft_pdf::PdfOptions::default()); + let _ = vectorcraft_format::save_file(d); + }); + r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}"))) +} + +/// Junk of every JSON type. +fn arb_junk() -> impl Strategy { + let leaf = prop_oneof![ + Just(Value::Null), + any::().prop_map(Value::from), + any::().prop_map(Value::from), + (-1e3f64..1e3).prop_map(Value::from), + "[a-zA-Z#]{0,6}".prop_map(Value::from), + ]; + leaf.prop_recursive(2, 12, 3, |inner| { + prop_oneof![ + prop::collection::vec(inner.clone(), 0..3).prop_map(Value::Array), + prop::collection::btree_map("[a-z]{1,5}", inner, 0..3).prop_map(|m| Value::Object(m.into_iter().collect())), + ] + }) +} + +/// Numbers that tend to break arithmetic. +fn arb_num() -> impl Strategy { + prop_oneof![ + 6 => (-600.0f64..600.0).prop_map(Value::from), + 1 => Just(json!(0)), + 1 => Just(json!(-0.0)), + 1 => Just(json!(1e308)), + 1 => Just(json!(-1e308)), + 1 => Just(json!(4.0e6)), + 1 => Just(json!(4.000001e6)), + 1 => Just(json!(1e-308)), + 1 => Just(json!(u64::MAX)), + 1 => Just(json!(i64::MIN)), + 1 => arb_junk(), + ] +} + +fn arb_point() -> impl Strategy { + prop_oneof![ + 8 => (arb_num(), arb_num()).prop_map(|(x, y)| json!([x, y])), + 1 => (arb_num(), arb_num()).prop_map(|(x, y)| json!({"x": x, "y": y})), + 1 => arb_junk(), + ] +} + +fn arb_path() -> impl Strategy { + let anchor = ( + arb_point(), + prop::option::of(arb_point()), + prop::option::of(arb_point()), + prop::option::of(prop_oneof![Just("Smooth"), Just("Corner"), Just("Weird")]), + ) + .prop_map(|(p, i, o, k)| { + let mut a = json!({"p": p}); + if let Some(i) = i { + a["in"] = i; + } + if let Some(o) = o { + a["out"] = o; + } + if let Some(k) = k { + a["kind"] = json!(k); + } + a + }); + let sub = (prop::collection::vec(anchor, 0..6), any::()).prop_map(|(anchors, closed)| json!({"anchors": anchors, "closed": closed})); + prop_oneof![9 => prop::collection::vec(sub, 0..3).prop_map(|s| json!({"subpaths": s})), 1 => arb_junk()] +} + +fn arb_color() -> impl Strategy { + prop_oneof![ + (arb_num(), arb_num(), arb_num()).prop_map(|(r, g, b)| json!({"model": "rgb", "r": r, "g": g, "b": b})), + (arb_num(), arb_num(), arb_num(), arb_num()).prop_map(|(c, m, y, k)| json!({"model": "cmyk", "c": c, "m": m, "y": y, "k": k})), + arb_num().prop_map(|k| json!({"model": "gray", "k": k})), + (arb_num(), arb_num(), arb_num()).prop_map(|(l, a, b)| json!({"model": "lab", "l": l, "a": a, "b": b})), + arb_junk(), + ] +} + +fn arb_paint() -> impl Strategy { + let stop = (arb_num(), arb_color(), arb_num(), arb_num()).prop_map(|(o, c, a, m)| json!({"offset": o, "color": c, "opacity": a, "midpoint": m})); + let freeform = ( + prop::collection::vec((arb_point(), arb_color(), arb_num()).prop_map(|(at, c, s)| json!({"at": at, "color": c, "spread": s})), 0..3), + arb_junk(), + ) + .prop_map(|(points, lines)| json!({"points": points, "lines": if lines.is_array() { lines } else { json!([[0, 1, 9]]) }})); + let gradient = ( + prop_oneof![Just("Linear"), Just("Radial"), Just("Freeform"), Just("Conic")], + prop::collection::vec(stop, 0..4), + prop::option::of((arb_point(), arb_point(), arb_num(), prop::option::of(arb_point()))), + arb_num(), + prop::option::of(freeform), + ) + .prop_map(|(kind, stops, geom, angle, ff)| { + let mut g = json!({"type": "gradient", "gradient": {"kind": kind, "stops": stops}, "angle": angle}); + if let Some((start, end, aspect, focal)) = geom { + g["geom"] = json!({"start": start, "end": end, "aspect": aspect, "focal": focal}); + } + if let Some(ff) = ff { + g["freeform"] = ff; + } + g + }); + prop_oneof![ + 2 => Just(Value::Null), + 3 => (arb_color(), prop::option::of(prop_oneof![Just("White"), Just("Nope")]), arb_num()) + .prop_map(|(c, sw, t)| json!({"type": "solid", "color": c, "swatch": sw, "tint": t})), + 3 => gradient, + 1 => (prop_oneof![Just("Nope"), Just("")], prop::collection::vec(arb_num(), 6)).prop_map(|(p, xf)| json!({"type": "pattern", "pattern": p, "xf": xf})), + 1 => Just(json!({"type": "none"})), + 1 => arb_junk(), + ] +} + +/// An output object: an input object's id (by index into the inputs) or a random one, and any of +/// the object keys. +fn arb_object() -> impl Strategy, Value)> { + let stroke = (arb_paint(), prop::option::of(arb_num())).prop_map(|(p, w)| json!({"paint": p, "width": w})); + ( + prop::option::of(0usize..8), + prop::option::of(prop_oneof![Just(json!("path")), Just(json!("compound")), arb_junk()]), + prop::option::of(arb_path()), + prop::option::of(prop_oneof![Just(json!("evenodd")), Just(json!("nonzero")), arb_junk()]), + prop::option::of(prop::collection::vec(arb_paint(), 0..3)), + prop::option::of(prop::collection::vec(stroke, 0..3)), + prop::option::of(arb_num()), + prop::option::of(prop_oneof![Just(json!("name")), arb_junk()]), + ) + .prop_map(|(id, t, path, rule, fills, strokes, opacity, name)| { + let mut o = Map::new(); + for (k, v) in [ + ("type", t), + ("path", path), + ("fillRule", rule), + ("fills", fills.map(Value::from)), + ("strokes", strokes.map(Value::from)), + ("opacity", opacity), + ("name", name), + ] { + if let Some(v) = v { + o.insert(k.into(), v); + } + } + (id, Value::Object(o)) + }) +} + +/// The output document: objects (ids resolved against `inputs`; out-of-range indices become +/// unknown ids), or junk. +fn output(objects: &[(Option, Value)], inputs: &[u64], junk: Option) -> String { + if let Some(j) = junk { + return j.to_string(); + } + let list: Vec = objects + .iter() + .map(|(id, o)| { + let mut o = o.clone(); + if let Some(i) = id { + o["id"] = json!(inputs.get(*i).copied().unwrap_or(9_999_999 + *i as u64)); + } + o + }) + .collect(); + json!({ "objects": list }).to_string() +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn hostile_filter_and_effect_output_never_crashes( + objects in prop::collection::vec(arb_object(), 0..5), + junk in prop::option::weighted(0.1, arb_junk()), + effect in any::(), + ) { + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let inputs: Vec = vectorcraft_plugins::objects::filter_targets(&st.doc, &st.selection.objects).iter().map(|i| i.0).collect(); + let out = output(&objects, &inputs, junk); + let id = if effect { "fuzz.effect" } else { "fuzz.filter" }; + let src = tpl::returning(&tpl::manifest(id, if effect { "effect" } else { "filter" }, "{}"), &out); + install(&mut s, &wat::parse_str(src).unwrap()).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let r = catch_quiet(|| { + if effect { + s.execute("effect.apply", &json!({"effect": format!("plugin.{id}")})).map(|_| ()) + } else { + s.execute("plugin.run", &json!({"id": id})).map(|_| ()) + } + }); + match r { + Err(msg) => return Err(TestCaseError::fail(format!("panicked on {out}: {msg}"))), + Ok(Err(_)) => prop_assert!(s.doc().unwrap().doc == before, "a failed run changed the document: {}", out), + Ok(Ok(())) => {} + } + renders(&s, &out)?; + } + + #[test] + fn mutated_and_garbage_modules_never_crash( + flips in prop::collection::vec((any::(), any::()), 0..8), + cut in prop::option::of(any::()), + garbage in prop::collection::vec(any::(), 0..64), + ) { + let mut bytes = DESATURATE.to_vec(); + for (i, b) in &flips { + let i = i.index(bytes.len()); + bytes[i] = *b; + } + if let Some(c) = cut { + bytes.truncate(c.index(bytes.len())); + } + let mut s = rich_session(); + s.execute("select.all", &json!({})).unwrap(); + for module in [bytes, [b"\0asm\x01\0\0\0".as_slice(), &garbage].concat(), garbage.clone()] { + let r = catch_quiet(|| { + if let Ok(info) = install(&mut s, &module) { + let _ = s.execute("plugin.run", &json!({"id": info["id"]})); + } + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } + renders(&s, "after mutated modules")?; + } +} diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 823e2ed9d..005a433db 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -429,10 +429,8 @@ impl Interp<'_> { return Err(PsError::Limit("a path has too many points")); } let mut bp = BezPath::new(); - for r in nums.chunks_exact(4) { - if let [x, y, w, h] = r { - bp.extend(self.rect_path(*x, *y, *w, *h)); - } + for [x, y, w, h] in nums.as_chunks::<4>().0 { + bp.extend(self.rect_path(*x, *y, *w, *h)); } Ok(bp) } diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 127046b49..90d004857 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -24,6 +24,8 @@ mod text; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_fontnames; use std::sync::Arc; diff --git a/crates/eps/src/import/tests_fontnames.rs b/crates/eps/src/import/tests_fontnames.rs new file mode 100644 index 000000000..01061bbbe --- /dev/null +++ b/crates/eps/src/import/tests_fontnames.rs @@ -0,0 +1,38 @@ +//! Type names the available family as the font spells it, not as the PostScript name splits into +//! words (#130). + +use vectorcraft_doc::NodeKind; +use vectorcraft_text::FontDb; + +use crate::import::import; + +/// The bundled Source Sans 3 renamed `family` (as long as the original name). +fn renamed(family: &str) -> Vec { + let mut data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf").to_vec(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + assert_eq!(from.len(), to.len()); + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +#[test] +fn postscript_names_resolve_to_the_available_family() { + // Split into words, its PostScript name would read "My Face Ya Hei3". + assert!(FontDb::global().add_font(renamed("MyFaceYaHei 3")) > 0); + let body = "/MyFaceYaHei3-Bold findfont 12 scalefont setfont 10 20 moveto (Hi) show"; + let eps = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n"); + let r = import(eps.as_bytes()).unwrap(); + let mut styles = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + styles.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone()))); + } + }); + assert_eq!(styles, [("MyFaceYaHei 3".to_string(), "Bold".to_string())]); +} diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 7869f41b4..d1ce6170d 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -223,7 +223,9 @@ impl Interp<'_> { if !(size.is_finite() && size > 1e-3 && size < 1e5) { return ps_err("undefinedresult", "a font size"); } - let (font_family, font_style) = family_style(&name); + let (family, font_style) = family_style(&name); + // The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei". + let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect(); let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() }; let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() }; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index bef20136b..0c154ac6e 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -292,7 +292,7 @@ impl<'a> Scene<'a> { } // Live blends, envelopes, meshes and repeats are written as their evaluated art. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 79ed3e2f4..7273c34aa 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -10,6 +10,7 @@ pub mod bez; pub mod hit; pub mod path; +pub mod projective; pub mod recognize; pub mod shapes; pub mod snap; @@ -17,6 +18,7 @@ pub mod snap; pub use kurbo; pub use kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathEl, PathSeg, Point, Rect, Shape, Size, Vec2}; pub use path::{Anchor, AnchorKind, FillRule, PathData, SubPath}; +pub use projective::Homography; /// Tolerance used when comparing handle positions to anchor positions. pub const EPS: f64 = 1e-9; diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 339427909..67c538733 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -140,6 +140,25 @@ impl Anchor { self.h_in += d; self.h_out += d; } + /// Place the end of the outgoing (`out`) or incoming handle at `pos`. `independent` makes the + /// anchor a corner first; a smooth anchor keeps its other handle in line, at its length. + pub fn set_handle(&mut self, out: bool, pos: Point, independent: bool) { + if independent { + self.kind = AnchorKind::Corner; + } + let p = self.p; + let smooth = self.kind == AnchorKind::Smooth; + let (moved, other) = if out { (&mut self.h_out, &mut self.h_in) } else { (&mut self.h_in, &mut self.h_out) }; + *moved = pos; + if smooth { + let len = (*other - p).hypot(); + let dir = p - pos; + let l = dir.hypot(); + if l > 1e-9 { + *other = p + dir * (len / l); + } + } + } /// Swap handles (used when reversing path direction). pub fn reversed(&self) -> Self { Self { p: self.p, h_in: self.h_out, h_out: self.h_in, kind: self.kind } diff --git a/crates/geom/src/projective.rs b/crates/geom/src/projective.rs new file mode 100644 index 000000000..349ddb80f --- /dev/null +++ b/crates/geom/src/projective.rs @@ -0,0 +1,186 @@ +//! Projective (perspective) transforms of the plane: 3×3 homographies. +//! +//! The Perspective Grid maps each grid plane to the page with one, objects in perspective move by +//! conjugating plane-space affine maps with it, and type and symbol instances in perspective keep +//! one that projects their flat art. + +use kurbo::{Affine, Point, Rect}; + +/// A 3×3 projective transform (row-major), acting on `(x, y, 1)`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Homography(pub [[f64; 3]; 3]); + +impl Homography { + pub const IDENTITY: Self = Self([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + + /// From the images of (1,0,0), (0,1,0) and (0,0,1). + pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self { + Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]]) + } + + /// The affine map `a` as a homography. + pub fn from_affine(a: Affine) -> Self { + let [m0, m1, m2, m3, m4, m5] = a.as_coeffs(); + Self([[m0, m2, m4], [m1, m3, m5], [0.0, 0.0, 1.0]]) + } + + /// From nine row-major numbers (as stored in documents); `None` unless all are finite and the + /// map is invertible. + pub fn from_array(v: [f64; 9]) -> Option { + if v.iter().any(|x| !x.is_finite()) { + return None; + } + let h = Self([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]]); + (h.det().abs() > 1e-15).then_some(h) + } + + /// The nine row-major numbers. + pub fn to_array(&self) -> [f64; 9] { + let m = &self.0; + [m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]] + } + + /// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0). + pub fn apply(&self, p: Point) -> Option { + let m = &self.0; + let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2]; + let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2]; + let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; + if w <= 1e-9 || !x.is_finite() || !y.is_finite() { + return None; + } + Some(Point::new(x / w, y / w)) + } + + pub fn det(&self) -> f64 { + let m = &self.0; + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + } + + /// Inverse. + pub fn inverse(&self) -> Option { + let m = &self.0; + let d = self.det(); + if d.abs() < 1e-15 || !d.is_finite() { + return None; + } + let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; + let adj = [ + [c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)], + [-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)], + [c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)], + ]; + let mut out = [[0.0; 3]; 3]; + for (r, row) in adj.iter().enumerate() { + for (k, v) in row.iter().enumerate() { + out[r][k] = v / d; + } + } + // H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0. + Some(Self(out)) + } + + /// `self ∘ other`: `other` first. + pub fn then_after(&self, other: &Self) -> Self { + let (a, b) = (&self.0, &other.0); + let mut out = [[0.0; 3]; 3]; + for (r, row) in out.iter_mut().enumerate() { + for (k, v) in row.iter_mut().enumerate() { + *v = a[r][0] * b[0][k] + a[r][1] * b[1][k] + a[r][2] * b[2][k]; + } + } + Self(out) + } + + /// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it + /// maps from and to is moved by `a`). `None` when `a` isn't invertible. + pub fn conjugated(&self, a: Affine) -> Option { + if a.determinant().abs() < 1e-15 { + return None; + } + Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse()))) + } + + /// The affine map that matches `self` to first order at `p` (its derivative there), `None` + /// beyond the horizon. + pub fn affine_at(&self, p: Point) -> Option { + let m = &self.0; + let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; + let q = self.apply(p)?; + // d(x/w) = (dx − (x/w)·dw) / w, and the same for y. + let j = |r: usize, c: usize| (m[r][c] - [q.x, q.y][r] * m[2][c]) / w; + let lin = Affine::new([j(0, 0), j(1, 0), j(0, 1), j(1, 1), 0.0, 0.0]); + Some(Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2())) + } + + /// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when + /// part of it crosses the horizon. + pub fn map_rect_bbox(&self, r: Rect) -> Option { + let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let mut out: Option = None; + for p in pts { + let q = self.apply(p)?; + out = Some(out.map_or(Rect::from_points(q, q), |b| b.union_pt(q))); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn h() -> Homography { + Homography([[1.2, 0.1, 5.0], [-0.2, 0.9, 3.0], [0.001, 0.002, 1.0]]) + } + + #[test] + fn compose_invert_and_round_trip() { + let a = h(); + let inv = a.inverse().unwrap(); + let id = a.then_after(&inv); + for (r, row) in id.0.iter().enumerate() { + for (k, v) in row.iter().enumerate() { + assert!((v - if r == k { 1.0 } else { 0.0 }).abs() < 1e-9); + } + } + let p = Point::new(30.0, -12.0); + assert!(inv.apply(a.apply(p).unwrap()).unwrap().distance(p) < 1e-9); + assert_eq!(Homography::from_array(a.to_array()), Some(a)); + assert!(Homography::from_array([f64::NAN; 9]).is_none()); + assert!(Homography::from_array([0.0; 9]).is_none()); + } + + #[test] + fn affine_and_conjugation() { + let t = Affine::translate((10.0, -4.0)) * Affine::scale(2.0); + let p = Point::new(3.0, 7.0); + assert!(Homography::from_affine(t).apply(p).unwrap().distance(t * p) < 1e-12); + // Conjugating: moving the input and the output by `t` keeps the picture moved by `t`. + let c = h().conjugated(t).unwrap(); + assert!(c.apply(t * p).unwrap().distance(t * h().apply(p).unwrap()) < 1e-9); + assert!(h().conjugated(Affine::scale(0.0)).is_none()); + } + + #[test] + fn affine_at_is_the_derivative() { + let p = Point::new(12.0, -7.0); + let a = h().affine_at(p).unwrap(); + assert!(a * p == h().apply(p).unwrap() || (a * p).distance(h().apply(p).unwrap()) < 1e-9); + let e = 1e-4; + for d in [kurbo::Vec2::new(e, 0.0), kurbo::Vec2::new(0.0, e)] { + let exact = h().apply(p + d).unwrap(); + assert!((a * (p + d)).distance(exact) < 1e-7, "{d:?}"); + } + } + + #[test] + fn rect_bbox_is_the_corners_box() { + let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap(); + for p in [Point::new(0.0, 0.0), Point::new(10.0, 20.0), Point::new(10.0, 0.0), Point::new(5.0, 10.0)] { + let q = h().apply(p).unwrap(); + assert!(b.inflate(1e-9, 1e-9).contains(q)); + } + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 1bd0b46f8..9750d13d6 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -38,6 +38,11 @@ const HOST_COMMANDS: &[(&str, &str, &str)] = &[ "{folder?, zip?, artboards? | range? | fullDocument? | assets?, includeBleed?, subfolders?, preset?, formats?, settings?, prefix?} = document.exportForScreens (no folder → the files, or one zip, as dataBase64)", ), ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), + ( + "tool.setOption", + "Tool Option", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them); kept options last across tool switches", + ), ]; fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { @@ -68,6 +73,7 @@ fn tool_key(name: &str) -> Option { "[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft, "]" | "bracketright" | "closebracket" => ToolKey::BracketRight, "tab" => ToolKey::Tab, + k if k.len() == 1 => ToolKey::Digit(k.parse().ok()?), _ => return None, }) } @@ -113,6 +119,7 @@ impl Headless { "file.export" => self.export(params), "file.exportForScreens" => self.session.execute("document.exportForScreens", params).map_err(|e| e.to_string()), "tool.select" => self.select_tool(params), + "tool.setOption" => self.session.set_tool_option_cmd(params), _ => self.session.execute(id, params).map_err(|e| e.to_string()), } } @@ -141,6 +148,7 @@ impl Headless { self.session.select_tool(&t, self.view).map_err(|e| e.to_string())?; } UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})), + UiRequest::Status(msg) => out.push(json!({"status": msg})), } } Ok(()) @@ -160,9 +168,14 @@ impl Headless { let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); - let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: 1.0 }; + let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: PointerEvent::json_pressure(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; + let hold = PointerEvent::json_hold(e); + if hold > 0.0 { + let reqs = self.session.tool_tick(hold, self.view).map_err(|e| e.to_string())?; + self.apply_ui_requests(reqs, &mut requests)?; + } } let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::>()).unwrap_or_default(); Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests})) @@ -191,6 +204,11 @@ impl Headless { self.session.select_tool(t.id, self.view).map_err(|e| e.to_string())?; return Ok(json!({"handledBy": "tool.select", "tool": t.id})); } + // Backspace clears the selection as Delete does (as in the desktop app). + if tk == Some(ToolKey::Backspace) && mods == Mods::default() && self.session.active().is_some_and(|d| !d.selection.is_empty()) { + let r = self.exec("edit.clear", &json!({}))?; + return Ok(json!({"handledBy": "command", "command": "edit.clear", "result": r})); + } if let Some(k) = tk { let mut out = vec![]; let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?; diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 39807d6c4..711c6b097 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -30,6 +30,8 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7979"; #[cfg(test)] mod tests; #[cfg(test)] +mod tests_distortkeys; +#[cfg(test)] mod tests_exportas; #[cfg(test)] mod tests_fileio; @@ -40,6 +42,10 @@ mod tests_gradient; #[cfg(test)] mod tests_links; #[cfg(test)] +mod tests_liquify; +#[cfg(test)] +mod tests_persp; +#[cfg(test)] mod tests_place; #[cfg(test)] mod tests_svg; diff --git a/crates/mcp/src/tests_distortkeys.rs b/crates/mcp/src/tests_distortkeys.rs new file mode 100644 index 000000000..275831b9e --- /dev/null +++ b/crates/mcp/src/tests_distortkeys.rs @@ -0,0 +1,66 @@ +//! `press_key` with the Width and Puppet Warp tools: Delete and Backspace reach the selected width +//! point or pin, and clear the selected art when the tool has nothing selected. + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + ok(h, "run_command", json!({"command": command, "params": params})) +} + +fn exists(h: &Headless, id: u64) -> bool { + h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).is_some() +} + +fn click(h: &mut Headless, tool: &str, x: f64, y: f64) { + ok(h, "pointer_gesture", json!({"tool": tool, "events": [{"kind": "down", "x": x, "y": y}, {"kind": "up", "x": x, "y": y}]})); +} + +fn session() -> Headless { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 400}})).unwrap(); + h +} + +#[test] +fn delete_and_backspace_reach_the_width_tool() { + for key in ["Delete", "Backspace"] { + let mut h = session(); + let id = run(&mut h, "shape.line", json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200}))["id"].as_u64().unwrap(); + run(&mut h, "stroke.set", json!({"weight": 10})); + run(&mut h, "stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 12, "right": 12})); + click(&mut h, "width", 200.0, 200.0); + let r = ok(&mut h, "press_key", json!({"key": key})); + assert_eq!(r["handledBy"], "tool", "{key}: {r}"); + let n = h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().unwrap().profile.clone().unwrap(); + assert_eq!(n.points.len(), 2, "{key} removed the width point"); + // With no width point selected the key clears the selected line. + ok(&mut h, "press_key", json!({"key": key})); + assert!(!exists(&h, id), "{key} cleared the line"); + } +} + +#[test] +fn delete_and_backspace_reach_the_puppet_warp_tool() { + for key in ["Delete", "Backspace"] { + let mut h = session(); + let id = run(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100}))["id"].as_u64().unwrap(); + ok(&mut h, "select_tool", json!({"tool": "puppetWarp"})); + let pins = |h: &Headless| h.session.tool_options()["pins"].as_array().map_or(0, Vec::len); + click(&mut h, "puppetWarp", 112.0, 188.0); + let n = pins(&h); + let r = ok(&mut h, "press_key", json!({"key": key})); + assert_eq!(r["handledBy"], "tool", "{key}: {r}"); + assert!(exists(&h, id), "{key} kept the art"); + assert_eq!(pins(&h), n - 1, "{key} removed the pin"); + ok(&mut h, "press_key", json!({"key": key})); + assert!(!exists(&h, id), "{key} cleared the art"); + } +} diff --git a/crates/mcp/src/tests_liquify.rs b/crates/mcp/src/tests_liquify.rs new file mode 100644 index 000000000..4760380bc --- /dev/null +++ b/crates/mcp/src/tests_liquify.rs @@ -0,0 +1,64 @@ +//! The Liquify tools through MCP: their options (`tool.setOption`, kept across tool switches) and +//! brush strokes with `pointer_gesture`. + +use serde_json::{Value, json}; + +use crate::{Headless, call_tool}; + +fn ok(h: &mut Headless, name: &str, args: Value) -> Value { + let r = call_tool(h, name, &args); + assert!(!r.is_error, "{name} {args}: {r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap_or(Value::Null) +} + +fn command(h: &mut Headless, command: &str, params: Value) -> Value { + ok(h, "run_command", json!({"command": command, "params": params})) +} + +#[test] +fn tool_options_are_set_headless_and_kept_across_tool_switches() { + let mut h = Headless::with_document(); + command(&mut h, "tool.select", json!({"tool": "warp"})); + let o = command(&mut h, "tool.setOption", json!({"values": {"width": 64, "detail": 6}})); + assert_eq!((o["width"].as_f64(), o["detail"].as_f64()), (Some(64.0), Some(6.0))); + command(&mut h, "tool.select", json!({"tool": "bloat"})); + let o = command(&mut h, "tool.setOption", json!({})); + assert_eq!((o["tool"].as_str(), o["width"].as_f64(), o["detail"].as_f64()), (Some("bloat"), Some(64.0), Some(2.0))); + command(&mut h, "tool.select", json!({"tool": "warp"})); + assert_eq!(command(&mut h, "tool.setOption", json!({}))["detail"], json!(6.0)); +} + +#[test] +fn pointer_gestures_carry_pen_pressure() { + let bloat = |pressure: f64| { + let mut h = Headless::with_document(); + let id = command(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 200}))["id"].clone(); + command(&mut h, "select.set", json!({"ids": []})); + command(&mut h, "tool.select", json!({"tool": "bloat"})); + command(&mut h, "tool.setOption", json!({"key": "usePressure", "value": true})); + let ev = |kind: &str, y: f64| json!({"kind": kind, "x": 280, "y": y, "pressure": pressure}); + ok(&mut h, "pointer_gesture", json!({"events": [ev("down", 150.0), ev("drag", 200.0), ev("up", 250.0)]})); + command(&mut h, "document.node", json!({"id": id})) + }; + let (none, full) = (bloat(0.0), bloat(1.0)); + assert_eq!(none, bloat(0.0)); + assert_ne!(none, full); +} + +#[test] +fn holding_still_through_mcp_and_skips_are_reported() { + let pucker = |hold: u64| { + let mut h = Headless::with_document(); + let id = command(&mut h, "shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 200}))["id"].clone(); + command(&mut h, "text.create", json!({"x": 290, "y": 200, "text": "Hi"})); + command(&mut h, "select.set", json!({"ids": []})); + let ev = |kind: &str| json!({"kind": kind, "x": 290, "y": 200, "holdMs": hold}); + let r = ok(&mut h, "pointer_gesture", json!({"tool": "pucker", "events": [ev("down"), ev("up")]})); + (r, command(&mut h, "document.node", json!({"id": id}))) + }; + let (r, still) = pucker(0); + assert!(r["requests"].as_array().unwrap().iter().any(|q| q["status"].as_str().is_some_and(|m| m.contains("type"))), "{r}"); + let (_, held) = pucker(500); + assert_ne!(still, held); + assert_eq!(held, pucker(500).1, "the same hold, the same result"); +} diff --git a/crates/mcp/src/tests_persp.rs b/crates/mcp/src/tests_persp.rs new file mode 100644 index 000000000..2afc60f38 --- /dev/null +++ b/crates/mcp/src/tests_persp.rs @@ -0,0 +1,39 @@ +//! The Perspective Selection tool over MCP: a drag with 5 pressed half-way moves perpendicular to +//! the plane, the arrows nudge in perspective. + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane}; + +use crate::{Headless, call_tool}; + +fn text(r: &crate::ToolResult) -> Value { + assert!(!r.is_error, "{r:?}"); + serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap() +} + +fn run(h: &mut Headless, command: &str, params: Value) -> Value { + text(&call_tool(h, "run_command", &json!({"command": command, "params": params}))) +} + +#[test] +fn perspective_selection_takes_5_and_the_arrows_over_mcp() { + let mut h = Headless::with_document(); + run(&mut h, "perspective.grid.preset", json!({"kind": 2})); + let id = run(&mut h, "shape.rectangle", json!({"x": 330, "y": 500, "width": 60, "height": 60}))["id"].as_u64().unwrap(); + run(&mut h, "perspective.attach", json!({"ids": [id], "plane": "right"})); + let depth = |h: &Headless| persp::attachment(h.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap()).unwrap(); + let doc = &h.session.doc().unwrap().doc; + let g = PerspectiveGrid::from_doc(doc).unwrap(); + let (_, _, rect) = g.plane_bounds(doc, &[vectorcraft_doc::NodeId(id)]).unwrap(); + let from = g.homography_at(Plane::Right, 0.0).unwrap().apply(rect.center()).unwrap(); + let to = g.homography_at(Plane::Right, 30.0).unwrap().apply(rect.center()).unwrap(); + let ev = |kind: &str, p: vectorcraft_geom::Point| json!({"kind": kind, "x": p.x, "y": p.y}); + text(&call_tool(&mut h, "pointer_gesture", &json!({"tool": "perspectiveSelection", "events": [ev("down", from), ev("drag", to)]}))); + assert_eq!(text(&call_tool(&mut h, "press_key", &json!({"key": "5"})))["handledBy"], "tool"); + text(&call_tool(&mut h, "pointer_gesture", &json!({"events": [ev("up", to)]}))); + let (plane, d) = depth(&h); + assert_eq!(plane, Plane::Right); + assert!((d - 30.0).abs() < 1e-6, "{d}"); + assert_eq!(text(&call_tool(&mut h, "press_key", &json!({"key": "Right"})))["handledBy"], "tool"); + assert_eq!(h.session.journal.last().unwrap().0, "perspective.nudge"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f974efe56..f12cb198c 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -127,6 +127,8 @@ pub fn tool_definitions() -> Vec { "x": num("Document x (pt)"), "y": num("Document y (pt)"), "mods": mods_schema(), + "pressure": num("Pen pressure 0..1 (default 1): the Liquify tools' intensity with Use Pressure Pen on"), + "holdMs": num("Milliseconds the pointer then holds still, button down (0..60000): Twirl, Pucker and Bloat keep applying"), }), &["kind", "x", "y"]), }, "mods": mods_schema(), diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs index d63e5837a..ef98ef155 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -92,7 +92,7 @@ pub(crate) fn rgb24(img: &Rgba, opacity: f32) -> (Vec, Vec) { // Bottom-up: the first stored row is the image's last. let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue }; let Some(dst) = bits.get_mut(at..at + img.width as usize * 3) else { continue }; - for (d, p) in dst.chunks_exact_mut(3).zip(src.as_chunks::<4>().0) { + for (d, p) in dst.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<4>().0) { let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32; d.copy_from_slice(&[over(p[2], a), over(p[1], a), over(p[0], a)]); } @@ -107,7 +107,7 @@ pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec, Vec) { for (y, src) in img.pixels.chunks_exact(row.max(1)).enumerate() { let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue }; let Some(dst) = bits.get_mut(at..at + row) else { continue }; - for (d, p) in dst.chunks_exact_mut(4).zip(src.as_chunks::<4>().0) { + for (d, p) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) { let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32; let pre = |c: u8| ((u32::from(c) * a + 127) / 255) as u8; d.copy_from_slice(&[pre(p[2]), pre(p[1]), pre(p[0]), a as u8]); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 482f2293f..ecc6a7f1d 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -583,7 +583,7 @@ impl Player { let mut bp = BezPath::new(); bp.move_to(*first); if bezier { - for c in rest.chunks_exact(3) { + for c in rest.as_chunks::<3>().0 { bp.curve_to(c[0], c[1], c[2]); } } else { @@ -636,7 +636,7 @@ impl Player { } }; if bezier { - for c in pts.chunks_exact(3) { + for c in pts.as_chunks::<3>().0 { target.curve_to(xf * c[0], xf * c[1], xf * c[2]); } } else { diff --git a/crates/metafile/src/import/emf.rs b/crates/metafile/src/import/emf.rs index e0eedd01f..e2024af22 100644 --- a/crates/metafile/src/import/emf.rs +++ b/crates/metafile/src/import/emf.rs @@ -314,7 +314,7 @@ fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { let off = r.u32()?; let s = if kind == 84 { let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?; - String::from_utf16_lossy(&raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::>()) + String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::>()) } else { slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect() }; diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 10077ad04..3734f27cf 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -190,7 +190,7 @@ impl<'a> Scene<'a> { } // Live blends, envelopes, meshes and repeats are written as their evaluated art. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(c, false); } diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index 5ef36bf0f..0e4b82e08 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -68,7 +68,7 @@ pub(crate) fn units_per_inch(w: f64, h: f64) -> u16 { /// The XOR of the first ten 16-bit words of a placeable header. pub(crate) fn checksum(header: &[u8]) -> u16 { - header.chunks_exact(2).take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes([w[0], w[1]])) + header.as_chunks::<2>().0.iter().take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes(*w)) } /// `bp` flattened to within `tol`: each subpath's points and whether it closes. diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 3772c158b..ea36e569e 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -1,10 +1,13 @@ //! Illustrator's Pathfinder panel and Shape Builder regions, over an ordered stack of shapes //! (index 0 = back-most, last = front-most). +use std::collections::HashMap; + use kurbo::{BezPath, ParamCurve, ParamCurveNearest, Point, Shape as _}; +use linesweeper::topology::ContourIdx; use vectorcraft_geom::{FillRule, PathData}; -use crate::boolean::{Arrangement, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; +use crate::boolean::{Arrangement, Multi, Seg, all_contours_to_path, contours_to_path, fill_bezpath, segs_to_subpath, unite_all}; /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. @@ -142,21 +145,108 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { } } +/// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face grouped as +/// `arr.contours(|m| m == mask).grouped()` groups it. +/// +/// A contour pass walks the whole arrangement, so one pass per mask would cost masks × edges +/// (two blends crossing make thousands of each). Instead the masks go into batches whose faces +/// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as +/// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first +/// point. fn regions_of(arr: &Arrangement) -> Vec { - let mut out = Vec::new(); - for mask in arr.distinct_masks() { - let c = arr.contours(|m| m == mask.as_slice()); - let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); - for g in c.grouped() { - let path = contours_to_path(&c, g, &arr.tidy); - if !path.is_empty() { - out.push(Region { path, sources: sources.clone() }); + let masks = arr.distinct_masks(); + let ids: HashMap<&[bool], usize> = masks.iter().enumerate().map(|(i, m)| (m.as_slice(), i)).collect(); + let id = |w: &Multi| ids.get(arr.mask(w).as_slice()).copied(); + let top = &arr.top; + // The masks of the faces around each vertex. + let mut around: HashMap<(u64, u64), Vec> = HashMap::new(); + for s in top.segment_indices() { + let h = s.first_half(); + let sides = [id(top.winding_clockwise(h)), id(top.winding_counter_clockwise(h))]; + for end in [h, s.second_half()] { + let at = around.entry(vertex_key(top.point(end).to_kurbo())).or_default(); + for m in sides.into_iter().flatten() { + if !at.contains(&m) { + at.push(m); + } } } } + let mut near = vec![Vec::new(); masks.len()]; + for at in around.values() { + for &a in at { + near[a].extend(at.iter().copied().filter(|&b| b != a)); + } + } + // Greedy colouring: each mask takes the first batch none of its neighbours is in. + let mut batch = vec![usize::MAX; masks.len()]; + let mut batches = 0; + for m in 0..masks.len() { + let mut taken = vec![false; batches]; + for &n in &near[m] { + if let Some(t) = taken.get_mut(batch[n]) { + *t = true; + } + } + batch[m] = taken.iter().position(|t| !t).unwrap_or(batches); + batches = batches.max(batch[m] + 1); + } + let mut faces: Vec> = vec![Vec::new(); masks.len()]; + for b in 0..batches { + let c = top.contours(|w| id(w).is_some_and(|m| batch[m] == b)); + let owner: Option> = c + .contours() + .map(|ct| { + let Some(kurbo::PathEl::MoveTo(p)) = ct.path.elements().first() else { return None }; + around.get(&vertex_key(*p))?.iter().copied().find(|&m| batch[m] == b) + }) + .collect(); + let Some(owner) = owner else { + // Not expected: every contour starts at a vertex of the arrangement. Fall back to a + // pass per mask for this batch. + for (m, mask) in masks.iter().enumerate().filter(|(m, _)| batch[*m] == b) { + let c = arr.contours(|k| k == mask.as_slice()); + faces[m].extend(c.grouped().into_iter().map(|g| contours_to_path(&c, g, &arr.tidy))); + } + continue; + }; + // A contour belongs under the nearest enclosing contour of the same mask, as in that + // mask's own pass. + let mut children = vec![Vec::new(); owner.len()]; + let mut roots = Vec::new(); + for (i, &m) in owner.iter().enumerate() { + let mut up = c[ContourIdx(i)].parent; + while let Some(p) = up.filter(|p| owner.get(p.0) != Some(&m)) { + up = c[p].parent; + } + match up { + Some(p) => children[p.0].push(i), + None => roots.push(i), + } + } + for r in roots { + let mut group = Vec::new(); + let mut stack = vec![r]; + while let Some(i) = stack.pop() { + group.push(ContourIdx(i)); + stack.extend(children[i].iter().rev()); + } + faces[owner[r]].push(contours_to_path(&c, group, &arr.tidy)); + } + } + let mut out = Vec::new(); + for (mask, paths) in masks.iter().zip(faces) { + let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); + out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); + } out } +/// A point as a map key (the arrangement's vertices are exact). +fn vertex_key(p: Point) -> (u64, u64) { + (p.x.to_bits(), p.y.to_bits()) +} + /// All faces of the planar arrangement of `shapes` (every area covered by at least one shape, /// split wherever coverage changes). pub fn regions(shapes: &[Shape]) -> Vec { @@ -277,3 +367,82 @@ fn mid_point(c: &[Seg]) -> Point { } c[0].c.p0 } + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use vectorcraft_geom::{Rect, SubPath, shapes}; + + /// The faces one contour pass per mask finds: what [`regions_of`] must match exactly. + fn regions_one_mask_at_a_time(arr: &Arrangement) -> Vec { + let mut out = Vec::new(); + for mask in arr.distinct_masks() { + let c = arr.contours(|m| m == mask.as_slice()); + let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); + for g in c.grouped() { + let path = contours_to_path(&c, g, &arr.tidy); + if !path.is_empty() { + out.push(Region { path, sources: sources.clone() }); + } + } + } + out + } + + fn same_as_one_mask_at_a_time(shapes: &[Shape]) { + let arr = arrangement(shapes).expect("arrangement"); + assert_eq!(regions_of(&arr), regions_one_mask_at_a_time(&arr)); + } + + fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> PathData { + shapes::rectangle(Rect::new(x0, y0, x1, y1)) + } + + /// Shapes on a coarse grid, so that edges and corners coincide: rectangles, rectangles with + /// a hole, ellipses and polygons that may cross themselves, under either fill rule. + fn arb_shape() -> impl Strategy { + let c = || (0..12i32).prop_map(|v| f64::from(v) * 10.0); + let r = (c(), c(), 1..6i32, 1..6i32).prop_map(|(x, y, w, h)| (x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0)); + let path = prop_oneof![ + r.clone().prop_map(|(x0, y0, x1, y1)| rect(x0, y0, x1, y1)), + r.clone().prop_map(|(x0, y0, x1, y1)| { + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let mut p = rect(x0 - 10.0, y0 - 10.0, x1 + 10.0, y1 + 10.0); + p.subpaths.extend(rect(x0, y0, cx.max(x0 + 5.0), cy.max(y0 + 5.0)).subpaths); + p + }), + r.prop_map(|(x0, y0, x1, y1)| shapes::ellipse(Rect::new(x0, y0, x1, y1))), + prop::collection::vec((c(), c()), 3..7).prop_map(|pts| { + let pts: Vec = pts.into_iter().map(|(x, y)| Point::new(x, y)).collect(); + PathData::single(SubPath::polyline(&pts, true)) + }), + ]; + (path, any::(), any::()) + .prop_map(|(p, even_odd, key)| Shape::new(p, if even_odd { FillRule::EvenOdd } else { FillRule::NonZero }, key)) + } + + #[test] + fn crossing_strips_match_one_mask_at_a_time() { + let mut v = Vec::new(); + for i in 0..12 { + let x = 10.0 + 20.0 * f64::from(i); + v.push(Shape::new(rect(x, 0.0, x + 4.0, 250.0), FillRule::NonZero, 1)); + v.push(Shape::new(rect(0.0, x, 250.0, x + 4.0), FillRule::NonZero, 2)); + } + same_as_one_mask_at_a_time(&v); + // Every crossing, and each strip in 13 pieces. + assert_eq!(regions(&v).len(), 12 * 12 + 2 * 12 * 13); + } + + proptest! { + #![proptest_config(ProptestConfig { cases: 256, failure_persistence: None, rng_seed: proptest::test_runner::RngSeed::Fixed(0x5eed_9a7f), ..ProptestConfig::default() })] + + /// Batching the masks changes nothing: the same regions, in the same order, with the same + /// contours in each. + #[test] + fn batched_regions_match_one_mask_at_a_time(v in prop::collection::vec(arb_shape(), 1..8)) { + same_as_one_mask_at_a_time(&v); + } + } +} diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index 173f1de57..39a9ff48b 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -232,12 +232,11 @@ impl Aes { prev = self.block(std::array::from_fn(|i| chunk[i] ^ prev[i])); out.extend_from_slice(&prev); }; - let mut chunks = data.chunks_exact(16); - for c in &mut chunks { - put(std::array::from_fn(|i| c.get(i).copied().unwrap_or_default()), &mut out); + let (chunks, rest) = data.as_chunks::<16>(); + for c in chunks { + put(*c, &mut out); } if pad { - let rest = chunks.remainder(); let n = (16 - rest.len()) as u8; put(std::array::from_fn(|i| rest.get(i).copied().unwrap_or(n)), &mut out); } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 955270e97..c8b7c0ce2 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -983,7 +983,7 @@ impl Exporter<'_> { } // Live blends/envelopes/meshes export their evaluated (expanded) form. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); for c in g.children().into_iter().flatten() { self.node(s, c, page, false); } diff --git a/crates/pdf/tests/system_fonts.rs b/crates/pdf/tests/system_fonts.rs new file mode 100644 index 000000000..262c4bde0 --- /dev/null +++ b/crates/pdf/tests/system_fonts.rs @@ -0,0 +1,34 @@ +//! PDF import names type by the installed fonts in a new session (#130). One test in its own +//! process, so the process-wide font database starts fresh, as it does when the app starts. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_doc::NodeKind; +use vectorcraft_pdf::{ImportOptions, import_with_report}; +use vectorcraft_testkit::pdf::{PdfPage, pdf_with}; +use vectorcraft_text::{FontDb, system_font_dirs}; + +#[test] +fn a_fresh_session_resolves_pdf_fonts_to_installed_families() { + let bundled = FontDb::with_font_dirs(vec![]).families(); + let probe = FontDb::with_font_dirs(system_font_dirs()); + probe.load_system_fonts(); + let installed = probe.families(); + // A family whose PDF name is its name without the spaces (as "TimesNewRoman"). + let Some(family) = installed.iter().find(|f| !bundled.contains(f) && f.contains(' ') && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ')) + else { + eprintln!("no system fonts installed: nothing to check"); + return; + }; + let resources = format!("/Font << /F1 << /Type /Font /Subtype /TrueType /BaseFont /{} >> >>", family.replace(' ', "")); + let page = PdfPage { resources, ..PdfPage::new(100.0, 100.0, "BT /F1 12 Tf 10 30 Td (Hi) Tj ET") }; + let r = import_with_report(&pdf_with(&[page], &[], None), &ImportOptions::default()).unwrap(); + let mut families = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + families.extend(t.runs.iter().map(|r| r.style.font_family.clone())); + } + }); + assert_eq!(families, std::slice::from_ref(family)); + assert!(!r.warnings.iter().any(|w| w.contains(family.as_str())), "{:?}", r.warnings); +} diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml new file mode 100644 index 000000000..1fb4bd77a --- /dev/null +++ b/crates/plugins/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "vectorcraft-plugins" +description = "Sandboxed WebAssembly plug-ins for VectorCraft: a resource-limited wasmi host for object filters and live effects" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +vectorcraft-geom = { workspace = true } +vectorcraft-color = { workspace = true } +vectorcraft-doc = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +wasmi = { workspace = true } + +[dev-dependencies] +wat = { workspace = true } diff --git a/crates/plugins/example/Cargo.lock b/crates/plugins/example/Cargo.lock new file mode 100644 index 000000000..b42995700 --- /dev/null +++ b/crates/plugins/example/Cargo.lock @@ -0,0 +1,105 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "vectorcraft-plugin-desaturate" +version = "1.0.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/plugins/example/Cargo.toml b/crates/plugins/example/Cargo.toml new file mode 100644 index 000000000..4b4da0a0d --- /dev/null +++ b/crates/plugins/example/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "vectorcraft-plugin-desaturate" +description = "Example VectorCraft WebAssembly object filter plug-in: Desaturate" +version = "1.0.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +# Not a member of the VectorCraft workspace: it builds for wasm32-unknown-unknown only. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +serde_json = "1" + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/crates/plugins/example/build.sh b/crates/plugins/example/build.sh new file mode 100644 index 000000000..1bedbc2fb --- /dev/null +++ b/crates/plugins/example/build.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Builds the Desaturate example plug-in and refreshes the test fixture built from it. +# Needs the wasm32-unknown-unknown target: `rustup target add wasm32-unknown-unknown`. +set -eu +cd "$(dirname "$0")" +cargo build --release --target wasm32-unknown-unknown +out=target/wasm32-unknown-unknown/release/vectorcraft_plugin_desaturate.wasm +cp "$out" ../tests/fixtures/desaturate.wasm +echo "built $out ($(wc -c < "$out") bytes) -> crates/plugins/tests/fixtures/desaturate.wasm" diff --git a/crates/plugins/example/src/lib.rs b/crates/plugins/example/src/lib.rs new file mode 100644 index 000000000..a1b08d869 --- /dev/null +++ b/crates/plugins/example/src/lib.rs @@ -0,0 +1,104 @@ +//! Desaturate: an example VectorCraft object filter plug-in (plug-in ABI v1, see +//! `docs/plugins.md`). +//! +//! It reads the selected paths as JSON, moves every colour (solid fills and strokes, gradient +//! stops, freeform gradient points) towards grey by `amount` percent, keeping each colour in its +//! own colour model, and hands the objects back. Only `serde_json` is used: no imports, no WASI. + +use serde_json::{Value, json}; + +const MANIFEST: &str = r#"{ + "id": "org.vectorcraft.example.desaturate", + "name": "Desaturate", + "version": "1.0.0", + "kind": "filter", + "author": "VectorCraft contributors", + "description": "Moves the colours of the selected paths towards grey; an example of the plug-in ABI.", + "params": { + "amount": {"type": "number", "min": 0, "max": 100, "default": 100} + } +}"#; + +#[unsafe(no_mangle)] +pub extern "C" fn vc_abi_version() -> u32 { + 1 +} + +/// `len << 32 | ptr` of the UTF-8 manifest JSON. +#[unsafe(no_mangle)] +pub extern "C" fn vc_manifest() -> u64 { + pack(MANIFEST.as_bytes()) +} + +/// A block of `size` bytes the host writes the input and the parameters into. Never freed: every +/// run gets a fresh instance. +#[unsafe(no_mangle)] +pub extern "C" fn vc_alloc(size: u32) -> u32 { + let mut block = Vec::::with_capacity(size as usize); + let ptr = block.as_mut_ptr(); + std::mem::forget(block); + ptr as u32 +} + +/// Runs the filter: `input` holds `{"objects": [...]}`, `params` the parameters. Returns the +/// output JSON as `len << 32 | ptr`, or a negative error code. +/// +/// # Safety +/// The host guarantees both blocks came from `vc_alloc` and hold `*_len` bytes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn vc_run(input: *const u8, input_len: u32, params: *const u8, params_len: u32) -> i64 { + // SAFETY: see the function's contract. + let (input, params) = unsafe { (std::slice::from_raw_parts(input, input_len as usize), std::slice::from_raw_parts(params, params_len as usize)) }; + let (Ok(mut doc), Ok(params)) = (serde_json::from_slice::(input), serde_json::from_slice::(params)) else { return -1 }; + let amount = (params["amount"].as_f64().unwrap_or(100.0) / 100.0).clamp(0.0, 1.0); + if doc["objects"].as_array().is_none_or(|o| o.is_empty()) { + return output(&json!({"error": "Select some paths to desaturate."})); + } + desaturate(&mut doc, amount); + output(&json!({"objects": doc["objects"]})) +} + +/// Every colour (an object with a `model`) inside `v`, moved `k` of the way to grey. +fn desaturate(v: &mut Value, k: f64) { + match v { + Value::Object(o) => { + let model = o.get("model").and_then(Value::as_str).map(str::to_string); + let get = |key: &str| o.get(key).and_then(Value::as_f64).unwrap_or(0.0); + let new: Option> = match model.as_deref() { + Some("rgb") => { + let (r, g, b) = (get("r"), get("g"), get("b")); + let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + Some(vec![("r", r + (y - r) * k), ("g", g + (y - g) * k), ("b", b + (y - b) * k)]) + } + Some("cmyk") => { + let (c, m, y) = (get("c"), get("m"), get("y")); + let mean = (c + m + y) / 3.0; + Some(vec![("c", c + (mean - c) * k), ("m", m + (mean - m) * k), ("y", y + (mean - y) * k)]) + } + Some("lab") => Some(vec![("a", get("a") * (1.0 - k)), ("b", get("b") * (1.0 - k))]), + _ => None, + }; + match new { + Some(new) => { + for (key, x) in new { + o.insert(key.into(), json!(x)); + } + } + None => o.values_mut().for_each(|c| desaturate(c, k)), + } + } + Value::Array(a) => a.iter_mut().for_each(|c| desaturate(c, k)), + _ => {} + } +} + +/// Keeps `v` serialised in memory for the host to read, as `len << 32 | ptr`. +fn output(v: &Value) -> i64 { + let Ok(bytes) = serde_json::to_vec(v) else { return -2 }; + let bytes = bytes.leak(); + pack(bytes) as i64 +} + +fn pack(bytes: &[u8]) -> u64 { + ((bytes.len() as u64) << 32) | bytes.as_ptr() as u64 +} diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs new file mode 100644 index 000000000..b95f12a0f --- /dev/null +++ b/crates/plugins/src/effect.rs @@ -0,0 +1,130 @@ +//! Live-effect plug-ins: a plug-in of kind `effect` rewrites one object's geometry each time it is +//! drawn, from its appearance stack (an `Effect` record with id `plugin.`). +//! +//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by +//! plug-in installation, parameters and input geometry: the plug-in runs again only when one of +//! them changes. Failures are cached too (the geometry is then drawn unchanged) and reported by +//! [`last_error`]. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::sync::Mutex; + +use serde_json::{Value, json}; +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use crate::objects::{decode, output_geometry}; +use crate::{Error, Kind, Limits, Result, registry}; + +/// The effect id prefix of live-effect plug-ins: `plugin.`. +pub const PREFIX: &str = "plugin."; + +/// Wall-clock budget of one live-effect run (drawing waits for it), in milliseconds. +pub const WALL_TIME_MS: u64 = 5_000; + +/// Cached results kept before the cache starts over. +const CACHE_SIZE: usize = 512; + +/// The plug-in id of effect id `id` (`plugin.`). +pub fn plugin_id(effect_id: &str) -> Option<&str> { + effect_id.strip_prefix(PREFIX) +} + +/// The effect id of plug-in `id`. +pub fn effect_id(plugin_id: &str) -> String { + format!("{PREFIX}{plugin_id}") +} + +/// An installed live-effect plug-in by its effect id. +pub fn installed(effect_id: &str) -> Option> { + registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect) +} + +#[derive(PartialEq)] +struct Key { + serial: u64, + params: String, + bounds: [u64; 4], + rule: FillRule, + path: PathData, +} + +impl Key { + fn hash64(&self) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (self.serial, &self.params, self.bounds, self.rule).hash(&mut h); + for sp in &self.path.subpaths { + sp.closed.hash(&mut h); + for a in &sp.anchors { + for p in [a.p, a.h_in, a.h_out] { + (p.x.to_bits(), p.y.to_bits()).hash(&mut h); + } + } + } + h.finish() + } +} + +type Cache = HashMap)>; + +static CACHE: Mutex> = Mutex::new(None); +static ERRORS: Mutex>> = Mutex::new(None); + +/// The last error of live-effect plug-in `id` (cleared when it next succeeds). +pub fn last_error(id: &str) -> Option { + ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned() +} + +fn note(id: &str, r: &Result) { + let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner()); + let errors = errors.get_or_insert_with(HashMap::new); + match r { + Ok(_) => errors.remove(id), + Err(e) => errors.insert(id.to_string(), e.to_string()), + }; +} + +/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`): the new geometry, +/// or `None` when the plug-in isn't installed, isn't an effect, or fails (the caller then keeps +/// the geometry). +pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule) -> Option { + let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?; + let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?); + let key = Key { + serial: plugin.serial(), + params: resolved.to_string(), + bounds: [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(f64::to_bits), + rule, + path: path.clone(), + }; + let h = key.hash64(); + if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&h)) + && *k == key + { + return out.clone(); + } + let r = run(&plugin, resolved, path, bounds, rule); + note(id, &r); + let out = r.ok(); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let cache = cache.get_or_insert_with(HashMap::new); + if cache.len() >= CACHE_SIZE { + cache.clear(); + } + cache.insert(h, (key, out.clone())); + out +} + +fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule) -> Result { + let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]); + params["_context"] = json!({"mode": "effect", "bounds": b}); + let rule = match rule { + FillRule::NonZero => "nonzero", + FillRule::EvenOdd => "evenodd", + }; + let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b}]}); + let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(¶ms)); + let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) }; + let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() }; + Ok(output_geometry(decode(&plugin.run_limited(&input, ¶ms, &limits)?)?)) +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs new file mode 100644 index 000000000..beafd045b --- /dev/null +++ b/crates/plugins/src/lib.rs @@ -0,0 +1,59 @@ +//! Sandboxed WebAssembly plug-ins for VectorCraft. +//! +//! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented +//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: +//! +//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that +//! imports anything is rejected; +//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is +//! also enforced (native builds), so infinite loops end with an error; +//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; +//! - a **fresh instance per run**, so no state leaks between calls. +//! +//! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing +//! a module does can panic or hang the host. +//! +//! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with +//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns +//! the objects to keep, change or add, applied to the document as one edit +//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each +//! time it is drawn, from an appearance stack. The process-wide [`registry`] holds the installed +//! plug-ins. +#![forbid(unsafe_code)] + +pub mod effect; +pub mod manifest; +pub mod objects; +pub mod registry; +mod runtime; +pub mod wat; + +pub use manifest::{Kind, Manifest, ParamSpec}; +pub use runtime::{ABI_VERSION, Limits, Plugin}; + +/// A plug-in error. Every way a module can misbehave ends here, never in a panic. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("not a valid plug-in module: {0}")] + Module(String), + #[error("plug-in ABI: {0}")] + Abi(String), + #[error("plug-in manifest: {0}")] + Manifest(String), + #[error("plug-in trapped: {0}")] + Trap(String), + #[error("plug-in exceeded its {0}")] + Limit(String), + #[error("plug-in parameters: {0}")] + Params(String), + #[error("plug-in output: {0}")] + Output(String), + #[error("plug-in failed: {0}")] + Failed(String), + #[error("no plug-in {0:?} is installed")] + NotFound(String), + #[error("{0}")] + Io(String), +} + +pub type Result = std::result::Result; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs new file mode 100644 index 000000000..e38bb5796 --- /dev/null +++ b/crates/plugins/src/manifest.rs @@ -0,0 +1,331 @@ +//! The plug-in manifest (`vc_manifest`) and parameter schema. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::{Error, Result}; + +/// Longest manifest the host reads. +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; +/// Most parameters a plug-in may declare. +pub const MAX_PARAMS: usize = 64; + +/// What a plug-in is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kind { + /// An object filter: reads the selected paths and returns the objects to keep, change or add + /// (Object › Plug-ins, `plugin.run`). + Filter, + /// A live effect: rewrites one object's geometry each time it is drawn, from its appearance + /// stack (Effect › Plug-ins, `effect.apply`). + Effect, +} + +impl Kind { + pub fn name(self) -> &'static str { + match self { + Kind::Filter => "filter", + Kind::Effect => "effect", + } + } +} + +/// One declared parameter. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ParamSpec { + /// A real number in `min..=max`. + Number { min: f64, max: f64, default: f64 }, + /// A whole number in `min..=max`. + Int { + #[serde(default = "int_min")] + min: i64, + #[serde(default = "int_max")] + max: i64, + #[serde(default)] + default: i64, + }, + /// A checkbox. + Bool { + #[serde(default)] + default: bool, + }, + /// One of `options` (a dropdown). + Choice { + options: Vec, + #[serde(default)] + default: Option, + }, +} + +fn int_min() -> i64 { + -1_000_000 +} +fn int_max() -> i64 { + 1_000_000 +} + +impl ParamSpec { + /// The value a parameter takes when it isn't given. + pub fn default_value(&self) -> Value { + match self { + ParamSpec::Number { default, .. } => json!(default), + ParamSpec::Int { default, .. } => json!(default), + ParamSpec::Bool { default } => json!(default), + ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()), + } + } +} + +/// The manifest a plug-in returns from `vc_manifest` (UTF-8 JSON). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + /// Stable id, `[A-Za-z0-9._-]{1,64}` (reverse-DNS style is recommended: `org.example.tidy`). + pub id: String, + /// Menu label. + pub name: String, + #[serde(default)] + pub version: String, + pub kind: Kind, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + /// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest. + #[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")] + pub params: Vec<(String, ParamSpec)>, +} + +impl Manifest { + /// Parses and validates manifest JSON. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_MANIFEST_BYTES { + return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len()))); + } + let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?; + let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?; + man.validate()?; + Ok(man) + } + + /// The manifest of a module still being loaded. + pub(crate) fn placeholder() -> Manifest { + Manifest { + id: String::new(), + name: String::new(), + version: String::new(), + kind: Kind::Filter, + description: String::new(), + author: String::new(), + params: Vec::new(), + } + } + + fn validate(&self) -> Result<()> { + if !valid_id(&self.id) { + return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); + } + let name = self.name.trim(); + if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) { + return Err(Error::Manifest("`name` must be 1-64 printable characters".into())); + } + if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 { + return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); + } + for (k, spec) in &self.params { + if !valid_key(k) { + return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); + } + match spec { + ParamSpec::Number { min, max, default } => { + if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max { + return Err(Error::Manifest(format!("parameter `{k}`: bad range"))); + } + } + ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))), + ParamSpec::Choice { options, default } => { + if options.is_empty() + || options.len() > 64 + || options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control)) + { + return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings"))); + } + if default.as_ref().is_some_and(|d| !options.contains(d)) { + return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options"))); + } + } + _ => {} + } + } + Ok(()) + } + + /// The parameters documented the way command and effect parameters are + /// (`{amount: 0..100 (50), mode: "soft"|"hard" (soft), mono: bool (false)}`). + pub fn params_doc(&self) -> String { + let parts: Vec = self + .params + .iter() + .map(|(k, spec)| match spec { + ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({default})"), + ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({default})"), + ParamSpec::Bool { default } => format!("{k}: bool ({default})"), + ParamSpec::Choice { options, .. } => { + let opts: Vec = options.iter().map(|o| format!("{o:?}")).collect(); + format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default()) + } + }) + .collect(); + format!("{{{}}}", parts.join(", ")) + } + + /// Every parameter at its default value. + pub fn defaults(&self) -> Map { + self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect() + } + + /// Validates user `params` against the schema: missing keys take their defaults, numbers are + /// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong + /// type is an error. + pub fn resolve_params(&self, params: &Value) -> Result> { + let given = match params { + Value::Null => &Map::new(), + Value::Object(m) => m, + _ => return Err(Error::Params("plug-in parameters must be a JSON object".into())), + }; + let mut out = Map::new(); + for (k, spec) in &self.params { + let Some(v) = given.get(k).filter(|v| !v.is_null()) else { + out.insert(k.clone(), spec.default_value()); + continue; + }; + let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}")); + let resolved = match spec { + ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)), + ParamSpec::Int { min, max, .. } => { + let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?; + json!((f.round().clamp(*min as f64, *max as f64)) as i64) + } + ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?), + ParamSpec::Choice { options, .. } => { + let s = v.as_str().ok_or_else(|| wrong("a string"))?; + if !options.iter().any(|o| o == s) { + return Err(Error::Params(format!("`{k}` must be one of {}", options.join(", ")))); + } + json!(s) + } + }; + out.insert(k.clone(), resolved); + } + Ok(out) + } +} + +/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it). +fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Vec<(String, ParamSpec)>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("an object of parameter specs") + } + fn visit_unit(self) -> std::result::Result { + Ok(Vec::new()) + } + fn visit_map>(self, mut map: A) -> std::result::Result { + let mut out: Vec<(String, ParamSpec)> = Vec::new(); + while let Some((k, v)) = map.next_entry::()? { + if out.len() >= MAX_PARAMS { + return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})"))); + } + if out.iter().any(|(o, _)| *o == k) { + return Err(serde::de::Error::custom(format!("parameter `{k}` is declared twice"))); + } + out.push((k, v)); + } + Ok(out) + } + } + d.deserialize_any(V) +} + +fn params_as_object(p: &[(String, ParamSpec)], s: S) -> std::result::Result { + use serde::ser::SerializeMap; + let mut m = s.serialize_map(Some(p.len()))?; + for (k, v) in p { + m.serialize_entry(k, v)?; + } + m.end() +} + +/// `[A-Za-z0-9._-]{1,64}`. +pub fn valid_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) +} + +/// `[A-Za-z0-9_]{1,64}`, not starting with `_` and not a key the commands and dialogs use +/// themselves. +fn valid_key(k: &str) -> bool { + (1..=64).contains(&k.len()) + && k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + && !k.starts_with('_') + && !matches!(k, "id" | "ids" | "params" | "preview") +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL: &str = r#"{"id":"org.example.tone","name":"Tone","version":"1.2","kind":"effect", + "params":{"amount":{"type":"number","min":0,"max":100,"default":50}, + "mode":{"type":"choice","options":["soft","hard"],"default":"hard"}, + "mono":{"type":"bool"},"seed":{"type":"int","min":0,"max":9}}}"#; + + #[test] + fn parses_and_keeps_param_order() { + let m = Manifest::parse(FULL.as_bytes()).unwrap(); + assert_eq!(m.id, "org.example.tone"); + assert_eq!(m.kind, Kind::Effect); + assert_eq!(m.params.iter().map(|p| p.0.as_str()).collect::>(), ["amount", "mode", "mono", "seed"]); + assert_eq!(m.params_doc(), r#"{amount: 0..100 (50), mode: "soft"|"hard" (hard), mono: bool (false), seed: int 0..9 (0)}"#); + assert_eq!(Value::Object(m.defaults()), json!({"amount": 50.0, "mode": "hard", "mono": false, "seed": 0})); + } + + #[test] + fn resolves_defaults_clamps_and_rejects_wrong_types() { + let m = Manifest::parse(FULL.as_bytes()).unwrap(); + let p = m.resolve_params(&json!({"amount": 500, "seed": 3.6, "junk": 1})).unwrap(); + assert_eq!(Value::Object(p), json!({"amount": 100.0, "mode": "hard", "mono": false, "seed": 4})); + assert!(m.resolve_params(&json!({"amount": "lots"})).is_err()); + assert!(m.resolve_params(&json!({"mode": "medium"})).is_err()); + assert!(m.resolve_params(&json!({"mono": 1})).is_err()); + assert!(m.resolve_params(&json!([1, 2])).is_err()); + assert!(m.resolve_params(&Value::Null).is_ok()); + } + + #[test] + fn rejects_bad_manifests() { + for bad in [ + "", + "[]", + "not json", + r#"{"id":"","name":"x","kind":"filter"}"#, + r#"{"id":"a b","name":"x","kind":"filter"}"#, + r#"{"id":"a","name":"","kind":"filter"}"#, + r#"{"id":"a","name":"x","kind":"panel"}"#, + r#"{"id":"a","name":"x"}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#, + r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#, + ] { + assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}"); + } + assert!(Manifest::parse(&[0xff, 0xfe]).is_err()); + assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err()); + } +} diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs new file mode 100644 index 000000000..7c0d60f02 --- /dev/null +++ b/crates/plugins/src/objects.rs @@ -0,0 +1,597 @@ +//! The object model plug-ins read and write: paths and compound paths as JSON. +//! +//! The host hands a plug-in `{"objects": [...]}` (see [`encode`]) and reads the same shape back +//! ([`decode`]). Geometry and paint use the native file format's forms (`PathData`, `Paint`), so +//! nothing is lost on the way through; everything a plug-in returns is validated (finite, +//! in-range coordinates, capped counts, clamped colours) before it reaches the document. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData, Point, Rect}; + +use crate::{Error, Result}; + +/// Coordinates a plug-in may return, in points (the document's own canvas limit). +pub const MAX_COORD: f64 = 4.0e6; +/// Most objects one output may hold. +pub const MAX_OBJECTS: usize = 100_000; +/// Most anchor points one output may hold, over all objects. +pub const MAX_ANCHORS: usize = 4_000_000; +/// Most fills or strokes per object, gradient stops per gradient and freeform points or lines +/// per freeform gradient. +pub const MAX_ITEMS: usize = 256; +/// Thickest stroke a plug-in may set, in points. +pub const MAX_STROKE_WIDTH: f64 = 1000.0; + +/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the +/// defaults: a path, non-zero rule, no fills or strokes, full opacity). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OutObject { + /// The input object this updates; `None`: a new object. + pub id: Option, + /// `"compound"` (true) or `"path"` (false). + pub compound: Option, + /// `Some("")` clears the name. + pub name: Option, + pub path: Option, + pub rule: Option, + /// Fill paints in paint order (bottom first). + pub fills: Option>, + pub strokes: Option>, + pub opacity: Option, +} + +/// One stroke of an [`OutObject`]. +#[derive(Clone, Debug, PartialEq)] +pub struct OutStroke { + pub paint: Paint, + /// `None`: keep the stroke's weight (1 pt for a new stroke). + pub width: Option, +} + +fn rule_name(r: FillRule) -> &'static str { + match r { + FillRule::NonZero => "nonzero", + FillRule::EvenOdd => "evenodd", + } +} + +fn rect_json(r: Option) -> Value { + r.map_or(Value::Null, |r| json!([r.x0, r.y0, r.x1, r.y1])) +} + +fn paint_json(p: &Paint) -> Value { + match p { + Paint::None => Value::Null, + p => serde_json::to_value(p).unwrap_or(Value::Null), + } +} + +/// The geometry of a path or compound path (a compound's members' subpaths in order) and its fill +/// rule. `None` for other objects. +pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, false)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule, true)) + } + _ => None, + } +} + +/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path, +/// fillRule, fills, strokes, opacity, bounds}`. +pub fn encode(n: &Node) -> Option { + let (path, rule, compound) = geometry(n)?; + let fills: Vec = + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect(); + let strokes: Vec = n + .appearance + .items + .iter() + .filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(json!({"paint": paint_json(&s.paint), "width": s.width})) } else { None }) + .collect(); + let mut o = json!({ + "id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule), + "fills": fills, "strokes": strokes, "opacity": n.opacity, "bounds": rect_json(path.bounds()), + }); + if let Some(name) = &n.name { + o["name"] = json!(name); + } + Some(o) +} + +/// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths; +/// others are left out). +pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec { + let objects: Vec = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect(); + serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default() +} + +fn out_err(at: &str, msg: impl std::fmt::Display) -> Error { + Error::Output(format!("{at}: {msg}")) +} + +fn check_point(p: Point, at: &str) -> Result<()> { + if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD { + Ok(()) + } else { + Err(out_err(at, format!("coordinate out of range (limit ±{MAX_COORD} pt)"))) + } +} + +/// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty +/// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`]. +fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result { + let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; + path.subpaths.retain(|sp| !sp.anchors.is_empty()); + *anchors = anchors.saturating_add(path.anchor_count()); + if *anchors > MAX_ANCHORS { + return Err(out_err(at, format!("too many anchor points (limit {MAX_ANCHORS})"))); + } + for (_, _, a) in path.anchors() { + for p in [a.p, a.h_in, a.h_out] { + check_point(p, at)?; + } + } + if path.is_empty() { + return Err(out_err(at, "the path has no anchor points")); + } + Ok(path) +} + +fn unit(v: f32) -> f32 { + v.clamp(0.0, 1.0) +} + +/// `c` with its components in range. +fn clamp_color(c: Color) -> Color { + match c { + Color::Rgb { r, g, b } => Color::Rgb { r: unit(r), g: unit(g), b: unit(b) }, + Color::Cmyk { c, m, y, k } => Color::Cmyk { c: unit(c), m: unit(m), y: unit(y), k: unit(k) }, + Color::Gray { k } => Color::Gray { k: unit(k) }, + Color::Lab { l, a, b } => Color::Lab { l: l.clamp(0.0, 100.0), a: a.clamp(-128.0, 127.0), b: b.clamp(-128.0, 127.0) }, + } +} + +/// A paint from plug-in output (`null`: none), with colours, opacities and offsets clamped and +/// gradient and pattern placements checked. +fn decode_paint(v: &Value, at: &str) -> Result { + if v.is_null() { + return Ok(Paint::None); + } + let mut p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?; + match &mut p { + Paint::None => {} + Paint::Solid { color, tint, .. } => { + *color = clamp_color(*color); + *tint = unit(*tint); + } + Paint::Gradient(g) => { + let stops = &mut g.gradient.stops; + if stops.is_empty() || stops.len() > MAX_ITEMS { + return Err(out_err(at, format!("a gradient needs 1-{MAX_ITEMS} stops"))); + } + for s in stops.iter_mut() { + s.offset = unit(s.offset); + s.opacity = unit(s.opacity); + s.midpoint = s.midpoint.clamp(0.13, 0.87); + s.tint = unit(s.tint); + s.color = clamp_color(s.color); + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + g.angle = g.angle.rem_euclid(360.0); + if let Some(geom) = &mut g.geom { + check_point(geom.start, at)?; + check_point(geom.end, at)?; + if let Some(f) = geom.focal { + check_point(f, at)?; + } + geom.aspect = if geom.aspect.is_finite() { geom.aspect.clamp(0.01, 100.0) } else { 1.0 }; + } + if let Some(ff) = &mut g.freeform { + if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) { + return Err(out_err(at, format!("a freeform gradient holds at most {MAX_ITEMS} points and lines"))); + } + let n = ff.points.len(); + if ff.lines.iter().flatten().any(|i| *i >= n) { + return Err(out_err(at, "a freeform gradient line names a point it doesn't have")); + } + for pt in &mut ff.points { + check_point(pt.at, at)?; + pt.color = clamp_color(pt.color); + pt.opacity = unit(pt.opacity); + pt.spread = unit(pt.spread); + } + } + } + Paint::Pattern { xf, .. } => { + if !xf.as_coeffs().iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) { + return Err(out_err(at, "pattern transform out of range")); + } + } + } + Ok(p) +} + +fn opt<'a>(o: &'a serde_json::Map, key: &str) -> Option<&'a Value> { + o.get(key).filter(|v| !v.is_null()) +} + +fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result { + let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?; + let mut out = OutObject::default(); + if let Some(id) = opt(o, "id") { + out.id = Some(NodeId(id.as_u64().ok_or_else(|| out_err(at, "`id` must be the id of an input object"))?)); + } + if let Some(t) = opt(o, "type") { + out.compound = Some(match t.as_str() { + Some("path") => false, + Some("compound") => true, + _ => return Err(out_err(at, "`type` must be \"path\" or \"compound\"")), + }); + } + if let Some(n) = o.get("name") { + let name = match n { + Value::Null => String::new(), + n => n.as_str().ok_or_else(|| out_err(at, "`name` must be a string"))?.chars().filter(|c| !c.is_control()).take(256).collect(), + }; + out.name = Some(name); + } + if let Some(p) = opt(o, "path") { + out.path = Some(decode_path(p, &format!("{at}.path"), anchors)?); + } + if let Some(r) = opt(o, "fillRule") { + out.rule = Some(match r.as_str().map(str::to_ascii_lowercase).as_deref() { + Some("nonzero") => FillRule::NonZero, + Some("evenodd") => FillRule::EvenOdd, + _ => return Err(out_err(at, "`fillRule` must be \"nonzero\" or \"evenodd\"")), + }); + } + let list = |key: &str| -> Result>> { + match opt(o, key) { + None => Ok(None), + Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)), + Some(_) => Err(out_err(at, format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))), + } + }; + if let Some(fills) = list("fills")? { + out.fills = Some(fills.iter().enumerate().map(|(i, f)| decode_paint(f, &format!("{at}.fills[{i}]"))).collect::>()?); + } + if let Some(strokes) = list("strokes")? { + let mut v = Vec::with_capacity(strokes.len()); + for (i, s) in strokes.iter().enumerate() { + let at = format!("{at}.strokes[{i}]"); + let s = s.as_object().ok_or_else(|| out_err(&at, "a stroke must be {\"paint\", \"width\"}"))?; + let paint = decode_paint(s.get("paint").unwrap_or(&Value::Null), &at)?; + let width = match opt(s, "width") { + None => None, + Some(w) => Some(w.as_f64().ok_or_else(|| out_err(&at, "`width` must be a number"))?.clamp(0.0, MAX_STROKE_WIDTH)), + }; + v.push(OutStroke { paint, width }); + } + out.strokes = Some(v); + } + if let Some(op) = opt(o, "opacity") { + out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32)); + } + Ok(out) +} + +/// Parses a plug-in's output: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in +/// declined (the message reaches the user). +pub fn decode(bytes: &[u8]) -> Result> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; + if let Some(e) = v.get("error").filter(|e| !e.is_null()) { + let msg: String = e.as_str().map_or_else(|| e.to_string(), str::to_string).chars().filter(|c| !c.is_control()).take(300).collect(); + return Err(Error::Failed(msg)); + } + let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?; + if objects.len() > MAX_OBJECTS { + return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len()))); + } + let mut anchors = 0; + objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect() +} + +/// The paints of `ap`'s fills (`fill`) or strokes, in paint order. +fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> { + ap.items.iter().filter(|i| i.is_fill() == fill).map(AppearanceItem::paint).collect() +} + +/// `p` without swatch links: a plug-in's colours are its own, so only paints it returned +/// unchanged keep theirs. +fn unlinked(mut p: Paint) -> Paint { + match &mut p { + Paint::Solid { swatch, tint, .. } => { + *swatch = None; + *tint = 1.0; + } + Paint::Gradient(g) => { + g.swatch = None; + for s in &mut g.gradient.stops { + s.swatch = None; + s.tint = 1.0; + } + } + Paint::None | Paint::Pattern { .. } => {} + } + p +} + +/// `p` as it may enter `doc`: unchanged from `was` (the paint in the same slot before), or +/// without swatch links; a pattern must exist in the document. +fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result { + if let Paint::Pattern { pattern, .. } = &p + && doc.pattern(pattern).is_none() + { + return Err(out_err(at, format!("the document has no pattern {pattern:?}"))); + } + Ok(if was == Some(&p) { p } else { unlinked(p) }) +} + +/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new`, in paint order: extra items are +/// removed (from the top), missing ones added above the last item of their kind (fills at the +/// bottom, strokes on top when the object has none). +fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option)>) { + let idx: Vec = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect(); + for &i in idx.iter().skip(new.len()).rev() { + ap.remove_item(i); + } + let kept = new.len().min(idx.len()); + let mut at = idx.get(..kept).and_then(<[usize]>::last).map_or(if fill { 0 } else { ap.items.len() }, |i| i + 1); + for (k, (paint, width)) in new.into_iter().enumerate() { + match idx.get(k).and_then(|&i| ap.items.get_mut(i)) { + Some(AppearanceItem::Fill(f)) => f.paint = paint, + Some(AppearanceItem::Stroke(s)) => { + s.paint = paint; + if let Some(w) = width { + s.width = w; + } + } + None => { + let item = if fill { + AppearanceItem::Fill(FillLayer::new(paint)) + } else { + AppearanceItem::Stroke(StrokeLayer::new(paint, width.unwrap_or(1.0))) + }; + ap.insert_item(at, item); + at += 1; + } + } + } +} + +/// A compound path's members: one path per subpath of `path`, keeping the ids of `old` members. +fn members(doc: &mut Document, old: &[Arc], path: PathData) -> Vec> { + path.subpaths + .into_iter() + .enumerate() + .map(|(i, sp)| { + let id = old.get(i).map_or_else(|| doc.alloc_id(), |c| c.id); + Arc::new(Node::path(id, PathData::single(sp), Appearance::default())) + }) + .collect() +} + +/// Writes `o` into `n` (an input object, or a fresh node for a new one). +fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> { + let (cur_path, cur_rule, cur_compound) = geometry(n).unwrap_or((PathData::default(), FillRule::NonZero, false)); + let compound = o.compound.unwrap_or(cur_compound); + let rule = o.rule.unwrap_or(cur_rule); + match (&mut n.kind, compound) { + (NodeKind::Path { path, rule: r, live, .. }, false) => { + if let Some(p) = o.path { + *path = p; + *live = None; + } + *r = rule; + } + (NodeKind::Compound { children, rule: r }, true) => { + if let Some(p) = o.path { + *children = members(doc, children, p); + } + *r = rule; + } + (NodeKind::Compound { .. }, false) => { + n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }; + } + (_, true) => n.kind = NodeKind::Compound { children: members(doc, &[], o.path.unwrap_or(cur_path)), rule }, + (_, false) => n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }, + } + for (fill, new) in [ + (true, o.fills.map(|f| f.into_iter().map(|p| (p, None)).collect::>())), + (false, o.strokes.map(|s| s.into_iter().map(|s| (s.paint, s.width)).collect())), + ] { + let Some(new) = new else { continue }; + let was = paints(&n.appearance, fill); + let admitted = new + .into_iter() + .enumerate() + .map(|(i, (p, w))| Ok((admit(doc, p, was.get(i).copied(), &format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }))?, w))) + .collect::>>()?; + set_items(&mut n.appearance, fill, admitted); + } + if let Some(op) = o.opacity { + n.opacity = op; + } + if let Some(name) = o.name { + n.name = Some(name).filter(|s| !s.trim().is_empty()); + } + Ok(()) +} + +/// The paths and compound paths a filter reads from the selection `selected`, in paint order: +/// selected ones, and those inside selected groups and layers; hidden, locked, guide and clipping +/// paths are left out (so are paths inside compound paths, blends and other live objects). +pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec { + fn visit(list: &[Arc], sel: &HashSet, inside: bool, out: &mut Vec) { + for n in list { + if !n.visible || n.locked { + continue; + } + let picked = inside || sel.contains(&n.id); + match &n.kind { + NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked => out.push(n.id), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out), + _ => {} + } + } + } + let sel: HashSet = selected.iter().copied().collect(); + let mut out = vec![]; + visit(&doc.layers, &sel, false, &mut out); + out +} + +/// The tree edits of [`apply_output`], made in one pass over the document. +#[derive(Default)] +struct Plan { + replace: HashMap, + delete: HashSet, + before: HashMap>, + after: HashMap>, + /// New objects that go on top of a container (`None`: the top-level list). + top: Option<(Option, Vec)>, +} + +impl Plan { + fn pending(&self) -> bool { + !(self.replace.is_empty() && self.delete.is_empty() && self.before.is_empty() && self.after.is_empty() && self.top.is_none()) + } + + /// The edited `list` (the children of `parent`), `None` when nothing in it changed. + fn list(&mut self, parent: Option, list: &[Arc]) -> Option>> { + let mut out: Option>> = None; + for (i, c) in list.iter().enumerate() { + if !self.pending() { + if let Some(o) = &mut out { + o.extend(list.iter().skip(i).cloned()); + } + return out; + } + let mut emitted: Vec> = self.before.remove(&c.id).unwrap_or_default().into_iter().map(Arc::new).collect(); + let mut changed = !emitted.is_empty(); + if self.delete.remove(&c.id) { + changed = true; + } else if let Some(r) = self.replace.remove(&c.id) { + emitted.push(Arc::new(r)); + changed = true; + } else if let Some(ch) = match &c.kind { + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => self.list(Some(c.id), children), + _ => None, + } { + let mut m = (**c).clone(); + if let Some(slot) = m.children_mut() { + *slot = ch; + } + emitted.push(Arc::new(m)); + changed = true; + } else { + emitted.push(c.clone()); + } + if let Some(a) = self.after.remove(&c.id) { + emitted.extend(a.into_iter().map(Arc::new)); + changed = true; + } + if changed && out.is_none() { + out = Some(list.iter().take(i).cloned().collect()); + } + if let Some(o) = &mut out { + o.extend(emitted); + } + } + if self.top.as_ref().is_some_and(|(p, _)| *p == parent) + && let Some((_, nodes)) = self.top.take() + { + out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); + } + out + } +} + +/// Applies an object filter's output to `doc`. +/// +/// `inputs` are the objects the plug-in was given, in paint order ([`filter_targets`]). Output +/// objects with an `id` update that input object (keys they leave out keep their values); objects +/// without one are new and go just above the object listed before them, or, listed before any +/// input object, just below the first one listed after them (at the first input object's place +/// when the output keeps none; on top of `parent` when there were no inputs). Input objects missing +/// from the output are deleted. Returns the resulting objects in output order (to select). +pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec, parent: Option) -> Result> { + let wanted: HashSet = inputs.iter().copied().collect(); + let mut originals: HashMap = HashMap::new(); + doc.walk(|n| { + if wanted.contains(&n.id) { + originals.insert(n.id, n.clone()); + } + }); + let mut plan = Plan::default(); + let mut result = Vec::with_capacity(out.len()); + // The run of new objects since the last input object listed, and that object. + let mut run: Vec = vec![]; + let mut prev: Option = None; + for (i, o) in out.into_iter().enumerate() { + let at = format!("objects[{i}]"); + match o.id { + Some(id) => { + let mut n = originals.remove(&id).ok_or_else(|| { + let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; + out_err(&at, format!("{} {why}", id.0)) + })?; + write(doc, &mut n, o, &at)?; + if !run.is_empty() { + let nodes = std::mem::take(&mut run); + match prev { + Some(p) => plan.after.insert(p, nodes), + None => plan.before.insert(id, nodes), + }; + } + plan.replace.insert(id, n); + prev = Some(id); + result.push(id); + } + None => { + if o.path.is_none() { + return Err(out_err(&at, "a new object needs a `path`")); + } + let mut n = Node::path(doc.alloc_id(), PathData::default(), Appearance::default()); + write(doc, &mut n, o, &at)?; + result.push(n.id); + run.push(n); + } + } + } + if !run.is_empty() { + match prev.or(inputs.first().copied()) { + Some(p) if prev.is_some() => { + plan.after.insert(p, run); + } + Some(first) => { + plan.before.insert(first, run); + } + None => plan.top = Some((parent, run)), + } + } + // Input objects the output left out are deleted. + plan.delete = originals.into_keys().collect(); + if let Some(list) = plan.list(None, &doc.layers) { + doc.layers = list; + } + if plan.pending() { + return Err(Error::Output("the objects to change are no longer in the document".into())); + } + Ok(result) +} + +/// The geometry of an effect's output: every returned object's path, in order. +pub fn output_geometry(objects: Vec) -> PathData { + PathData::new(objects.into_iter().filter_map(|o| o.path).flat_map(|p| p.subpaths).collect()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs new file mode 100644 index 000000000..80f16ff11 --- /dev/null +++ b/crates/plugins/src/objects/tests.rs @@ -0,0 +1,160 @@ +use super::*; +use vectorcraft_geom::SubPath; + +fn square(x: f64) -> PathData { + PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)], true)) +} + +/// A document with paths A, B and C (bottom to top) on its layer, plus a locked path and a guide. +fn doc() -> (Document, Vec, NodeId) { + let mut d = Document::new(200.0, 200.0); + let layer = d.layers[0].id; + let mut ids = vec![]; + for (i, name) in ["A", "B", "C"].into_iter().enumerate() { + let mut n = Node::path(d.alloc_id(), square(i as f64 * 20.0), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0)); + n.name = Some(name.into()); + ids.push(d.insert(Some(layer), usize::MAX, n).unwrap()); + } + let mut locked = Node::path(d.alloc_id(), square(80.0), Appearance::default()); + locked.locked = true; + d.insert(Some(layer), usize::MAX, locked).unwrap(); + let guide = Node::new(d.alloc_id(), NodeKind::Path { path: square(90.0), rule: FillRule::NonZero, live: None, clipping: false, guide: true }); + d.insert(Some(layer), usize::MAX, guide).unwrap(); + (d, ids, layer) +} + +fn names(d: &Document, layer: NodeId) -> Vec { + d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect() +} + +fn decode_json(v: Value) -> Result> { + decode(v.to_string().as_bytes()) +} + +#[test] +fn targets_are_selected_paths_in_paint_order() { + let (mut d, ids, layer) = doc(); + assert_eq!(filter_targets(&d, &[ids[2], ids[0]]), [ids[0], ids[2]]); + // A selected layer gives its paths, without the locked one and the guide. + assert_eq!(filter_targets(&d, &[layer]), ids); + d.node_mut(ids[1]).unwrap().visible = false; + assert_eq!(filter_targets(&d, &[layer]), [ids[0], ids[2]]); +} + +#[test] +fn objects_round_trip_through_the_json_model() { + let (mut d, ids, _) = doc(); + let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); + g.gradient.stops[0].swatch = Some("White".into()); + d.node_mut(ids[0]).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(g))); + let input: Value = serde_json::from_slice(&encode_input(&d, &ids)).unwrap(); + let a = &input["objects"][0]; + assert_eq!((a["type"].as_str(), a["name"].as_str(), a["fillRule"].as_str()), (Some("path"), Some("A"), Some("nonzero"))); + assert_eq!(a["fills"][0]["color"], json!({"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0})); + assert_eq!(a["strokes"][0]["paint"]["type"], "gradient"); + assert_eq!(a["bounds"], json!([0.0, 0.0, 10.0, 10.0])); + // Echoed back unchanged, nothing changes (swatch links included). + let before = d.clone(); + let out = decode(&serde_json::to_vec(&input).unwrap()).unwrap(); + assert_eq!(apply_output(&mut d, &ids, out, None).unwrap(), ids); + assert_eq!(d, before); +} + +#[test] +fn outputs_update_add_and_delete_in_place() { + let (mut d, ids, layer) = doc(); + let [a, b, c] = [ids[0].0, ids[1].0, ids[2].0]; + let new = |name: &str| json!({"name": name, "path": square(5.0), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}}]}); + // A recoloured, B dropped, C kept; new objects around them. + let out = json!({"objects": [new("n1"), {"id": a, "fills": [null, {"type": "solid", "color": {"model": "rgb", "r": 5, "g": 0, "b": 0}}]}, new("n2"), {"id": c}, new("n3")]}); + let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None).unwrap(); + assert_eq!(names(&d, layer), ["n1", "A", "n2", "C", "n3", "?", "?"]); + assert_eq!(sel.len(), 5); + assert!(d.node(NodeId(b)).is_none()); + let na = d.node(NodeId(a)).unwrap(); + let fills = paints(&na.appearance, true); + assert_eq!(fills, [&Paint::None, &Paint::solid(Color::rgb(1.0, 0.0, 0.0))], "a second fill was added; colours are clamped"); + let n1 = d.node(sel[0]).unwrap(); + assert_eq!(n1.appearance.fill_paint(), Paint::solid(Color::gray(0.5))); + assert!(n1.appearance.stroke().is_none()); +} + +#[test] +fn new_objects_take_the_place_of_replaced_inputs_or_go_on_top() { + let (mut d, ids, layer) = doc(); + let out = json!({"objects": [{"name": "X", "path": square(0.0)}]}); + apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None).unwrap(); + assert_eq!(names(&d, layer), ["A", "X", "?", "?"], "B and C replaced by X at B's place"); + // No inputs (a generator): on top of the given container. + let (mut d, _, layer) = doc(); + apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer)).unwrap(); + assert_eq!(names(&d, layer).last().map(String::as_str), Some("X")); +} + +#[test] +fn geometry_type_and_strokes_change() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect()); + let blue = json!({"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}}); + let out = json!({"objects": [{"id": a.0, "type": "compound", "path": two, "fillRule": "evenodd", "strokes": [{"paint": blue, "width": 4000}], "opacity": 0.5, "name": ""}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap(); + let n = d.node(a).unwrap(); + let NodeKind::Compound { children, rule } = &n.kind else { panic!("not a compound: {:?}", n.kind) }; + assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd)); + assert_eq!(n.appearance.stroke().unwrap().width, MAX_STROKE_WIDTH); + assert_eq!((n.opacity, n.name.as_deref()), (0.5, None)); + assert_eq!(geometry(n).unwrap().0, two); + // And back to a single path; strokes removed. + let out = json!({"objects": [{"id": a.0, "type": "path", "strokes": []}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap(); + let n = d.node(a).unwrap(); + assert!(matches!(&n.kind, NodeKind::Path { path, .. } if *path == two)); + assert!(n.appearance.stroke().is_none()); +} + +#[test] +fn changed_paints_lose_swatch_links_and_patterns_must_exist() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + d.node_mut(a).unwrap().appearance.set_fill(Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 }); + let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Red", "tint": 0.5}); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None).unwrap(); + assert!(matches!(d.node(a).unwrap().appearance.fill_paint(), Paint::Solid { swatch: Some(_), .. }), "unchanged: kept"); + let other = json!({"type": "solid", "color": {"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}, "swatch": "Red", "tint": 0.5}); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None).unwrap(); + assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 1.0, 0.0))); + let pattern = json!({"objects": [{"id": a.0, "fills": [{"type": "pattern", "pattern": "Dots"}]}]}); + assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None).is_err()); + d.patterns.push(serde_json::from_value(json!({"name": "Dots", "tile": {"x0": 0, "y0": 0, "x1": 10, "y1": 10}, "art": []})).unwrap()); + assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None).is_ok()); +} + +#[test] +fn bad_outputs_are_refused() { + let (d, ids, _) = doc(); + let a = ids[0].0; + let far = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e9, 0.0)], false)); + for bad in [ + json!([]), + json!({"objects": 3}), + json!({"objects": [7]}), + json!({"objects": [{"id": 999}]}), + json!({"objects": [{"id": a}, {"id": a}]}), + json!({"objects": [{"name": "no path"}]}), + json!({"objects": [{"id": a, "path": far}]}), + json!({"objects": [{"id": a, "path": {"subpaths": []}}]}), + json!({"objects": [{"id": a, "type": "text"}]}), + json!({"objects": [{"id": a, "fillRule": "odd"}]}), + json!({"objects": [{"id": a, "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": []}}]}]}), + json!({"objects": [{"id": a, "fills": [{"type": "solid"}]}]}), + json!({"objects": [{"id": a, "strokes": [3]}]}), + json!({"objects": [{"id": a, "opacity": "full"}]}), + json!({"error": "nothing to do"}), + ] { + let mut d = d.clone(); + let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None)); + assert!(r.is_err(), "{bad}"); + } + assert!(decode(b"\xff\xfe").is_err()); +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs new file mode 100644 index 000000000..a0f18692c --- /dev/null +++ b/crates/plugins/src/registry.rs @@ -0,0 +1,111 @@ +//! The process-wide set of installed plug-ins. +//! +//! Plug-ins are installed per process, not per document: live effects are drawn from every open +//! document (and from scratch sessions such as previews), and they must find the same plug-ins. + +use std::collections::{BTreeMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +#[cfg(not(target_arch = "wasm32"))] +use crate::Error; +use crate::{Limits, Plugin, Result}; + +static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); +static REVISION: AtomicU64 = AtomicU64::new(0); + +/// Installs `plugin`, replacing one with the same id. +pub fn install(mut plugin: Plugin) -> Arc { + plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); + let p = Arc::new(plugin); + PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone()); + p +} + +/// Loads `bytes` with the default limits and installs the plug-in. +pub fn install_bytes(bytes: &[u8]) -> Result> { + Ok(install(Plugin::load(bytes, Limits::default())?)) +} + +pub fn get(id: &str) -> Option> { + PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() +} + +/// Installed plug-ins, sorted by id. +pub fn list() -> Vec> { + PLUGINS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() +} + +/// Uninstalls `id`; false if it wasn't installed. +pub fn remove(id: &str) -> bool { + let gone = PLUGINS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some(); + if gone { + REVISION.fetch_add(1, Ordering::Relaxed); + } + gone +} + +/// Changes whenever a plug-in is installed or removed (for caches such as menus). +pub fn revision() -> u64 { + REVISION.load(Ordering::Relaxed) +} + +/// `s` as a `&'static str`, for catalogues that hold static strings (effect and menu labels). Each +/// distinct string is stored once, so memory stays bounded by the plug-ins ever installed. +pub fn intern(s: &str) -> &'static str { + static SET: Mutex>> = Mutex::new(None); + let mut set = SET.lock().unwrap_or_else(|e| e.into_inner()); + let set = set.get_or_insert_with(HashSet::new); + if let Some(v) = set.get(s) { + return v; + } + let v: &'static str = Box::leak(s.to_string().into_boxed_str()); + set.insert(v); + v +} + +/// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit) +/// and loads it. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_file(path: &std::path::Path, limits: Limits) -> Result { + use std::io::Read; + let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + if !meta.is_file() { + return Err(Error::Io(format!("{} is not a file", path.display()))); + } + if meta.len() > limits.max_module_bytes as u64 { + return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes))); + } + let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + let mut bytes = Vec::new(); + f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; + Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string())) +} + +/// What [`load_folder`] did. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FolderReport { + /// Ids of the plug-ins installed. + pub loaded: Vec, + /// `(file, error)` for each `.wasm` that failed to load. + pub failed: Vec<(String, String)>, +} + +/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is +/// reported and skipped; the others still load. +#[cfg(not(target_arch = "wasm32"))] +pub fn load_folder(dir: &std::path::Path) -> Result { + let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; + let mut files: Vec = + rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); + files.sort(); + files.truncate(1000); + let mut report = FolderReport::default(); + for f in files { + match load_file(&f, Limits::default()) { + Ok(p) => report.loaded.push(install(p).id().to_string()), + Err(e) => report.failed.push((f.display().to_string(), e.to_string())), + } + } + Ok(report) +} diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs new file mode 100644 index 000000000..d33aab7ef --- /dev/null +++ b/crates/plugins/src/runtime.rs @@ -0,0 +1,275 @@ +//! The wasmi host: loading, instantiating and calling a plug-in under resource limits. + +use wasmi::{ + Config, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, TypedResumableCall, + WasmParams, WasmResults, +}; + +use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; +use crate::{Error, Result}; + +/// The plug-in ABI version this host implements (`vc_abi_version` must return it). +pub const ABI_VERSION: i32 = 1; + +/// Fuel handed out per slice; between slices the host checks the deadline. +const FUEL_SLICE: u64 = 20_000_000; + +/// Resource limits for running plug-ins. +#[derive(Clone, Debug, PartialEq)] +pub struct Limits { + /// Largest module accepted. + pub max_module_bytes: usize, + /// Cap on a plug-in's linear memory. The input and the parameters may use half of it. + pub max_memory_bytes: usize, + /// Largest output JSON read back. + pub max_output_bytes: usize, + /// Instructions (fuel) any call may use, plus [`Limits::fuel_per_byte`] per byte of input for + /// `vc_run`. + pub fuel_base: u64, + pub fuel_per_byte: u64, + /// Deepest call nesting. + pub max_recursion_depth: usize, + /// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no + /// monotonic clock in `std`, so the fuel budget alone bounds it there. + pub wall_time_ms: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + max_module_bytes: 32 << 20, + max_memory_bytes: 512 << 20, + max_output_bytes: 64 << 20, + fuel_base: 50_000_000, + fuel_per_byte: 4_000, + max_recursion_depth: 1024, + wall_time_ms: 60_000, + } + } +} + +/// A loaded, validated plug-in. Cheap to share (`Arc`) and safe to run from several threads: +/// every run gets its own store and instance. +pub struct Plugin { + manifest: Manifest, + engine: Engine, + module: Module, + limits: Limits, + size: usize, + source: Option, + serial: u64, +} + +impl std::fmt::Debug for Plugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Plugin").field("manifest", &self.manifest).field("size", &self.size).field("source", &self.source).finish() + } +} + +/// When a run must stop (native: a wall-clock deadline). +struct Deadline { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant, +} + +impl Deadline { + fn new(limits: &Limits) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = limits; + Self { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms), + } + } + fn check(&self) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + if std::time::Instant::now() >= self.at { + return Err(Error::Limit("time budget".into())); + } + Ok(()) + } +} + +/// One live instance: a store with its own memory limit, and the module's exports. +struct Live { + store: Store, + instance: Instance, + memory: Memory, +} + +impl Live { + fn func(&self, name: &str) -> Result> { + self.instance.get_typed_func::(&self.store, name).map_err(|e| Error::Abi(format!("export `{name}`: {e}"))) + } + + /// The `len << 32 | ptr` block a plug-in returned, copied out (at most `max` bytes). + fn read_packed(&self, packed: i64, what: &str, max: usize) -> Result> { + let packed = packed as u64; + let (ptr, len) = ((packed & 0xffff_ffff) as usize, (packed >> 32) as usize); + if len > max { + return Err(Error::Limit(format!("output size ({what} is {len} bytes, limit {max})"))); + } + let bytes = self.memory.data(&self.store).get(ptr..ptr.saturating_add(len)); + bytes.map(<[u8]>::to_vec).ok_or_else(|| Error::Abi(format!("{what} points outside memory"))) + } +} + +fn wasm_err(e: wasmi::Error) -> Error { + if e.as_trap_code() == Some(wasmi::TrapCode::OutOfFuel) { Error::Limit("instruction budget".into()) } else { Error::Trap(e.to_string()) } +} + +/// `vc_run(input_ptr, input_len, params_ptr, params_len) -> len << 32 | ptr` (negative: error). +type RunArgs = (i32, i32, i32, i32); + +impl Plugin { + /// Validates and loads a module: checks the ABI version, the exports and reads the manifest. + pub fn load(bytes: &[u8], limits: Limits) -> Result { + if bytes.len() > limits.max_module_bytes { + return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes))); + } + if !bytes.starts_with(b"\0asm") { + return Err(Error::Module("not a WebAssembly binary (missing \\0asm header)".into())); + } + let mut config = Config::default(); + config.consume_fuel(true).enforced_limits(EnforcedLimits::strict()).set_max_recursion_depth(limits.max_recursion_depth.max(16)); + let engine = Engine::new(&config); + let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?; + if let Some(imp) = module.imports().next() { + return Err(Error::Abi(format!( + "the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)", + imp.module(), + imp.name() + ))); + } + let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 }; + let deadline = Deadline::new(&p.limits); + let mut live = p.instantiate()?; + let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; + let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; + if v != ABI_VERSION { + return Err(Error::Abi(format!("vc_abi_version returned {v}; this host implements version {ABI_VERSION}"))); + } + let manifest: TypedFunc<(), i64> = live.func("vc_manifest")?; + let packed = p.call(&mut live, &manifest, (), p.limits.fuel_base, &deadline)?; + let text = live.read_packed(packed, "vc_manifest", MAX_MANIFEST_BYTES).map_err(|e| match e { + Error::Limit(_) => Error::Manifest(format!("manifest is too long (limit {MAX_MANIFEST_BYTES} bytes)")), + e => e, + })?; + p.manifest = Manifest::parse(&text)?; + // The other exports must exist with the right types. + live.func::("vc_alloc")?; + live.func::("vc_run")?; + Ok(p) + } + + pub fn manifest(&self) -> &Manifest { + &self.manifest + } + pub fn id(&self) -> &str { + &self.manifest.id + } + pub fn limits(&self) -> &Limits { + &self.limits + } + /// Module size in bytes. + pub fn size(&self) -> usize { + self.size + } + /// Where the module was loaded from, if it came from a file. + pub fn source(&self) -> Option<&str> { + self.source.as_deref() + } + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + /// A number unique to this installation (re-installing a plug-in gives it a new one), so + /// caches of its results can tell versions apart. 0 until installed. + pub fn serial(&self) -> u64 { + self.serial + } + pub(crate) fn set_serial(&mut self, serial: u64) { + self.serial = serial; + } + + fn instantiate(&self) -> Result { + let limits = + StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); + let mut store = Store::new(&self.engine, limits); + store.limiter(|l| l); + // The start function (if any) runs on this fuel. + store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?; + let linker = Linker::::new(&self.engine); + let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; + let memory = + instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; + Ok(Live { store, instance, memory }) + } + + /// Calls `f` with at most `fuel` instructions, in slices, checking the deadline between them. + fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { + let mut left = fuel; + let give = left.min(FUEL_SLICE); + left -= give; + live.store.set_fuel(give).map_err(wasm_err)?; + let mut call = f.call_resumable(&mut live.store, params).map_err(wasm_err)?; + loop { + match call { + TypedResumableCall::Finished(r) => return Ok(r), + TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), + TypedResumableCall::OutOfFuel(inv) => { + let need = inv.required_fuel(); + if left == 0 || need > left { + return Err(Error::Limit("instruction budget".into())); + } + deadline.check()?; + let give = left.min(FUEL_SLICE.max(need)); + left -= give; + live.store.set_fuel(give).map_err(wasm_err)?; + call = inv.resume(&mut live.store).map_err(wasm_err)?; + } + } + } + } + + /// Runs `vc_run` on `input` (the objects JSON) with `params` (the parameters JSON) in a fresh + /// instance and returns the output JSON bytes. + pub fn run(&self, input: &[u8], params: &[u8]) -> Result> { + self.run_limited(input, params, &self.limits) + } + + /// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one). + pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { + // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must + // still not take the document down (native builds; wasm aborts on panic anyway). + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits))); + run.map_err(|_| Error::Trap("the runtime panicked".into()))? + } + + fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { + let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); + total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; + let deadline = Deadline::new(limits); + let mut live = self.instantiate()?; + let alloc: TypedFunc = live.func("vc_alloc")?; + let run: TypedFunc = live.func("vc_run")?; + let place = |live: &mut Live, bytes: &[u8]| -> Result { + let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize; + if ptr == 0 { + return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into())); + } + let outside = || Error::Abi("vc_alloc returned a block outside memory".into()); + let end = ptr.checked_add(bytes.len()).ok_or_else(outside)?; + live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes); + Ok(ptr) + }; + let iptr = place(&mut live, input)?; + let pptr = place(&mut live, params)?; + let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte)); + let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?; + if packed < 0 { + return Err(Error::Failed(format!("vc_run returned error code {packed}"))); + } + live.read_packed(packed, "the output", limits.max_output_bytes) + } +} diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs new file mode 100644 index 000000000..df544a8e6 --- /dev/null +++ b/crates/plugins/src/wat.rs @@ -0,0 +1,47 @@ +//! Minimal plug-ins in WebAssembly text (WAT): the skeleton `docs/plugins.md` describes, which the +//! tests turn into modules (with the `wat` crate) to exercise the host. + +/// Where [`returning`] keeps its output in memory (the manifest sits at 16). +const OUTPUT_AT: usize = 0x1_0000; + +/// `s` as the body of a WAT string literal (every byte that isn't a letter, digit or space +/// escaped). +pub fn string(s: &str) -> String { + s.bytes().map(|b| if b.is_ascii_alphanumeric() || b == b' ' { char::from(b).to_string() } else { format!("\\{b:02x}") }).collect() +} + +/// A plug-in module: `manifest` (JSON), a `vc_run` whose body is `run` (parameters `$in $il $pp +/// $pl`, result i64), extra module fields `extra` and `pages` of initial memory. `vc_alloc` grows +/// memory by whole pages, so its blocks never overlap data segments. +pub fn module(manifest: &str, run: &str, extra: &str, pages: u32) -> String { + format!( + r#"(module + {extra} + (memory (export "memory") {pages}) + (data (i32.const 16) "{data}") + (func (export "vc_abi_version") (result i32) (i32.const 1)) + (func (export "vc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16))) + (func (export "vc_alloc") (param $n i32) (result i32) (local $old i32) + (local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16)))) + (if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536))))) + (func (export "vc_run") (param $in i32) (param $il i32) (param $pp i32) (param $pl i32) (result i64) + {run}))"#, + data = string(manifest), + len = manifest.len(), + ) +} + +/// A `vc_run` body returning the input unchanged. +pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64.const 32)) (i64.extend_i32_u (local.get $in)))"; + +/// A plug-in that always returns `output` (whatever it is given). +pub fn returning(manifest: &str, output: &str) -> String { + let run = format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len()); + let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); + module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) +} + +/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs). +pub fn manifest(id: &str, kind: &str, params: &str) -> String { + format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#) +} diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs new file mode 100644 index 000000000..337254b68 --- /dev/null +++ b/crates/plugins/tests/abi.rs @@ -0,0 +1,208 @@ +//! The plug-in ABI end to end: the example Desaturate plug-in, and hostile modules (infinite +//! loops, memory bombs, out-of-bounds access, bad manifests and outputs…), which must all fail +//! with an error, quickly. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_plugins::{Error, Kind, Limits, Plugin, registry, wat as tpl}; + +/// The example Desaturate plug-in, built from `crates/plugins/example`. +const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm"); + +const MANIFEST: &str = r#"{"id":"test.plugin","name":"Test","version":"1","kind":"filter"}"#; + +fn build(src: &str) -> Vec { + wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}")) +} + +/// A filter whose `vc_run` body is `run`, with `extra` module fields. +fn module(manifest: &str, run: &str, extra: &str) -> Vec { + build(&tpl::module(manifest, run, extra, 1)) +} + +fn ok_module(run: &str) -> Vec { + module(MANIFEST, run, "") +} + +const INPUT: &str = r#"{"objects":[]}"#; + +/// Runs `bytes` on an empty input; returns the error and how long it took. +fn run_err(bytes: &[u8], limits: Limits) -> (Error, Duration) { + let t = Instant::now(); + let r = Plugin::load(bytes, limits).and_then(|p| p.run(INPUT.as_bytes(), b"{}")); + (r.expect_err("hostile module must fail"), t.elapsed()) +} + +#[test] +fn example_desaturate_loads_and_reports_its_manifest() { + let p = Plugin::load(DESATURATE, Limits::default()).unwrap(); + let m = p.manifest(); + assert_eq!(m.id, "org.vectorcraft.example.desaturate"); + assert_eq!(m.name, "Desaturate"); + assert_eq!(m.kind, Kind::Filter); + assert_eq!(m.params_doc(), "{amount: 0..100 (100)}"); +} + +#[test] +fn example_desaturate_moves_every_colour_model_to_grey() { + let p = Plugin::load(DESATURATE, Limits::default()).unwrap(); + let input = json!({"objects": [{"id": 3, "type": "path", "fills": [ + {"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}}, + {"type": "gradient", "gradient": {"kind": "Linear", "stops": [ + {"offset": 0.0, "color": {"model": "lab", "l": 50.0, "a": 40.0, "b": -20.0}}, + {"offset": 1.0, "color": {"model": "cmyk", "c": 0.9, "m": 0.0, "y": 0.3, "k": 0.1}}]}}]}]}); + let out = p.run(input.to_string().as_bytes(), br#"{"amount": 100}"#).unwrap(); + let out: Value = serde_json::from_slice(&out).unwrap(); + let fills = &out["objects"][0]["fills"]; + let rgb = &fills[0]["color"]; + assert!((rgb["r"].as_f64().unwrap() - 0.2126).abs() < 1e-9 && rgb["r"] == rgb["g"] && rgb["g"] == rgb["b"], "{rgb}"); + let stops = &fills[1]["gradient"]["stops"]; + assert_eq!((stops[0]["color"]["a"].as_f64(), stops[0]["color"]["b"].as_f64()), (Some(0.0), Some(0.0))); + let cmyk = &stops[1]["color"]; + assert!((cmyk["c"].as_f64().unwrap() - 0.4).abs() < 1e-9 && cmyk["k"] == 0.1, "{cmyk}"); + assert_eq!(out["objects"][0]["id"], 3, "ids come back, so the objects are updated in place"); + // Half way. + let half: Value = serde_json::from_slice(&p.run(input.to_string().as_bytes(), br#"{"amount": 50}"#).unwrap()).unwrap(); + assert!((half["objects"][0]["fills"][0]["color"]["r"].as_f64().unwrap() - 0.6063).abs() < 1e-9); + // Nothing to work on: the plug-in's own message. + let none = p.run(INPUT.as_bytes(), b"{}").unwrap(); + assert!(matches!(vectorcraft_plugins::objects::decode(&none), Err(Error::Failed(m)) if m.contains("Select some paths"))); +} + +#[test] +fn input_and_params_reach_the_plugin_and_output_comes_back() { + let p = Plugin::load(&build(&tpl::module(MANIFEST, tpl::ECHO, "", 1)), Limits::default()).unwrap(); + let input = br#"{"objects":[{"id":1}]}"#; + assert_eq!(p.run(input, b"{}").unwrap(), input); + // Returns the parameters instead. + let params = module(MANIFEST, "(i64.or (i64.shl (i64.extend_i32_u (local.get $pl)) (i64.const 32)) (i64.extend_i32_u (local.get $pp)))", ""); + let p = Plugin::load(¶ms, Limits::default()).unwrap(); + assert_eq!(p.run(input, br#"{"amount":5}"#).unwrap(), br#"{"amount":5}"#); + let fixed = Plugin::load(&build(&tpl::returning(MANIFEST, r#"{"objects":[]}"#)), Limits::default()).unwrap(); + assert_eq!(fixed.run(input, b"{}").unwrap(), br#"{"objects":[]}"#); +} + +#[test] +fn hostile_modules_fail_quickly() { + let quick = Duration::from_secs(5); + let cases: Vec<(&str, Vec)> = vec![ + ("infinite loop", ok_module("(loop $l (br $l)) (i64.const 0)")), + ("memory.grow bomb, then trap", ok_module("(loop $l (br_if $l (i32.ne (memory.grow (i32.const 512)) (i32.const -1)))) unreachable")), + ("memory.grow forever", ok_module("(loop $l (drop (memory.grow (i32.const 1))) (br $l)) (i64.const 0)")), + ("out-of-bounds write", ok_module("(i32.store (i32.const -4) (i32.const 0)) (i64.const 0)")), + ("trap", ok_module("unreachable")), + ("division by zero", ok_module("(drop (i32.div_u (i32.const 1) (i32.const 0))) (i64.const 0)")), + ("deep recursion", module(MANIFEST, "(call $r) (i64.const 0)", "(func $r (call $r))")), + ("error code", ok_module("(i64.const -7)")), + ("output outside memory", ok_module("(i64.const 0x00000100fffffff0)")), + ("huge output", ok_module("(i64.const 0x7fffffff00000000)")), + ( + "wrong ABI version", + build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace("(result i32) (i32.const 1))", "(result i32) (i32.const 2))")), + ), + ("bad manifest", module("not json", "(i64.const 0)", "")), + ("manifest with a bad id", module(r#"{"id":"a b","name":"x","kind":"filter"}"#, "(i64.const 0)", "")), + ("manifest of the wrong kind", module(r#"{"id":"a","name":"x","kind":"panel"}"#, "(i64.const 0)", "")), + ("start function loops", module(MANIFEST, "(i64.const 0)", "(func $spin (loop $l (br $l))) (start $spin)")), + ( + "WASI import", + module(MANIFEST, "(i64.const 0)", r#"(import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#), + ), + ]; + for (name, bytes) in cases { + let (e, t) = run_err(&bytes, Limits::default()); + assert!(t < quick, "{name} took {t:?}"); + eprintln!("{name}: {e} ({t:?})"); + } +} + +#[test] +fn malformed_and_oversized_modules_are_refused() { + let half = &DESATURATE[..DESATURATE.len() / 2]; + for (name, bytes) in [("empty", &b""[..]), ("garbage", &b"\0asm\x01\0\0\0\xff\xff\xff"[..]), ("not wasm", &b"MZ\x90\0"[..]), ("truncated", half)] + { + assert!(matches!(Plugin::load(bytes, Limits::default()), Err(Error::Module(_))), "{name}"); + } + let tiny = Limits { max_module_bytes: 100, ..Limits::default() }; + assert!(matches!(Plugin::load(DESATURATE, tiny), Err(Error::Module(_)))); + // Huge initial memory, manifest outside memory, missing exports, bogus allocations. + let huge = build(r#"(module (memory (export "memory") 60000))"#); + assert!(Plugin::load(&huge, Limits::default()).is_err()); + let no_run = build( + r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1)) + (func (export "vc_manifest") (result i64) (i64.const 0)))"#, + ); + assert!(Plugin::load(&no_run, Limits::default()).is_err()); + let src = r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1)) + (func (export "vc_manifest") (result i64) (i64.const 0x00000064ffffff00)) + (func (export "vc_alloc") (param i32) (result i32) (i32.const 16)) + (func (export "vc_run") (param i32 i32 i32 i32) (result i64) (i64.const 0)))"#; + assert!(matches!(Plugin::load(&build(src), Limits::default()), Err(Error::Abi(_)))); + let bogus_alloc = build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace( + "(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32)", + "(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32) (return (i32.const -16))", + )); + let (e, _) = run_err(&bogus_alloc, Limits::default()); + assert!(matches!(e, Error::Abi(_)), "{e}"); +} + +#[test] +fn budgets_stop_runaway_plugins() { + let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); + let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, fuel_per_byte: 10, ..Limits::default() }); + assert_eq!(e, Error::Limit("instruction budget".into())); +} + +#[test] +fn wall_clock_budget_stops_runaway_plugins() { + let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); + // Wall-clock budget, with an effectively unlimited instruction budget. + let (e, t) = run_err(&spin, Limits { fuel_base: u64::MAX / 4, wall_time_ms: 300, ..Limits::default() }); + assert_eq!(e, Error::Limit("time budget".into())); + assert!(t < Duration::from_secs(3), "{t:?}"); +} + +#[test] +fn memory_cap_limits_input_and_growth() { + let echo = build(&tpl::module(MANIFEST, tpl::ECHO, "", 1)); + let p = Plugin::load(&echo, Limits { max_memory_bytes: 1 << 20, ..Limits::default() }).unwrap(); + assert!(p.run(&vec![b' '; 300 << 10], b"{}").is_ok(), "fits in half the memory"); + assert!(matches!(p.run(&vec![b' '; 600 << 10], b"{}"), Err(Error::Limit(_))), "doesn't"); + // Growing past the cap fails inside the module (memory.grow returns -1): vc_alloc gives 0. + let p = Plugin::load(&echo, Limits { max_memory_bytes: 64 << 10, ..Limits::default() }).unwrap(); + assert!(matches!(p.run(b"{}", b"{}"), Err(Error::Failed(m)) if m.contains("vc_alloc"))); +} + +#[test] +fn registry_install_list_remove() { + // Its own id: the registry is process-wide and tests run in parallel. + let id = "test.registry"; + let p = registry::install_bytes(&module(r#"{"id":"test.registry","name":"R","kind":"filter"}"#, "(i64.const 0)", "")).unwrap(); + assert_eq!(p.id(), id); + let rev = registry::revision(); + assert!(registry::list().iter().any(|p| p.id() == id)); + assert!(registry::get(id).is_some()); + assert!(registry::remove(id)); + assert!(!registry::remove(id)); + assert!(registry::get(id).is_none()); + assert!(registry::revision() > rev); + assert!(std::ptr::eq(registry::intern("Abc"), registry::intern("Abc")), "interned once"); +} + +#[test] +fn folder_loading_skips_bad_files() { + let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a-desaturate.wasm"), DESATURATE).unwrap(); + std::fs::write(dir.join("b-broken.wasm"), b"\0asm junk").unwrap(); + std::fs::write(dir.join("notes.txt"), b"ignored").unwrap(); + let r = registry::load_folder(&dir).unwrap(); + assert_eq!(r.loaded, ["org.vectorcraft.example.desaturate"]); + assert_eq!(r.failed.len(), 1); + assert!(r.failed[0].0.ends_with("b-broken.wasm")); + assert!(registry::load_folder(&dir.join("missing")).is_err()); + assert!(registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/plugins/tests/fixtures/desaturate.wasm b/crates/plugins/tests/fixtures/desaturate.wasm new file mode 100644 index 000000000..a8b4cdf07 Binary files /dev/null and b/crates/plugins/tests/fixtures/desaturate.wasm differ diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 84f930ade..e5192e78c 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -166,7 +166,7 @@ fn anti_alias_none_is_the_same_on_every_thread_count() { }; let (st, mt) = (render(0), render(3)); for img in [&st, &mt] { - assert!(img.to_straight().chunks_exact(4).all(|p| p[3] == 0 || p[3] == 255), "hard edges"); + assert!(img.to_straight().as_chunks::<4>().0.iter().all(|p| p[3] == 0 || p[3] == 255), "hard edges"); } let worst = st.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); assert!(worst <= 4, "max channel difference {worst}"); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index f82974f3e..9b4f2517b 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -49,10 +49,13 @@ pub(crate) fn has_fx(n: &Node) -> bool { } /// Does `n` carry visible object-level effects that only apply through its art (type, images, -/// symbol instances, live objects; Crop Marks on anything), or is it a group or layer with an -/// appearance of its own (see [`Renderer::draw_object_fx`])? +/// symbol instances, live objects; Crop Marks and colour adjustments on anything), or is it a group +/// or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { - (effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n) || effects::has_crop_marks(n) + (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) + || effects::has_container_appearance(n) + || effects::has_crop_marks(n) + || effects::has_adjustment(n) } /// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? @@ -399,9 +402,10 @@ impl Renderer { } /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): - /// reshaped by its geometry effects, else type and images as themselves and the others as - /// their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their - /// knockout setting); without the object's transparency and effects. + /// reshaped by its geometry effects, else type, images and paths as themselves and the others + /// as their evaluated art (groups and layers: [`effects::evaluate_container`], keeping their + /// knockout setting), recoloured by the colour adjustments in it ([`Self::adjusted_art`]); + /// without the object's transparency and effects (a path keeps its geometry effects). fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; if cache @@ -420,9 +424,13 @@ impl Renderer { let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), - None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(), + None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; + let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); + // A path's geometry effects still apply when it is drawn. + let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); + art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); let art = Arc::new(Node { opacity: 1.0, blend: Default::default(), @@ -431,7 +439,6 @@ impl Renderer { // its knockout setting says). knockout: if container { a.knockout } else { vectorcraft_doc::Knockout::Off }, mask: None, - appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance }, ..art }); if cache { @@ -443,6 +450,25 @@ impl Renderer { art } + /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on + /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the + /// decoded images under a key naming the adjustment. + fn adjusted_art(&mut self, doc: &vectorcraft_doc::Document, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { + art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); + let mut wanted: Vec<(String, String, effects::ColorMap)> = vec![]; + let adjusted = effects::adjust(&art, outer, &mut |key, map| { + let mut h = std::hash::DefaultHasher::new(); + std::hash::Hash::hash(&format!("{map:?}"), &mut h); + let derived = format!("{key}\u{0}adjust\u{0}{:016x}", std::hash::Hasher::finish(&h)); + wanted.push((key.to_string(), derived.clone(), map.clone())); + Some(derived) + }); + for (key, derived, map) in wanted { + self.adjusted_image(doc, &key, derived, &map); + } + adjusted.unwrap_or(art) + } + /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). /// `paint` draws the content in the frame it is given. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 3b4007a90..b89e498b1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -274,6 +274,9 @@ type ClipPaintEntry = (Arc, Option>, Option>); /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { texts: PtrMap, Arc)>, + /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out + /// again when fonts are added or rescanned. + text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). clips: PtrMap, /// Context reused when rendering single-threaded (`threads == 0`). @@ -324,6 +327,9 @@ pub struct Renderer { ink_images: ink::InkImages, /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). stroke_slices: PtrMap<(usize, i32), SliceEntry>, + /// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest + /// first (see [`Self::adjusted_image`]). + adjusted: std::collections::VecDeque<(String, usize)>, } #[derive(Clone, Copy, Debug, Default)] @@ -373,6 +379,7 @@ impl Renderer { pub fn new() -> Self { Self { texts: PtrMap::default(), + text_fonts: 0, clips: PtrMap::default(), ctx_st: None, threads: default_threads(), @@ -398,6 +405,7 @@ impl Renderer { clip_paints: PtrMap::default(), ink_images: Default::default(), stroke_slices: PtrMap::default(), + adjusted: Default::default(), } } @@ -826,6 +834,16 @@ impl Renderer { && let Some(region) = self.clip_of(clip) { let (fill, stroke) = self.clip_paint_of(clip); + // An image inside its own frame painted straight into the clipping path, without + // a clip layer (envelopes cut distorted images into many such pieces). + if let ([image], None, None) = (rest, &fill, &stroke) + && let NodeKind::Image(im) = &image.kind + && plain_image(image, im, ®ion.0) + { + self.draw_image_in(ctx, f, im, Some(®ion)); + self.stats.drawn += 1; + return; + } let blends = self.blends_through(n); let bounds = if blends { Some(region.0.bounding_box()) } else { None }; let comp = Composite { clip: Some((®ion.0, region.1)), blends, bounds, ..Default::default() }; @@ -1138,6 +1156,11 @@ impl Renderer { /// Cached glyph geometry for a text node (keyed by Arc identity like paths). fn text_geom_of(&mut self, a: &Arc, t: &TextObject) -> Arc { + let fonts = vectorcraft_text::FontDb::global().generation(); + if fonts != self.text_fonts { + self.texts.clear(); + self.text_fonts = fonts; + } let key = Arc::as_ptr(a) as usize; if let Some((node, g)) = self.texts.get(&key) && Arc::ptr_eq(node, a) @@ -1251,6 +1274,12 @@ impl Renderer { } fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) { + self.draw_image_in(ctx, f, im, None); + } + + /// Draw an image, or with `area` (a region in the document inside the image's frame, see + /// [`plain_image`]) the image painted into that region. + fn draw_image_in(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject, area: Option<&(BezPath, FillRule)>) { let rect = Rect::new(0.0, 0.0, im.width as f64, im.height as f64); // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline // Mode its pixels in greyscale under the frame. @@ -1265,10 +1294,20 @@ impl Renderer { let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink) }; let sx = im.width as f64 / pm.width().max(1) as f64; let sy = im.height as f64 / pm.height().max(1) as f64; - ctx.set_transform(f.view * im.xf); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() }); - ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); - ctx.fill_rect(&rect); + match area { + Some((bp, rule)) => { + ctx.set_transform(f.view); + ctx.set_paint_transform(im.xf * Affine::scale_non_uniform(sx, sy)); + ctx.set_fill_rule(fill_rule(*rule)); + ctx.fill_path(bp); + } + None => { + ctx.set_transform(f.view * im.xf); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&rect); + } + } ctx.reset_paint_transform(); } if outline { @@ -1278,6 +1317,38 @@ impl Renderer { } } + /// Cache the pixels of image blob `key` recoloured by `map` as `derived` (the key the adjusted + /// art's image takes). The latest ones are kept up to a memory budget: dropping one drops the + /// cached art too, so it is made again. + fn adjusted_image(&mut self, doc: &Document, key: &str, derived: String, map: &vectorcraft_effects::ColorMap) { + const BUDGET: usize = 256 << 20; + if self.images.contains_key(&derived) { + return; + } + let Some(src) = self.image_pixmap(doc, key, key, false) else { return }; + let mut pm = (*src).clone(); + let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); + for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { + let a = px.a as u32; + let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { + // Premultiplied to straight, adjusted, then premultiplied again. + let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); + map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) + }); + (px.r, px.g, px.b) = (r, g, b); + } + let bytes = pm.data().len() * 4; + self.images.insert(derived.clone(), Arc::new(pm)); + self.adjusted.push_back((derived, bytes)); + let mut total: usize = self.adjusted.iter().map(|(_, b)| b).sum(); + while total > BUDGET && self.adjusted.len() > 1 { + let Some((old, b)) = self.adjusted.pop_front() else { break }; + self.images.remove(&old); + self.fx_arts.clear(); + total -= b; + } + } + /// The decoded pixels of image blob `key` (cached as `cache_key`), or a greyscale copy of them. fn image_pixmap(&mut self, doc: &Document, key: &str, cache_key: &str, grey: bool) -> Option> { let colour = match self.images.get(cache_key) { @@ -1307,6 +1378,21 @@ impl Renderer { } } +/// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible, +/// with no transparency, effects or paint of its own, and the region inside the image's frame +/// (within a pixel), so no paint past the image's edge shows? +fn plain_image(n: &Node, im: &vectorcraft_doc::ImageObject, region: &BezPath) -> bool { + if !(n.visible && n.has_default_transparency() && n.appearance.items.is_empty() && n.appearance.effects.is_empty()) { + return false; + } + let det = im.xf.determinant(); + if !(det.abs() > 1e-12 && det.is_finite()) { + return false; + } + let b = im.xf.inverse().transform_rect_bbox(region.bounding_box()); + b.x0 >= -1.0 && b.y0 >= -1.0 && b.x1 <= im.width as f64 + 1.0 && b.y1 <= im.height as f64 + 1.0 +} + /// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask /// art black (clip) or white (no clip), optionally inverted. fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 { @@ -1480,6 +1566,8 @@ fn now() -> u64 { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_blend; #[cfg(test)] mod tests_charstroke; @@ -1490,6 +1578,8 @@ mod tests_cmykblend; #[cfg(test)] mod tests_container; #[cfg(test)] +mod tests_fontchange; +#[cfg(test)] mod tests_freeform; #[cfg(test)] mod tests_isolation; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 1af0de424..f1b5fb3d2 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -1,8 +1,10 @@ //! Live objects in the renderer. //! -//! Blends and envelopes are evaluated by `vectorcraft_doc::live` and the resulting objects cached -//! by `Arc` identity of the live node (so the steps keep stable `Arc`s and hit the geometry cache -//! frame after frame). Gradient meshes are tessellated into many small solid-colour quads (vello +//! Blends and envelopes are evaluated by `vectorcraft_doc::live` (through +//! `vectorcraft_effects::expand_live`, as the exporters do) and the resulting objects cached by +//! `Arc` identity of the live node and of the symbols and patterns it draws (so the steps keep +//! stable `Arc`s and hit the geometry cache frame after frame, and editing a symbol redraws an +//! envelope around an instance of it). Gradient meshes are tessellated into many small solid-colour quads (vello //! has no mesh shading), with the density chosen from the on-screen patch size; each quad is //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams //! show. @@ -10,13 +12,13 @@ use std::collections::HashMap; use std::sync::Arc; -use vectorcraft_doc::live::{self, GradientMesh, MeshQuad}; -use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::{BezPath, PathData, Vec2}; +use vectorcraft_doc::live::{GradientMesh, MeshQuad}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_geom::{BezPath, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko; -use crate::{Frame, Renderer, text_geom}; +use crate::{Frame, Renderer}; type Expanded = Arc>>; type MeshEntry = (Arc, Arc>, u64); @@ -24,7 +26,7 @@ type MeshEntry = (Arc, Arc>, u64); /// Per-renderer cache of evaluated live objects. #[derive(Default)] pub(crate) struct LiveCache { - expanded: HashMap, Expanded, u64)>, + expanded: HashMap, Expanded, u64, u64)>, meshes: HashMap<(usize, usize), MeshEntry>, stamp: u64, } @@ -41,29 +43,34 @@ impl LiveCache { } } -/// Text converted to one outline path (for envelopes): glyphs in document space, painted with the -/// first run's fill/stroke. -pub(crate) fn outline_text(n: &Node) -> Option { - let NodeKind::Text(t) = &n.kind else { return None }; - let g = text_geom(t); - let mut bp = g.all.clone(); - bp.apply_affine(t.xf); - let mut out = Node::path(n.id, PathData::from_bezpath(&bp), n.appearance.clone()); - if out.appearance.items.is_empty() - && let Some(r) = t.runs.first() - { - out.appearance = r.style.appearance(); - } - out.opacity = n.opacity; - out.blend = n.blend; - Some(out) +/// One level of evaluation of a live node (blend steps, envelope result with type outlined run by +/// run, mesh → flat pieces) without a document (symbol instances and pattern tiles in envelopes +/// stay as they are). Non-live nodes return themselves. +pub fn expand_live(n: &Node) -> Vec { + crate::effects::expand_live(None, n) } -/// Evaluate a live node one level (blend steps / envelope result, text outlined; mesh → flat -/// pieces). Non-live nodes return themselves. -pub fn expand_live(n: &Node) -> Vec { - let hook: &dyn Fn(&Node) -> Option = &outline_text; - live::expand_live_with(n, Some(hook)) +/// What an evaluated live node depends on besides itself: the art of the symbols and the patterns +/// it draws (their `Arc`s outlive document copies, so this changes only when one is edited). +fn resources_key(doc: &Document, n: &Node) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + n.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind + && let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) + { + (Arc::as_ptr(&s.art) as usize).hash(&mut h); + } + for i in &c.appearance.items { + if let vectorcraft_color::Paint::Pattern { pattern, .. } = i.paint() + && let Some(def) = doc.pattern(pattern) + { + def.art.iter().for_each(|a| (Arc::as_ptr(a) as usize).hash(&mut h)); + [def.tile.x0, def.tile.y0, def.tile.x1, def.tile.y1].iter().for_each(|v| v.to_bits().hash(&mut h)); + } + } + }); + h.finish() } /// Subdivisions per patch for a patch about `size_px` device pixels across. @@ -73,22 +80,23 @@ fn mesh_level(m: &GradientMesh, bounds_px: f64) -> usize { } impl Renderer { - fn live_expanded(&mut self, a: &Arc, cache: bool) -> Expanded { + fn live_expanded(&mut self, doc: &Document, a: &Arc, cache: bool) -> Expanded { let key = Arc::as_ptr(a) as usize; + let resources = resources_key(doc, a); if cache && let Some(e) = self.live.expanded.get_mut(&key) && Arc::ptr_eq(&e.0, a) + && e.3 == resources { e.2 = self.live.stamp; return e.1.clone(); } - let hook: &dyn Fn(&Node) -> Option = &outline_text; - let v: Expanded = match crate::effects::pathfinder_children(a, Some(hook)) { + let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { Some(children) => Arc::new(children), - None => Arc::new(expand_live(a).into_iter().map(Arc::new).collect()), + None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()), }; if cache { - self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp)); + self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp, resources)); } v } @@ -114,7 +122,13 @@ impl Renderer { let stamp = self.stamp; self.live.tick(stamp); let opacity = self.opacity_of(a); - if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate) { + // A knockout blend's steps knock each other out, as in the exports (opaque ones can't show it). + let knockout = !f.opts.outline + && matches!(a.kind, NodeKind::Blend { .. }) + && a.knockout.resolve(self.knockout) + && vectorcraft_doc::live::steps_knockout_shows(&self.live_expanded(f.doc, a, true)); + let enclosing = std::mem::replace(&mut self.knockout, knockout); + if !f.opts.outline && (opacity < 1.0 || a.blend != vectorcraft_color::BlendMode::Normal || a.isolate || knockout) { let blends = self.blends_through(a); let bounds = if blends { self.bounds_of(a) } else { None }; let comp = crate::group::Composite { blend: a.blend, opacity, isolated: a.isolate, blends, bounds, ..Default::default() }; @@ -122,6 +136,7 @@ impl Renderer { } else { self.draw_live_body(ctx, f, a, true); } + self.knockout = enclosing; self.stats.drawn += 1; } @@ -130,9 +145,14 @@ impl Renderer { match &a.kind { NodeKind::Mesh(m) => self.draw_mesh(ctx, f, a, m, cache), NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => { - let items = self.live_expanded(a, cache); - for c in items.iter() { - self.draw_arc(ctx, f, c); + let items = self.live_expanded(f.doc, a, cache); + // The flag holds while this blend is drawn (see `draw_live`). + if self.knockout && matches!(a.kind, NodeKind::Blend { .. }) { + self.draw_knockout(ctx, f, &items); + } else { + for c in items.iter() { + self.draw_arc(ctx, f, c); + } } } _ => {} diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs new file mode 100644 index 000000000..8e5690a6d --- /dev/null +++ b/crates/render/src/tests_adjust.rs @@ -0,0 +1,114 @@ +//! Live colour adjustments (Effect → Color Adjustments) as drawn: on paths, type, embedded images, +//! groups and symbol instances, alone and with other effects. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol}; +use vectorcraft_geom::{Point, shapes}; + +use super::*; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// Hue +120°: red becomes green, green blue. +fn hue() -> Effect { + fx("adjust.hueSaturation", json!({"hue": 120})) +} + +fn doc_with(nodes: Vec) -> Document { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + for mut n in nodes { + n.id = d.alloc_id(); + d.insert(Some(l), 0, n).unwrap(); + } + d +} + +fn render(d: &Document) -> Rendered { + let mut r = Renderer::new(); + r.threads = 0; + r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn red_square(x: f64, y: f64) -> Node { + Node::path( + NodeId(0), + shapes::rectangle(Rect::new(x, y, x + 30.0, y + 30.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ) +} + +fn red_image() -> (String, ImageBlob) { + let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + ("red".into(), ImageBlob::new("image/png", png)) +} + +#[test] +fn a_path_takes_its_adjusted_colour_and_keeps_its_other_effects() { + let mut n = red_square(10.0, 10.0); + n.appearance.effects = vec![hue(), fx("distort.transform", json!({"moveH": 40}))]; + let img = render(&doc_with(vec![n])); + assert_eq!(img.pixel(65, 25), [0, 255, 0, 255], "green, and moved by the geometry effect"); + assert_eq!(img.pixel(25, 25), [255, 255, 255, 255]); + // Hidden, it changes nothing. + let mut n = red_square(10.0, 10.0); + n.appearance.effects = vec![Effect { visible: false, ..hue() }]; + assert_eq!(render(&doc_with(vec![n])).pixel(25, 25), [255, 0, 0, 255]); +} + +#[test] +fn a_group_recolours_its_members_and_type() { + let style = CharStyle { size: 60.0, fill: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), ..Default::default() }; + let text = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(50.0, 90.0), "I", style)))); + let mut g = Node::group(NodeId(0), vec![Arc::new(red_square(5.0, 5.0)), Arc::new(text)]); + g.appearance.effects.push(hue()); + let d = doc_with(vec![g]); + let img = render(&d); + assert_eq!(img.pixel(20, 20), [0, 255, 0, 255]); + let inked: Vec<[u8; 4]> = + (50..70).flat_map(|x| (50..90).map(move |y| (x, y))).map(|(x, y)| img.pixel(x, y)).filter(|p| p[0] < 128 || p[2] < 128).collect(); + assert!(inked.contains(&[0, 255, 0, 255]), "the type turns green too"); + assert!(inked.iter().all(|p| p[1] == 255 && p[0] == p[2]), "no red left: {:?}", inked.iter().find(|p| p[1] != 255 || p[0] != p[2])); +} + +#[test] +fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { + let mut d = doc_with(vec![]); + let (key, blob) = red_image(); + d.images.insert(key.clone(), blob.clone()); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(ImageObject { key, width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default() }), + ); + n.appearance.effects.push(hue()); + d = { + let l = d.layers[0].id; + n.id = d.alloc_id(); + d.insert(Some(l), 0, n).unwrap(); + d + }; + let img = render(&d); + assert_eq!(img.pixel(35, 35), [0, 255, 0, 255]); + assert_eq!(d.images.get("red"), Some(&blob), "non-destructive"); +} + +#[test] +fn a_symbol_instance_recolours_its_art() { + let mut d = Document::new(100.0, 100.0); + let art = red_square(-15.0, -15.0); + d.symbols.push(Symbol { name: "Red".into(), art: Arc::new(art) }); + let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Red".into(), xf: Affine::translate((50.0, 50.0)) }); + n.appearance.effects.push(hue()); + let l = d.layers[0].id; + n.id = d.alloc_id(); + d.insert(Some(l), 0, n).unwrap(); + assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]); +} diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs new file mode 100644 index 000000000..551ad3bfa --- /dev/null +++ b/crates/render/src/tests_fontchange.rs @@ -0,0 +1,41 @@ +//! Type redraws in a font that becomes available while it is on screen (fonts added, Refresh Font +//! List). + +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Point; + +use super::*; + +/// The bundled Source Serif 4 renamed `family` (as long as the original name). +fn serif_as(family: &str) -> Vec { + let mut data = include_bytes!("../../../assets/fonts/SourceSerif4-Regular.ttf").to_vec(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Serif 4"), utf16(family)), (b"Source Serif 4".to_vec(), family.as_bytes().to_vec())] { + assert_eq!(from.len(), to.len()); + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +#[test] +fn type_redraws_when_its_font_becomes_available() { + const FAMILY: &str = "Arrival Serif4"; + let mut d = Document::new(300.0, 150.0); + let style = CharStyle { size: 60.0, font_family: FAMILY.into(), ..Default::default() }; + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style)))); + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0); + let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels; + assert_ne!(before, after, "drawn in the font that arrived"); + assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "as a new renderer draws it"); +} diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index df45d5b89..37e2772a0 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -72,21 +72,18 @@ pub(crate) fn font_descriptor(st: &CharStyle) -> (u16, bool) { } /// The font of character style `st`: family, size (the vertical scale; writers stretch the -/// horizontal one), weight and style, lengths written by `len`. `numeric_weight`: the face's -/// weight as a number (embedded faces are told apart by it), else `bold` for bold styles. -pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String, numeric_weight: bool) -> Props { +/// horizontal one), weight and style, lengths written by `len`. The weight is the style's number +/// (600 for Semibold, 300 for Light), `bold` for 700 and left out for 400. +pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { let mut p = Props::new(); let fam = if st.font_family.contains(|c: char| c.is_whitespace() || c == ',') { format!("'{}'", st.font_family) } else { st.font_family.clone() }; p.push(("font-family", fam)); p.push(("font-size", len(st.size * st.v_scale / 100.0))); - let fs = st.font_style.to_ascii_lowercase(); let (weight, italic) = font_descriptor(st); - if numeric_weight { - if weight != 400 { - p.push(("font-weight", weight.to_string())); - } - } else if fs.contains("bold") || fs.contains("black") || fs.contains("heavy") { - p.push(("font-weight", "bold".into())); + match weight { + 400 => {} + 700 => p.push(("font-weight", "bold".into())), + w => p.push(("font-weight", w.to_string())), } if italic { p.push(("font-style", "italic".into())); @@ -531,7 +528,7 @@ impl Rules<'_> { self.warn("type with several character styles is written in its first one"); } let len = |v: f64| self.len(v); - art.props.extend(font_props(&st, &len, false)); + art.props.extend(font_props(&st, &len)); match &st.fill { Paint::Solid { color, .. } => art.props.push(("color", color.to_hex())), Paint::None => art.props.push(("color", "transparent".into())), diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 663992a95..253394e0e 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -67,6 +67,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: symbols: HashMap::new(), symbol_nest: 0, fonts: Vec::new(), + shared_images: HashMap::new(), }; w.assign_name_ids(); // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. @@ -247,6 +248,9 @@ struct Writer<'a> { symbol_nest: u32, /// The characters type uses from each face ([`ExportOptions::embed_fonts`]). fonts: Vec, + /// Images written once in the defs and drawn with ``, by key and size: the pieces an + /// envelope cuts a distorted image into share its pixels. + shared_images: HashMap<(String, u32, u32), String>, } /// The characters type uses from one face, under one `@font-face` description: the family the @@ -411,10 +415,6 @@ impl Writer<'_> { fn tiny(&self) -> bool { self.opts.profile == Profile::Tiny12 } - /// Are the fonts type uses embedded? - fn embeds_fonts(&self) -> bool { - self.opts.embed_fonts && !self.opts.outline_text && !self.tiny() - } fn num(&self, v: f64) -> String { fmt_num(v, self.opts.decimals) } @@ -437,6 +437,58 @@ impl Writer<'_> { self.defs.push('\n'); } } + /// The `href` of image `im`: its file with [`ImageMode::Link`] (or when its pixels are + /// missing), else its bytes. `None` when it has neither. + fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { + let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); + match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) { + (Some(l), _) => Some(l.path.clone()), + (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(b)), + _ => im.link.as_ref().map(|l| l.path.clone()), + } + } + + /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs + /// once; each piece then draws them with ``. + fn share_images(&mut self, art: &Node) { + let mut seen: HashMap<(String, u32, u32), usize> = HashMap::new(); + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind { + *seen.entry((im.key.clone(), im.width, im.height)).or_default() += 1; + } + }); + let mut repeated: Vec<_> = seen.into_iter().filter(|(k, n)| *n > 1 && !self.shared_images.contains_key(k)).map(|(k, _)| k).collect(); + repeated.sort(); + for (key, width, height) in repeated { + let link = self.linked_of(art, &key); + let im = vectorcraft_doc::ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link, placement: Default::default() }; + let Some(href) = self.image_href(&im) else { continue }; + let id = self.fresh_id("image"); + self.def( + 0, + &format!( + "", + xml_escape(&href) + ), + ); + self.shared_images.insert((key, width, height), id); + } + } + + /// The link of the first image with blob `key` in `art`. + fn linked_of(&self, art: &Node, key: &str) -> Option { + let mut link = None; + art.walk(&mut |c| { + if let NodeKind::Image(im) = &c.kind + && im.key == key + && link.is_none() + { + link = im.link.clone(); + } + }); + link + } + fn fresh_id(&mut self, prefix: &str) -> String { let mut i = 1; loop { @@ -1237,18 +1289,15 @@ impl Writer<'_> { } NodeKind::Text(t) => self.text_node(n, t), NodeKind::Image(im) => { - let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); - let href = match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) { - (Some(l), _) => l.path.clone(), - (None, Some(b)) if !b.bytes.is_empty() => self.blob_href(b), - _ => match &im.link { - Some(l) => l.path.clone(), - None => return, - }, - }; let id = self.id_attr(n); let m = self.matrix(self.xf * im.xf); let a = self.attrs(&css::transparency(n)); + if let Some(shared) = self.shared_images.get(&(im.key.clone(), im.width, im.height)) { + let shared = shared.clone(); + self.line(&format!("")); + return; + } + let Some(href) = self.image_href(im) else { return }; self.line(&format!( "", im.width, @@ -1294,7 +1343,8 @@ impl Writer<'_> { } // Live blends/envelopes/meshes export their evaluated (expanded) form. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { - let g = vectorcraft_doc::live::expand_deep(n, None); + let g = vectorcraft_effects::expand_live_deep(Some(self.doc), n); + self.share_images(&g); self.node_body(&g); } } @@ -1505,7 +1555,7 @@ impl Writer<'_> { fn char_props(&mut self, st: &CharStyle, space: (Rect, Affine)) -> Props { let decimals = self.opts.decimals; let len = |v: f64| fmt_num(v, decimals); - let mut p = css::font_props(st, &len, self.embeds_fonts()); + let mut p = css::font_props(st, &len); // Character paints resolve in the text's space (`to_user` maps it into user space). let (bounds, to_user) = space; let saved = std::mem::replace(&mut self.xf, to_user); @@ -1526,7 +1576,7 @@ impl Writer<'_> { /// as the canvas paints them, the object's own fills and strokes on the glyph outlines: those /// below the Characters row under the characters, the others over them. fn text_node(&mut self, n: &Node, t: &TextObject) { - let chars = |w: &mut Self, n: &Node| if w.opts.outline_text { w.text_outlines(n, t) } else { w.text(n, t) }; + let chars = |w: &mut Self, n: &Node| if w.opts.outline_text || t.vertical { w.text_outlines(n, t) } else { w.text(n, t) }; if !n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { return chars(self, n); } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 83da2c122..1506d395f 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -25,6 +25,7 @@ mod files; mod fx; mod spread; mod text; +mod vector_effect; pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { let (svg, found) = prepass(svg, opts); @@ -62,6 +63,8 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec, /// The files `` elements link to. files: files::Files, + /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). + non_scaling: HashSet, + /// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling + /// stroke's width is measured in. + screen: f64, } /// A `` made a symbol: its name, its art with no object ids (what each `` has to show @@ -296,6 +304,8 @@ struct Found { uses: HashMap, /// The files `` elements link to. files: files::Files, + /// Ids of the shapes whose strokes don't scale ([`vector_effect`]). + non_scaling: HashSet, warnings: Vec, } @@ -361,11 +371,12 @@ impl Edits { /// ([`MADE_UP_ID`]…), recorded with its URL or symbol; /// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded, /// to come back hidden; one something links to (a `` template) stays as it is; +/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]); /// * linked files are read ([`files`]). fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) - if !["(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { found.uses.insert(edits.id(svg, u), sym.to_string()); } } - if svg.contains("display") { - hidden_objects(svg, &xml, &mut edits, &mut found.hidden); + let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING)); + if display || non_scaling { + let css = css::Styles::new(&xml); + if display { + hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden); + } + if non_scaling { + vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings); + } } found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings); (edits.apply(svg), found) @@ -395,8 +413,7 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"]; /// Show the undisplayed objects outside `` (and the like) for usvg, recording their ids. -fn hidden_objects(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet) { - let css = css::Styles::new(xml); +fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits, ids: &mut HashSet) { let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); let mut todo: Vec = xml.root_element().children().filter(XNode::is_element).collect(); while let Some(n) = todo.pop() { @@ -659,19 +676,18 @@ impl Importer { if !matches!(kids.get(main), Some(usvg::Node::Path(p)) if p.is_visible()) { return Some(vec![]); } - let text::PendingText { name, mut obj, servers, paints, .. } = self.slots.texts.get(i)?.clone(); + let text::PendingText { name, mut obj, servers, paints, non_scaling, under, .. } = self.slots.texts.get(i)?.clone(); // usvg resolved the `url(#…)` paints (the paths after the main one) in the element's user // space; runs paint in text space. let to_text = obj.xf.inverse(); - let resolved: Vec = (0..paints) - .map(|k| match kids.get(main + 1 + k) { - Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { - let area = kurbo::Shape::bounding_box(&bezpath(p.data(), to_text)); - self.paint(f.paint(), to_text, Some(area)) - }), - _ => Paint::None, - }) - .collect(); + let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) { + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { + let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m)); + im.paint(f.paint(), m, Some(area)) + }), + _ => Paint::None, + }; + let resolved: Vec = (0..paints).map(|k| slot_paint(self, k, to_text)).collect(); let server = |k: usize| resolved.get(k).cloned().unwrap_or(Paint::None); for (run, (fill, stroke)) in obj.runs.iter_mut().zip(servers) { if let Some(k) = fill { @@ -682,7 +698,25 @@ impl Importer { } } obj.xf = ts * obj.xf; - Some(vec![Arc::new(self.named(&name, NodeKind::Text(Box::new(obj))))]) + if non_scaling && !vector_effect::unscale_text(&mut obj, self.screen) { + let label = if name.is_empty() || made_up(&name) { "a text".to_string() } else { format!("text '{name}'") }; + self.warn(format!("non-scaling stroke on {label} approximated: the text is stretched or skewed")); + } + // A stroke under the characters is the object's own: it paints in the document. + let under = under.map(|(mut sl, k)| { + if let Some(k) = k { + sl.paint = slot_paint(self, k, ts); + } + let mut ap = Appearance { items: vec![AppearanceItem::Stroke(sl)], ..Default::default() }; + ap.scale_strokes(if non_scaling { self.screen } else { obj.xf.determinant().abs().sqrt() }); + ap.set_contents_at(1); + ap + }); + let mut n = self.named(&name, NodeKind::Text(Box::new(obj))); + if let Some(ap) = under { + n.appearance = ap; + } + Some(vec![Arc::new(n)]) } fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option { @@ -930,7 +964,9 @@ impl Importer { } let mut stroke = None; if let Some(s) = p.stroke() { - let scale = m.determinant().abs().sqrt(); + // A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a + // stroke of that width in the document is exact. + let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() }; // The stroke reaches past the path (miters further: a generous margin). let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); @@ -1025,10 +1061,15 @@ impl Importer { /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. /// Its ids are its own: they mean nothing in the SVG around it. fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option { - let outer = - (std::mem::take(&mut self.links), std::mem::take(&mut self.hidden), std::mem::take(&mut self.uses), std::mem::take(&mut self.labels)); + let outer = ( + std::mem::take(&mut self.links), + std::mem::take(&mut self.hidden), + std::mem::take(&mut self.uses), + std::mem::take(&mut self.labels), + std::mem::take(&mut self.non_scaling), + ); let art = self.group_node(tree.root(), acc); - (self.links, self.hidden, self.uses, self.labels) = outer; + (self.links, self.hidden, self.uses, self.labels, self.non_scaling) = outer; art } } diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 18f282f34..b6ae4bd29 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -26,13 +26,13 @@ use std::str::FromStr; use usvg::roxmltree; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, StrokeLayer, TextKind, TextObject, TextRun}; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect}; use vectorcraft_text::{FontDb, TextLayout}; use super::css::{Styles, XNode}; -use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href}; +use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect}; use crate::xml_escape; const SVG_NS: &str = "http://www.w3.org/2000/svg"; @@ -72,6 +72,8 @@ struct ElemStyle { stroke: Option, /// Extra space after each space character (`word-spacing`). word_spacing: f64, + /// `paint-order` paints the stroke before the fill. + stroke_first: bool, } /// One character of a text and the adjustments its position needs. @@ -82,6 +84,8 @@ struct Cell { stroke: Option, /// Space added after the character, in points (becomes manual kerning). kern: f64, + /// Its stroke paints before its fill (`paint-order`). + stroke_first: bool, } impl Cell { @@ -123,6 +127,11 @@ pub(super) struct PendingText { pub servers: Vec<(Option, Option)>, /// Number of `url(#…)` paints (the placeholder paths after the main one, in order). pub paints: usize, + /// `vector-effect="non-scaling-stroke"`: the character strokes keep their width on screen. + pub non_scaling: bool, + /// The character stroke `paint-order` puts under the characters (in text space; it becomes + /// the object's own stroke below the Characters row) and the index of its `url(#…)` paint. + pub under: Option<(StrokeLayer, Option)>, /// The placeholder rectangle in the element's user space. marker: Rect, } @@ -410,7 +419,10 @@ impl Ctx { (st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None); } let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); - let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing }); + let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; + let at = |k| order.iter().position(|o| *o == k); + let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill); + let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first }); self.elems.borrow_mut().insert(n.id(), e.clone()); e } @@ -534,9 +546,11 @@ impl Ctx { fill: paint_index(&e.fill), stroke: paint_index(&e.stroke), kern: if *ch == ' ' { e.word_spacing } else { 0.0 }, + stroke_first: e.stroke_first, } }) .collect(); + let under = stroke_under(&mut cells, label, warnings); let anchor = match self.css.prop(cs.chars[0].1, "text-anchor").as_deref() { Some("middle") => 0.5, @@ -602,10 +616,36 @@ impl Ctx { let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default(); let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0)); let name = t.attribute("id").unwrap_or("").to_string(); - Some((PendingText { name, obj, servers, paints: paints.len(), marker }, paints)) + let non_scaling = vector_effect::is_non_scaling(&self.css, t); + Some((PendingText { name, obj, servers, paints: paints.len(), non_scaling, under, marker }, paints)) } } +/// `paint-order` with the stroke first: the characters' stroke moves under all of them, as the +/// object's own stroke (returned with its `url(#…)` paint index), and their own strokes go. Only +/// when every character has that same stroke under a fill; otherwise a warning. +fn stroke_under(cells: &mut [Cell], label: &str, warnings: &mut Vec) -> Option<(StrokeLayer, Option)> { + let stroked = |c: &Cell| (c.stroke.is_some() || !c.style.stroke.is_none()) && c.style.stroke_width > 0.0; + let filled = |c: &Cell| c.fill.is_some() || !c.style.fill.is_none(); + // Spaces paint nothing. + let mut ink = cells.iter().filter(|c| !c.ch.is_whitespace()); + if !ink.clone().any(|c| c.stroke_first && stroked(c) && filled(c)) { + return None; + } + let key = |c: &Cell| (c.stroke_first && stroked(c)).then(|| (c.style.stroke_layer(), c.stroke)); + let first = ink.next().and_then(key); + if first.is_none() || !ink.all(|c| key(c) == first) { + warnings.push(format!("paint-order on {label} ignored: its characters' strokes differ")); + return None; + } + let none = CharStyle::default().stroke_layer(); + for c in cells.iter_mut() { + c.style.set_stroke_layer(&none); + c.stroke = None; + } + first +} + /// `v`, or 0 when far-off positions added up past f64's range. fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } diff --git a/crates/svg/src/import/vector_effect.rs b/crates/svg/src/import/vector_effect.rs new file mode 100644 index 000000000..4a80a2fa0 --- /dev/null +++ b/crates/svg/src/import/vector_effect.rs @@ -0,0 +1,79 @@ +//! `vector-effect="non-scaling-stroke"`: the stroke keeps its width (and dashes) in screen space +//! whatever the transforms above it. usvg knows the property but drops it, so [`find`] records +//! the elements that ask for it before usvg runs. Their transforms are baked into the path, so a +//! document-space stroke of the screen width is exact; type and the art usvg drops ids from +//! (patterns, markers, `` copies) can't always keep it, and a warning says so. + +use std::collections::HashSet; + +use usvg::roxmltree; +use vectorcraft_doc::TextObject; +use vectorcraft_geom::Affine; + +use super::css::{Styles, XNode}; +use super::{Edits, href}; + +/// The property value this module reads. +pub(super) const NON_SCALING: &str = "non-scaling-stroke"; + +/// The shapes the property applies to (`` is read by the text import). +const SHAPES: &[&str] = &["path", "rect", "circle", "ellipse", "line", "polyline", "polygon"]; + +/// Does element `n` ask for a non-scaling stroke? +pub(super) fn is_non_scaling(css: &Styles, n: XNode) -> bool { + css.own(n, "vector-effect").is_some_and(|v| v.trim() == NON_SCALING) +} + +/// Record the ids of the shapes with a non-scaling stroke (made up for those without one) in +/// `ids`, and warn about those shown where usvg drops ids: their strokes scale like others. +pub(super) fn find(svg: &str, xml: &roxmltree::Document, css: &Styles, edits: &mut Edits, ids: &mut HashSet, warnings: &mut Vec) { + let reused: HashSet<&str> = + xml.descendants().filter(|n| n.tag_name().name() == "use").filter_map(href).filter_map(|h| h.trim().strip_prefix('#')).collect(); + for n in xml.descendants().filter(|n| n.is_element() && SHAPES.contains(&n.tag_name().name()) && is_non_scaling(css, *n)) { + // usvg drops the ids of marker and `` copies; a pattern's paint scales its art. + let placed = n.ancestors().any(|a| matches!(a.tag_name().name(), "pattern" | "marker" | "symbol")); + if placed || n.ancestors().any(|a| a.attribute("id").is_some_and(|id| reused.contains(id))) { + let label = n.attribute("id").map_or_else(|| format!("a {}", n.tag_name().name()), |id| format!("'{id}'")); + let w = format!("non-scaling stroke on {label} in a pattern, marker or reused element: it scales with the transform there"); + if !warnings.contains(&w) { + warnings.push(w); + } + } + if !placed { + ids.insert(edits.id(svg, n)); + } + } +} + +/// Is `m` free of non-uniform scaling and skew (a uniform scale, rotation and reflection)? +pub(super) fn conformal(m: Affine) -> bool { + let [a, b, c, d, _, _] = m.as_coeffs(); + let tol = 1e-9 * (a.abs() + b.abs() + c.abs() + d.abs()).max(1e-12); + ((a - d).abs() <= tol && (b + c).abs() <= tol) || ((a + d).abs() <= tol && (b - c).abs() <= tol) +} + +/// Give the character strokes of `t` (whose `xf` maps text space to the document) the widths and +/// dashes they have in screen space, `screen` points per screen pixel. `false` when the text is +/// stretched or skewed, so its strokes can only be approximated. +pub(super) fn unscale_text(t: &mut TextObject, screen: f64) -> bool { + let k = screen / t.xf.determinant().abs().sqrt(); + if !k.is_finite() || k <= 0.0 { + return false; + } + t.scale_char_strokes(k); + conformal(t.xf) || !t.runs.iter().any(|r| r.style.has_stroke()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conformal_transforms_keep_circles_round() { + assert!(conformal(Affine::scale(3.0))); + assert!(conformal(Affine::rotate(0.7) * Affine::scale(2.0))); + assert!(conformal(Affine::scale_non_uniform(-2.0, 2.0))); + assert!(!conformal(Affine::scale_non_uniform(2.0, 1.0))); + assert!(!conformal(Affine::skew(0.5, 0.0))); + } +} diff --git a/crates/svg/tests/font_weights.rs b/crates/svg/tests/font_weights.rs new file mode 100644 index 000000000..f642930c5 --- /dev/null +++ b/crates/svg/tests/font_weights.rs @@ -0,0 +1,78 @@ +//! Font weights in SVG and CSS: every style is written with its numeric weight (Semibold 600, +//! Light 300, Black 900…; Bold as `bold`), and SVG import reads the numbers back to the same face. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, TextObject}; +use vectorcraft_geom::Point; +use vectorcraft_svg::{CssOptions, ExportOptions, css_rules, export, import}; +use vectorcraft_text::FontDb; + +/// A document with one text in `family` `style`. +fn doc(family: &str, style: &str) -> (Document, vectorcraft_doc::NodeId) { + let mut d = Document::new(300.0, 100.0); + let st = CharStyle { font_family: family.into(), font_style: style.into(), size: 24.0, ..CharStyle::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), "Weight", st)))); + let (l, id) = (d.layers[0].id, n.id); + d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +/// The `font-weight` the SVG export and the CSS of the text write. +fn weights(style: &str) -> (Option, Option) { + let (d, id) = doc("Source Sans 3", style); + let svg = export(&d, &ExportOptions::default()); + let attr = svg.split("font-weight=\"").nth(1).map(|s| s.split('"').next().unwrap().to_string()); + let sheet = css_rules(&d, &[id], &CssOptions::default()); + let css = sheet.rules[0].props.iter().find(|(k, _)| *k == "font-weight").map(|(_, v)| v.clone()); + (attr, css) +} + +fn style_back(family: &str, style: &str) -> String { + let svg = export(&doc(family, style).0, &ExportOptions::default()); + let mut found = None; + import(&svg).unwrap().walk(|n| { + if let NodeKind::Text(t) = &n.kind { + found = Some(t.runs[0].style.font_style.clone()); + } + }); + found.expect("the text comes back") +} + +#[test] +fn every_style_is_written_with_its_numeric_weight() { + for (style, want) in [ + ("Thin", Some("100")), + ("ExtraLight", Some("200")), + ("Light", Some("300")), + ("Regular", None), + ("Italic", None), + ("Medium", Some("500")), + ("Semibold", Some("600")), + ("Demibold", Some("600")), + ("Bold", Some("bold")), + ("Bold Italic", Some("bold")), + ("Extrabold", Some("800")), + ("Black", Some("900")), + ("Heavy", Some("900")), + ] { + let want = want.map(str::to_string); + assert_eq!(weights(style), (want.clone(), want), "{style}"); + } +} + +#[test] +fn numeric_weights_read_back_to_the_style() { + let db = FontDb::global(); + for (family, style) in + [("Source Sans 3", "Semibold"), ("Source Sans 3", "Bold"), ("Source Sans 3", "Italic"), ("Inter", "Medium"), ("Inter", "SemiBold")] + { + let back = style_back(family, style); + let face = |s: &str| db.face(family, s).map(|f| f.style.clone()); + assert_eq!(face(&back), face(style), "{family} {style} came back as {back}"); + } + // Styles the family doesn't have keep their weight. + assert_eq!(style_back("Source Sans 3", "Light"), "Light"); + assert_eq!(style_back("Source Sans 3", "Black Italic"), "Black Italic"); + assert_eq!(style_back("Source Sans 3", "Heavy"), "Black"); +} diff --git a/crates/svg/tests/non_scaling_stroke.rs b/crates/svg/tests/non_scaling_stroke.rs new file mode 100644 index 000000000..e82f7c063 --- /dev/null +++ b/crates/svg/tests/non_scaling_stroke.rs @@ -0,0 +1,94 @@ +//! `vector-effect="non-scaling-stroke"` on import: the stroke keeps its screen width and dashes +//! whatever the transforms above it, on shapes and type; a warning where it can't be kept. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_doc::{Document, Node, NodeKind, TextObject}; +use vectorcraft_svg::import_with_report; + +/// The stroke width and dash pattern of the object named `name`. +fn stroke(d: &Document, name: &str) -> (f64, Option>) { + let mut found = None; + d.walk(|n: &Node| { + if n.name.as_deref() == Some(name) { + let s = n.appearance.stroke().expect("stroked"); + found = Some((s.width, s.dash.as_ref().map(|d| d.pattern.clone()))); + } + }); + found.unwrap_or_else(|| panic!("no object '{name}'")) +} + +fn text(d: &Document) -> TextObject { + let mut found = None; + d.walk(|n: &Node| { + if let NodeKind::Text(t) = &n.kind { + found = Some((**t).clone()); + } + }); + found.expect("a text") +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn a_non_scaling_stroke_keeps_its_width_under_a_scale() { + let svg = r##" + + + + + "##; + let (d, warnings) = import_with_report(svg).unwrap(); + assert!(close(stroke(&d, "red").0, 12.0)); + assert!(close(stroke(&d, "green").0, 4.0), "{:?}", stroke(&d, "green")); + assert!(warnings.is_empty(), "{warnings:?}"); +} + +#[test] +fn widths_and_dashes_are_in_screen_pixels_past_the_view_box() { + // The viewBox makes a user unit 2 px; a rule asks for the non-scaling stroke. + let svg = r##" + + + + "##; + let (d, _) = import_with_report(svg).unwrap(); + let (w, dash) = stroke(&d, "box"); + assert!(close(w, 2.0) && dash == Some(vec![6.0, 3.0]), "{w} {dash:?}"); + assert!(close(stroke(&d, "ring").0, 4.0)); + + // In a root sized in millimetres a CSS pixel is 0.75 pt. + let mm = svg.replace(r#"width="200" height="100""#, r#"width="200mm" height="100mm""#); + let (d, _) = import_with_report(&mm).unwrap(); + assert!(close(stroke(&d, "box").0, 1.5), "a 2 px stroke is 1.5 pt: {:?}", stroke(&d, "box")); +} + +#[test] +fn a_reused_non_scaling_stroke_warns() { + let svg = r##" + + + "##; + let (_, warnings) = import_with_report(svg).unwrap(); + assert!(warnings.iter().any(|w| w.contains("non-scaling stroke on 'tick'")), "{warnings:?}"); +} + +#[test] +fn type_keeps_its_non_scaling_stroke_width() { + let svg = r##" + Ab + "##; + let (d, warnings) = import_with_report(svg).unwrap(); + let t = text(&d); + let st = &t.runs[0].style; + // Text space is scaled 2×: half the width there is the width on screen. + assert!(close(st.stroke_width * t.xf.determinant().sqrt(), 3.0), "{} {:?}", st.stroke_width, t.xf); + assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0])); + assert!(warnings.is_empty(), "{warnings:?}"); + + let (_, warnings) = import_with_report(&svg.replace("scale(2)", "scale(2 1)")).unwrap(); + assert!(warnings.iter().any(|w| w.contains("non-scaling stroke on a text approximated")), "{warnings:?}"); +} diff --git a/crates/svg/tests/text_paint_order.rs b/crates/svg/tests/text_paint_order.rs new file mode 100644 index 000000000..da0038750 --- /dev/null +++ b/crates/svg/tests/text_paint_order.rs @@ -0,0 +1,96 @@ +//! `paint-order="stroke"` on imported type: the characters' stroke becomes the object's own stroke +//! below the Characters row, so it paints under the letters as on shapes. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, StrokeLayer, TextObject}; +use vectorcraft_svg::import_with_report; +use vectorcraft_testkit::raster::render_artboard; + +const HALO: &str = r##" + + Ab +"##; + +fn text(d: &Document) -> (Node, TextObject) { + let mut found = None; + d.walk(|n: &Node| { + if let NodeKind::Text(t) = &n.kind { + found = Some((n.clone(), (**t).clone())); + } + }); + found.expect("a text") +} + +fn under(n: &Node) -> &StrokeLayer { + assert_eq!((n.appearance.items.len(), n.appearance.contents_at()), (1, 1), "one stroke below the characters: {:?}", n.appearance); + match &n.appearance.items[0] { + AppearanceItem::Stroke(s) => s, + i => panic!("{i:?}"), + } +} + +/// Pixels of the letters' yellow left showing (a stroke drawn over them hides them). +fn yellow_letters(d: &Document) -> usize { + let img = render_artboard(d); + (100..190).flat_map(|x| (0..80).map(move |y| (x, y))).filter(|&(x, y)| img.over_white(x, y) == [0xff, 0xcc, 0]).count() +} + +#[test] +fn a_stroke_first_text_strokes_under_its_characters() { + let (d, warnings) = import_with_report(HALO).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (n, t) = text(&d); + let s = under(&n); + assert_eq!((s.paint.clone(), s.width, s.join), (Paint::solid(Color::BLACK), 8.0, vectorcraft_doc::LineJoin::Round)); + assert!(t.runs.iter().all(|r| !r.style.has_stroke() && r.style.fill == Paint::solid(Color::rgb8(0xff, 0xcc, 0))), "{:?}", t.runs); + + // The letters stay yellow inside their black halo. + assert!(yellow_letters(&d) > 150, "{}", yellow_letters(&d)); +} + +#[test] +fn the_stroke_under_scales_with_the_text_and_keeps_its_gradient() { + let svg = r##" + + Ab + "##; + let (d, warnings) = import_with_report(svg).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (n, t) = text(&d); + let s = under(&n); + assert_eq!(s.width, 6.0); + let Paint::Gradient(g) = &s.paint else { panic!("{:?}", s.paint) }; + // The bounding-box gradient spans the text's ink in the document (x from about 20). + let geom = g.geom.unwrap(); + assert!(geom.start.x > 15.0 && geom.end.x > geom.start.x + 40.0, "{geom:?}"); + assert!(t.runs.iter().all(|r| !r.style.has_stroke())); + + // Non-scaling: the stroke under the characters is as wide as on screen. + let svg = svg.replace("paint-order", r#"vector-effect="non-scaling-stroke" paint-order"#); + let (d, _) = import_with_report(&svg).unwrap(); + assert_eq!(under(&text(&d).0).width, 3.0); +} + +#[test] +fn characters_with_different_strokes_keep_them_and_warn() { + let svg = r##" + Ab + "##; + let (d, warnings) = import_with_report(svg).unwrap(); + assert!(warnings.iter().any(|w| w.contains("paint-order on text 'mixed'")), "{warnings:?}"); + let (n, t) = text(&d); + assert!(n.appearance.items.is_empty()); + assert!(t.runs.iter().all(|r| r.style.has_stroke())); +} + +#[test] +fn normal_paint_order_leaves_the_character_strokes() { + let (d, warnings) = import_with_report(&HALO.replace(r#"paint-order="stroke">Ab"#, ">Ab")).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (n, t) = text(&d); + assert!(n.appearance.items.is_empty() && t.runs.iter().all(|r| r.style.has_stroke())); + assert!(yellow_letters(&d) < 20, "{}", yellow_letters(&d)); +} diff --git a/crates/text/build.rs b/crates/text/build.rs new file mode 100644 index 000000000..1bdbbd31d --- /dev/null +++ b/crates/text/build.rs @@ -0,0 +1,74 @@ +//! The optional craft-fonts build input (https://github.com/storytold/craft-fonts, +//! `docs/integration.md`): with `CRAFT_FONTS_DIR=` set, every font in its +//! `fonts/manifest.txt` is embedded as `CRAFT_FONTS`; unset, `CRAFT_FONTS` is empty and the app +//! uses its own and the installed fonts. `CRAFT_FONTS_REQUIRED=1` turns a bad checkout into a build +//! error (release builds). Nothing is fetched: the build reads only the local checkout. +//! +//! Web (wasm32) builds embed only the UI font, BIZ UDPGothic Regular (~4.7 MB): the browser has no +//! system fonts, and all four fonts (~24 MB) would push the `.wasm` past static hosts' per-file +//! limits (Cloudflare Pages: 25 MiB). + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +fn main() { + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { + let dir = from_workspace(dir); + match craft_fonts(&dir) { + Ok(entries) => src.push_str(&entries), + Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { + println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + } + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + src.push_str("];\n"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + if let Err(e) = std::fs::write(&out, src) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// A relative `CRAFT_FONTS_DIR` is taken from the workspace root (where `cargo` is usually run), +/// not from this crate's directory, which is where build scripts run. +fn from_workspace(dir: PathBuf) -> PathBuf { + if dir.is_absolute() { + return dir; + } + let crate_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()); + crate_dir.join("../..").join(dir) +} + +/// The one craft-fonts face web builds embed (family, style). +const WEB_FONT: (&str, &str) = ("BIZ UDPGothic", "Regular"); + +/// One `CraftFont { .. }` initialiser per manifest line. +fn craft_fonts(dir: &Path) -> Result { + let manifest = dir.join("fonts/manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = String::new(); + for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); + let [family, style, file, scripts, ..] = f.as_slice() else { + return Err(format!("malformed manifest line: {line}")); + }; + if wasm && !(*family == WEB_FONT.0 && *style == WEB_FONT.1) { + continue; + } + let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; + println!("cargo::rerun-if-changed={}", path.display()); + let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); + let _ = writeln!( + out, + " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},", + scripts.join(", "), + path.display().to_string(), + ); + } + Ok(out) +} diff --git a/crates/text/src/craft_fonts.rs b/crates/text/src/craft_fonts.rs new file mode 100644 index 000000000..00b942709 --- /dev/null +++ b/crates/text/src/craft_fonts.rs @@ -0,0 +1,52 @@ +//! Fonts from the optional craft-fonts build input (). +//! +//! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its +//! manifest (today the Japanese UI and document fonts); built without it, it is empty and Japanese +//! text falls back to the installed system fonts. Release builds always set it. See `build.rs` +//! and craftrules `standards/fonts.md`. + +/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the font is for, e.g. `"Jpan"`. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); + +impl CraftFont { + /// Is the font for Japanese text? + pub fn is_japanese(&self) -> bool { + self.scripts.contains(&"Jpan") + } + /// A serif (Mincho) family, for document text. + pub fn is_mincho(&self) -> bool { + self.family.contains("Mincho") + } + /// A sans (Gothic) family, for UI text. + pub fn is_gothic(&self) -> bool { + self.family.contains("Gothic") + } + /// A bold face. + pub fn is_bold(&self) -> bool { + crate::style_weight(self.style) >= 600.0 + } +} + +/// The craft-fonts faces for Japanese text, best for document text first: Mincho (serif) families, +/// then the others, regular weights before bold. Empty when built without craft-fonts. +pub fn japanese_document_fonts() -> Vec<&'static CraftFont> { + let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect(); + v.sort_by_key(|f| (!f.is_mincho(), f.is_bold())); + v +} + +/// The craft-fonts faces for Japanese text, best for UI text first: Gothic (sans) families, then +/// the others; `bold` puts bold faces before regular ones. Empty when built without craft-fonts. +pub fn japanese_ui_fonts(bold: bool) -> Vec<&'static CraftFont> { + let mut v: Vec<&'static CraftFont> = CRAFT_FONTS.iter().filter(|f| f.is_japanese()).collect(); + v.sort_by_key(|f| (!f.is_gothic(), f.is_bold() != bold)); + v +} diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 64af1706e..ade5c8a17 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -7,6 +7,8 @@ use vectorcraft_doc::CharStyle; /// by the character style (`kerning`, `all_caps`, `tracking`); the rest are layout-wide options. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct OtFeatures { + /// Vertical glyph alternates (vert/vrt2), set by the writing direction. + pub vertical: bool, /// Standard ligatures (`liga`, `clig`). Suppressed automatically when tracking is non-zero. pub ligatures: bool, /// Contextual alternates (`calt`). @@ -30,6 +32,7 @@ pub struct OtFeatures { impl Default for OtFeatures { fn default() -> Self { Self { + vertical: false, ligatures: true, contextual: true, discretionary_ligatures: false, @@ -138,6 +141,10 @@ impl OtFeatures { v.push(f(tag, true)); } } + if self.vertical { + v.push(f(b"vert", true)); + v.push(f(b"vrt2", true)); + } v } } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 63c9e2b4d..ff5fa7a11 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -1,7 +1,9 @@ -//! Font database: bundled OFL fonts, user fonts, optional system font catalog, outline cache. +//! Font database: bundled OFL fonts, user fonts, the installed system fonts (cataloged once, loaded +//! on demand), outline cache. use std::collections::HashMap; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; @@ -139,21 +141,34 @@ impl FontFace { } } -#[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Debug)] -struct CatalogEntry { - family: String, - style: String, - path: std::path::PathBuf, +/// One installed family found by the system font scan: its name and the file of each style. +#[derive(Debug, Default)] +struct CatalogFamily { + name: String, + /// (style, file) of each face. + faces: Vec<(String, PathBuf)>, } +/// The installed fonts by ASCII-lowercased family name (lookups ignore ASCII case, as +/// [`FontDb::face`] does). Always empty on wasm, which has no system fonts. +type Catalog = HashMap; + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, + catalog: RwLock, + /// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + font_dirs: Vec, + /// Set once the font folders have been scanned. Lookups by family name wait for the first + /// scan, so what they find never depends on what ran before them. #[cfg(not(target_arch = "wasm32"))] - catalog: RwLock>, - /// System fallback state: scanned yet, and characters no system font covers. + cataloged: std::sync::OnceLock<()>, + /// [`FontDb::family_list`], built on demand and dropped when fonts are added or rescanned. + family_cache: Mutex>>, + generation: AtomicU64, + /// System fallback state: characters no system font covers. #[cfg(not(target_arch = "wasm32"))] sys: Mutex, } @@ -162,7 +177,6 @@ pub struct FontDb { #[derive(Default)] struct SysFallback { enabled: bool, - scanned: bool, misses: std::collections::HashSet, } @@ -203,6 +217,13 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } +/// A face's (family, style) names. +fn face_names(f: &skrifa::FontRef<'_>) -> Option<(String, String)> { + let family = name(f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; + let style = name(f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + Some((family, style)) +} + /// Parse every face in `data` (a font file or collection). Returns `(index, family, style)`. fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { let count = match FileRef::new(data) { @@ -212,14 +233,90 @@ fn enumerate_faces(data: &[u8]) -> Vec<(u32, String, String)> { }; (0..count) .filter_map(|i| { - let f = skrifa::FontRef::from_index(data, i).ok()?; - let family = name(&f, &[StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME])?; - let style = name(&f, &[StringId::TYPOGRAPHIC_SUBFAMILY_NAME, StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into()); + let (family, style) = face_names(&skrifa::FontRef::from_index(data, i).ok()?)?; Some((i, family, style)) }) .collect() } +/// (family, style) of every face in the font file at `path`, reading only its table directories +/// and `name` tables: a scan opens hundreds of font files, many of them megabytes long. +#[cfg(not(target_arch = "wasm32"))] +fn file_face_names(path: &Path) -> Vec<(String, String)> { + use std::io::{Read, Seek, SeekFrom}; + /// Caps on what a (possibly damaged) file can make the scan read. + const MAX_FACES: u32 = 256; + const MAX_NAME_TABLE: u32 = 1 << 20; + let Ok(mut file) = std::fs::File::open(path) else { return vec![] }; + let mut read_at = |offset: u64, len: usize| -> Option> { + let mut buf = vec![0; len]; + file.seek(SeekFrom::Start(offset)).ok()?; + file.read_exact(&mut buf).ok()?; + Some(buf) + }; + let be32 = |b: &[u8], at: usize| b.get(at..at + 4).and_then(|s| s.try_into().ok()).map(u32::from_be_bytes); + let Some(head) = read_at(0, 12) else { return vec![] }; + // A collection lists where each face's table directory starts. + let starts: Vec = if head.starts_with(b"ttcf") { + let n = be32(&head, 8).unwrap_or(0).min(MAX_FACES) as usize; + read_at(12, n * 4).map(|b| b.as_chunks::<4>().0.iter().map(|c| u32::from_be_bytes(*c)).collect()).unwrap_or_default() + } else { + vec![0] + }; + starts + .into_iter() + .filter_map(|start| { + let dir = read_at(start.into(), 12)?; + let tables = u16::from_be_bytes(dir.get(4..6)?.try_into().ok()?) as usize; + let records = read_at(u64::from(start) + 12, tables * 16)?; + let rec: &[u8] = records.as_chunks::<16>().0.iter().find(|r| r.starts_with(b"name"))?; + let (offset, len) = (be32(rec, 8)?, be32(rec, 12)?); + if len > MAX_NAME_TABLE { + return None; + } + let table = read_at(offset.into(), len as usize)?; + // A one-table font holding just the `name` table, to read it as the font itself would. + let mut font = Vec::with_capacity(28 + table.len()); + font.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + font.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]); + font.extend_from_slice(b"name"); + for v in [0, 28, len] { + font.extend_from_slice(&u32::to_be_bytes(v)); + } + font.extend_from_slice(&table); + face_names(&skrifa::FontRef::new(&font).ok()?) + }) + .collect() +} + +/// The platform's font folders (the system's and the user's), scanned by [`FontDb::global`]. +pub fn system_font_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + if cfg!(target_arch = "wasm32") { + return dirs; + } + let home = std::env::var_os("HOME").map(PathBuf::from); + if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join("Library/Fonts")); + } + } else if cfg!(windows) { + let root = std::env::var_os("WINDIR").map(PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + dirs.push(root.join("Fonts")); + if let Some(l) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); + } + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); + if let Some(h) = &home { + dirs.push(h.join(".fonts")); + dirs.push(h.join(".local/share/fonts")); + } + } + dirs +} + fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: Option) -> Option { let data: &[u8] = match &bytes { FontBytes::Static(b) => b, @@ -247,8 +344,13 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, path: }) } +/// `s` lowercased, without anything but letters and digits. +fn norm_chars(s: &str) -> impl Iterator + '_ { + s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase) +} + fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() + norm_chars(s).collect() } /// Weight implied by a style name (400 = regular). @@ -278,9 +380,13 @@ fn style_italic(style: &str) -> bool { } impl FontDb { - fn new_bundled() -> Self { + /// A database holding the bundled fonts, whose system font scan reads `font_dirs`. + pub fn with_font_dirs(font_dirs: Vec) -> Self { let mut faces = Vec::new(); - for data in BUNDLED { + // The bundled fonts, then the Japanese craft-fonts faces (when built with them), Mincho + // first: fallbacks for Japanese text after the requested and bundled fonts. + let craft = crate::craft_fonts::japanese_document_fonts().into_iter().map(|f| f.bytes); + for data in BUNDLED.iter().copied().chain(craft) { for (i, family, style) in enumerate_faces(data) { if let Some(f) = make_face(FontBytes::Static(data), i, family, style, None) { faces.push(Arc::new(f)); @@ -290,8 +396,12 @@ impl FontDb { Self { faces: RwLock::new(faces), outlines: Mutex::new(HashMap::new()), + catalog: RwLock::new(Catalog::new()), + font_dirs, #[cfg(not(target_arch = "wasm32"))] - catalog: RwLock::new(Vec::new()), + cataloged: std::sync::OnceLock::new(), + family_cache: Mutex::new(None), + generation: AtomicU64::new(0), #[cfg(not(target_arch = "wasm32"))] sys: Mutex::new(SysFallback { enabled: true, ..Default::default() }), } @@ -308,34 +418,65 @@ impl FontDb { let _ = on; } - /// Process-wide database preloaded with the bundled fonts. + /// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the + /// first time a lookup by family name needs them (or ahead of time by + /// [`FontDb::scan_in_background`]). pub fn global() -> &'static FontDb { static DB: std::sync::OnceLock = std::sync::OnceLock::new(); - DB.get_or_init(FontDb::new_bundled) + DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs())) } fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec>> { self.faces.read().unwrap_or_else(|e| e.into_inner()) } - /// Family names available (loaded plus cataloged system fonts), sorted and deduplicated. - pub fn families(&self) -> Vec { - let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); - #[cfg(not(target_arch = "wasm32"))] - v.extend(self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|c| c.family.clone())); - v.sort_by_key(|a| a.to_lowercase()); - v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - v + fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Catalog> { + self.ensure_catalog(); + self.catalog.read().unwrap_or_else(|e| e.into_inner()) } - /// Style names available for `family` (Regular first, then by weight). + /// Fonts were added or the catalog changed: cached family lists are stale. + fn changed(&self) { + *self.family_cache.lock().unwrap_or_else(|e| e.into_inner()) = None; + self.generation.fetch_add(1, Ordering::Relaxed); + } + + /// A number that changes whenever the available fonts do (fonts added, system fonts + /// rescanned): lists built from [`FontDb::family_list`] are current while it doesn't. + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Relaxed) + } + + /// Family names available (loaded plus installed system fonts), sorted and deduplicated. + pub fn families(&self) -> Vec { + self.family_list().to_vec() + } + + /// [`FontDb::families`], shared: cheap to call every frame. + pub fn family_list(&self) -> Arc<[String]> { + let catalog = self.read_catalog(); + let mut cache = self.family_cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(list) = cache.as_ref() { + return list.clone(); + } + let mut v: Vec = self.read_faces().iter().map(|f| f.family.clone()).collect(); + v.extend(catalog.values().map(|c| c.name.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let list: Arc<[String]> = v.into(); + *cache = Some(list.clone()); + list + } + + /// Style names available for `family`: upright styles by weight, then italics. pub fn styles(&self, family: &str) -> Vec { let mut v: Vec<(bool, f32, String)> = self.read_faces().iter().filter(|f| f.family.eq_ignore_ascii_case(family)).map(|f| (f.italic, f.weight, f.style.clone())).collect(); - #[cfg(not(target_arch = "wasm32"))] - for c in self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter() { - if c.family.eq_ignore_ascii_case(family) && !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(&c.style)) { - v.push((style_italic(&c.style), style_weight(&c.style), c.style.clone())); + if let Some(c) = self.read_catalog().get(&family.to_ascii_lowercase()) { + for (style, _) in &c.faces { + if !v.iter().any(|(_, _, s)| s.eq_ignore_ascii_case(style)) { + v.push((style_italic(style), style_weight(style), style.clone())); + } } } v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1)).then(a.2.cmp(&b.2))); @@ -350,49 +491,66 @@ impl FontDb { } /// [`FontDb::add_font`] for the bytes of the file at `path` (when known). - fn add_font_from(&self, bytes: Vec, path: Option<&std::path::Path>) -> usize { + fn add_font_from(&self, bytes: Vec, path: Option<&Path>) -> usize { let data = Arc::new(bytes); let mut added = 0; for (i, family, style) in enumerate_faces(&data) { if self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(&family) && f.style.eq_ignore_ascii_case(&style)) { continue; } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(std::path::Path::to_path_buf)) { + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, family, style, path.map(Path::to_path_buf)) { self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); added += 1; } } + if added > 0 { + self.changed(); + } added } - /// Scan the platform's font directories and catalog the faces found (native only; not called by - /// default). Font data is loaded lazily when a cataloged family is first resolved. Returns the - /// number of faces cataloged. + /// Catalog the installed fonts unless that has been done: the first caller scans the font + /// folders, any other waits for that scan to finish. A no-op on wasm. + fn ensure_catalog(&self) { + #[cfg(not(target_arch = "wasm32"))] + self.cataloged.get_or_init(|| { + self.scan_font_dirs(); + }); + } + + /// Catalog the installed fonts on a background thread, so the first lookup by family name + /// (opening a file, the font menus) doesn't wait for the scan. A no-op once they are + /// cataloged, and on wasm. + pub fn scan_in_background(&'static self) { + #[cfg(not(target_arch = "wasm32"))] + if self.cataloged.get().is_none() { + // A failed spawn leaves the scan to the first lookup that needs it. + let _ = std::thread::Builder::new().name("font-scan".into()).spawn(move || self.ensure_catalog()); + } + } + + /// Scan the font folders again (fonts installed or removed since), cataloging the faces + /// found (native only). Font data is loaded when a cataloged family is first resolved. + /// Returns the number of faces cataloged. #[cfg(not(target_arch = "wasm32"))] pub fn load_system_fonts(&self) -> usize { - let mut dirs: Vec = Vec::new(); - let home = std::env::var_os("HOME").map(std::path::PathBuf::from); - if cfg!(target_os = "macos") { - dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(Into::into)); - if let Some(h) = &home { - dirs.push(h.join("Library/Fonts")); - } - } else if cfg!(windows) { - let root = std::env::var_os("WINDIR").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); - dirs.push(root.join("Fonts")); - if let Some(l) = std::env::var_os("LOCALAPPDATA") { - dirs.push(std::path::PathBuf::from(l).join("Microsoft\\Windows\\Fonts")); - } - } else { - dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(Into::into)); - if let Some(h) = &home { - dirs.push(h.join(".fonts")); - dirs.push(h.join(".local/share/fonts")); - } - } - let mut found = Vec::new(); - let mut stack = dirs; + // The first scan, or a rescan once it is done (never both at once). + let mut first = None; + self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs())); + first.unwrap_or_else(|| self.scan_font_dirs()) + } + + #[cfg(not(target_arch = "wasm32"))] + fn scan_font_dirs(&self) -> usize { + let mut catalog = Catalog::new(); + let mut n = 0; + let mut stack = self.font_dirs.clone(); + // Each folder once, however links lead back to it. + let mut visited = std::collections::HashSet::new(); while let Some(d) = stack.pop() { + if !visited.insert(std::fs::canonicalize(&d).unwrap_or_else(|_| d.clone())) { + continue; + } let Ok(rd) = std::fs::read_dir(&d) else { continue }; for e in rd.flatten() { let p = e.path(); @@ -404,26 +562,29 @@ impl FontDb { if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) { continue; } - let Ok(data) = std::fs::read(&p) else { continue }; - for (_, family, style) in enumerate_faces(&data) { - found.push(CatalogEntry { family, style, path: p.clone() }); + for (family, style) in file_face_names(&p) { + let entry = catalog.entry(family.to_ascii_lowercase()).or_default(); + if entry.name.is_empty() { + entry.name = family; + } + entry.faces.push((style, p.clone())); + n += 1; } } } - let n = found.len(); log::debug!("cataloged {n} system font faces"); - *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found; + *self.catalog.write().unwrap_or_else(|e| e.into_inner()) = catalog; + self.changed(); n } + /// Load the files of the installed `family`. Returns whether any face was added. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { - let paths: Vec = { - let cat = self.catalog.read().unwrap_or_else(|e| e.into_inner()); - let mut p: Vec<_> = cat.iter().filter(|c| c.family.eq_ignore_ascii_case(family)).map(|c| c.path.clone()).collect(); - p.dedup(); - p - }; + let mut paths: Vec = + self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|(_, p)| p.clone()).collect()).unwrap_or_default(); + paths.sort(); + paths.dedup(); let mut any = false; for p in paths { if let Ok(data) = std::fs::read(&p) { @@ -434,19 +595,33 @@ impl FontDb { } /// Resolve a family + style to a face, falling back to the closest style of the family, then to - /// Source Sans 3 Regular. `None` only if no font at all is loaded (the bundled fonts failed to - /// parse), in which case text has no glyphs. + /// Source Sans 3 Regular. Installed system fonts are found by name whatever ran before. `None` + /// only if no font at all is loaded (the bundled fonts failed to parse), in which case text has + /// no glyphs. pub fn face(&self, family: &str, style: &str) -> Option> { - if let Some(f) = self.find(family, style) { - return Some(f); + let found = self.find(family, style); + if found.as_ref().is_some_and(|f| norm(&f.style) == norm(style)) { + return found; } + // The family, or this style of it, is installed but not loaded yet. #[cfg(not(target_arch = "wasm32"))] - if self.load_cataloged(family) + if (found.is_none() || self.is_cataloged(family, style)) + && self.load_cataloged(family) && let Some(f) = self.find(family, style) { return Some(f); } - self.find(FALLBACK_FAMILY, style).or_else(|| self.find(FALLBACK_FAMILY, "Regular")).or_else(|| self.read_faces().first().cloned()) + found + .or_else(|| self.find(FALLBACK_FAMILY, style)) + .or_else(|| self.find(FALLBACK_FAMILY, "Regular")) + .or_else(|| self.read_faces().first().cloned()) + } + + /// Is `style` of `family` among the installed fonts? + #[cfg(not(target_arch = "wasm32"))] + fn is_cataloged(&self, family: &str, style: &str) -> bool { + let ns = norm(style); + self.read_catalog().get(&family.to_ascii_lowercase()).is_some_and(|c| c.faces.iter().any(|(s, _)| norm(s) == ns)) } /// The loaded face with [`FontFace::id`] `id` (the face a laid-out glyph came from). @@ -454,12 +629,22 @@ impl FontDb { self.read_faces().iter().find(|f| f.id == id).cloned() } - /// Is `family` available (loaded)? + /// The available family `name` names, ignoring case and anything but letters and digits, as + /// PostScript names write families ("MicrosoftYaHei" is Microsoft YaHei). + pub fn find_family(&self, name: &str) -> Option { + self.family_list().iter().find(|f| norm_chars(f).eq(norm_chars(name))).cloned() + } + + /// Is `family` available (loaded, or installed on the system)? pub fn has_family(&self, family: &str) -> bool { + self.is_loaded(family) || self.read_catalog().contains_key(&family.to_ascii_lowercase()) + } + + pub(crate) fn is_loaded(&self, family: &str) -> bool { self.read_faces().iter().any(|f| f.family.eq_ignore_ascii_case(family)) } - fn find(&self, family: &str, style: &str) -> Option> { + pub(crate) fn find(&self, family: &str, style: &str) -> Option> { let faces = self.read_faces(); let cands: Vec<&Arc> = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)).collect(); if cands.is_empty() { @@ -481,7 +666,7 @@ impl FontDb { } /// First face (fallback family first, then load order) that covers `c`; on native, system - /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. + /// fonts are loaded lazily the first time no loaded face covers a character. pub(crate) fn fallback_for(&self, c: char, exclude: u32) -> Option> { if let Some(f) = self.loaded_fallback(c, exclude) { return Some(f); @@ -493,6 +678,13 @@ impl FontDb { None } + /// A face that covers `c`, for text drawn outside the canvas (the app's own UI): the loaded + /// fonts first, then (native) an installed one, loaded on demand. Characters no font covers + /// are remembered, so they are looked for once. + pub fn face_covering(&self, c: char) -> Option> { + self.fallback_for(c, 0) + } + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); @@ -508,29 +700,18 @@ impl FontDb { return false; } { - let mut sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); + let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner()); if !sys.enabled || sys.misses.contains(&c) { return false; } - if !sys.scanned { - sys.scanned = true; - if self.catalog.read().unwrap_or_else(|e| e.into_inner()).is_empty() { - drop(sys); - self.load_system_fonts(); - } - } } let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); - let cataloged: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.family.clone()).collect(); for fam in SYSTEM_FALLBACKS { - if !self.has_family(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { - self.load_cataloged(fam); - if covered(self) { - return true; - } + if !self.is_loaded(fam) && self.load_cataloged(fam) && covered(self) { + return true; } } - let mut paths: Vec = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect(); + let mut paths: Vec = self.read_catalog().values().flat_map(|c| c.faces.iter().map(|(_, p)| p.clone())).collect(); paths.sort(); paths.dedup(); for p in paths { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c13bd2f5b..afaef60f5 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -14,6 +14,8 @@ use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, PositionedGlyph, T const EPS: f64 = 1e-6; struct Ctx<'a> { + vertical: bool, + on_path: bool, db: &'a FontDb, text: &'a str, runs: Vec<(Range, &'a CharStyle)>, @@ -37,7 +39,22 @@ impl Ctx<'_> { fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { let src = self.db.outline(&g.face, g.gid); let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); - let m = pre * local; + let mut m = pre * local; + let mut origin = origin; + let mut angle = angle; + if self.vertical { + let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + if self.on_path { + // Vertical path type keeps the baseline path and turns each glyph across it. + m = Affine::translate(origin.to_vec2()) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate(-origin.to_vec2()) * m; + } else { + let physical = writing * origin; + let upright = matches!(g.ch as u32, 0x3000..=0x30ff | 0x3400..=0x9fff | 0xf900..=0xfaff | 0xfe10..=0xfe4f | 0xff01..=0xff60 | 0x20000..=0x3134f); + m = if upright { Affine::translate((physical - origin) + Vec2::new(-g.descent, g.ascent)) * m } else { writing * m }; + origin = physical; + angle += std::f64::consts::FRAC_PI_2; + } + } // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() { BezPath::new() @@ -99,16 +116,43 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay } } paras.push(s..text.len()); - let mut cx = Ctx { db, text: &text, runs, default: CharStyle::default(), opts, out: TextLayout::default() }; + let mut vertical_opts = opts.clone(); + vertical_opts.features.vertical = t.vertical; + let opts = &vertical_opts; + let is_on_path = matches!(&t.kind, TextKind::OnPath { .. }); + let mut cx = Ctx { + vertical: t.vertical, + on_path: is_on_path, + db, + text: &text, + runs, + default: CharStyle::default(), + opts, + out: TextLayout { vertical: t.vertical && !is_on_path, ..Default::default() }, + }; match &t.kind { TextKind::Point => flow(&mut cx, ¶s, &t.para, None), TextKind::Area { frame } => { - let regions = Region::cells(&frame.to_bezpath(), opts, &t.wrap); + let inverse = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + let frame = if t.vertical { frame.transformed(inverse) } else { frame.clone() }; + let mut wrap = t.wrap.clone(); + if t.vertical { + for w in &mut wrap { + w.path.transform(inverse); + } + } + let regions = Region::cells(&frame.to_bezpath(), opts, &wrap); cx.out.frames = regions.iter().map(|r| r.cell).collect(); flow(&mut cx, ¶s, &t.para, Some(®ions)); } TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect), } + if cx.out.vertical { + let writing = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + for frame in &mut cx.out.frames { + *frame = writing.transform_rect_bbox(*frame); + } + } finish_bounds(&mut cx.out); cx.out } @@ -123,7 +167,8 @@ fn finish_bounds(out: &mut TextLayout) { } if !out.on_path { for l in &out.lines { - add(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)); + let r = Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent); + add(if out.vertical { Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]).transform_rect_bbox(r) } else { r }); } } out.bounds = b.unwrap_or_default(); diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 65f51b0d6..bf251e695 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -10,6 +10,7 @@ #![forbid(unsafe_code)] mod composer; +pub mod craft_fonts; pub mod edit; pub mod embed; mod features; @@ -19,8 +20,9 @@ mod layout; mod shape; pub mod thread; +pub use craft_fonts::{CRAFT_FONTS, CraftFont}; pub use features::OtFeatures; -pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight}; +pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, style_weight, system_font_dirs}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; @@ -124,6 +126,8 @@ pub struct LineInfo { #[derive(Clone, Debug, Default)] pub struct TextLayout { + /// Lines retain inline/block coordinates; glyph geometry is in physical text space. + pub vertical: bool, pub glyphs: Vec, pub lines: Vec, /// Ink/advance bounds in text space. @@ -138,6 +142,13 @@ pub struct TextLayout { } impl TextLayout { + /// Convert physical text coordinates to inline/block coordinates. + pub fn logical_point(&self, p: Point) -> Point { + if self.vertical { Point::new(p.y, -p.x) } else { p } + } + pub fn physical_point(&self, p: Point) -> Point { + if self.vertical { Point::new(-p.y, p.x) } else { p } + } /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); @@ -192,7 +203,7 @@ pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { } else if let Some(g) = glyphs.last() { (g.origin + dir(g.angle) * g.advance, g.angle) } else { - (Point::new(line.x0, line.baseline), 0.0) + (layout.physical_point(Point::new(line.x0, line.baseline)), if layout.vertical { std::f64::consts::FRAC_PI_2 } else { 0.0 }) }; // Up vector in y-down space, rotated with the baseline. let d = dir(angle); @@ -214,6 +225,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { None => layout.lines.first().map_or(0, |l| l.start), }; } + let p = layout.logical_point(p); // Nearest line band (vertical distance first, then distance to the line's column). let dist = |l: &LineInfo| { let dy = if p.y < l.baseline - l.ascent { @@ -247,7 +259,7 @@ pub fn hit_byte(layout: &TextLayout, p: Point) -> usize { pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - if let Some(g) = glyphs.iter().find(|g| x < g.origin.x + g.advance * 0.5) { + if let Some(g) = glyphs.iter().find(|g| x < layout.logical_point(g.origin).x + g.advance * 0.5) { return g.byte; } line_end(layout, li) @@ -281,9 +293,9 @@ fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 { let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; if let Some(g) = glyphs.iter().find(|g| g.byte + g.len > byte) { let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; - return g.origin.x + g.advance * frac; + return layout.logical_point(g.origin).x + g.advance * frac; } - glyphs.last().map_or(line.x0, |g| g.origin.x + g.advance) + glyphs.last().map_or(line.x0, |g| layout.logical_point(g.origin).x + g.advance) } /// Move the caret `delta` lines up (negative) or down, keeping horizontal position `goal_x` @@ -345,14 +357,14 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 let mut xb = x_in_line(layout, li, b.min(l.end)); if b > l.end { // The selection continues past the line: include the line break / trailing space. - let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| g.origin.x + g.advance); + let last = layout.glyphs[l.glyph_start..l.glyph_end].last().map_or(l.x1, |g| layout.logical_point(g.origin).x + g.advance); xb = last.max(l.x1) + (l.ascent + l.descent) * 0.25; } if xb - xa <= 1e-9 { continue; } let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent); - out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)]); + out.push([Point::new(xa, t), Point::new(xb, t), Point::new(xb, bt), Point::new(xa, bt)].map(|p| layout.physical_point(p))); } out } @@ -366,4 +378,6 @@ mod tests_scripts; #[cfg(test)] mod tests_snap; #[cfg(test)] +mod tests_sysfonts; +#[cfg(test)] mod tests_typo; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e0bd49e90..dde4c2a9a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -3,8 +3,10 @@ use kurbo::{Affine, Shape}; use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; +/// The bundled fonts only: what these tests measure doesn't change with the fonts installed. fn db() -> &'static FontDb { - FontDb::global() + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb::with_font_dirs(vec![])) } fn style(size: f64) -> CharStyle { @@ -419,3 +421,73 @@ fn tab_stops_position_text() { p.para.tabs = vec![stop(50.0, TabAlign::Left)]; assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } + +/// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when +/// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. +fn japanese_fonts() -> bool { + let built_in = CRAFT_FONTS.iter().any(|f| f.is_japanese()); + if !built_in { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + } + built_in +} + +#[test] +fn japanese_text_uses_the_craft_fonts_mincho_after_the_bundled_fonts() { + if !japanese_fonts() { + return; + } + let l = layout(db(), &point("日本語の文字", style(20.0))); + assert_eq!(l.glyphs.len(), 6); + assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty()), "real glyphs, no tofu"); + let face = db().face_covering('日').unwrap(); + assert!(face.family.contains("Mincho"), "document text falls back to a Mincho face: {}", face.family); + assert!(CRAFT_FONTS.iter().any(|f| f.family == face.family)); + // Latin keeps the bundled fallback family. + assert_eq!(db().face_covering('a').unwrap().family, FALLBACK_FAMILY); +} + +#[test] +fn japanese_text_lays_out_without_the_craft_fonts() { + // Built either way, Japanese text lays out (as missing glyphs when no font has them). + let l = layout(db(), &point("日本語の文字 abc", style(20.0))); + assert_eq!(l.lines.len(), 1); + assert!(l.glyphs.iter().all(|g| g.advance.is_finite())); + if CRAFT_FONTS.is_empty() { + assert!(db().families().iter().all(|f| !f.contains("Mincho") && !f.contains("Gothic")), "no Japanese font is bundled"); + } +} + +#[test] +fn vertical_japanese_columns_have_upright_ink_and_edit_geometry() { + if !japanese_fonts() { + return; + } + let mut t = point("日本語\n縦書き", style(30.0)); + t.vertical = true; + let l = layout(db(), &t); + assert!(l.vertical); + assert_eq!(l.lines.len(), 2); + assert!(l.glyphs.iter().all(|g| g.gid != 0 && !g.outline.elements().is_empty())); + assert!(l.glyphs[3].origin.x < l.glyphs[0].origin.x); + assert!(l.glyphs[1].origin.y > l.glyphs[0].origin.y); + for g in &l.glyphs { + let p = g.origin + dir(g.angle) * (g.advance * 0.1); + assert_eq!(hit_byte(&l, p), g.byte); + let (a, b) = caret_position(&l, g.byte); + assert!((a.y - b.y).abs() < 1e-6); + assert!(a.x > b.x); + } + let selection = selection_quads(&l, 0, "日本語".len()); + assert_eq!(selection.len(), 1); +} + +#[test] +fn vertical_area_type_wraps_into_columns_inside_the_frame() { + let mut t = area("日本語日本語日本語", style(20.0), Rect::new(0.0, 0.0, 100.0, 65.0), Justify::Left); + t.vertical = true; + let l = layout(db(), &t); + assert!(l.lines.len() > 1); + assert!(l.glyphs.iter().all(|g| g.origin.x >= 0.0 && g.origin.x <= 100.0 && g.origin.y >= 0.0 && g.origin.y <= 65.0)); + assert!(l.glyphs[l.lines[1].glyph_start].origin.x < l.glyphs[0].origin.x); +} diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs new file mode 100644 index 000000000..5b9c96e12 --- /dev/null +++ b/crates/text/src/tests_sysfonts.rs @@ -0,0 +1,144 @@ +//! Installed system fonts: found by family name by every lookup, whatever ran before it (#130). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::*; + +const FAMILY: &str = "Sysfont Sans3"; + +/// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name). +fn renamed(file: &str) -> Vec { + let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("Source Sans 3"), utf16(FAMILY)), (b"Source Sans 3".to_vec(), FAMILY.as_bytes().to_vec())] { + let mut i = 0; + while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { + data[i + at..i + at + to.len()].copy_from_slice(&to); + i += at + to.len(); + } + } + data +} + +/// The fonts in `faces` as one collection (TTC) file. +fn collection(faces: &[Vec]) -> Vec { + let mut out = b"ttcf".to_vec(); + out.extend_from_slice(&0x0001_0000_u32.to_be_bytes()); + out.extend_from_slice(&(faces.len() as u32).to_be_bytes()); + let mut base = 12 + 4 * faces.len(); + for f in faces { + out.extend_from_slice(&(base as u32).to_be_bytes()); + base += f.len(); + } + for f in faces { + // Table offsets count from the start of the collection. + let start = out.len() as u32; + let mut f = f.clone(); + let tables = u16::from_be_bytes([f[4], f[5]]) as usize; + for r in 0..tables { + let at = 12 + r * 16 + 8; + let off = u32::from_be_bytes(f[at..at + 4].try_into().unwrap()) + start; + f[at..at + 4].copy_from_slice(&off.to_be_bytes()); + } + out.extend_from_slice(&f); + } + out +} + +/// A font folder (with a subfolder, a damaged font and a file that isn't a font) holding +/// [`FAMILY`] Regular and Bold. +fn font_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Sub")).unwrap(); + std::fs::write(dir.join("Sysfont-Regular.ttf"), renamed("SourceSans3-Regular.ttf")).unwrap(); + std::fs::write(dir.join("Sub/Sysfont-Bold.TTF"), renamed("SourceSans3-Bold.ttf")).unwrap(); + std::fs::write(dir.join("damaged.ttf"), b"ttcf\0\x01\0\0\xff\xff\xff\xff").unwrap(); + std::fs::write(dir.join("readme.txt"), FAMILY).unwrap(); + dir +} + +fn has(list: &[String], family: &str) -> bool { + list.iter().any(|f| f == family) +} + +#[test] +fn every_lookup_by_name_finds_installed_fonts_in_a_fresh_database() { + let dir = font_dir("fresh"); + // Each lookup is the first thing a new database is asked. A folder listed twice is read once. + let db = || FontDb::with_font_dirs(vec![dir.clone(), dir.join("Sub/..")]); + let f = db().face("sysfont sans3", "Bold").unwrap(); + assert_eq!((f.family.as_str(), f.style.as_str()), (FAMILY, "Bold")); + assert_eq!(f.path().map(std::fs::canonicalize).unwrap().unwrap(), std::fs::canonicalize(dir.join("Sub/Sysfont-Bold.TTF")).unwrap()); + assert!(db().has_family(FAMILY)); + assert!(has(&db().families(), FAMILY)); + assert_eq!(db().styles(FAMILY), ["Regular", "Bold"]); + assert_eq!(db().load_system_fonts(), 2); + assert_eq!(db().find_family("SysfontSans3").as_deref(), Some(FAMILY), "a PostScript-style name"); + // Missing fonts stay missing. + let db = db(); + assert!(!db.has_family("No Such Font")); + assert_eq!(db.face("No Such Font", "Regular").unwrap().family, FALLBACK_FAMILY); +} + +#[test] +fn the_scan_reads_collections_and_loads_fonts_only_when_used() { + let dir = font_dir("collection"); + std::fs::remove_file(dir.join("Sysfont-Regular.ttf")).unwrap(); + std::fs::remove_file(dir.join("Sub/Sysfont-Bold.TTF")).unwrap(); + std::fs::write(dir.join("Sysfont.ttc"), collection(&[renamed("SourceSans3-Regular.ttf"), renamed("SourceSans3-Bold.ttf")])).unwrap(); + let db = FontDb::with_font_dirs(vec![dir.clone()]); + assert_eq!(db.styles(FAMILY), ["Regular", "Bold"]); + // Cataloged, not loaded. + assert!(!db.is_loaded(FAMILY)); + let bold = db.face(FAMILY, "Bold").unwrap(); + assert_eq!((bold.style.as_str(), bold.face_index()), ("Bold", 1)); + assert_eq!(db.find(FAMILY, "Regular").unwrap().face_index(), 0, "the collection's faces load together"); +} + +#[test] +fn rescanning_finds_fonts_installed_since() { + let dir = font_dir("rescan"); + let later = dir.join("Later"); + let db = FontDb::with_font_dirs(vec![later.clone()]); + assert!(!db.has_family(FAMILY)); + let (generation, families) = (db.generation(), db.family_list()); + std::fs::create_dir_all(&later).unwrap(); + std::fs::copy(dir.join("Sysfont-Regular.ttf"), later.join("Sysfont-Regular.ttf")).unwrap(); + assert!(!db.has_family(FAMILY), "the folders are scanned once, not on every miss"); + assert_eq!(db.load_system_fonts(), 1); + assert!(db.has_family(FAMILY) && has(&db.family_list(), FAMILY) && !has(&families, FAMILY)); + assert_ne!(db.generation(), generation); +} + +#[test] +fn a_background_scan_serves_the_first_lookup() { + let dir = font_dir("background"); + let db: &'static FontDb = Box::leak(Box::new(FontDb::with_font_dirs(vec![dir]))); + db.scan_in_background(); + // Waits for the scan when it is still running. + assert!(has(&db.families(), FAMILY)); + assert_eq!(db.load_system_fonts(), 2, "a rescan catalogs the two faces again"); +} + +#[test] +fn the_family_list_is_shared_until_fonts_change() { + let db = FontDb::with_font_dirs(vec![]); + let a = db.family_list(); + assert!(Arc::ptr_eq(&a, &db.family_list())); + assert!(db.add_font(renamed("SourceSans3-Regular.ttf")) > 0); + let b = db.family_list(); + assert!(!Arc::ptr_eq(&a, &b) && has(&b, FAMILY) && !has(&a, FAMILY)); +} + +#[test] +fn installed_styles_of_a_loaded_family_load_when_asked_for() { + let dir = font_dir("styles"); + let db = FontDb::with_font_dirs(vec![dir]); + // The family is loaded with only its Regular face (as when a bundled family is also installed). + assert_eq!(db.add_font(renamed("SourceSans3-Regular.ttf")), 1); + assert_eq!(db.face(FAMILY, "Bold").unwrap().style, "Bold"); + // A style nobody has still gets the closest one. + assert_eq!(db.face(FAMILY, "Black").unwrap().style, "Bold"); +} diff --git a/crates/text/tests/system_fonts.rs b/crates/text/tests/system_fonts.rs new file mode 100644 index 000000000..d711bfab9 --- /dev/null +++ b/crates/text/tests/system_fonts.rs @@ -0,0 +1,22 @@ +//! A new session finds the installed system fonts by family name (#130). One test in its own +//! process, so the process-wide database starts fresh, as it does when the app starts. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use vectorcraft_text::{FontDb, system_font_dirs}; + +#[test] +fn a_fresh_session_finds_installed_fonts_by_name() { + let bundled = FontDb::with_font_dirs(vec![]).families(); + let probe = FontDb::with_font_dirs(system_font_dirs()); + probe.load_system_fonts(); + let installed = probe.families(); + let Some(family) = installed.iter().find(|f| !bundled.contains(f)) else { + eprintln!("no system fonts installed: nothing to check"); + return; + }; + // The first lookup in the session: nothing has scanned the font folders yet. + let face = FontDb::global().face(family, "Regular").unwrap(); + assert_eq!(&face.family, family); + assert!(FontDb::global().families().contains(family)); +} diff --git a/crates/tools/src/catalog.rs b/crates/tools/src/catalog.rs index c4b5301e3..400a07afd 100644 --- a/crates/tools/src/catalog.rs +++ b/crates/tools/src/catalog.rs @@ -68,6 +68,9 @@ pub const TOOL_GROUPS: &[&[ToolInfo]] = &[ t("eraser", "Eraser Tool", Some("Shift+E"), "tool-eraser"), t("scissors", "Scissors Tool", Some("C"), "tool-scissors"), t("knife", "Knife", None, "tool-knife"), + t("mirrorCut", "Mirror & Cut Tool", None, "tool-mirror-cut"), + t("lineCut", "Line Cut Tool", None, "tool-line-cut"), + t("rectCut", "Rectangle Cut Tool", None, "tool-rect-cut"), ], &[t("rotate", "Rotate Tool", Some("R"), "tool-rotate"), t("reflect", "Reflect Tool", Some("O"), "tool-reflect")], &[ diff --git a/crates/tools/src/cut.rs b/crates/tools/src/cut.rs new file mode 100644 index 000000000..906a632fb --- /dev/null +++ b/crates/tools/src/cut.rs @@ -0,0 +1,303 @@ +//! Mirror & Cut, Line Cut and Rectangle Cut: drag across the selected art. +//! +//! The cut previews live while dragging (`path.mirrorCut`, `path.lineCut` or `path.rectCut` +//! re-applied on the snapshot) and the release keeps it as one undo step; Escape cancels. +//! +//! - Mirror & Cut: the drag draws the axis (Shift: 45° steps from the constrain angle). With the +//! `axis` option vertical or horizontal the axis follows the pointer instead, and a click places +//! it. `keep` names the side kept; Alt on release keeps the other one. +//! - Line Cut: the drag draws the cut line (Shift: 45° steps). +//! - Rectangle Cut: the drag draws the rectangle (Shift: a square, Alt: from its centre). + +use serde_json::{Value, json}; +use vectorcraft_geom::{Point, Vec2, constrain_angle_from}; + +use crate::draw2::FEEDBACK; +use crate::shape::drag_rect; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +/// Create a cutting tool by id (None = not one of ours). +pub fn create(id: &str) -> Option> { + matches!(id, "mirrorCut" | "lineCut" | "rectCut").then(|| Box::new(CutTool::new(id)) as Box) +} + +pub struct CutTool { + id: &'static str, + start: Option, + end: Point, + mods: Mods, + began: bool, + /// Mirror & Cut: "free", "vertical" or "horizontal". + pub axis: &'static str, + /// Mirror & Cut: the side kept, "left", "right", "top" or "bottom" (a side across the axis + /// stands for its counterpart: left for top, right for bottom). + pub keep: &'static str, +} + +/// Drags shorter than this many screen pixels don't cut. +const MIN_DRAG_PX: f64 = 3.0; + +impl CutTool { + pub fn new(id: &str) -> Self { + let id = match id { + "mirrorCut" => "mirrorCut", + "lineCut" => "lineCut", + _ => "rectCut", + }; + Self { id, start: None, end: Point::ZERO, mods: Mods::default(), began: false, axis: "free", keep: "left" } + } + + fn label(&self) -> &'static str { + match self.id { + "mirrorCut" => "Mirror & Cut", + "lineCut" => "Line Cut", + _ => "Rectangle Cut", + } + } + + /// A constrained Mirror & Cut axis (it follows the pointer; a click places it). + fn constrained(&self) -> bool { + self.id == "mirrorCut" && self.axis != "free" + } + + /// The drag's end point: Shift snaps lines to 45° steps. + fn end_point(&self, cx: &ToolContext, start: Point, p: Point, m: Mods) -> Point { + if m.shift && self.id != "rectCut" { start + constrain_angle_from(p - start, 45.0, cx.constrain_angle) } else { p } + } + + /// The side Mirror & Cut keeps for an axis running along `d`: the `keep` option named for the + /// axis's orientation, the other side with Alt. + fn keep_side(&self, d: Vec2, alt: bool) -> &'static str { + let upright = match self.axis { + "vertical" => true, + "horizontal" => false, + _ => d.y.abs() >= d.x.abs(), + }; + let first = matches!(self.keep, "left" | "top"); + match (upright, first != alt) { + (true, true) => "left", + (true, false) => "right", + (false, true) => "top", + (false, false) => "bottom", + } + } + + /// The command for the current drag, `None` while it is too short to cut. + pub fn command(&self, cx: &ToolContext) -> Option<(&'static str, Value)> { + let start = self.start?; + let (a, b) = (start, self.end); + let short = a.distance(b) < cx.tol(MIN_DRAG_PX); + match self.id { + "mirrorCut" if self.constrained() => { + Some(("path.mirrorCut", json!({"axis": self.axis, "from": [b.x, b.y], "keep": self.keep_side(Vec2::ZERO, self.mods.alt)}))) + } + _ if short => None, + "mirrorCut" => { + Some(("path.mirrorCut", json!({"axis": "free", "from": [a.x, a.y], "to": [b.x, b.y], "keep": self.keep_side(b - a, self.mods.alt)}))) + } + "lineCut" => Some(("path.lineCut", json!({"from": [a.x, a.y], "to": [b.x, b.y]}))), + _ => { + let r = drag_rect(a, b, self.mods); + (r.width() > 0.0 && r.height() > 0.0).then(|| ("path.rectCut", json!({"rect": [r.x0, r.y0, r.width(), r.height()]}))) + } + } + } + + fn reset(&mut self) { + self.start = None; + self.began = false; + } +} + +impl Tool for CutTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + self.start.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + // The tools cut the selection: without one there is nothing to do. + if cx.selection.is_empty() { + return vec![]; + } + self.start = Some(ev.pos); + self.end = ev.pos; + self.mods = ev.mods; + self.began = false; + vec![] + } + PointerKind::Drag | PointerKind::Up => { + let Some(start) = self.start else { return vec![] }; + self.end = self.end_point(cx, start, ev.pos, ev.mods); + self.mods = ev.mods; + let mut out = vec![]; + let cmd = self.command(cx); + if let Some((cmd, p)) = cmd { + if !self.began { + self.began = true; + out.push(Action::Begin(self.label().into())); + } + out.push(Action::Preview(cmd.into(), p)); + } + if ev.kind == PointerKind::Up { + if self.began { + out.push(Action::Commit); + } + self.reset(); + } + out + } + _ => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { + if key == ToolKey::Escape && self.start.is_some() { + let began = self.began; + self.reset(); + return if began { vec![Action::Cancel] } else { vec![] }; + } + vec![] + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + let began = self.began; + self.reset(); + if began { vec![Action::Commit] } else { vec![] } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(a) = self.start else { return vec![] }; + let b = self.end; + if self.id == "rectCut" { + return vec![Overlay::Marquee(drag_rect(a, b, self.mods))]; + } + // The axis and the cut line run across the art: draw them across the view. + let d = match (self.id, self.axis) { + ("mirrorCut", "vertical") => Vec2::new(0.0, 1.0), + ("mirrorCut", "horizontal") => Vec2::new(1.0, 0.0), + _ if a.distance(b) < cx.tol(MIN_DRAG_PX) => return vec![], + _ => (b - a).normalize(), + }; + let far = d * cx.tol(10_000.0); + let mid = if self.constrained() { b } else { a }; + vec![Overlay::Line { a: mid - far, b: mid + far, color: FEEDBACK, dashed: true }] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Crosshair + } + fn options(&self) -> Value { + match self.id { + "mirrorCut" => json!({"axis": self.axis, "keep": self.keep}), + _ => Value::Null, + } + } + fn set_option(&mut self, key: &str, v: &Value) { + match (key, v.as_str()) { + ("axis", Some(a)) => { + self.axis = match a { + "vertical" => "vertical", + "horizontal" => "horizontal", + "free" => "free", + _ => self.axis, + } + } + ("keep", Some(k)) => { + self.keep = match k { + "left" => "left", + "right" => "right", + "top" => "top", + "bottom" => "bottom", + _ => self.keep, + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + use vectorcraft_doc::Selection; + + fn drag(t: &mut CutTool, cx: &ToolContext, from: (f64, f64), to: (f64, f64), m: Mods) -> Vec { + let mut out = t.pointer(cx, &PointerEvent::new(PointerKind::Down, from.0, from.1)); + out.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0).with_mods(m))); + out.extend(t.pointer(cx, &PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m))); + out + } + + #[test] + fn line_cut_previews_and_commits_once() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CutTool::new("lineCut"); + let a = drag(&mut t, &cx, (150.0, 50.0), (150.0, 250.0), Mods::default()); + assert_eq!(a[0], Action::Begin("Line Cut".into())); + assert_eq!(a.iter().filter(|x| matches!(x, Action::Begin(_))).count(), 1); + assert!(matches!(&a[a.len() - 2], Action::Preview(c, v) if c == "path.lineCut" && v["to"] == json!([150.0, 250.0]))); + assert_eq!(a.last(), Some(&Action::Commit)); + assert!(!t.busy()); + } + + #[test] + fn needs_a_selection_and_a_drag() { + let (d, id) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut t = CutTool::new("rectCut"); + assert!(drag(&mut t, &cx(&d, &s, &p), (0.0, 0.0), (300.0, 300.0), Mods::default()).is_empty()); + let mut s = Selection::default(); + s.set([id]); + let cx = cx(&d, &s, &p); + assert!(drag(&mut t, &cx, (10.0, 10.0), (11.0, 11.0), Mods::default()).is_empty(), "a click doesn't cut"); + let a = drag(&mut t, &cx, (120.0, 120.0), (160.0, 140.0), Mods { shift: true, ..Default::default() }); + assert!(matches!(&a[a.len() - 2], Action::Preview(c, v) if c == "path.rectCut" && v["rect"] == json!([120.0, 120.0, 40.0, 40.0]))); + } + + #[test] + fn mirror_cut_axis_options_and_alt() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CutTool::new("mirrorCut"); + // A free, mostly horizontal axis keeps the top; Alt the bottom. + let a = drag(&mut t, &cx, (50.0, 150.0), (250.0, 160.0), Mods::default()); + assert!(matches!(&a[a.len() - 2], Action::Preview(_, v) if v["axis"] == "free" && v["keep"] == "top")); + let a = drag(&mut t, &cx, (50.0, 150.0), (250.0, 160.0), Mods { alt: true, ..Default::default() }); + assert!(matches!(&a[a.len() - 2], Action::Preview(_, v) if v["keep"] == "bottom")); + // A vertical axis follows the pointer, and a click places it. + t.set_option("axis", &json!("vertical")); + t.set_option("keep", &json!("right")); + assert_eq!(t.options(), json!({"axis": "vertical", "keep": "right"})); + let mut a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 140.0, 150.0)); + a.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 140.0, 150.0))); + assert_eq!(a.len(), 3); + assert!(matches!(&a[1], Action::Preview(_, v) if v["axis"] == "vertical" && v["from"] == json!([140.0, 150.0]) && v["keep"] == "right")); + t.set_option("axis", &json!("diagonal")); + assert_eq!(t.axis, "vertical"); + } + + #[test] + fn escape_cancels_the_preview() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = CutTool::new("lineCut"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 250.0)); + assert_eq!(a.len(), 2); + assert_eq!(t.overlays(&cx).len(), 1); + assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy() && t.overlays(&cx).is_empty()); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a18b461f6..998d4aeab 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -4,35 +4,114 @@ //! path's anchors on that segment, drag to move selected anchors, drag a direction handle to //! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners. //! Group Selection: click selects the leaf; each further click on it adds the next enclosing group. +//! Both pick the key objects of a blend. Direct Selection also edits a blend's spine: drag its +//! points (a key object on a point moves with it) and, once a point is clicked, its handles; and +//! the points of selected gradient meshes and mesh envelopes and their handles ([`MeshEdit`]). use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; use vectorcraft_doc::{AnchorRef, NodeId, NodeKind}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{PathData, Point, Rect}; use crate::bbox::move_delta; use crate::corners::{CornerDrag, over_widget}; +use crate::meshedit::MeshEdit; use crate::select::matrix_json; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Clone, Debug)] enum State { Idle, - MoveAnchors { start: Point, began: bool }, - MoveObject { start: Point, began: bool }, - Handle { id: NodeId, si: usize, ai: usize, out: bool }, - Marquee { start: Point, cur: Point, add: bool }, + MoveAnchors { + start: Point, + began: bool, + }, + MoveObject { + start: Point, + began: bool, + }, + Handle { + id: NodeId, + si: usize, + ai: usize, + out: bool, + }, + Marquee { + start: Point, + cur: Point, + add: bool, + }, Corner(CornerDrag), + /// Dragging a point of a blend's spine from `from`. + SpinePoint { + id: NodeId, + anchor: usize, + from: Point, + start: Point, + began: bool, + }, + /// Dragging a handle of the clicked spine point. + SpineHandle { + id: NodeId, + anchor: usize, + out: bool, + }, + /// Dragging a mesh point or handle ([`MeshEdit`]). + Mesh, } pub struct DirectSelectionTool { group: bool, state: State, + /// The spine point last clicked (blend, anchor): its handles show and can be dragged. + spine: Option<(NodeId, usize)>, + mesh: MeshEdit, } impl DirectSelectionTool { pub fn new(group: bool) -> Self { - Self { group, state: State::Idle } + Self { group, state: State::Idle, spine: None, mesh: MeshEdit::default() } + } +} + +/// The spines of the editable blends, topmost first: (blend, spine). +pub(crate) fn spines(cx: &ToolContext) -> Vec<(NodeId, PathData)> { + let mut v = vec![]; + cx.doc.walk(|n| { + if let NodeKind::Blend { children, spec } = &n.kind + && let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) + { + v.push((n.id, path)); + } + }); + v.reverse(); + v.retain(|(id, _)| cx.doc.is_editable(*id)); + v +} + +/// The spine point under `p`: (blend, anchor, where it is). +fn hit_spine_point(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, Point)> { + spines(cx).into_iter().find_map(|(id, path)| { + let (_, ai, a) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol)?; + Some((id, ai, a.p)) + }) +} + +/// The spine point `sel` and whether `p` is on its outgoing handle's end (`Some(true)`) or its +/// incoming one's. +fn hit_spine_handle(cx: &ToolContext, sel: (NodeId, usize), p: Point, tol: f64) -> Option { + let a = spine_anchor(cx, sel)?; + if a.has_out() && a.h_out.distance(p) <= tol { + return Some(true); + } + (a.has_in() && a.h_in.distance(p) <= tol).then_some(false) +} + +/// Anchor `sel.1` of blend `sel.0`'s spine. +fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option { + match &cx.doc.node(id)?.kind { + NodeKind::Blend { children, spec } => vectorcraft_doc::live::blend_spine(children, spec)?.0.subpaths.first()?.anchors.get(ai).copied(), + _ => None, } } @@ -123,10 +202,27 @@ impl Tool for DirectSelectionTool { self.state = State::Corner(c); return vec![]; } + if let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) + && let Some(out) = hit_spine_handle(cx, sel, p, tol) + { + self.state = State::SpineHandle { id: sel.0, anchor: sel.1, out }; + return vec![Action::Begin("Reshape Spine".into())]; + } + self.spine = None; + if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) { + self.state = State::Mesh; + return self.mesh.press(g); + } + self.mesh.unfocus(); if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) { self.state = State::Handle { id, si, ai, out }; return vec![Action::Begin("Reshape".into())]; } + if let Some((id, anchor, from)) = hit_spine_point(cx, p, tol) { + self.spine = Some((id, anchor)); + self.state = State::SpinePoint { id, anchor, from, start: p, began: false }; + return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; + } if let Some((id, si, ai)) = hit_anchor(cx, p, tol, false) { let already = cx.selection.partial(id).is_some_and(|s| s.contains(&(si, ai))); self.state = State::MoveAnchors { start: p, began: false }; @@ -187,6 +283,28 @@ impl Tool for DirectSelectionTool { json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}), )] } + (PointerKind::Drag, State::SpinePoint { id, anchor, from, start, began }) => { + let mut out = vec![]; + if !began { + if p.distance(start) < cx.tol(3.0) { + return out; + } + out.push(Action::Begin("Reshape Spine".into())); + self.state = State::SpinePoint { id, anchor, from, start, began: true }; + } + let q = from + move_delta(start, p, ev.mods.shift); + out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y}))); + out + } + (PointerKind::Drag, State::SpineHandle { id, anchor, out }) => vec![Action::Preview( + "object.blend.spine.moveAnchor".into(), + json!({"id": id.0, "anchor": anchor, "handle": if out {"out"} else {"in"}, "x": p.x, "y": p.y, "independent": ev.mods.alt}), + )], + (PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(), + (PointerKind::Up, State::Mesh) => { + self.state = State::Idle; + self.mesh.release().unwrap_or_default() + } (PointerKind::Drag, State::Marquee { start, add, .. }) => { self.state = State::Marquee { start, cur: p, add }; vec![] @@ -200,11 +318,11 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; c.finish() } - (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => { + (PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. } | State::SpinePoint { began, .. }) => { self.state = State::Idle; if began { vec![Action::Commit] } else { vec![] } } - (PointerKind::Up, State::Handle { .. }) => { + (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { self.state = State::Idle; vec![Action::Commit] } @@ -235,7 +353,11 @@ impl Tool for DirectSelectionTool { match &self.state { State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))], State::Corner(c) => c.overlays(cx), - _ => vec![], + _ => { + let mut out = self.spine_overlays(cx); + out.extend(self.mesh.overlays(cx)); + out + } } } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { @@ -246,12 +368,73 @@ impl Tool for DirectSelectionTool { } } +impl DirectSelectionTool { + /// The clicked spine point, filled, with its handles. + fn spine_overlays(&self, cx: &ToolContext) -> Vec { + let Some(sel) = self.spine.filter(|(id, _)| cx.selection.contains(*id)) else { return vec![] }; + let Some(a) = spine_anchor(cx, sel) else { return vec![] }; + let color = cx.doc.layer_color(sel.0); + let mut out = vec![]; + for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) { + out.push(Overlay::Line { a: a.p, b: h, color, dashed: false }); + out.push(Overlay::Handle { p: h, color }); + } + out.push(Overlay::Anchor { p: a.p, color, filled: true, size: 5.0 }); + out + } +} + #[cfg(test)] mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::Selection; + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. + fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let b = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }); + d.insert(Some(l), 1, b).unwrap(); + (d, g, k1) + } + + #[test] + fn dragging_a_spine_point_moves_it() { + let (d, g, _) = blend_doc(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 211.0, 310.0)), vec![]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 221.0, 350.0)); + assert_eq!(a[0], Action::Begin("Reshape Spine".into())); + assert_eq!(a[1], Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": g.0, "anchor": 1, "x": 220.0, "y": 350.0}))); + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 221.0, 350.0)), vec![Action::Commit]); + assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Anchor { filled: true, .. })), "the clicked point shows"); + } + + #[test] + fn direct_and_group_selection_pick_blend_keys() { + let (d, g, k1) = blend_doc(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let a = DirectSelectionTool::new(false).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 104.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [k1.0]}))]); + // A click on a step (between the keys) still selects the blend. + let a = DirectSelectionTool::new(true).pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 304.0)); + assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [g.0]}))]); + } + #[test] fn click_anchor_selects_it() { let (d, id) = doc_with_rect(); diff --git a/crates/tools/src/distort.rs b/crates/tools/src/distort.rs index 0eb1d57c5..0437ffffc 100644 --- a/crates/tools/src/distort.rs +++ b/crates/tools/src/distort.rs @@ -20,14 +20,14 @@ mod width; use std::sync::Arc; use vectorcraft_doc::{Node, NodeKind}; -use vectorcraft_geom::{Affine, Point, Vec2}; +use vectorcraft_geom::{Homography, Point}; use crate::Tool; pub use liquify::LiquifyTool; pub use perspective::{PerspectiveGridTool, PerspectiveSelectionTool}; -pub use puppet::{PuppetWarpTool, mesh_for, mesh_input}; -pub use width::WidthTool; +pub use puppet::{PinSet, PuppetWarpTool, mesh_for, mesh_input, rest_and_pins, warp_from_rest}; +pub use width::{WidthTool, stroke_owner}; /// Create a distortion tool by id (None = not one of ours). pub fn create(id: &str) -> Option> { @@ -46,6 +46,24 @@ pub fn create(id: &str) -> Option> { /// editable points (text, images, symbols, meshes, live objects) get the approximation at their /// centre. Live shapes become plain paths. pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { + warp_node(n, f, None); +} + +/// [`warp_node_with`] for the projective map `h` (perspective): type and symbol instances keep +/// their flat art and are drawn through `h` (composed with the projection they had). +pub fn project_node(n: &mut Node, h: &Homography) { + warp_node(n, &|p| h.apply(p).unwrap_or(p), Some(h)); +} + +fn warp_node(n: &mut Node, f: &dyn Fn(Point) -> Point, h: Option<&Homography>) { + if let Some(h) = h + && matches!(n.kind, NodeKind::Text(_) | NodeKind::SymbolInstance { .. }) + { + let rec = n.perspective.get_or_insert_with(Default::default); + let had = rec.homography().unwrap_or(Homography::IDENTITY); + rec.projection = Some(h.then_after(&had).to_array()); + return; + } let bounds = n.geometric_bounds(); // Finite-difference step: 5% of the object (at least half a point). let eps = bounds.map_or(0.5, |b| (b.width().max(b.height()) * 0.05).max(0.5)); @@ -66,7 +84,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { | NodeKind::Compound { children, .. } | NodeKind::Blend { children, .. } => { for c in children.iter_mut() { - warp_node_with(Arc::make_mut(c), f); + warp_node(Arc::make_mut(c), f, h); } } _ => { @@ -79,14 +97,7 @@ pub fn warp_node_with(n: &mut Node, f: &dyn Fn(Point) -> Point) { n.appearance.warp_gradients(&|p| affine_near(f, p, eps)); } -/// The affine map that best matches `f` near `p` (finite differences with step `eps`). -pub fn affine_near(f: &dyn Fn(Point) -> Point, p: Point, eps: f64) -> Affine { - let o = f(p); - let ex = (f(p + Vec2::new(eps, 0.0)) - f(p - Vec2::new(eps, 0.0))) / (2.0 * eps); - let ey = (f(p + Vec2::new(0.0, eps)) - f(p - Vec2::new(0.0, eps))) / (2.0 * eps); - let lin = Affine::new([ex.x, ex.y, ey.x, ey.y, 0.0, 0.0]); - Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2()) -} +pub use vectorcraft_doc::live::affine_near; /// Every anchor and handle position in `n` (for meshes and bounds). pub fn collect_points(n: &Node, out: &mut Vec) { @@ -127,7 +138,7 @@ mod tests { use super::*; use vectorcraft_color::{Gradient, GradientPaint, Paint}; use vectorcraft_doc::Appearance; - use vectorcraft_geom::{PathData, Rect, shapes}; + use vectorcraft_geom::{Affine, PathData, Rect, shapes}; #[test] fn create_covers_all_distort_tools() { diff --git a/crates/tools/src/distort/arap.rs b/crates/tools/src/distort/arap.rs index 946ed52a9..10c355544 100644 --- a/crates/tools/src/distort/arap.rs +++ b/crates/tools/src/distort/arap.rs @@ -132,7 +132,8 @@ impl Mesh { /// Build a mesh covering `bounds` restricted to the region of `outlines` (filled interiors of /// closed subpaths plus a band around every path). With no outlines, the whole bounds. pub fn build(outlines: &[BezPath], bounds: Rect, opt: MeshOptions) -> Mesh { - let side = bounds.width().max(bounds.height()).max(1e-3); + // The cells cover the expanded art, so tiny art with a wide Expand stays a small grid. + let side = (bounds.width().max(bounds.height()) + 2.0 * opt.expand.max(0.0)).max(1e-3); let cell = (side / opt.resolution.max(1) as f64).max(1e-3); let pad = opt.expand.max(0.0) + cell * 0.01; let b = bounds.inflate(pad, pad); @@ -267,6 +268,99 @@ impl Mesh { let [a, b, c] = self.tris[t].map(|i| pos[i]); (b - a).cross(c - a) / 2.0 } + + /// Is `p` (rest space) on the mesh? + pub fn contains(&self, p: Point) -> bool { + self.cell_at(p).is_some_and(|k| self.cells.get(k).is_some_and(Option::is_some)) + } + + /// The grid cell under `p`, if inside the grid. + fn cell_at(&self, p: Point) -> Option { + let fi = ((p.x - self.origin.x) / self.cell).floor(); + let fj = ((p.y - self.origin.y) / self.cell).floor(); + (fi >= 0.0 && fj >= 0.0 && fi < self.nx as f64 && fj < self.ny as f64).then(|| fj as usize * self.nx + fi as usize) + } + + /// The rest-space point that the `deformed` mesh shows at `q` (None: `q` is off the mesh). + /// Where the warp folds the mesh over itself, the triangle `q` is deepest inside wins. + pub fn unmap(&self, deformed: &[Point], q: Point) -> Option { + let mut best: Option<(f64, Point)> = None; + for t in &self.tris { + let [a, b, c] = t.map(|i| deformed.get(i).copied().unwrap_or(self.verts[i])); + let den = (b - a).cross(c - a); + if den.abs() < 1e-18 { + continue; + } + let l1 = (q - a).cross(c - a) / den; + let l2 = (b - a).cross(q - a) / den; + let w = [1.0 - l1 - l2, l1, l2]; + let depth = w.iter().copied().fold(f64::MAX, f64::min); + if depth >= -1e-9 && best.is_none_or(|(d, _)| depth > d) { + let [ra, rb, rc] = t.map(|i| self.verts[i].to_vec2()); + best = Some((depth, (ra * w[0] + rb * w[1] + rc * w[2]).to_point())); + } + } + best.map(|b| b.1) + } + + /// How far (radians) the `deformed` mesh turned the art at rest point `p`. + pub fn turn_at(&self, deformed: &[Point], p: Point) -> Option { + let (t, _) = self.locate(p)?; + let tri = self.tris.get(t)?; + let cur = [*deformed.get(tri[0])?, *deformed.get(tri[1])?, *deformed.get(tri[2])?]; + Some(fit_angle(&tri.map(|i| self.verts[i]), &cur)) + } + + /// Centre of grid cell `k`. + fn cell_centre(&self, k: usize) -> Point { + Point::new(self.origin.x + ((k % self.nx) as f64 + 0.5) * self.cell, self.origin.y + ((k / self.nx) as f64 + 0.5) * self.cell) + } + + /// The cells pins go in: those inside the art (or every kept cell when none is). + fn pin_cells(&self) -> Vec { + let any_core = self.core.iter().any(|c| *c); + self.cells.iter().zip(&self.core).map(|(c, core)| c.is_some() && (*core || !any_core)).collect() + } + + /// The 8 neighbours of cell `k` inside the grid, with the step length (in cells). + fn neighbours(&self, k: usize) -> impl Iterator + use<> { + let (nx, ny) = (self.nx as i64, self.ny as i64); + let (i, j) = ((k % self.nx) as i64, (k / self.nx) as i64); + (-1i64..=1).flat_map(move |dj| (-1i64..=1).map(move |di| (di, dj))).filter_map(move |(di, dj)| { + let (a, b) = (i + di, j + dj); + let step = if di != 0 && dj != 0 { std::f64::consts::SQRT_2 } else { 1.0 }; + ((di, dj) != (0, 0) && a >= 0 && b >= 0 && a < nx && b < ny).then_some(((b * nx + a) as usize, step)) + }) + } + + /// Distances (in cells, through the cells where `inside`) from `sources` (cells with their + /// start distances) to every cell; cells out of reach stay at infinity. + fn geodesic(&self, inside: &[bool], sources: &[(usize, f64)]) -> Vec { + let mut dist = vec![f64::INFINITY; self.cells.len()]; + for &(k, d) in sources { + if let Some(v) = dist.get_mut(k) { + *v = v.min(d); + } + } + let inside = |k: usize| inside.get(k).copied().unwrap_or(false); + // Relax until nothing improves (the grid is small: a few dozen cells a side). + let mut changed = true; + while changed { + changed = false; + for k in 0..dist.len() { + if !inside(k) || !dist[k].is_finite() { + continue; + } + for (m, step) in self.neighbours(k) { + if inside(m) && dist[k] + step < dist[m] - 1e-12 { + dist[m] = dist[k] + step; + changed = true; + } + } + } + } + dist + } } /// Winding number of the closed subpaths of `o` at `p` (open subpaths ignored). @@ -308,6 +402,26 @@ const REG: f64 = 1.0e-7; pub struct Pin { pub rest: Point, pub target: Point, + /// The rotation (radians) the art takes around the pin; `None` leaves it free (the solve + /// picks it), as for a pin that was only moved. + pub angle: Option, +} + +impl Pin { + /// A pin free to turn. + pub fn new(rest: Point, target: Point) -> Self { + Self { rest, target, angle: None } + } + + /// The soft constraints this pin puts on the mesh: the pin itself and, when its rotation is + /// held, two points a cell away along the rest axes, turned by the angle around the target. + fn constraints(&self, reach: f64) -> impl Iterator + use<> { + let frame = self.angle.map(|a| { + let (s, c) = a.sin_cos(); + [Vec2::new(reach, 0.0), Vec2::new(0.0, reach)].map(|d| (self.rest + d, self.target + Vec2::new(d.x * c - d.y * s, d.x * s + d.y * c))) + }); + std::iter::once((self.rest, self.target)).chain(frame.into_iter().flatten()) + } } /// Run both ARAP steps. Returns the deformed vertex positions (the rest mesh if fewer than one @@ -317,7 +431,8 @@ pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec { if n == 0 || pins.is_empty() { return mesh.verts.clone(); } - let located: Vec<(usize, [f64; 3], Point)> = pins.iter().filter_map(|p| mesh.locate(p.rest).map(|(t, w)| (t, w, p.target))).collect(); + let located: Vec<(usize, [f64; 3], Point)> = + pins.iter().flat_map(|p| p.constraints(mesh.cell)).filter_map(|(rest, target)| mesh.locate(rest).map(|(t, w)| (t, w, target))).collect(); let vb = mesh.vertex_band(); // ---- step 1: similarity-invariant error, 2n unknowns interleaved (x0, y0, x1, y1, ...). let mut g = BandMatrix::new(2 * n, 2 * vb + 1); @@ -380,16 +495,8 @@ pub fn deform(mesh: &Mesh, pins: &[Pin]) -> Vec { for tri in &mesh.tris { let rest = tri.map(|i| mesh.verts[i]); let cur = tri.map(|i| step1[i]); - let rc = centroid(&rest); - let cc = centroid(&cur); - let (mut sc, mut ss) = (0.0, 0.0); - for k in 0..3 { - let (r, c) = (rest[k] - rc, cur[k] - cc); - sc += r.dot(c); - ss += r.cross(c); - } - let th = ss.atan2(sc); - let (s, co) = th.sin_cos(); + let (rc, cc) = (centroid(&rest), centroid(&cur)); + let (s, co) = fit_angle(&rest, &cur).sin_cos(); let fit = rest.map(|r| { let d = r - rc; cc + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) @@ -434,24 +541,88 @@ fn centroid(p: &[Point; 3]) -> Point { Point::new((p[0].x + p[1].x + p[2].x) / 3.0, (p[0].y + p[1].y + p[2].y) / 3.0) } +/// The rotation (radians) that best fits triangle `rest` onto `cur` (least squares, about their +/// centroids). +fn fit_angle(rest: &[Point; 3], cur: &[Point; 3]) -> f64 { + let (rc, cc) = (centroid(rest), centroid(cur)); + let (mut sc, mut ss) = (0.0, 0.0); + for k in 0..3 { + let (r, c) = (rest[k] - rc, cur[k] - cc); + sc += r.dot(c); + ss += r.cross(c); + } + ss.atan2(sc) +} + /// Pick `count` well-spread pin positions inside the mesh (farthest-point sampling over the /// kept cell centres, starting from the one nearest the centroid). pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec { - let centres: Vec = mesh - .cells - .iter() - .enumerate() - .filter(|(k, c)| c.is_some() && (mesh.core[*k] || !mesh.core.iter().any(|x| *x))) - .map(|(k, _)| Point::new(mesh.origin.x + ((k % mesh.nx) as f64 + 0.5) * mesh.cell, mesh.origin.y + ((k / mesh.nx) as f64 + 0.5) * mesh.cell)) + let mut out: Vec = centre_of(mesh).map(|c| c.1).into_iter().collect(); + spread_pins(mesh, &mut out, count); + out +} + +/// The centre of the cells pins go in: the cell nearest their centroid, and the centroid itself +/// (that cell's centre when the centroid falls off the art, as in a ring). +fn centre_of(mesh: &Mesh) -> Option<(usize, Point)> { + let cells: Vec = mesh.pin_cells().iter().enumerate().filter(|(_, c)| **c).map(|(k, _)| k).collect(); + let c = (cells.iter().fold(Vec2::ZERO, |a, k| a + mesh.cell_centre(*k).to_vec2()) / cells.len().max(1) as f64).to_point(); + let k = cells.into_iter().min_by(|a, b| mesh.cell_centre(*a).distance_squared(c).total_cmp(&mesh.cell_centre(*b).distance_squared(c)))?; + Some((k, if mesh.contains(c) { c } else { mesh.cell_centre(k) })) +} + +/// The most pins placed automatically. +const MAX_AUTO_PINS: usize = 12; + +/// The pins the Puppet Warp tool places by itself: one at the centre of the art and one at the +/// end of each limb (a part reaching well beyond the art's thickness, like the arms of a star or +/// the ends of a long bar); at least three in all where the art has room. +pub fn default_pins(mesh: &Mesh) -> Vec { + let Some((centre, middle)) = centre_of(mesh) else { return vec![] }; + let inside = mesh.pin_cells(); + // Thickness: the largest distance from inside the art to its edge. + let edge: Vec<(usize, f64)> = (0..inside.len()) + .filter(|k| inside[*k] && (mesh.neighbours(*k).count() < 8 || mesh.neighbours(*k).any(|(m, _)| !inside[m]))) + .map(|k| (k, 0.5)) .collect(); - if centres.is_empty() { - return vec![]; + let radius = mesh.geodesic(&inside, &edge).into_iter().filter(|d| d.is_finite()).fold(0.0, f64::max); + // Limb ends: the cells farthest from the centre (through the art), well beyond the thickness. + let from_centre = mesh.geodesic(&inside, &[(centre, 0.0)]); + let mut ends: Vec = (0..inside.len()) + .filter(|k| { + let d = from_centre[*k]; + inside[*k] && d.is_finite() && d >= 1.5 * radius && mesh.neighbours(*k).all(|(m, _)| !inside[m] || from_centre[m] <= d) + }) + .collect(); + ends.sort_by(|a, b| from_centre[*b].total_cmp(&from_centre[*a])); + // One pin per limb: ends closer than about the art's width belong to the same limb. + let apart = 2.2 * radius * mesh.cell; + let mut out = vec![middle]; + let mut tips: Vec = vec![]; + for k in ends { + let p = mesh.cell_centre(k); + if out.len() >= MAX_AUTO_PINS || tips.iter().any(|q| q.distance(p) < apart) { + continue; + } + tips.push(p); + // The pin goes in the middle of the limb's end: the centroid of the cells about as far out + // near its tip (the tip itself if that falls off the art). + let cap: Vec = (0..inside.len()) + .filter(|m| inside[*m] && from_centre[*m] >= from_centre[k] - radius) + .map(|m| mesh.cell_centre(m)) + .filter(|q| q.distance(p) < apart) + .collect(); + let mid = (cap.iter().fold(Vec2::ZERO, |a, q| a + q.to_vec2()) / cap.len().max(1) as f64).to_point(); + out.push(if mesh.contains(mid) { mid } else { p }); } - let c = centres.iter().fold(Vec2::ZERO, |a, p| a + p.to_vec2()) / centres.len() as f64; - let Some(&first) = centres.iter().min_by(|a, b| a.distance_squared(c.to_point()).total_cmp(&b.distance_squared(c.to_point()))) else { - return vec![]; - }; - let mut out = vec![first]; + spread_pins(mesh, &mut out, 3); + out +} + +/// Add pins to `out` until there are `count`, each as far as possible from the others +/// (farthest-point sampling over the cells pins go in). +fn spread_pins(mesh: &Mesh, out: &mut Vec, count: usize) { + let centres: Vec = mesh.pin_cells().iter().enumerate().filter(|(_, c)| **c).map(|(k, _)| mesh.cell_centre(k)).collect(); while out.len() < count.min(centres.len()) { let Some(&next) = centres.iter().max_by(|a, b| { let da = out.iter().map(|o| o.distance_squared(**a)).fold(f64::MAX, f64::min); @@ -462,7 +633,6 @@ pub fn auto_pins(mesh: &Mesh, count: usize) -> Vec { }; out.push(next); } - out } #[cfg(test)] @@ -528,7 +698,7 @@ mod tests { fn pins_at_rest_leave_the_mesh_unchanged() { let m = rect_mesh(); let pins: Vec = - [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin { rest: Point::new(x, y), target: Point::new(x, y) }).collect(); + [(20.0, 50.0), (180.0, 50.0), (100.0, 20.0)].iter().map(|&(x, y)| Pin::new(Point::new(x, y), Point::new(x, y))).collect(); let d = deform(&m, &pins); let err = m.verts.iter().zip(&d).map(|(a, b)| a.distance(*b)).fold(0.0, f64::max); assert!(err < 1e-4, "{err}"); @@ -541,10 +711,8 @@ mod tests { let (s, c) = 0.5f64.sin_cos(); Point::new(p.x * c - p.y * s + 30.0, p.x * s + p.y * c - 10.0) }; - let pins: Vec = [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)] - .iter() - .map(|&(x, y)| Pin { rest: Point::new(x, y), target: rot(Point::new(x, y)) }) - .collect(); + let pins: Vec = + [(20.0, 50.0), (180.0, 50.0), (100.0, 90.0)].iter().map(|&(x, y)| Pin::new(Point::new(x, y), rot(Point::new(x, y)))).collect(); let d = deform(&m, &pins); let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max); assert!(err < 1e-2, "{err}"); @@ -555,9 +723,9 @@ mod tests { let m = rect_mesh(); // Hold the left end, lift the right end. let pins = vec![ - Pin { rest: Point::new(10.0, 30.0), target: Point::new(10.0, 30.0) }, - Pin { rest: Point::new(10.0, 70.0), target: Point::new(10.0, 70.0) }, - Pin { rest: Point::new(190.0, 50.0), target: Point::new(180.0, -30.0) }, + Pin::new(Point::new(10.0, 30.0), Point::new(10.0, 30.0)), + Pin::new(Point::new(10.0, 70.0), Point::new(10.0, 70.0)), + Pin::new(Point::new(190.0, 50.0), Point::new(180.0, -30.0)), ]; let d = deform(&m, &pins); for p in &pins { @@ -579,6 +747,71 @@ mod tests { assert!(worst < 0.3, "edge stretch {worst}"); } + #[test] + fn a_held_rotation_turns_the_art_around_its_pin() { + let m = rect_mesh(); + let c = Point::new(100.0, 50.0); + let rot = |p: Point| { + let (s, co) = 0.4f64.sin_cos(); + let d = p - c; + c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) + }; + // One pin, held at 0.4 rad: the whole shape turns rigidly around it. + let d = deform(&m, &[Pin { angle: Some(0.4), ..Pin::new(c, c) }]); + let err = m.verts.iter().zip(&d).map(|(a, b)| rot(*a).distance(*b)).fold(0.0, f64::max); + assert!(err < 0.05, "{err}"); + // A free pin alone leaves the shape where it is. + let d = deform(&m, &[Pin::new(c, c)]); + assert!(m.verts.iter().zip(&d).all(|(a, b)| a.distance(*b) < 1e-3)); + // Held ends: the turned end turns, the other end stays. + let left = Point::new(10.0, 50.0); + let pins = [Pin { angle: Some(0.5), ..Pin::new(left, left) }, Pin::new(Point::new(190.0, 50.0), Point::new(190.0, 50.0))]; + let d = deform(&m, &pins); + let near = m.map(&d, Point::new(10.0, 62.0)) - left; + assert!((near.atan2() - (Vec2::new(0.0, 12.0).atan2() + 0.5)).abs() < 0.1, "the left end turned: {near:?}"); + assert!(m.map(&d, Point::new(190.0, 80.0)).distance(Point::new(190.0, 80.0)) < 5.0); + } + + #[test] + fn default_pins_go_at_the_centre_and_the_end_of_each_limb() { + // A long bar: the centre and both ends. + let m = rect_mesh(); + let p = default_pins(&m); + assert_eq!(p.len(), 3, "{p:?}"); + assert!(p[0].distance(Point::new(100.0, 50.0)) < 10.0, "{p:?}"); + let mut xs: Vec = p[1..].iter().map(|q| q.x).collect(); + xs.sort_by(f64::total_cmp); + assert!(xs[0] < 25.0 && xs[1] > 175.0, "{p:?}"); + // A five-pointed star: the centre and its five tips. + let star = vectorcraft_geom::shapes::star(Point::new(100.0, 100.0), 100.0, 35.0, 5, 0.0).to_bezpath(); + let sm = Mesh::build(std::slice::from_ref(&star), star.bounding_box(), MeshOptions { resolution: 30, expand: 0.0 }); + let p = default_pins(&sm); + assert_eq!(p.len(), 6, "{p:?}"); + assert!(p[1..].iter().all(|q| q.distance(Point::new(100.0, 100.0)) > 55.0), "{p:?}"); + // A disc has no limbs: the centre, topped up to three. + let disc = vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0).to_path(0.1); + let dm = Mesh::build(std::slice::from_ref(&disc), disc.bounding_box(), MeshOptions::default()); + assert_eq!(default_pins(&dm).len(), 3); + // A point with a wide Expand stays a small grid. + let dot = Mesh::build(&[], Rect::new(5.0, 5.0, 5.0, 5.0), MeshOptions { expand: 1000.0, ..Default::default() }); + assert!(dot.verts.len() < 30 * 30 && default_pins(&dot).len() == 3, "{}", dot.verts.len()); + } + + #[test] + fn unmap_and_turn_read_a_warped_mesh_back() { + let m = rect_mesh(); + let rot = |p: Point| { + let (s, c) = 0.3f64.sin_cos(); + Point::new(p.x * c - p.y * s + 20.0, p.x * s + p.y * c) + }; + let d: Vec = m.verts.iter().map(|v| rot(*v)).collect(); + let p = Point::new(60.0, 40.0); + assert!(m.unmap(&d, rot(p)).unwrap().distance(p) < 1e-6); + assert!(m.unmap(&d, Point::new(-500.0, 0.0)).is_none(), "off the mesh"); + assert!((m.turn_at(&d, p).unwrap() - 0.3).abs() < 1e-9); + assert!(m.contains(p) && !m.contains(Point::new(300.0, 50.0))); + } + #[test] fn auto_pins_are_spread_inside_the_shape() { let m = rect_mesh(); diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index a02bf22f1..4606be587 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -1,11 +1,14 @@ //! Liquify tools: Warp (Shift+R), Twirl, Pucker, Bloat, Scallop, Crystallize, Wrinkle. //! //! A drag previews `object.liquify {tool, points, width, height, angle, intensity, detail, -//! simplify, rate?, complexity?, horizontal?, vertical?, ids?}` with every pointer sample so far; -//! the engine re-applies the whole stroke to the interaction snapshot with [`apply_stroke`], so the -//! result is a pure function of the parameters (replay reproduces it exactly). +//! simplify, rate?, complexity?, horizontal?, vertical?, affect…?, usePressure?, ids?}` with every +//! pointer sample so far (and its pen pressure while Use Pressure Pen is on), so the result is a +//! pure function of the parameters (replay reproduces it exactly). Holding the brush still with +//! Twirl, Pucker or Bloat repeats the last sample every [`HOLD_EVERY`] seconds of [`Tool::tick`] +//! time: one more dab there each time. //! -//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart. For each dab it +//! The kernel resamples the stroke into dabs spaced a fraction of the brush apart ([`Dabber`]: +//! more samples only add dabs, so a growing stroke can be applied as it grows). For each dab it //! first subdivides the segments the brush touches (Detail: more anchors where the brush passes, //! lines become curves so the result stays smooth), then moves anchors and handles by the tool's //! displacement field weighted by a smooth falloff `(1 − r²)²` inside the (elliptical, rotated) @@ -64,6 +67,18 @@ impl LiquifyKind { Self::Wrinkle => "Wrinkle", } } + /// Holding the brush still keeps applying the tool (a repeated stroke sample is one more dab). + pub fn holds(self) -> bool { + matches!(self, Self::Twirl | Self::Pucker | Self::Bloat) + } + /// Has the Simplify option (Scallop, Crystallize and Wrinkle have Brush Affects instead). + pub fn simplifies(self) -> bool { + matches!(self, Self::Warp | Self::Twirl | Self::Pucker | Self::Bloat) + } + /// Has the Brush Affects options (anchor points, in and out tangent handles). + pub fn has_affects(self) -> bool { + !self.simplifies() + } } /// Brush and tool options (Warp Tool Options and friends). @@ -76,10 +91,14 @@ pub struct LiquifyParams { pub angle: f64, /// 0..1. pub intensity: f64, + /// Use Pressure Pen: each stroke sample's pen pressure is the intensity there. + pub use_pressure: bool, /// 1..10: anchor density added where the brush passes. pub detail: f64, - /// 0..100: removal of redundant flat anchors afterwards. + /// 0..100: removal of redundant flat anchors afterwards (Warp, Twirl, Pucker, Bloat). pub simplify: f64, + /// The Simplify checkbox. + pub simplify_on: bool, /// Twirl rate, degrees (−180..180). pub rate: f64, /// Scallop/Crystallize/Wrinkle complexity (0..15). @@ -100,8 +119,10 @@ impl LiquifyParams { height: 100.0, angle: 0.0, intensity: 0.5, + use_pressure: false, detail: 2.0, simplify: 50.0, + simplify_on: true, rate: 40.0, complexity: 1.0, horizontal: 0.0, @@ -132,23 +153,36 @@ impl LiquifyParams { s.complexity = f("complexity").unwrap_or(1.0).clamp(0.0, 15.0); s.horizontal = pct(f("horizontal").unwrap_or(0.0)).clamp(0.0, 1.0); s.vertical = pct(f("vertical").unwrap_or(1.0)).clamp(0.0, 1.0); - let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true); - s.affect_anchors = b("affectAnchors"); - s.affect_in = b("affectIn"); - s.affect_out = b("affectOut"); + let b = |k: &str, d: bool| p.get(k).and_then(Value::as_bool).unwrap_or(d); + s.affect_anchors = b("affectAnchors", true); + s.affect_in = b("affectIn", true); + s.affect_out = b("affectOut", true); + s.use_pressure = b("usePressure", false); + s.simplify_on = b("simplifyOn", true); Some(s) } + /// The command parameters (those the kind uses). pub fn to_json(&self) -> Value { let mut v = json!({ "tool": self.kind.id(), "width": self.width, "height": self.height, "angle": self.angle, - "intensity": self.intensity, "detail": self.detail, "simplify": self.simplify, + "intensity": self.intensity, "detail": self.detail, }); + if self.use_pressure { + v["usePressure"] = json!(true); + } + if self.kind.simplifies() { + v["simplify"] = json!(self.simplify); + v["simplifyOn"] = json!(self.simplify_on); + } else { + v["complexity"] = json!(self.complexity); + v["affectAnchors"] = json!(self.affect_anchors); + v["affectIn"] = json!(self.affect_in); + v["affectOut"] = json!(self.affect_out); + } match self.kind { LiquifyKind::Twirl => v["rate"] = json!(self.rate), - LiquifyKind::Scallop | LiquifyKind::Crystallize => v["complexity"] = json!(self.complexity), LiquifyKind::Wrinkle => { - v["complexity"] = json!(self.complexity); v["horizontal"] = json!(self.horizontal); v["vertical"] = json!(self.vertical); } @@ -157,6 +191,36 @@ impl LiquifyParams { v } + /// Every option, whichever tool these are (the tool's options: [`Self::to_json`] gives only + /// the parameters its kind uses). + pub fn options_json(&self) -> Value { + let mut v = self.to_json(); + for (k, x) in [ + ("simplify", self.simplify), + ("rate", self.rate), + ("complexity", self.complexity), + ("horizontal", self.horizontal), + ("vertical", self.vertical), + ] { + v[k] = json!(x); + } + for (k, x) in [ + ("usePressure", self.use_pressure), + ("simplifyOn", self.simplify_on), + ("affectAnchors", self.affect_anchors), + ("affectIn", self.affect_in), + ("affectOut", self.affect_out), + ] { + v[k] = json!(x); + } + v + } + + /// How far off the chord Simplify removes anchors (0: Simplify is off or not an option). + fn simplify_tolerance(&self) -> f64 { + if self.kind.simplifies() && self.simplify_on { self.simplify / 100.0 * self.detail_spacing() * 0.02 } else { 0.0 } + } + fn radii(&self) -> (f64, f64) { (self.width / 2.0, self.height / 2.0) } @@ -196,34 +260,96 @@ impl LiquifyParams { /// Resample a stroke polyline into dabs spaced `spacing` apart (the first point is always a dab). pub fn dabs(points: &[Point], spacing: f64) -> Vec { - let mut out: Vec = vec![]; - let Some(&first) = points.first() else { return out }; - out.push(first); - let mut last = first; - let mut carry = 0.0; - for w in points.windows(2) { - let (a, b) = (w[0], w[1]); + let mut d = Dabber::with(spacing, Some(1.0), false); + for p in points { + d.push((*p, 1.0)); + } + d.dabs.iter().chain(d.tail().as_ref()).map(|d| d.c).collect() +} + +/// One brush dab: its centre and intensity (0..1). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Dab { + pub c: Point, + pub intensity: f64, +} + +/// A stroke sample: the pointer position and its pen pressure (0..1). +pub type Sample = (Point, f64); + +/// Most dabs a stroke makes (later samples add none). +const MAX_DABS: usize = 20_000; + +/// Resamples a growing stroke into dabs `spacing` apart along it, the first sample always a dab. +/// More samples only add dabs ([`Self::dabs`] is a prefix of what it becomes), so a stroke can be +/// applied as it grows; the stroke's end, when it is off the spacing, is the [`Self::tail`]. +#[derive(Clone, Debug, Default)] +pub struct Dabber { + spacing: f64, + /// Every dab's intensity; None: each sample's pressure. + intensity: Option, + /// A repeated sample is one more dab there (the brush held still). + holds: bool, + samples: Vec, + /// Distance from the last dab to the last sample. + carry: f64, + /// The dabs on the spacing so far. + pub dabs: Vec, +} + +impl Dabber { + /// The dabs of a stroke with brush `prm`. + pub fn new(prm: &LiquifyParams) -> Self { + Self::with(prm.dab_spacing(), (!prm.use_pressure).then_some(prm.intensity), prm.kind.holds()) + } + + fn with(spacing: f64, intensity: Option, holds: bool) -> Self { + Self { spacing, intensity, holds, ..Self::default() } + } + + /// The samples so far. + pub fn samples(&self) -> &[Sample] { + &self.samples + } + + fn dab(&self, c: Point, pressure: f64) -> Dab { + Dab { c, intensity: self.intensity.unwrap_or(pressure.clamp(0.0, 1.0)) } + } + + /// Add a sample, and the dabs up to it. + pub fn push(&mut self, (b, pb): Sample) { + let prev = self.samples.last().copied(); + self.samples.push((b, pb)); + let Some((a, pa)) = prev else { + self.dabs.push(self.dab(b, pb)); + return; + }; + if self.dabs.len() > MAX_DABS { + return; + } let len = a.distance(b); if len < 1e-12 { - continue; + if self.holds { + self.dabs.push(self.dab(b, pb)); + self.carry = 0.0; + } + return; } - let mut s = spacing - carry; + let mut s = self.spacing - self.carry; while s <= len { - last = a.lerp(b, s / len); - out.push(last); - s += spacing; - } - carry = len - (s - spacing); - if out.len() > 20_000 { - break; + let t = s / len; + self.dabs.push(self.dab(a.lerp(b, t), pa + (pb - pa) * t)); + s += self.spacing; } + self.carry = len - (s - self.spacing); } - if let Some(&end) = points.last() - && end.distance(last) > spacing * 0.25 - { - out.push(end); + + /// The dab at the last sample when it is off the spacing (the stroke ends there). + pub fn tail(&self) -> Option { + let &(end, p) = self.samples.last()?; + let last = self.dabs.last()?.c; + (end.distance(last) > self.spacing * 0.25).then(|| self.dab(end, p)) } - out } fn hash(mut x: u64) -> u64 { @@ -404,37 +530,105 @@ fn simplify(sp: &mut SubPath, tol: f64, region: Rect) { } } -/// Apply a liquify stroke to `path`. `salt` decorrelates noise between paths. Returns whether -/// anything moved. +/// Apply a liquify stroke (its dabs, at the brush's intensity) to `path`. `salt` decorrelates +/// noise between paths. Returns whether anything moved. pub fn apply_stroke(path: &mut PathData, dab_pts: &[Point], prm: &LiquifyParams, salt: u64) -> bool { - let spacing = prm.detail_spacing(); - let mut changed = false; - let mut region: Option = None; - for (si, sp) in path.subpaths.iter_mut().enumerate() { - let salt = hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1)); - for (di, c) in dab_pts.iter().enumerate() { - let prev = if di == 0 { *c } else { dab_pts[di - 1] }; - let bb = prm.brush_bounds(*c); - let Some(spb) = sp_bounds(sp) else { continue }; - if spb.intersect(bb).area() <= 0.0 && !(spb.width() == 0.0 || spb.height() == 0.0) { - continue; - } - subdivide(sp, prm, *c, spacing); - if apply_dab(sp, prm, *c, prev, di as u64, salt) { - changed = true; - region = Some(region.map_or(bb, |r| r.union(bb))); - } + let dabs: Vec = dab_pts.iter().map(|c| Dab { c: *c, intensity: prm.intensity }).collect(); + let mut ps = PathStroke::new(std::mem::take(path), salt); + ps.advance(&dabs, prm); + match ps.finish(&dabs, None, prm) { + Some(done) => { + *path = done; + true + } + None => { + *path = ps.path; + false } } - if changed && let Some(r) = region { - let tol = prm.simplify / 100.0 * spacing * 0.02; - for sp in &mut path.subpaths { - simplify(sp, tol, r); +} + +/// Does the brush box `bb` reach subpath `sp`? +fn touches(sp: &SubPath, bb: Rect) -> bool { + sp_bounds(sp).is_some_and(|spb| spb.intersect(bb).area() > 0.0 || spb.width() == 0.0 || spb.height() == 0.0) +} + +/// Apply dab `d` (number `di`, after the dab at `prev`) to every subpath of `path` it reaches. +fn dab_on(path: &mut PathData, di: usize, d: Dab, prev: Point, prm: &LiquifyParams, salt: u64) -> bool { + let prm = LiquifyParams { intensity: d.intensity, ..*prm }; + let (bb, spacing) = (prm.brush_bounds(d.c), prm.detail_spacing()); + let mut changed = false; + for (si, sp) in path.subpaths.iter_mut().enumerate() { + if !touches(sp, bb) { + continue; } + subdivide(sp, &prm, d.c, spacing); + changed |= apply_dab(sp, &prm, d.c, prev, di as u64, hash(salt ^ (si as u64 + 1).wrapping_mul(0x1234_5678_9abc_def1))); } changed } +/// A path under a growing stroke: the dabs applied so far (Simplify comes at [`Self::finish`]). +/// Applying a stroke's dabs in any number of steps gives the same path as applying them at once. +#[derive(Clone, Debug)] +pub struct PathStroke { + path: PathData, + /// Decorrelates noise between paths. + salt: u64, + /// Dabs applied. + applied: usize, + /// Where dabs moved something (Simplify works there), None while nothing moved. + region: Option, +} + +impl PathStroke { + pub fn new(path: PathData, salt: u64) -> Self { + Self { path, salt, applied: 0, region: None } + } + + /// Apply the stroke's dabs not applied yet. + pub fn advance(&mut self, dabs: &[Dab], prm: &LiquifyParams) { + for di in self.applied..dabs.len() { + let Some(&d) = dabs.get(di) else { break }; + let prev = di.checked_sub(1).and_then(|i| dabs.get(i)).map_or(d.c, |p| p.c); + if dab_on(&mut self.path, di, d, prev, prm, self.salt) { + let bb = prm.brush_bounds(d.c); + self.region = Some(self.region.map_or(bb, |r| r.union(bb))); + } + } + self.applied = self.applied.max(dabs.len()); + } + + /// The path after the stroke (`dabs`, all applied, then `tail`), simplified; None when + /// nothing moved. + pub fn finish(&self, dabs: &[Dab], tail: Option, prm: &LiquifyParams) -> Option { + let mut region = self.region; + let mut out = None; + if let Some(t) = tail.filter(|t| self.path.subpaths.iter().any(|sp| touches(sp, prm.brush_bounds(t.c)))) { + let mut p = self.path.clone(); + if dab_on(&mut p, dabs.len(), t, dabs.last().map_or(t.c, |d| d.c), prm, self.salt) { + let bb = prm.brush_bounds(t.c); + region = Some(region.map_or(bb, |r| r.union(bb))); + } + out = Some(p); + } + let region = region?; + let mut path = out.unwrap_or_else(|| self.path.clone()); + let tol = prm.simplify_tolerance(); + if tol > 0.0 { + for sp in &mut path.subpaths { + simplify(sp, tol, region); + } + } + Some(path) + } +} + +/// The box around every anchor and handle of `path`: no dab outside it changes the path. +pub fn reach_bounds(path: &PathData) -> Option { + path.subpaths.iter().filter_map(sp_bounds).reduce(|a, b| a.union(b)) +} + fn sp_bounds(sp: &SubPath) -> Option { let mut it = sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]); let f = it.next()?; @@ -443,30 +637,66 @@ fn sp_bounds(sp: &SubPath) -> Option { // ---------- the tool ---------- +/// Holding the brush still repeats the stroke's last sample this often (seconds): Twirl, Pucker +/// and Bloat keep applying, scaled by the time held. +pub const HOLD_EVERY: f64 = 0.1; + +/// Most repeats one tick adds (a minute held). +const MAX_HOLDS_PER_TICK: u32 = 600; + +/// An Alt-drag sizing the brush: where it started and the brush size then. +#[derive(Clone, Copy, Debug)] +struct Sizing { + start: Point, + width: f64, + height: f64, +} + pub struct LiquifyTool { pub params: LiquifyParams, - points: Vec, + /// Show Brush Size: the brush outline follows the pointer. + pub show_brush: bool, + /// The stroke's samples (position, pen pressure). + points: Vec, active: bool, - /// Alt-drag resizes the brush from this centre. - sizing: Option, + sizing: Option, hover: Option, + /// Time held still since the last sample or repeat (seconds). + held: f64, } impl LiquifyTool { pub fn new(id: &str) -> Self { let kind = LiquifyKind::parse(id).unwrap_or(LiquifyKind::Warp); - Self { params: LiquifyParams::new(kind), points: vec![], active: false, sizing: None, hover: None } + Self { params: LiquifyParams::new(kind), show_brush: true, points: vec![], active: false, sizing: None, hover: None, held: 0.0 } } - /// The command + params for the stroke so far. + /// The command + params for the stroke so far (with each sample's pressure while Use Pressure + /// Pen is on). pub fn command(&self, cx: &ToolContext) -> (String, Value) { let mut v = self.params.to_json(); - v["points"] = Value::Array(self.points.iter().map(|p| json!([p.x, p.y])).collect()); + let pressure = self.params.use_pressure; + v["points"] = Value::Array(self.points.iter().map(|(p, f)| if pressure { json!([p.x, p.y, f]) } else { json!([p.x, p.y]) }).collect()); if !cx.selection.is_empty() { v["ids"] = crate::json_ids(&cx.selection.objects); } ("object.liquify".into(), v) } + + /// Alt-drag from `s` to `p`: the brush grows or shrinks from its size at the press (its edge + /// follows the pointer); with Shift it keeps its proportions. + fn resize(&mut self, s: Sizing, p: Point, proportional: bool) { + let (w, h) = (s.width + 2.0 * (p.x - s.start.x), s.height + 2.0 * (p.y - s.start.y)); + let (w, h) = if !proportional { + (w, h) + } else if (p.x - s.start.x).abs() >= (p.y - s.start.y).abs() { + (w, s.height * w / s.width) + } else { + (s.width * h / s.height, h) + }; + self.params.width = w.clamp(1.0, 10_000.0); + self.params.height = h.clamp(1.0, 10_000.0); + } } impl Tool for LiquifyTool { @@ -476,28 +706,30 @@ impl Tool for LiquifyTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; self.hover = Some(p); + let sample = (p, f64::from(ev.pressure).clamp(0.0, 1.0)); match ev.kind { PointerKind::Down if ev.mods.alt => { - self.sizing = Some(p); + self.sizing = Some(Sizing { start: p, width: self.params.width, height: self.params.height }); vec![] } PointerKind::Down => { - self.points = vec![p]; + self.points = vec![sample]; self.active = true; + self.held = 0.0; let (c, v) = self.command(cx); vec![Action::Begin(self.params.kind.label().into()), Action::Preview(c, v)] } PointerKind::Drag => { - if let Some(c) = self.sizing { - self.params.width = ((p.x - c.x).abs() * 2.0).max(1.0); - self.params.height = if ev.mods.shift { self.params.width } else { ((p.y - c.y).abs() * 2.0).max(1.0) }; - self.hover = Some(c); + if let Some(s) = self.sizing { + self.resize(s, p, ev.mods.shift); + self.hover = Some(s.start); return vec![]; } - if !self.active || self.points.last().is_some_and(|l| l.distance(p) < cx.tol(2.0)) { + if !self.active || self.points.last().is_some_and(|(l, _)| l.distance(p) < cx.tol(2.0)) { return vec![]; } - self.points.push(p); + self.points.push(sample); + self.held = 0.0; let (c, v) = self.command(cx); vec![Action::Preview(c, v)] } @@ -516,7 +748,7 @@ impl Tool for LiquifyTool { } } fn overlays(&self, _cx: &ToolContext) -> Vec { - let Some(c) = self.hover else { return vec![] }; + let Some(c) = self.hover.filter(|_| self.show_brush || self.sizing.is_some()) else { return vec![] }; vec![Overlay::Path { path: ellipse_path(c, self.params.width / 2.0, self.params.height / 2.0, self.params.angle), color: [0x80, 0x80, 0x80], @@ -527,18 +759,28 @@ impl Tool for LiquifyTool { fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { Cursor::Crosshair } + /// Every option ([`LiquifyParams::options_json`]) and `showBrush`. fn options(&self) -> Value { - self.params.to_json() + let mut v = self.params.options_json(); + v["showBrush"] = json!(self.show_brush); + v } fn set_option(&mut self, key: &str, value: &Value) { - let mut v = self.params.to_json(); - v[key] = value.clone(); - if key == "diameter" { - v["width"] = value.clone(); - v["height"] = value.clone(); - } - if let Some(p) = LiquifyParams::from_json(&v) { - self.params = p; + match key { + // The kind is the tool's: another tool is another tool. + "tool" => {} + "showBrush" => self.show_brush = value.as_bool().unwrap_or(self.show_brush), + _ => { + let mut v = self.params.options_json(); + v[key] = value.clone(); + if key == "diameter" { + v["width"] = value.clone(); + v["height"] = value.clone(); + } + if let Some(p) = LiquifyParams::from_json(&v) { + self.params = p; + } + } } } fn busy(&self) -> bool { @@ -549,6 +791,27 @@ impl Tool for LiquifyTool { self.sizing = None; if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] } } + /// Held still: every [`HOLD_EVERY`] the last sample repeats (one more dab there). + fn tick(&mut self, cx: &ToolContext, dt: f64) -> Vec { + if !self.wants_ticks() || !dt.is_finite() || dt <= 0.0 { + return vec![]; + } + self.held += dt; + let mut n = 0; + // A hair under the period, so a sum of ticks that is one in exact arithmetic counts. + while self.held >= HOLD_EVERY - 1e-9 && n < MAX_HOLDS_PER_TICK { + self.held -= HOLD_EVERY; + n += 1; + } + self.held = self.held.clamp(0.0, HOLD_EVERY); + let Some(&last) = self.points.last().filter(|_| n > 0) else { return vec![] }; + self.points.extend(std::iter::repeat_n(last, n as usize)); + let (c, v) = self.command(cx); + vec![Action::Preview(c, v)] + } + fn wants_ticks(&self) -> bool { + self.active && self.params.kind.holds() + } } #[cfg(test)] diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 0ab111f35..8256ac36f 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -19,10 +19,16 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, PerspectiveAttachment}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::bbox::{Handle as BoxHandle, scale_for_drag}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +pub mod define; +pub mod view; +pub mod widget; +pub use define::{GridDefinition, Rgb, Station}; /// Key under `Document.unknown`. pub const DOC_KEY: &str = "perspectiveGrid"; @@ -71,54 +77,7 @@ impl Plane { /// Plane Switching widget: (plane, face quad) list and the "no plane" circle (centre, radius). pub type WidgetGeom = (Vec<(Plane, [Point; 4])>, (Point, f64)); -/// A 3×3 projective transform (row-major). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Homography(pub [[f64; 3]; 3]); - -impl Homography { - /// From the images of (1,0,0), (0,1,0) and (0,0,1). - pub fn from_cols(c1: [f64; 3], c2: [f64; 3], c3: [f64; 3]) -> Self { - Self([[c1[0], c2[0], c3[0]], [c1[1], c2[1], c3[1]], [c1[2], c2[2], c3[2]]]) - } - /// Map a point; None when it lands on or beyond the horizon (w ≤ 0). - pub fn apply(&self, p: Point) -> Option { - let m = &self.0; - let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2]; - let y = m[1][0] * p.x + m[1][1] * p.y + m[1][2]; - let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2]; - if w <= 1e-9 || !x.is_finite() || !y.is_finite() { - return None; - } - Some(Point::new(x / w, y / w)) - } - pub fn det(&self) -> f64 { - let m = &self.0; - m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) - + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) - } - /// Inverse. - pub fn inverse(&self) -> Option { - let m = &self.0; - let d = self.det(); - if d.abs() < 1e-15 { - return None; - } - let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; - let adj = [ - [c(1, 1, 2, 2), -c(0, 1, 2, 2), c(0, 1, 1, 2)], - [-c(1, 0, 2, 2), c(0, 0, 2, 2), -c(0, 0, 1, 2)], - [c(1, 0, 2, 1), -c(0, 0, 2, 1), c(0, 0, 1, 1)], - ]; - let mut out = [[0.0; 3]; 3]; - for (r, row) in adj.iter().enumerate() { - for (k, v) in row.iter().enumerate() { - out[r][k] = v / d; - } - } - // H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0. - Some(Self(out)) - } -} +pub use vectorcraft_geom::Homography; fn two() -> u8 { 2 @@ -126,6 +85,9 @@ fn two() -> u8 { fn yes() -> bool { true } +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} /// The perspective grid definition (Define Grid) plus view state. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -160,6 +122,53 @@ pub struct PerspectiveGrid { /// Objects attached to planes (node id → plane). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub attached: BTreeMap, + /// The preset the grid came from ("" = custom). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, + /// Viewing angle in degrees (two/three-point): where the station point stands between the + /// vanishing points ([`Station`]). None for grids made before it: they foreshorten every axis + /// by `distance`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub angle: Option, + /// The unit Define Grid measures in (a unit name, e.g. `points`, `inches`). + #[serde(default = "define::points", skip_serializing_if = "define::is_points")] + pub units: String, + /// Scale: `[artboard, real world]` lengths (Define Grid's real-world lengths over this). + #[serde(default = "define::one_to_one", skip_serializing_if = "define::is_one_to_one")] + pub scale: [f64; 2], + /// Gridline colours of the left, right and horizontal (ground) planes. + #[serde(default = "define::left_rgb")] + pub left_color: Rgb, + #[serde(default = "define::right_rgb")] + pub right_color: Rgb, + #[serde(default = "define::ground_rgb")] + pub ground_color: Rgb, + /// Gridline opacity, 0–100 %. + #[serde(default = "define::half")] + pub opacity: f64, + /// Where the left, right and horizontal planes were moved along their normals (points; 0: their + /// place in the grid's definition): the plane widgets and `perspective.plane.move` set them. + #[serde(default, skip_serializing_if = "is_zero")] + pub left_offset: f64, + #[serde(default, skip_serializing_if = "is_zero")] + pub right_offset: f64, + #[serde(default, skip_serializing_if = "is_zero")] + pub ground_offset: f64, + /// View → Perspective Grid → Lock Grid: the grid's widgets can't be dragged. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub locked: bool, + /// Lock Station Point: dragging one vanishing point moves the other around the station point. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub lock_station: bool, + /// Snap to Grid: art drawn or moved in perspective lands on gridlines within a quarter cell. + #[serde(default = "yes")] + pub snap: bool, + /// Show Rulers: a ruler up the line where the planes meet. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub rulers: bool, + /// Horizontal extent of the right plane (points, plane units); None: `extent`, as the left. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extent_right: Option, } impl PerspectiveGrid { @@ -185,31 +194,109 @@ impl PerspectiveGrid { visible: true, plane: Plane::Left, attached: BTreeMap::new(), + name: String::new(), + angle: None, + units: define::points(), + scale: define::one_to_one(), + left_color: define::left_rgb(), + right_color: define::right_rgb(), + ground_color: define::ground_rgb(), + opacity: define::half(), + left_offset: 0.0, + right_offset: 0.0, + ground_offset: 0.0, + locked: false, + lock_station: false, + snap: true, + rulers: false, + extent_right: None, } } - /// The document's grid, if one was defined. + /// Where `plane` is along its normal (points; 0: its place in the grid's definition). + pub fn offset(&self, plane: Plane) -> f64 { + match plane { + Plane::Left => self.left_offset, + Plane::Right => self.right_offset, + Plane::Ground => self.ground_offset, + Plane::None => 0.0, + } + } + + /// Move `plane` to `offset` along its normal. + pub fn set_offset(&mut self, plane: Plane, offset: f64) { + match plane { + Plane::Left => self.left_offset = offset, + Plane::Right => self.right_offset = offset, + Plane::Ground => self.ground_offset = offset, + Plane::None => {} + } + } + + /// The document's grid, if one was defined, with [`Self::attached`] collected from the objects. pub fn from_doc(doc: &Document) -> Option { + let mut g = Self::stored(doc)?; + g.collect_attached(doc); + Some(g) + } + + /// The document's grid, or the default two-point preset for the first artboard (hidden), with + /// [`Self::attached`] collected from the objects. + pub fn effective(doc: &Document) -> Self { + let mut g = Self::current(doc); + g.collect_attached(doc); + g + } + + /// [`Self::effective`] without walking the document for attached objects (for drawing and + /// tools: ask single objects with [`Self::attachment_of`]). + pub fn current(doc: &Document) -> Self { + Self::stored(doc).unwrap_or_else(|| Self { visible: false, ..Self::normal(2, define::first_artboard(doc)) }) + } + + /// The grid as stored. Its `attached` holds what files from before attachments were kept on the + /// objects recorded. + fn stored(doc: &Document) -> Option { doc.unknown.get(DOC_KEY).and_then(|v| serde_json::from_value(v.clone()).ok()) } - /// The document's grid, or the default 2-point preset for the first artboard (hidden). - pub fn effective(doc: &Document) -> Self { - Self::from_doc(doc).unwrap_or_else(|| { - let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); - Self { visible: false, ..Self::preset(2, ab) } - }) + fn collect_attached(&mut self, doc: &Document) { + doc.walk(|n| { + if let Some((pl, _)) = attachment(n) { + self.attached.insert(n.id.0.to_string(), pl); + } + }); } - /// Store into the document. + /// Store into the document. Attachments live on the objects: the stored grid keeps none. pub fn store(&self, doc: &mut Document) { + self.adopt_stored_attachments(doc); let mut g = self.clone(); - g.attached.retain(|k, _| k.parse::().ok().is_some_and(|id| doc.node(NodeId(id)).is_some())); + g.attached.clear(); if let Ok(v) = serde_json::to_value(&g) { doc.unknown.insert(DOC_KEY.into(), v); } } + /// Move the attachments a grid stored before they were kept on the objects onto the objects, + /// unless this grid no longer has them (released since); ids of deleted objects are dropped. + pub fn adopt_stored_attachments(&self, doc: &mut Document) { + let Some(old) = Self::stored(doc) else { return }; + for (k, pl) in &old.attached { + if self.attached.get(k) != Some(pl) { + continue; + } + if let Some(n) = k.parse::().ok().and_then(|id| doc.node_mut(NodeId(id))) + && n.perspective.is_none() + { + n.perspective = Some(Box::new(PerspectiveAttachment::new(pl.id(), 0.0))); + } + } + if let Some(v) = doc.unknown.get_mut(DOC_KEY).and_then(Value::as_object_mut) { + v.remove("attached"); + } + } + /// Definition only (for tool previews and the Define Grid dialog). pub fn definition_json(&self) -> Value { let mut v = serde_json::to_value(self).unwrap_or(Value::Null); @@ -230,7 +317,8 @@ impl PerspectiveGrid { o.insert(k.clone(), val.clone()); } } - let g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + let mut g: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + g.reconcile(self, patch); g.validate()?; Ok(g) } @@ -261,42 +349,142 @@ impl PerspectiveGrid { if (self.origin[1] - self.horizon).abs() < 1e-6 { return Err("ground level must differ from the horizon".into()); } - Ok(()) + // A moved plane stays in front of the viewer. + let behind = |p: Plane| self.axes(p).is_some_and(|[.., n]| 1.0 + self.offset(p) * self.axis_col(n)[2] <= 1e-9); + if Plane::ALL.iter().any(|p| !self.offset(*p).is_finite() || self.offset(*p).abs() > MAX_DEPTH || behind(*p)) { + return Err("a plane is moved too far (behind the viewer)".into()); + } + self.validate_definition() } pub fn planes(&self) -> [Plane; 3] { Plane::ALL } - /// The homography of `plane` (plane coordinates in points → page). - pub fn homography(&self, plane: Plane) -> Option { - let l = 1.0 / self.distance.max(1e-9); - let o = [self.origin[0], self.origin[1], 1.0]; - let vl = [self.vp_left * l, self.horizon * l, l]; - let vr = [self.vp_right * l, self.horizon * l, l]; - let up = if self.kind == 3 { [self.vp_vertical[0] * l, self.vp_vertical[1] * l, l] } else { [0.0, -1.0, 0.0] }; - let flat = [1.0, 0.0, 0.0]; - let (c1, c2) = match (self.kind, plane) { + /// The image of `axis`'s point at infinity: a vanishing point scaled by `1/distance` for a + /// receding axis, a direction for an axis parallel to the picture plane. + fn axis_col(&self, axis: Axis) -> [f64; 3] { + // Each receding axis foreshortens by the viewer's distance to its vanishing point. + let (ll, lr, lu) = self.foreshortening(); + match axis { + Axis::X if self.kind == 1 => [1.0, 0.0, 0.0], + Axis::X => [self.vp_right * lr, self.horizon * lr, lr], + Axis::Y if self.kind == 3 => [self.vp_vertical[0] * lu, self.vp_vertical[1] * lu, lu], + Axis::Y => [0.0, -1.0, 0.0], + Axis::Z => [self.vp_left * ll, self.horizon * ll, ll], + } + } + + /// A plane's axes: `[u, v, normal]`. + pub fn axes(&self, plane: Plane) -> Option<[Axis; 3]> { + Some(match (self.kind, plane) { (_, Plane::None) => return None, - (1, Plane::Left) => (flat, [0.0, -1.0, 0.0]), - (1, Plane::Right) => (vl, [0.0, -1.0, 0.0]), - (1, Plane::Ground) => (flat, vl), - (_, Plane::Left) => (vl, up), - (_, Plane::Right) => (vr, up), - (_, Plane::Ground) => (vr, vl), - }; - let h = Homography::from_cols(c1, c2, o); + (1, Plane::Left) => [Axis::X, Axis::Y, Axis::Z], + (1, Plane::Right) => [Axis::Z, Axis::Y, Axis::X], + (_, Plane::Left) => [Axis::Z, Axis::Y, Axis::X], + (_, Plane::Right) => [Axis::X, Axis::Y, Axis::Z], + (_, Plane::Ground) => [Axis::X, Axis::Z, Axis::Y], + }) + } + + /// The homography of `plane` where it is now (plane coordinates in points → page). + pub fn homography(&self, plane: Plane) -> Option { + self.homography_at(plane, self.offset(plane)) + } + + /// The homography of the plane parallel to `plane` that lies `depth` points along its normal + /// (`None` behind the viewer). + pub fn homography_at(&self, plane: Plane, depth: f64) -> Option { + let [u, v, n] = self.axes(plane)?; + self.frame_homography(u, v, (n, depth)) + } + + /// The plane spanned by axes `u` and `v` through the point `at.1` along axis `at.0`. + fn frame_homography(&self, u: Axis, v: Axis, at: (Axis, f64)) -> Option { + if !at.1.is_finite() { + return None; + } + let c = self.axis_col(at.0); + let o = [self.origin[0] + at.1 * c[0], self.origin[1] + at.1 * c[1], 1.0 + at.1 * c[2]]; + if o[2] <= 1e-9 { + return None; + } + let h = Homography::from_cols(self.axis_col(u), self.axis_col(v), o); (h.det().abs() > 1e-15).then_some(h) } - /// Plane-coordinate extent of a plane. + /// The plane-space offset between page points `from` and `to` on `plane` at `depth`. + pub fn plane_delta(&self, plane: Plane, depth: f64, from: Point, to: Point) -> Option { + let hi = self.homography_at(plane, depth)?.inverse()?; + Some(hi.apply(to)? - hi.apply(from)?) + } + + /// The depth an object on `plane` at `depth` dragged from `from` to `to` perpendicular to its + /// plane reaches: the pointer is read on the plane through the drag start that holds the + /// normal and the plane's v axis. + pub fn depth_at(&self, plane: Plane, depth: f64, from: Point, to: Point) -> Option { + let [u, v, n] = self.axes(plane)?; + let q = self.homography_at(plane, depth)?.inverse()?.apply(from)?; + let ci = self.frame_homography(n, v, (u, q.x))?.inverse()?; + let d = depth + (ci.apply(to)?.x - ci.apply(from)?.x); + (d.is_finite() && d.abs() <= MAX_DEPTH).then_some(d) + } + + /// The page map of plane-space affine map `m` for art on `plane` at `depth`, then moved `dz` + /// along the plane's normal: `H(depth + dz) · m · H(depth)⁻¹`. + pub fn transform_map(&self, plane: Plane, depth: f64, m: Affine, dz: f64) -> Option { + let h = self.homography_at(plane, depth)?; + let to = self.homography_at(plane, depth + dz)?; + Some(to.then_after(&Homography::from_affine(m)).then_after(&h.inverse()?)) + } + + /// Where object `id` is attached (plane, depth): its own record, else the one a grid stored + /// before attachments were kept on the objects. + pub fn attachment_of(&self, doc: &Document, id: NodeId) -> Option<(Plane, f64)> { + doc.node(id).and_then(attachment).or_else(|| self.attached_plane(id).map(|p| (p, 0.0))) + } + + /// The plane-space bounds of the attached objects among `ids` lying on the plane of the first + /// one: (that plane, the first one's depth, bounds). + pub fn plane_bounds(&self, doc: &Document, ids: &[NodeId]) -> Option<(Plane, f64, Rect)> { + let mut out: Option<(Plane, f64, Rect)> = None; + for id in ids { + let Some((pl, depth)) = self.attachment_of(doc, *id) else { continue }; + if out.is_some_and(|o| o.0 != pl) { + continue; + } + let hi = self.homography_at(pl, depth).and_then(|h| h.inverse()); + let Some(b) = hi.zip(doc.node(*id)).and_then(|(hi, n)| mapped_bounds(n, &hi)) else { continue }; + out = Some(match out { + Some((p, d, r)) => (p, d, r.union(b)), + None => (pl, depth, b), + }); + } + out + } + + /// Plane-coordinate extent of a plane: the left plane is `extent` wide, the right one + /// [`Self::right_extent`]; the ground spans both (one-point: the front plane's width by the + /// side wall's depth). pub fn domain(&self, plane: Plane) -> Rect { - match plane { - Plane::Ground => Rect::new(0.0, 0.0, self.extent, self.extent), + match (plane, self.kind) { + (Plane::Ground, 1) => Rect::new(0.0, 0.0, self.extent, self.right_extent()), + (Plane::Ground, _) => Rect::new(0.0, 0.0, self.right_extent(), self.extent), + (Plane::Right, _) => Rect::new(0.0, 0.0, self.right_extent(), self.height), _ => Rect::new(0.0, 0.0, self.extent, self.height), } } + /// The right plane's horizontal extent. + pub fn right_extent(&self) -> f64 { + self.extent_right.unwrap_or(self.extent) + } + + /// Is the grid drawn (shown, or a perspective tool active)? + pub fn shown(&self, tool_id: &str) -> bool { + self.visible || matches!(tool_id, "perspectiveGrid" | "perspectiveSelection") + } + /// Page → plane coordinates. pub fn to_plane(&self, plane: Plane, p: Point) -> Option { self.homography(plane)?.inverse()?.apply(p) @@ -311,25 +499,52 @@ impl PerspectiveGrid { /// bottom-right, then the plane homography projects it (so a drawn rectangle keeps the two /// corners the user dragged between). pub fn attach_map(&self, plane: Plane, b: Rect) -> Option Option + use<>> { + let h = self.attach_homography(plane, b, false)?; + Some(move |p: Point| h.apply(p)) + } + + /// [`Self::attach_map`]; with `snap` the corners land on the nearest gridlines (Snap to Grid). + pub fn attach_map_with(&self, plane: Plane, b: Rect, snap: bool) -> Option Option + use<>> { + let h = self.attach_homography(plane, b, snap)?; + Some(move |p: Point| h.apply(p)) + } + + /// [`Self::attach_map_with`] as one projective map. + pub fn attach_homography(&self, plane: Plane, b: Rect, snap: bool) -> Option { let h = self.homography(plane)?; let hi = h.inverse()?; - let a = hi.apply(Point::new(b.x0, b.y0))?; - let c = hi.apply(Point::new(b.x1, b.y1))?; + let snapped = |q: Point| if snap { self.snap_plane(q) } else { q }; + let a = snapped(hi.apply(Point::new(b.x0, b.y0))?); + let c = snapped(hi.apply(Point::new(b.x1, b.y1))?); let sx = if b.width().abs() > 1e-9 { (c.x - a.x) / b.width() } else { 1.0 }; let sy = if b.height().abs() > 1e-9 { (c.y - a.y) / b.height() } else { sx.abs() * if c.y < a.y { -1.0 } else { 1.0 } }; let sx = if b.width().abs() > 1e-9 { sx } else { sy.abs() }; - let (x0, y0) = (b.x0, b.y0); - Some(move |p: Point| h.apply(Point::new(a.x + (p.x - x0) * sx, a.y + (p.y - y0) * sy))) + let m = Affine::new([sx, 0.0, 0.0, sy, a.x - b.x0 * sx, a.y - b.y0 * sy]); + Some(h.then_after(&Homography::from_affine(m))) + } + + /// The map that puts flat art drawn at `at` onto `plane` keeping its sizes in plane units (a + /// click-to-size shape dialog's shape): `at` stays where it is, and right and down on the page + /// go the ways the plane's axes run on the page there. + /// With `snap` the click lands on the nearest gridline intersection (Snap to Grid). + pub fn size_homography(&self, plane: Plane, at: Point, snap: bool) -> Option { + let h = self.homography(plane)?; + let q = h.inverse()?.apply(at)?; + let q = if snap { self.snap_plane(q) } else { q }; + let at = h.apply(q)?; + let sx = if h.apply(q + Vec2::new(1.0, 0.0))?.x < at.x { -1.0 } else { 1.0 }; + let sy = if h.apply(q + Vec2::new(0.0, 1.0))?.y < at.y { -1.0 } else { 1.0 }; + let m = Affine::new([sx, 0.0, 0.0, sy, q.x - sx * at.x, q.y - sy * at.y]); + Some(h.then_after(&Homography::from_affine(m))) } /// The map that slides art lying on `plane` by the plane-space offset between `from` and `to`. pub fn move_map(&self, plane: Plane, from: Point, to: Point) -> Option Option + use<>> { - let h = self.homography(plane)?; - let hi = h.inverse()?; - let d = hi.apply(to)? - hi.apply(from)?; - Some(move |p: Point| h.apply(hi.apply(p)? + d)) + let hi = self.homography(plane)?.inverse()?; + self.move_map_by(plane, hi.apply(to)? - hi.apply(from)?) } + /// The plane object `id` is attached to, as collected by [`Self::from_doc`]. pub fn attached_plane(&self, id: NodeId) -> Option { self.attached.get(&id.0.to_string()).copied() } @@ -364,18 +579,10 @@ impl PerspectiveGrid { out } - /// Plane Switching widget geometry: (plane, quad) faces and the "no plane" circle (centre, r). + /// Plane Switching widget geometry in the first artboard's top-left corner (headless): (plane, + /// quad) faces and the "no plane" circle (centre, r). See [`Self::widget_at`]. pub fn widget(&self, doc: &Document, tol: f64) -> WidgetGeom { - let ab = doc.artboards.first().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); - let s = 40.0 * tol; - let (cx, cy) = (ab.x0 + 10.0 * tol + s / 2.0, ab.y0 + 10.0 * tol + s / 2.0); - let p = |dx: f64, dy: f64| Point::new(cx + dx * s, cy + dy * s); - let faces = vec![ - (Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]), - (Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]), - (Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]), - ]; - (faces, (p(0.62, 0.62), 0.12 * s)) + self.widget_at(doc, tol, widget::WidgetPlace::default()) } /// Which widget control is at `p`. @@ -386,6 +593,155 @@ impl PerspectiveGrid { } faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl) } + + /// The left, right and horizontal plane widgets: where each sits (on its plane: the middle of a + /// wall's ground edge, the middle of the horizontal plane). + pub fn plane_widgets(&self) -> Vec<(Plane, Point)> { + let e = self.extent / 2.0; + Plane::ALL + .into_iter() + .filter_map(|pl| { + let q = if pl == Plane::Ground { Point::new(e, e) } else { Point::new(e, 0.0) }; + Some((pl, self.homography(pl)?.apply(q)?)) + }) + .collect() + } +} + +/// Dragging a plane widget: the plane moves along its normal by as much as the pointer moved along +/// it (from the widget, wherever on it the press was). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlaneDrag { + pub plane: Plane, + anchor: Point, + press: Point, + offset: f64, +} + +impl PlaneDrag { + /// The plane widget within `tol` of `p` (drawn grids only). + pub fn hit(g: &PerspectiveGrid, tol: f64, p: Point) -> Option { + if !g.visible { + return None; + } + let (plane, anchor) = g.plane_widgets().into_iter().find(|(_, q)| q.distance(p) <= tol)?; + Some(Self { plane, anchor, press: p, offset: g.offset(plane) }) + } + + /// The preview for the pointer at `p`: Shift moves the objects on the plane with it, Alt copies + /// them. + pub fn preview(&self, g: &PerspectiveGrid, p: Point, m: Mods) -> Option { + let offset = g.depth_at(self.plane, self.offset, self.anchor, self.anchor + (p - self.press))?; + let objects = if m.alt { + "copy" + } else if m.shift { + "move" + } else { + "none" + }; + Some(Action::Preview("perspective.plane.move".into(), json!({"plane": self.plane.id(), "offset": offset, "objects": objects}))) + } + + /// The plane options dialog a double-click on the widget opens. + pub fn options(&self) -> Action { + Action::Dialog(PLANE_DIALOG.into(), json!({"plane": self.plane.id()})) + } +} + +/// The dialog kind of a plane's options (double-click its widget). +pub const PLANE_DIALOG: &str = "perspectivePlane"; + +/// The plane widgets as drawn: a diamond in each plane's colour. +fn plane_widget_overlays(g: &PerspectiveGrid, tol: f64) -> Vec { + let r = 5.0 * tol; + g.plane_widgets() + .into_iter() + .flat_map(|(pl, p)| { + let [cr, cg, cb] = pl.color(); + let quad = [Point::new(p.x, p.y - r), Point::new(p.x + r, p.y), Point::new(p.x, p.y + r), Point::new(p.x - r, p.y)]; + [ + Overlay::Highlight { quad, color: [cr, cg, cb, 220] }, + Overlay::Path { path: super::diamond(p, r), color: [0x20, 0x20, 0x20], width: 1.0, dashed: false }, + ] + }) + .collect() +} + +/// How far (points) along a plane's normal objects and planes may go. +pub const MAX_DEPTH: f64 = 1.0e6; + +/// An axis of the grid's 3-D frame: Y is up, X and Z recede to the right and left vanishing points +/// (in 1-point perspective X is the flat horizontal and Z recedes to the vanishing point). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Axis { + X, + Y, + Z, +} + +/// The plane and depth object `n` is attached to (its own record). +pub fn attachment(n: &Node) -> Option<(Plane, f64)> { + let a = n.perspective.as_deref()?; + Plane::parse(&a.plane).filter(|p| *p != Plane::None).map(|p| (p, a.depth)) +} + +/// The objects in perspective that aren't inside another one: (id, plane, depth). +pub fn attached_roots(doc: &Document) -> Vec<(NodeId, Plane, f64)> { + fn visit(n: &Node, out: &mut Vec<(NodeId, Plane, f64)>) { + match attachment(n) { + Some((pl, depth)) => out.push((n.id, pl, depth)), + None => n.children().into_iter().flatten().for_each(|c| visit(c, out)), + } + } + let mut out = vec![]; + for l in &doc.layers { + visit(l, &mut out); + } + out +} + +/// Attach `n` to `plane` at `depth` (type and symbols keep their projection). +pub fn set_attachment(n: &mut Node, plane: Plane, depth: f64) { + let rec = n.perspective.get_or_insert_with(Default::default); + rec.plane = plane.id().into(); + rec.depth = if depth.is_finite() { depth } else { 0.0 }; +} + +/// Release `n` from the grid (Release with Perspective): it keeps its look, type and symbols their +/// projection. +pub fn release(n: &mut Node) { + match n.perspective.as_deref_mut() { + Some(rec) if rec.projection.is_some() => *rec = PerspectiveAttachment { projection: rec.projection, ..Default::default() }, + _ => n.perspective = None, + } +} + +/// The bounds of `n` mapped by `hi`: paths by their mapped curves, other objects by their boxes' +/// corners. `None` when part of it maps beyond the horizon. +fn mapped_bounds(n: &Node, hi: &Homography) -> Option { + let mut acc: Option = None; + let mut ok = true; + n.walk(&mut |c| match &c.kind { + NodeKind::Path { path, .. } => { + let mut path = path.clone(); + for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) { + for q in [&mut a.p, &mut a.h_in, &mut a.h_out] { + match hi.apply(*q) { + Some(v) => *q = v, + None => ok = false, + } + } + } + acc = vectorcraft_geom::union_opt(acc, path.bounds()); + } + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } | NodeKind::Blend { .. } => {} + _ => match c.geometric_bounds().map(|b| hi.map_rect_bbox(b)) { + Some(Some(b)) => acc = vectorcraft_geom::union_opt(acc, Some(b)), + Some(None) => ok = false, + None => {} + }, + }); + if ok { acc } else { None } } fn in_quad(q: &[Point; 4], p: Point) -> bool { @@ -408,16 +764,20 @@ fn in_quad(q: &[Point; 4], p: Point) -> bool { /// Grid overlays (lines, horizon, vanishing points, widget) drawn whenever the grid is visible or /// a perspective tool is active. `tol` = document units per screen pixel. pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec { - let g = PerspectiveGrid::effective(doc); - let tool = matches!(tool_id, "perspectiveGrid" | "perspectiveSelection"); - if !g.visible && !tool { + grid_overlays_in(doc, tol, tool_id, Some(widget::WidgetPlace::default())) +} + +/// [`grid_overlays`] with the Plane Switching Widget at `place` (None: hidden). +pub fn grid_overlays_in(doc: &Document, tol: f64, tool_id: &str, place: Option) -> Vec { + let g = PerspectiveGrid::current(doc); + if !g.shown(tool_id) { return vec![]; } let mut out = vec![]; for pl in g.planes() { - let color = pl.color(); + let (line, color) = (g.line_color(pl), g.plane_color(pl)); for (a, b) in g.lines(pl) { - out.push(Overlay::Line { a, b, color, dashed: false }); + out.push(Overlay::GridLine { a, b, color: line }); } if pl == g.plane && let Some(h) = g.homography(pl) @@ -433,8 +793,12 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec { let xs = [g.vp_left, g.vp_right, g.origin[0]]; let (x0, x1) = (xs.iter().cloned().fold(f64::MAX, f64::min) - 50.0 * tol, xs.iter().cloned().fold(f64::MIN, f64::max) + 50.0 * tol); out.push(Overlay::Line { a: Point::new(x0, g.horizon), b: Point::new(x1, g.horizon), color: [0x60, 0x60, 0x60], dashed: true }); + if g.rulers { + out.extend(g.ruler_overlays(tol)); + } // Widget. - let (faces, (c, r)) = g.widget(doc, tol); + let Some(place) = place else { return out }; + let (faces, (c, r)) = g.widget_at(doc, tol, place); for (pl, q) in faces { let [cr, cg, cb] = pl.color(); out.push(Overlay::Highlight { quad: q, color: [cr, cg, cb, if pl == g.plane { 230 } else { 70 }] }); @@ -446,6 +810,7 @@ pub fn grid_overlays(doc: &Document, tol: f64, tool_id: &str) -> Vec { width: 1.5, dashed: false, }); + out.extend(plane_widget_overlays(&g, tol)); out } @@ -461,9 +826,25 @@ enum Handle { ExtentLeft, ExtentRight, Height, + /// The left and right ground-level points: they move the whole grid. + GroundLeft, + GroundRight, + /// The grid cell size widget, `n` cells up the line where the planes meet. + Cell, } -fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> { +/// The ground-level points and the cell widget sit at least this far (screen pixels) from the +/// origin. +const NEAR_ORIGIN_PX: f64 = 24.0; + +/// How many cells up the cell widget sits so it stays clear of the origin (`tol` = document units +/// per screen pixel). +fn cell_steps(g: &PerspectiveGrid, tol: f64) -> f64 { + (NEAR_ORIGIN_PX * tol / g.cell.max(1e-9)).ceil().clamp(1.0, 1.0e6) +} + +fn handles(g: &PerspectiveGrid, tol: f64) -> Vec<(Handle, Point)> { + let o = Point::new(g.origin[0], g.origin[1]); let mut v = vec![(Handle::VpLeft, Point::new(g.vp_left, g.horizon))]; if g.kind != 1 { v.push((Handle::VpRight, Point::new(g.vp_right, g.horizon))); @@ -471,44 +852,103 @@ fn handles(g: &PerspectiveGrid) -> Vec<(Handle, Point)> { if g.kind == 3 { v.push((Handle::VpVertical, Point::new(g.vp_vertical[0], g.vp_vertical[1]))); } - v.push((Handle::Origin, Point::new(g.origin[0], g.origin[1]))); - let (lp, rp) = (Plane::Left, Plane::Right); - if let Some(p) = g.to_page(lp, Point::new(g.extent, 0.0)) { - v.push((Handle::ExtentLeft, p)); - } - if let Some(p) = g.to_page(rp, Point::new(g.extent, 0.0)) { - v.push((Handle::ExtentRight, p)); - } - if let Some(p) = g.to_page(Plane::Left, Point::new(0.0, g.height)) { - v.push((Handle::Height, p)); + v.push((Handle::Origin, o)); + let mut at = |h: Handle, pl: Plane, q: Point| { + if let Some(p) = g.to_page(pl, q) { + v.push((h, p)); + } + }; + at(Handle::ExtentLeft, Plane::Left, Point::new(g.extent, 0.0)); + at(Handle::ExtentRight, Plane::Right, Point::new(g.right_extent(), 0.0)); + at(Handle::Height, Plane::Left, Point::new(0.0, g.height)); + at(Handle::Cell, Plane::Left, Point::new(0.0, g.cell * cell_steps(g, tol))); + // The ground-level points: a little way from the origin along each wall's ground line. + for (h, pl) in [(Handle::GroundLeft, Plane::Left), (Handle::GroundRight, Plane::Right)] { + if let Some(p) = g.to_page(pl, Point::new(g.cell, 0.0)).filter(|p| p.distance(o) > 1e-9) { + v.push((h, o + (p - o).normalize() * (NEAR_ORIGIN_PX * tol))); + } } // The horizon handle sits on the horizon above the origin. v.push((Handle::Horizon, Point::new(g.origin[0], g.horizon))); v } -/// Apply a handle drag to a copy of the grid. -fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid { +/// A widget drag: the widget, the grid and the pointer when it was pressed, and the cell widget's +/// steps then. +struct Press { + handle: Handle, + grid: PerspectiveGrid, + at: Point, + steps: f64, +} + +/// `g` moved by `d`: the whole grid, vanishing points and horizon too. +fn translated(g: &PerspectiveGrid, d: Vec2) -> PerspectiveGrid { let mut n = g.clone(); - match h { - Handle::VpLeft => { - n.vp_left = p.x; - n.horizon = p.y; + n.origin = [g.origin[0] + d.x, g.origin[1] + d.y]; + n.horizon += d.y; + n.vp_left += d.x; + n.vp_right += d.x; + n.vp_vertical = [g.vp_vertical[0] + d.x, g.vp_vertical[1] + d.y]; + n +} + +/// The grid with a widget dragged to `p`. Shift constrains: a vanishing point keeps the horizon, +/// the ground-level points and the origin move along one axis, extents and the height go by whole +/// cells. Alt drags both extents together. +fn drag_handle(press: &Press, p: Point, mods: Mods) -> PerspectiveGrid { + let g = &press.grid; + let mut n = g.clone(); + let along_axis = |p: Point| { + let d = p - press.at; + if !mods.shift { + d + } else if d.x.abs() >= d.y.abs() { + Vec2::new(d.x, 0.0) + } else { + Vec2::new(0.0, d.y) } - Handle::VpRight => { - n.vp_right = p.x; - n.horizon = p.y; + }; + let cells = |v: f64| if mods.shift { (v / g.cell).round().max(1.0) * g.cell } else { v.max(g.cell) }; + let h = press.handle; + match h { + Handle::VpLeft | Handle::VpRight => { + if !mods.shift { + n.horizon = p.y; + } + match (h == Handle::VpLeft, g.lock_station && g.kind != 1) { + (left, true) => n.swing(left, p.x), + (true, false) => n.vp_left = p.x, + (false, false) => n.vp_right = p.x, + } } Handle::VpVertical => n.vp_vertical = [p.x, p.y], Handle::Horizon => n.horizon = p.y, - Handle::Origin => n.origin = [p.x, p.y], - Handle::ExtentLeft | Handle::ExtentRight | Handle::Height => { + Handle::Origin => { + let d = along_axis(p); + n.origin = [g.origin[0] + d.x, g.origin[1] + d.y]; + } + Handle::GroundLeft | Handle::GroundRight => n = translated(g, along_axis(p)), + Handle::ExtentLeft | Handle::ExtentRight => { let pl = if h == Handle::ExtentRight { Plane::Right } else { Plane::Left }; if let Some(q) = g.to_plane(pl, p) { - match h { - Handle::Height => n.height = q.y.max(g.cell), - _ => n.extent = q.x.max(g.cell), + let e = cells(q.x); + if h == Handle::ExtentLeft || mods.alt { + n.extent = e; } + if h == Handle::ExtentRight || mods.alt { + n.extent_right = Some(e); + } + } + } + Handle::Height => { + if let Some(q) = g.to_plane(Plane::Left, p) { + n.height = cells(q.y); + } + } + Handle::Cell => { + if let Some(q) = g.to_plane(Plane::Left, p) { + n.cell = (q.y / press.steps).max(0.5); } } } @@ -517,7 +957,9 @@ fn drag_handle(g: &PerspectiveGrid, h: Handle, p: Point) -> PerspectiveGrid { #[derive(Default)] pub struct PerspectiveGridTool { - drag: Option, + drag: Option, + /// Dragging a plane widget. + plane: Option, } impl Tool for PerspectiveGridTool { @@ -526,70 +968,157 @@ impl Tool for PerspectiveGridTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; - let g = PerspectiveGrid::effective(cx.doc); + let g = PerspectiveGrid::current(cx.doc); match ev.kind { PointerKind::Down => { let mut pre = vec![]; if !g.visible { pre.push(Action::Exec("perspective.grid.show".into(), json!({"visible": true}))); } - if let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p) { + if let Some(pl) = g.widget_hit_cx(cx, p) { pre.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); return pre; } + if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + self.plane = Some(d); + pre.push(Action::Begin("Move Plane".into())); + return pre; + } let tol = cx.tol(6.0); - if let Some((h, _)) = - handles(&g).into_iter().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) + if !g.locked + && let Some((h, _)) = handles(&g, cx.tol(1.0)) + .into_iter() + .filter(|(_, q)| q.distance(p) <= tol) + .min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))) { - self.drag = Some(h); + let steps = cell_steps(&g, cx.tol(1.0)); + self.drag = Some(Press { handle: h, grid: g, at: p, steps }); pre.push(Action::Begin("Edit Perspective Grid".into())); } pre } - PointerKind::Drag => match self.drag { - Some(h) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(&g, h, p).definition_json())], + PointerKind::Drag if self.plane.is_some() => self.plane.and_then(|d| d.preview(&g, p, ev.mods)).into_iter().collect(), + PointerKind::Drag => match &self.drag { + Some(press) => vec![Action::Preview("perspective.grid.set".into(), drag_handle(press, p, ev.mods).definition_json())], None => vec![], }, - PointerKind::Up => match self.drag.take() { - Some(_) => vec![Action::Commit], - None => vec![], + PointerKind::Up => match (self.drag.take(), self.plane.take()) { + (None, None) => vec![], + _ => vec![Action::Commit], }, + PointerKind::DoubleClick => PlaneDrag::hit(&g, cx.tol(6.0), p).map(|d| d.options()).into_iter().collect(), _ => vec![], } } fn overlays(&self, cx: &ToolContext) -> Vec { - let g = PerspectiveGrid::effective(cx.doc); - handles(&g) + let g = PerspectiveGrid::current(cx.doc); + let ink = [0x20, 0x20, 0x20]; + handles(&g, cx.tol(1.0)) .into_iter() .map(|(h, p)| match h { - Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: true, size: 7.0 }, - _ => Overlay::Anchor { p, color: [0x20, 0x20, 0x20], filled: false, size: 6.0 }, + Handle::VpLeft | Handle::VpRight | Handle::VpVertical => Overlay::Anchor { p, color: ink, filled: true, size: 7.0 }, + Handle::GroundLeft | Handle::GroundRight => Overlay::Anchor { p, color: ink, filled: true, size: 5.0 }, + Handle::Cell => Overlay::Handle { p, color: ink }, + _ => Overlay::Anchor { p, color: ink, filled: false, size: 6.0 }, }) .collect() } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { - let g = PerspectiveGrid::effective(cx.doc); + let g = PerspectiveGrid::current(cx.doc); let tol = cx.tol(6.0); - match handles(&g).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) { - Some(Handle::Horizon | Handle::Height) => Cursor::ResizeV, + if self.plane.is_some() || PlaneDrag::hit(&g, tol, p).is_some() { + return Cursor::Move; + } + if g.locked { + return Cursor::Arrow; + } + match handles(&g, cx.tol(1.0)).into_iter().find(|(_, q)| q.distance(p) <= tol).map(|h| h.0) { + Some(Handle::Horizon | Handle::Height | Handle::Cell) => Cursor::ResizeV, Some(Handle::ExtentLeft | Handle::ExtentRight) => Cursor::ResizeH, Some(_) => Cursor::Move, None => Cursor::Arrow, } } fn busy(&self) -> bool { - self.drag.is_some() + self.drag.is_some() || self.plane.is_some() } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + match (self.drag.take(), self.plane.take()) { + (None, None) => vec![], + _ => vec![Action::Commit], + } } } // ---------- Perspective Selection tool ---------- +#[derive(Clone, Debug, Default)] +enum SelDrag { + #[default] + Idle, + /// Pressed on an object: moves `ids` once the pointer leaves the drag threshold. `perp`: along + /// the plane's normal (5 pressed while dragging); `copy`: Alt at the last event. + Move { ids: Vec, from: Point, last: Point, began: bool, perp: bool, copy: bool }, + /// Dragging `handle` of the perspective bounding box `rect` (plane space, on `plane` at `depth`). + Scale { ids: Vec, plane: Plane, depth: f64, rect: Rect, handle: BoxHandle }, + /// Dragging a plane widget. + Plane(PlaneDrag), +} + +/// The Perspective Selection tool: selects, moves (Alt copies; press 5 while dragging to move +/// perpendicular to the plane) and scales (bounding-box handles; Shift proportional, Alt from the +/// centre) objects within their perspective planes; the arrow keys nudge them in perspective. #[derive(Default)] pub struct PerspectiveSelectionTool { - drag: Option<(Vec, Point)>, + drag: SelDrag, + measure: Option<(Point, String)>, +} + +/// The selection's perspective bounding box: plane, depth, plane-space rect and the homography +/// that draws it. +fn selection_persp_box(cx: &ToolContext, g: &PerspectiveGrid) -> Option<(Plane, f64, Rect, Homography)> { + let (plane, depth, rect) = g.plane_bounds(cx.doc, &cx.selection.objects)?; + Some((plane, depth, rect, g.homography_at(plane, depth)?)) +} + +/// The handle of the perspective box (`rect` drawn by `h`) within `tol` of `p`. +fn persp_handle_at(rect: Rect, h: &Homography, p: Point, tol: f64) -> Option { + BoxHandle::ALL.into_iter().find(|k| h.apply(k.pos(rect)).is_some_and(|q| q.distance(p) <= tol)) +} + +/// The resize cursor that fits a handle seen in direction `d` from the box's centre. +fn resize_cursor(d: Vec2) -> Cursor { + // Counter-clockwise degrees on screen (y down), folded to 0..180. + let a = (-d.y).atan2(d.x).to_degrees().rem_euclid(180.0); + match a { + a if !(22.5..157.5).contains(&a) => Cursor::ResizeH, + a if a < 67.5 => Cursor::ResizeNeSw, + a if a < 112.5 => Cursor::ResizeV, + _ => Cursor::ResizeNwSe, + } +} + +impl PerspectiveSelectionTool { + /// The `perspective.move` preview for the move drag (and its measurement label). + fn move_preview(&mut self, cx: &ToolContext) -> Option { + let SelDrag::Move { ids, from, last, perp, copy, .. } = &self.drag else { return None }; + let g = PerspectiveGrid::current(cx.doc); + let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [last.x, last.y], "copy": copy, "perpendicular": perp}); + if g.plane != Plane::None { + v["plane"] = json!(g.plane.id()); + } + let at = ids.first().and_then(|id| g.attachment_of(cx.doc, *id)).or((g.plane != Plane::None).then_some((g.plane, g.offset(g.plane)))); + self.measure = at.and_then(|(pl, depth)| { + let text = if *perp { + format!("dZ: {}", cx.len(g.depth_at(pl, depth, *from, *last)? - depth)) + } else { + let d = g.plane_delta(pl, depth, *from, *last)?; + cx.offset_label(d.x, d.y) + }; + Some((*last, text)) + }); + Some(Action::Preview("perspective.move".into(), v)) + } } impl Tool for PerspectiveSelectionTool { @@ -597,19 +1126,28 @@ impl Tool for PerspectiveSelectionTool { "perspectiveSelection" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let p = ev.pos; - let g = PerspectiveGrid::effective(cx.doc); - match ev.kind { - PointerKind::Down => { - if g.visible - && let Some(pl) = g.widget_hit(cx.doc, cx.tol(1.0), p) - { + let (p, m) = (ev.pos, ev.mods); + let g = PerspectiveGrid::current(cx.doc); + match (ev.kind, std::mem::take(&mut self.drag)) { + (PointerKind::Down, _) => { + self.measure = None; + if let Some(pl) = g.widget_hit_cx(cx, p) { return vec![Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))]; } - let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else { + if let Some(d) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + self.drag = SelDrag::Plane(d); + return vec![Action::Begin("Move Plane".into())]; + } + if let Some((plane, depth, rect, h)) = selection_persp_box(cx, &g) + && let Some(handle) = persp_handle_at(rect, &h, p, cx.tol(5.0)) + { + self.drag = SelDrag::Scale { ids: cx.selection.objects.clone(), plane, depth, rect, handle }; + return vec![Action::Begin("Scale in Perspective".into())]; + } + let Some(hit) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) else { return vec![Action::Exec("select.set".into(), json!({"ids": []}))]; }; - let top = h.top_object(cx.isolation); + let top = hit.top_object(cx.isolation); let mut acts = vec![]; let ids = if cx.selection.objects.contains(&top) { cx.selection.objects.clone() @@ -617,53 +1155,142 @@ impl Tool for PerspectiveSelectionTool { acts.push(Action::Exec("select.set".into(), json!({"ids": [top.0]}))); vec![top] }; - acts.push(Action::Begin("Move in Perspective".into())); - self.drag = Some((ids, p)); + // Selecting an object in perspective makes its plane the active one. + if let Some((pl, _)) = g.attachment_of(cx.doc, top) + && pl != g.plane + { + acts.push(Action::Exec("perspective.plane.set".into(), json!({"plane": pl.id()}))); + } + self.drag = SelDrag::Move { ids, from: p, last: p, began: false, perp: false, copy: m.alt }; acts } - PointerKind::Drag => match &self.drag { - Some((ids, from)) => { - let mut v = json!({"ids": crate::json_ids(ids), "from": [from.x, from.y], "to": [p.x, p.y]}); - if g.plane != Plane::None { - v["plane"] = json!(g.plane.id()); - } - vec![Action::Preview("perspective.move".into(), v)] + (PointerKind::Drag, SelDrag::Move { ids, from, began, perp, .. }) => { + if !began && p.distance(from) < cx.tol(3.0) { + self.drag = SelDrag::Move { ids, from, last: p, began, perp, copy: m.alt }; + return vec![]; } - None => vec![], - }, - PointerKind::Up => match self.drag.take() { - Some(_) => vec![Action::Commit], - None => vec![], - }, - _ => vec![], + let mut out = vec![]; + if !began { + out.push(Action::Begin(if m.alt { "Copy in Perspective" } else { "Move in Perspective" }.into())); + } + self.drag = SelDrag::Move { ids, from, last: p, began: true, perp, copy: m.alt }; + out.extend(self.move_preview(cx)); + out + } + (PointerKind::Drag, SelDrag::Scale { ids, plane, depth, rect, handle }) => { + let q = g.homography_at(plane, depth).and_then(|h| h.inverse()).and_then(|hi| hi.apply(p)); + self.drag = SelDrag::Scale { ids: ids.clone(), plane, depth, rect, handle }; + let Some(q) = q else { return vec![] }; + let a = scale_for_drag(rect, handle, q, m.shift, m.alt); + let nr = a.transform_rect_bbox(rect); + self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); + vec![Action::Preview("perspective.transform".into(), json!({"ids": crate::json_ids(&ids), "matrix": crate::select::matrix_json(a)}))] + } + (PointerKind::Up, SelDrag::Move { began, .. }) => { + self.measure = None; + if began { vec![Action::Commit] } else { vec![] } + } + (PointerKind::Drag, SelDrag::Plane(d)) => { + self.drag = SelDrag::Plane(d); + d.preview(&g, p, m).into_iter().collect() + } + (PointerKind::Up, SelDrag::Scale { .. } | SelDrag::Plane(_)) => { + self.measure = None; + vec![Action::Commit] + } + (PointerKind::DoubleClick, d) => { + self.drag = d; + if let Some(w) = PlaneDrag::hit(&g, cx.tol(6.0), p) { + return vec![w.options()]; + } + // Type in perspective: Edit Text, then type. + match vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)) { + Some(id) if cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) => { + vec![Action::Exec("perspective.editText".into(), json!({"id": id.0})), Action::SwitchTool("type".into())] + } + _ => vec![], + } + } + (_, d) => { + self.drag = d; + vec![] + } } } - fn overlays(&self, cx: &ToolContext) -> Vec { - // Attached objects in the selection get their plane's colour outline. - let g = PerspectiveGrid::effective(cx.doc); - let mut out = vec![]; - for id in &cx.selection.objects { - if let (Some(pl), Some(n)) = (g.attached_plane(*id), cx.doc.node(*id)) - && let Some(b) = n.geometric_bounds() - { - out.push(Overlay::Path { - path: crate::xform::polygon(&crate::xform::rect_corners(b), true), - color: pl.color(), - width: 1.0, - dashed: true, - }); + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { + if let (ToolKey::Digit(5), SelDrag::Move { perp, began, .. }) = (key, &mut self.drag) { + *perp = !*perp; + return if *began { self.move_preview(cx).into_iter().collect() } else { vec![] }; + } + let (dx, dy) = match key { + ToolKey::Left => (-1, 0), + ToolKey::Right => (1, 0), + ToolKey::Up => (0, -1), + ToolKey::Down => (0, 1), + _ => return vec![], + }; + if !self.claims_key(cx, key) { + return vec![]; + } + vec![Action::Exec("perspective.nudge".into(), json!({"dx": dx, "dy": dy, "big": mods.shift, "copy": mods.alt}))] + } + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + // The arrows nudge a selection in perspective. + matches!(key, ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) + && matches!(self.drag, SelDrag::Idle) + && cx.selection.anchors.is_empty() + && { + let g = PerspectiveGrid::current(cx.doc); + cx.selection.objects.iter().any(|id| g.attachment_of(cx.doc, *id).is_some()) } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + // The selection's bounding box in perspective, in its plane's colour, with its handles. + let g = PerspectiveGrid::current(cx.doc); + let mut out = vec![]; + if let Some((plane, _, rect, h)) = selection_persp_box(cx, &g) { + let corners: Vec = crate::xform::rect_corners(rect).iter().filter_map(|q| h.apply(*q)).collect(); + if corners.len() == 4 { + out.push(Overlay::Path { path: crate::xform::polygon(&corners, true), color: plane.color(), width: 1.0, dashed: false }); + } + for k in BoxHandle::ALL { + if let Some(q) = h.apply(k.pos(rect)) { + out.push(Overlay::Anchor { p: q, color: plane.color(), filled: false, size: 6.0 }); + } + } + } + if let Some((p, t)) = &self.measure { + out.push(Overlay::Measure { p: *p, text: t.clone() }); } out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + let g = PerspectiveGrid::current(cx.doc); + let handle = match &self.drag { + SelDrag::Scale { handle, .. } => Some(*handle), + SelDrag::Move { began: true, .. } | SelDrag::Plane(_) => return Cursor::Move, + _ => None, + }; + if PlaneDrag::hit(&g, cx.tol(6.0), p).is_some() { + return Cursor::Move; + } + if let Some((_, _, rect, h)) = selection_persp_box(cx, &g) + && let Some(k) = handle.or_else(|| persp_handle_at(rect, &h, p, cx.tol(5.0))) + && let (Some(a), Some(c)) = (h.apply(k.pos(rect)), h.apply(rect.center())) + { + return resize_cursor(a - c); + } Cursor::Arrow } fn busy(&self) -> bool { - self.drag.is_some() + !matches!(self.drag, SelDrag::Idle) } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + self.measure = None; + match std::mem::take(&mut self.drag) { + SelDrag::Move { began: true, .. } | SelDrag::Scale { .. } | SelDrag::Plane(_) => vec![Action::Commit], + _ => vec![], + } } } @@ -787,4 +1414,101 @@ mod tests { assert_eq!(v["horizon"], json!(g.horizon + 10.0)); assert!(!grid_overlays(&d, 1.0, "selection").is_empty()); } + + #[test] + fn depth_moves_along_the_plane_normal() { + // A grid with a viewing angle foreshortens each axis by its own distance. + let angled = PerspectiveGrid { angle: Some(30.0), ..grid(3) }; + assert_ne!(angled.foreshortening().0, angled.foreshortening().1); + for g in [grid(1), grid(2), grid(3), angled] { + let kind = g.kind; + for pl in Plane::ALL { + let q = Point::new(40.0, 30.0); + let from = g.homography_at(pl, 0.0).unwrap().apply(q).unwrap(); + let to = g.homography_at(pl, 25.0).unwrap().apply(q).unwrap(); + let d = g.depth_at(pl, 0.0, from, to).unwrap(); + assert!((d - 25.0).abs() < 1e-6, "{kind} {pl:?} {d}"); + // The map to the parallel plane keeps plane coordinates. + let m = g.transform_map(pl, 0.0, Affine::IDENTITY, 25.0).unwrap(); + assert!((m.apply(from).unwrap() - to).hypot() < 1e-6); + // In-plane: a plane-space translation. + let m = g.transform_map(pl, 25.0, Affine::translate((10.0, -5.0)), 0.0).unwrap(); + let moved = g.homography_at(pl, 25.0).unwrap().inverse().unwrap().apply(m.apply(to).unwrap()).unwrap(); + assert!((moved - Point::new(50.0, 25.0)).hypot() < 1e-6, "{kind} {pl:?} {moved:?}"); + } + } + // Behind the viewer there is no plane. + assert!(grid(2).homography_at(Plane::Left, -1.0e9).is_none()); + } + + #[test] + fn attachments_live_on_objects_and_legacy_ones_are_adopted() { + let (mut d, id) = doc_with_rect(); + let mut g = grid(2); + g.attached.insert(id.0.to_string(), Plane::Right); + g.attached.insert("999999".into(), Plane::Left); + // A grid as files from before stored it: the attachments in the grid. + d.unknown.insert(DOC_KEY.into(), serde_json::to_value(&g).unwrap()); + let read = PerspectiveGrid::from_doc(&d).unwrap(); + assert_eq!(read.attachment_of(&d, id), Some((Plane::Right, 0.0))); + read.store(&mut d); + assert!(d.unknown[DOC_KEY].get("attached").is_none(), "the stored grid keeps none"); + assert_eq!(attachment(d.node(id).unwrap()), Some((Plane::Right, 0.0))); + assert_eq!(PerspectiveGrid::from_doc(&d).unwrap().attached_plane(id), Some(Plane::Right)); + // Released since: not adopted. + let (mut d2, id2) = doc_with_rect(); + let mut old = grid(2); + old.attached.insert(id2.0.to_string(), Plane::Left); + d2.unknown.insert(DOC_KEY.into(), serde_json::to_value(&old).unwrap()); + grid(2).store(&mut d2); + assert!(d2.node(id2).unwrap().perspective.is_none()); + } + + #[test] + fn selection_tool_scales_with_handles_and_5_moves_perpendicular() { + let (mut d, id) = doc_with_rect(); + grid(2).store(&mut d); + set_attachment(d.node_mut(id).unwrap(), Plane::Right, 0.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = cx(&d, &s, &p); + let g = PerspectiveGrid::current(&d); + let (plane, depth, rect) = g.plane_bounds(&d, &[id]).unwrap(); + assert_eq!((plane, depth), (Plane::Right, 0.0)); + let h = g.homography(Plane::Right).unwrap(); + let mut t = PerspectiveSelectionTool::default(); + assert!(t.claims_key(&c, ToolKey::Left)); + // A corner handle scales. + let corner = h.apply(Point::new(rect.x1, rect.y1)).unwrap(); + assert!(matches!(t.cursor(&c, corner, Mods::default()), Cursor::ResizeNwSe | Cursor::ResizeNeSw)); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Scale in Perspective".into())]); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, corner.x + 10.0, corner.y + 10.0)); + assert!(matches!(&acts[0], Action::Preview(cmd, _) if cmd == "perspective.transform")); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, corner.x + 10.0, corner.y + 10.0)), vec![Action::Commit]); + // Moving: 5 toggles the perpendicular move. + let mid = h.apply(rect.center()).unwrap(); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, mid.x, mid.y)); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, mid.x + 20.0, mid.y)); + assert_eq!(acts[0], Action::Begin("Move in Perspective".into())); + let Action::Preview(_, v) = &acts[1] else { panic!("{acts:?}") }; + assert_eq!(v["perpendicular"], json!(false)); + assert!(!t.claims_key(&c, ToolKey::Left), "busy"); + let acts = t.key(&c, ToolKey::Digit(5), Mods::default()); + let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((cmd.as_str(), &v["perpendicular"], &v["to"]), ("perspective.move", &json!(true), &json!([mid.x + 20.0, mid.y]))); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text.starts_with("dZ")))); + assert!(t.key(&c, ToolKey::Digit(3), Mods::default()).is_empty()); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, mid.x + 20.0, mid.y)), vec![Action::Commit]); + assert!(!t.busy()); + } + + #[test] + fn resize_cursors_follow_the_handle_on_screen() { + assert_eq!(resize_cursor(Vec2::new(1.0, 0.1)), Cursor::ResizeH); + assert_eq!(resize_cursor(Vec2::new(0.1, -1.0)), Cursor::ResizeV); + assert_eq!(resize_cursor(Vec2::new(1.0, -1.0)), Cursor::ResizeNeSw); + assert_eq!(resize_cursor(Vec2::new(1.0, 1.0)), Cursor::ResizeNwSe); + assert_eq!(resize_cursor(Vec2::new(-1.0, -1.0)), Cursor::ResizeNwSe); + } } diff --git a/crates/tools/src/distort/perspective/define.rs b/crates/tools/src/distort/perspective/define.rs new file mode 100644 index 000000000..a2dd6f424 --- /dev/null +++ b/crates/tools/src/distort/perspective/define.rs @@ -0,0 +1,519 @@ +//! Define Perspective Grid: the dialog's view of the grid ([`GridDefinition`]: type, units, scale, +//! gridline spacing, viewing angle and distance, horizon height, third vanishing point, gridline +//! colours and opacity), the camera behind it (the [`Station`] point) and the built-in presets. +//! +//! The station point is the viewer: `D` points in front of the picture plane, level with the +//! horizon at the centre of vision `cv`. In two- and three-point perspective the viewing angle θ +//! is the angle between the left plane and the picture plane, so the vanishing points sit at +//! `cv − D·cot θ` and `cv + D·tan θ` (the station point lies on the circle over them). A receding +//! axis foreshortens by the distance from the viewer to its vanishing point, which keeps the grid +//! cells square in the scene whatever the angle. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use vectorcraft_doc::{Document, Unit}; +use vectorcraft_geom::Rect; + +use super::{PerspectiveGrid, Plane}; + +/// A Letter page: where presets are fitted without a document. +pub const LETTER: Rect = Rect::new(0.0, 0.0, 612.0, 792.0); + +/// The viewing angle of grids made before it was stored, and of the presets. +pub const DEFAULT_ANGLE: f64 = 45.0; + +/// The largest length (points) a grid takes, as [`PerspectiveGrid::validate`] allows. +const MAX_LEN: f64 = 4.0e6; + +/// The built-in presets: (name, kind, horizon height as a fraction of the artboard height from +/// the top). The ground line sits at 78 % of the height in all of them. +pub const BUILTINS: [(&str, u8, f64); 8] = [ + ("[1P-Normal View]", 1, 0.42), + ("[1P-Low View]", 1, 0.66), + ("[1P-High View]", 1, 0.12), + ("[2P-Normal View]", 2, 0.42), + ("[2P-Low View]", 2, 0.66), + ("[2P-High View]", 2, 0.12), + ("[3P-Normal View]", 3, 0.42), + ("[3P-Low View]", 3, 0.66), +]; + +/// Is `name` a built-in preset's (any case)? +pub fn is_builtin(name: &str) -> bool { + BUILTINS.iter().any(|b| b.0.eq_ignore_ascii_case(name.trim())) +} + +/// The first artboard of `doc` (a Letter page without one). +pub fn first_artboard(doc: &Document) -> Rect { + doc.artboards.first().map_or(LETTER, |a| a.rect) +} + +// Serde defaults of the grid's definition fields. +pub(super) fn points() -> String { + Unit::Points.key().into() +} +pub(super) fn is_points(u: &String) -> bool { + u == Unit::Points.key() +} +pub(super) fn one_to_one() -> [f64; 2] { + [1.0, 1.0] +} +pub(super) fn is_one_to_one(s: &[f64; 2]) -> bool { + *s == one_to_one() +} +pub(super) fn left_rgb() -> Rgb { + Rgb(Plane::Left.color()) +} +pub(super) fn right_rgb() -> Rgb { + Rgb(Plane::Right.color()) +} +pub(super) fn ground_rgb() -> Rgb { + Rgb(Plane::Ground.color()) +} +pub(super) fn half() -> f64 { + 50.0 +} + +/// A `#rrggbb` colour (gridline colours). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct Rgb(pub [u8; 3]); + +impl Rgb { + /// `#rrggbb` or `rrggbb`. + pub fn parse(s: &str) -> Option { + let h = s.trim().trim_start_matches('#'); + if h.len() != 6 || !h.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| h.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); + Some(Self([byte(0)?, byte(2)?, byte(4)?])) + } + /// `#rrggbb`. + pub fn hex(self) -> String { + let [r, g, b] = self.0; + format!("#{r:02x}{g:02x}{b:02x}") + } +} + +impl TryFrom for Rgb { + type Error = String; + fn try_from(s: String) -> Result { + Self::parse(&s).ok_or_else(|| format!("`{s}` isn't a #rrggbb colour")) + } +} + +impl From for String { + fn from(c: Rgb) -> String { + c.hex() + } +} + +/// The viewer: the centre of vision `x` on the horizon and the distance to the picture plane. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Station { + pub x: f64, + pub distance: f64, +} + +/// Define Perspective Grid's fields: the grid as the dialog shows it. Lengths are real-world +/// lengths in `units`, drawn at `scale` (`[artboard, real world]`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct GridDefinition { + /// The preset's name ("" = custom). + pub name: String, + /// 1, 2 or 3-point perspective. + pub kind: u8, + /// A unit name (`points`, `pixels`, `inches`, `centimeters`…). + pub units: String, + /// `[artboard, real world]`: 1:4 draws 4 real-world units as 1. + pub scale: [f64; 2], + /// Gridline every (real-world units). + pub gridline: f64, + /// Viewing angle (degrees, between 0 and 90; two/three-point). + pub angle: f64, + /// Viewing distance: the station point's distance from the picture plane. + pub distance: f64, + /// Horizon height above the ground level. + pub horizon_height: f64, + /// The third vanishing point (three-point) from the centre of vision: x to the right, y up. + pub third_vp: [f64; 2], + pub left_color: Rgb, + pub right_color: Rgb, + pub ground_color: Rgb, + /// Gridline opacity, 0–100 %. + pub opacity: f64, +} + +impl Default for GridDefinition { + /// [2P-Normal View] on a Letter page. + fn default() -> Self { + PerspectiveGrid::normal(2, LETTER).definition() + } +} + +impl GridDefinition { + /// The unit the lengths are in. + pub fn unit(&self) -> Unit { + Unit::named(&self.units).unwrap_or_default() + } + + /// Artboard points per real-world unit. + pub fn ratio(&self) -> f64 { + self.unit().points() * self.scale[0] / self.scale[1] + } + + /// Merge `patch` (any subset of the fields; other keys are ignored) into this definition. + pub fn merged(&self, patch: &Value) -> Result { + let mut v = serde_json::to_value(self).map_err(|e| e.to_string())?; + if let (Some(o), Some(p)) = (v.as_object_mut(), patch.as_object()) { + for (k, val) in p { + if o.contains_key(k) { + o.insert(k.clone(), val.clone()); + } + } + } + let mut d: Self = serde_json::from_value(v).map_err(|e| e.to_string())?; + d.units = Unit::named(&d.units).ok_or_else(|| format!("unknown unit `{}`", d.units))?.key().into(); + d.validate()?; + Ok(d) + } + + pub fn validate(&self) -> Result<(), String> { + if !(1..=3).contains(&self.kind) { + return Err("kind must be 1, 2 or 3".into()); + } + if Unit::named(&self.units).is_none() { + return Err(format!("unknown unit `{}`", self.units)); + } + if !self.scale.iter().all(|s| s.is_finite() && *s > 0.0 && *s <= 1.0e6) { + return Err("scale must be two positive numbers [artboard, real world]".into()); + } + let r = self.ratio(); + let lens = [self.gridline, self.distance, self.horizon_height, self.third_vp[0], self.third_vp[1]]; + if lens.iter().any(|v| !(v * r).is_finite() || (v * r).abs() > MAX_LEN) { + return Err("grid lengths must be finite".into()); + } + if self.gridline * r <= 0.0 || self.distance * r <= 0.0 { + return Err("gridline spacing and viewing distance must be positive".into()); + } + if (self.horizon_height * r).abs() < 1e-6 { + return Err("the horizon height can't be 0".into()); + } + if !(self.angle > 0.0 && self.angle < 90.0) { + return Err("the viewing angle must be between 0 and 90°".into()); + } + if !(0.0..=100.0).contains(&self.opacity) { + return Err("opacity must be 0–100".into()); + } + Ok(()) + } + + /// The same grid (names aside, lengths compared in points)? + pub fn same(&self, o: &Self) -> bool { + let (r, s) = (self.ratio(), o.ratio()); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0); + self.kind == o.kind + && near(self.gridline * r, o.gridline * s) + && near(self.distance * r, o.distance * s) + && near(self.horizon_height * r, o.horizon_height * s) + && (self.kind != 3 || near(self.third_vp[0] * r, o.third_vp[0] * s) && near(self.third_vp[1] * r, o.third_vp[1] * s)) + && (self.kind == 1 || near(self.angle, o.angle)) + && (self.left_color, self.right_color, self.ground_color) == (o.left_color, o.right_color, o.ground_color) + && near(self.opacity, o.opacity) + } +} + +impl PerspectiveGrid { + /// The built-in preset `name` (any case) fitted to `ab`. + pub fn builtin(name: &str, ab: Rect) -> Option { + let &(name, kind, horizon) = BUILTINS.iter().find(|b| b.0.eq_ignore_ascii_case(name.trim()))?; + let mut g = Self::preset(kind, ab); + let ground = g.origin[1]; + g.horizon = ab.y0 + horizon * ab.height().max(1.0); + g.vp_vertical = [g.vp_vertical[0], g.horizon - 3.0 * (ground - g.horizon)]; + g.name = name.into(); + g.set_station(g.station(), DEFAULT_ANGLE); + Some(g) + } + + /// The normal view preset of `kind` (1–3) fitted to `ab`. + pub fn normal(kind: u8, ab: Rect) -> Self { + let name = BUILTINS.iter().find(|b| b.1 == kind.clamp(1, 3)).map_or("", |b| b.0); + Self::builtin(name, ab).unwrap_or_else(|| Self::preset(kind, ab)) + } + + /// The viewing angle in degrees. + pub fn viewing_angle(&self) -> f64 { + self.angle.unwrap_or(DEFAULT_ANGLE) + } + + /// The station point: one-point grids look straight at their vanishing point from + /// `distance`; two/three-point ones stand where the viewing angle puts them (grids without + /// one: halfway between the vanishing points). + pub fn station(&self) -> Station { + let w = self.vp_right - self.vp_left; + match self.angle { + _ if self.kind == 1 => Station { x: self.vp_left, distance: self.distance }, + Some(a) if w > 0.0 => { + let (s, c) = a.to_radians().sin_cos(); + Station { x: self.vp_left + w * c * c, distance: w * s * c } + } + _ => Station { x: (self.vp_left + self.vp_right) / 2.0, distance: if w.abs() > 1e-9 { w.abs() / 2.0 } else { self.distance } }, + } + } + + /// Place the vanishing points for a viewer at `st` looking at `angle` degrees (one-point: the + /// vanishing point at the centre of vision). + pub fn set_station(&mut self, st: Station, angle: f64) { + self.distance = st.distance; + self.angle = Some(angle); + if self.kind == 1 { + self.vp_left = st.x; + } else { + let t = angle.to_radians().tan(); + self.vp_left = st.x - st.distance / t; + self.vp_right = st.x + st.distance * t; + } + } + + /// 1 / the measuring distance of the left, right and vertical axes: the viewer's distance to + /// their vanishing points (grids without a viewing angle: `distance` for all). + pub(super) fn foreshortening(&self) -> (f64, f64, f64) { + let uniform = 1.0 / self.distance.max(1e-9); + if self.kind == 1 || self.angle.is_none() || self.vp_right <= self.vp_left { + return (uniform, uniform, uniform); + } + let st = self.station(); + let to = |x: f64, y: f64| 1.0 / (st.distance.powi(2) + (x - st.x).powi(2) + (y - self.horizon).powi(2)).sqrt().max(1e-9); + (to(self.vp_left, self.horizon), to(self.vp_right, self.horizon), to(self.vp_vertical[0], self.vp_vertical[1])) + } + + /// After merging `patch` over `old`: a new viewing angle, viewing distance or type moves the + /// vanishing points around the old station point (unless the patch places them), and + /// `distance` reads the station's. + pub(super) fn reconcile(&mut self, old: &Self, patch: &Value) { + let has = |k: &str| patch.get(k).is_some(); + let camera = has("angle") || has("distance") && self.kind != 1; + if !(has("vpLeft") || has("vpRight")) && (camera || self.kind != old.kind) { + let st = old.station(); + let st = Station { distance: if has("distance") { self.distance } else { st.distance }, ..st }; + self.set_station(st, self.viewing_angle()); + } + if self.kind != 1 && self.angle.is_some() && self.vp_right > self.vp_left { + self.distance = self.station().distance; + } + } + + /// The definition fields' part of [`PerspectiveGrid::validate`]. + pub(super) fn validate_definition(&self) -> Result<(), String> { + if self.angle.is_some_and(|a| !(a > 0.0 && a < 90.0)) { + return Err("the viewing angle must be between 0 and 90°".into()); + } + if self.kind != 1 && self.angle.is_some() && self.vp_right <= self.vp_left { + return Err("the right vanishing point must be right of the left one".into()); + } + if Unit::named(&self.units).is_none() { + return Err(format!("unknown unit `{}`", self.units)); + } + if !self.scale.iter().all(|s| s.is_finite() && *s > 0.0 && *s <= 1.0e6) { + return Err("scale must be two positive numbers [artboard, real world]".into()); + } + if !(0.0..=100.0).contains(&self.opacity) { + return Err("opacity must be 0–100".into()); + } + if self.extent_right.is_some_and(|e| !(e > 0.0 && e <= MAX_LEN)) { + return Err("extentRight must be positive".into()); + } + Ok(()) + } + + /// The grid as Define Grid shows it. + pub fn definition(&self) -> GridDefinition { + let mut d = GridDefinition { + name: self.name.clone(), + kind: self.kind, + units: self.units.clone(), + scale: self.scale, + gridline: 0.0, + angle: self.viewing_angle(), + distance: 0.0, + horizon_height: 0.0, + third_vp: [0.0; 2], + left_color: self.left_color, + right_color: self.right_color, + ground_color: self.ground_color, + opacity: self.opacity, + }; + let r = d.ratio(); + let st = self.station(); + d.gridline = self.cell / r; + d.distance = st.distance / r; + d.horizon_height = (self.origin[1] - self.horizon) / r; + d.third_vp = [(self.vp_vertical[0] - st.x) / r, (self.horizon - self.vp_vertical[1]) / r]; + d + } + + /// This grid redefined by `def`: only what differs from [`Self::definition`] changes, so the + /// unchanged fields leave the grid exactly as it was. A new type, viewing angle or distance + /// moves the vanishing points around the station point. + pub fn with_definition(&self, def: &GridDefinition) -> Result { + def.validate()?; + let cur = self.definition(); + let r = def.ratio(); + let near = |a: f64, b: f64| (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0); + let cr = cur.ratio(); + let mut g = self.clone(); + g.name = def.name.clone(); + g.units = def.unit().key().into(); + g.scale = def.scale; + (g.left_color, g.right_color, g.ground_color, g.opacity) = (def.left_color, def.right_color, def.ground_color, def.opacity); + g.kind = def.kind; + if !near(def.gridline * r, cur.gridline * cr) { + g.cell = def.gridline * r; + } + if !near(def.horizon_height * r, cur.horizon_height * cr) { + g.horizon = g.origin[1] - def.horizon_height * r; + } + if def.kind != self.kind || !near(def.distance * r, cur.distance * cr) || def.kind != 1 && !near(def.angle, cur.angle) { + let st = Station { distance: def.distance * r, ..self.station() }; + g.set_station(st, def.angle); + } + if !(near(def.third_vp[0] * r, cur.third_vp[0] * cr) && near(def.third_vp[1] * r, cur.third_vp[1] * cr)) || g.horizon != self.horizon { + let x = g.station().x; + g.vp_vertical = [x + def.third_vp[0] * r, g.horizon - def.third_vp[1] * r]; + } + g.validate()?; + Ok(g) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_geom::Point; + + const AB: Rect = Rect::new(0.0, 0.0, 800.0, 600.0); + + #[test] + fn the_station_point_places_the_vanishing_points() { + let mut g = PerspectiveGrid::normal(2, AB); + let st = g.station(); + assert!((st.x - g.origin[0]).abs() < 1e-9, "the normal view looks at the corner"); + // At 30° the left plane turns toward the picture plane: its vanishing point recedes. + g.set_station(st, 30.0); + assert!((st.x - g.vp_left - st.distance * 3f64.sqrt()).abs() < 1e-9); + assert!((g.vp_right - st.x - st.distance / 3f64.sqrt()).abs() < 1e-9); + let back = g.station(); + assert!((back.x - st.x).abs() < 1e-9 && (back.distance - st.distance).abs() < 1e-9); + // The station point sees the vanishing points at a right angle. + let (a, b) = (Point::new(g.vp_left - st.x, st.distance), Point::new(g.vp_right - st.x, st.distance)); + assert!((a.x * b.x + a.y * b.y).abs() < 1e-6 * st.distance * st.distance); + } + + #[test] + fn receding_axes_keep_true_lengths_at_the_picture_plane() { + // A true camera: at the picture plane a step along a receding axis spans its true width + // (cos θ of it on the left, sin θ on the right), whatever the viewing angle. + for angle in [20.0, 45.0, 70.0] { + let mut g = PerspectiveGrid::normal(2, AB); + g.set_station(g.station(), angle); + g.origin[0] = g.station().x; + for pl in [Plane::Left, Plane::Right] { + let h = g.homography(pl).unwrap(); + let (o, e) = (h.apply(Point::new(0.0, 0.0)).unwrap(), h.apply(Point::new(1e-3, 0.0)).unwrap()); + let s = angle.to_radians(); + let dx = if pl == Plane::Left { s.cos() } else { s.sin() }; + assert!(((e.x - o.x).abs() / 1e-3 - dx).abs() < 1e-3, "{angle} {pl:?}"); + } + } + } + + #[test] + fn definition_round_trips_and_unchanged_fields_change_nothing() { + for kind in [1u8, 2, 3] { + let g = PerspectiveGrid::normal(kind, AB); + let d = g.definition(); + assert_eq!(g.with_definition(&d).unwrap(), g); + // In inches at 1:4 the same grid reads differently but is the same. + let inches = GridDefinition { units: "inches".into(), scale: [1.0, 4.0], ..d.clone() }; + let r = inches.ratio() / d.ratio(); + let inches = GridDefinition { + gridline: d.gridline / r, + distance: d.distance / r, + horizon_height: d.horizon_height / r, + third_vp: d.third_vp.map(|v| v / r), + ..inches + }; + let g2 = g.with_definition(&inches).unwrap(); + assert_eq!((g2.origin, g2.horizon, g2.vp_left, g2.vp_right, g2.cell), (g.origin, g.horizon, g.vp_left, g.vp_right, g.cell)); + assert!(g2.definition().same(&d)); + } + // A grid from before the viewing angle keeps its foreshortening through an unchanged OK. + let legacy = PerspectiveGrid::preset(2, AB); + let d = legacy.definition(); + assert_eq!(legacy.with_definition(&GridDefinition { opacity: 80.0, ..d.clone() }).unwrap().angle, None); + assert!(legacy.with_definition(&GridDefinition { angle: 30.0, ..d }).unwrap().angle.is_some()); + } + + #[test] + fn definition_fields_map_to_the_grid() { + let g = PerspectiveGrid::normal(3, AB); + let d = GridDefinition { + gridline: 2.0, + angle: 30.0, + distance: 5.0, + horizon_height: 3.0, + third_vp: [1.0, 8.0], + units: "inches".into(), + ..g.definition() + }; + let n = g.with_definition(&d).unwrap(); + let st = n.station(); + assert!((n.cell - 144.0).abs() < 1e-9); + assert!((st.distance - 360.0).abs() < 1e-6 && (st.x - g.station().x).abs() < 1e-6, "the viewer stays put"); + assert!((n.viewing_angle() - 30.0).abs() < 1e-9); + assert!((n.origin[1] - n.horizon - 216.0).abs() < 1e-9); + assert!((n.vp_vertical[0] - st.x - 72.0).abs() < 1e-6 && (n.horizon - n.vp_vertical[1] - 576.0).abs() < 1e-6); + assert_eq!(n.units, "inches"); + // Bad values are refused. + for bad in [ + json!({"angle": 90}), + json!({"distance": 0}), + json!({"units": "furlongs"}), + json!({"scale": [1, 0]}), + json!({"opacity": 120}), + json!({"kind": 4}), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + assert_eq!(d.merged(&json!({"units": "mm", "leftColor": "#00ff00"})).unwrap().units, "millimeters"); + assert!(d.merged(&json!({"leftColor": "green"})).is_err()); + } + + #[test] + fn set_moves_the_vanishing_points_for_a_new_angle_or_distance() { + let g = PerspectiveGrid::normal(2, AB); + let st = g.station(); + let n = g.merged(&json!({"angle": 60})).unwrap(); + assert!((n.station().x - st.x).abs() < 1e-6 && (n.station().distance - st.distance).abs() < 1e-6); + let n = g.merged(&json!({"distance": 100})).unwrap(); + assert!((n.vp_right - n.vp_left - 200.0).abs() < 1e-6); + // Moving a vanishing point keeps the angle; `distance` follows. + let n = g.merged(&json!({"vpLeft": g.vp_left - 100.0})).unwrap(); + assert_eq!(n.angle, Some(45.0)); + assert!((n.distance - (n.vp_right - n.vp_left) / 2.0).abs() < 1e-6); + assert!(g.merged(&json!({"vpRight": g.vp_left - 10.0})).is_err()); + } + + #[test] + fn rgb_parses_hex() { + assert_eq!(Rgb::parse("#3366ff"), Some(Rgb([0x33, 0x66, 0xff]))); + assert_eq!(Rgb::parse("ff8C1A").map(Rgb::hex).as_deref(), Some("#ff8c1a")); + for bad in ["", "#12345", "#gg0000", "é12345", "#1234567", "+f+f+f"] { + assert_eq!(Rgb::parse(bad), None, "{bad}"); + } + } +} diff --git a/crates/tools/src/distort/perspective/view.rs b/crates/tools/src/distort/perspective/view.rs new file mode 100644 index 000000000..676736cad --- /dev/null +++ b/crates/tools/src/distort/perspective/view.rs @@ -0,0 +1,142 @@ +//! View → Perspective Grid options on the grid: Snap to Grid (plane coordinates land on +//! gridlines), the gridline colours and opacity, and Show Rulers (a ruler up the vertical line +//! where the planes meet, in Define Grid's real-world units). + +use vectorcraft_doc::Unit; +use vectorcraft_geom::{Point, Rect, Vec2}; + +use super::{PerspectiveGrid, Plane}; +use crate::Overlay; + +/// Snap to Grid reaches this fraction of a cell from a gridline. +const SNAP: f64 = 0.25; + +/// Ruler ticks are this many screen pixels long, and labels at least this far apart. +const TICK_PX: f64 = 6.0; +const LABEL_PX: f64 = 36.0; + +impl PerspectiveGrid { + /// The gridline (a multiple of the cell) within a quarter cell of `v`, if any. + fn gridline_near(&self, v: f64) -> Option { + let line = (v / self.cell).round() * self.cell; + ((line - v).abs() <= SNAP * self.cell).then_some(line) + } + + /// Plane coordinates `q` on the nearest gridlines within a quarter cell (each axis). + pub fn snap_plane(&self, q: Point) -> Point { + Point::new(self.gridline_near(q.x).unwrap_or(q.x), self.gridline_near(q.y).unwrap_or(q.y)) + } + + /// A plane-space move `d` of art spanning `b` (plane coordinates) adjusted so the edge nearer + /// a gridline on each axis lands on it, within a quarter cell. + pub fn snap_offset(&self, b: Rect, d: Vec2) -> Vec2 { + let axis = |lo: f64, hi: f64, d: f64| { + let pulls = [lo + d, hi + d].map(|e| self.gridline_near(e).map(|l| l - e)); + pulls.into_iter().flatten().min_by(|a, b| a.abs().total_cmp(&b.abs())).map_or(d, |c| d + c) + }; + Vec2::new(axis(b.x0, b.x1, d.x), axis(b.y0, b.y1, d.y)) + } + + /// The map that slides art lying on `plane` by `d` in plane coordinates. + pub fn move_map_by(&self, plane: Plane, d: Vec2) -> Option Option + use<>> { + let h = self.homography(plane)?; + let hi = h.inverse()?; + Some(move |p: Point| h.apply(hi.apply(p)? + d)) + } + + /// Lock Station Point: the left (`left`) or right vanishing point dragged to `x` turns the + /// view around the station point, which stays put: the other vanishing point moves so the + /// station still sees them at a right angle. A drag past the centre of vision is ignored. + pub fn swing(&mut self, left: bool, x: f64) { + let st = self.station(); + let off = if left { st.x - x } else { x - st.x }; + if off <= 1e-6 || st.distance <= 0.0 { + return; + } + let a = off.atan2(st.distance).to_degrees(); + self.set_station(st, if left { 90.0 - a } else { a }); + } + + /// A plane's gridline colour (Define Grid). + pub fn plane_color(&self, plane: Plane) -> [u8; 3] { + match plane { + Plane::Left => self.left_color.0, + Plane::Right => self.right_color.0, + Plane::Ground => self.ground_color.0, + Plane::None => plane.color(), + } + } + + /// A plane's gridline colour with the grid's opacity. + pub fn line_color(&self, plane: Plane) -> [u8; 4] { + let [r, g, b] = self.plane_color(plane); + [r, g, b, (self.opacity.clamp(0.0, 100.0) * 2.55).round() as u8] + } + + /// Show Rulers: ticks at the gridlines up the vertical line where the planes meet (the true + /// height line), labelled in Define Grid's real-world units. `tol` = document units per + /// screen pixel. + pub fn ruler_overlays(&self, tol: f64) -> Vec { + let Some(h) = self.homography(Plane::Left) else { return vec![] }; + let unit = Unit::named(&self.units).unwrap_or_default(); + let ratio = unit.points() * self.scale[0] / self.scale[1]; + let color = [0x40, 0x40, 0x40]; + let mut out = vec![]; + let (Some(o), Some(top)) = (h.apply(Point::ZERO), h.apply(Point::new(0.0, self.height))) else { return out }; + out.push(Overlay::Line { a: o, b: top, color, dashed: false }); + // Labels every k cells, k the least that keeps them LABEL_PX apart at the origin. + let cell_px = self.cell / tol.max(1e-9); + let every = (LABEL_PX / cell_px.max(1e-9)).ceil().clamp(1.0, 1.0e6) as u64; + let tick = TICK_PX * tol; + let count = (self.height / self.cell).floor().min(2000.0) as u64; + for i in 0..=count { + let v = i as f64 * self.cell; + let Some(p) = h.apply(Point::new(0.0, v)) else { continue }; + let long = i % every == 0; + out.push(Overlay::Line { a: p, b: p + Vec2::new(if long { -2.0 * tick } else { -tick }, 0.0), color, dashed: false }); + if long { + let text = format!("{} {}", unit.number(unit.to_pt(v / ratio)), unit.suffix()); + out.push(Overlay::Label { p: p + Vec2::new(-2.0 * tick, 0.0), text, color }); + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grid() -> PerspectiveGrid { + PerspectiveGrid { cell: 20.0, ..PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0)) } + } + + #[test] + fn snapping_lands_within_a_quarter_cell_on_gridlines() { + let g = grid(); + assert_eq!(g.snap_plane(Point::new(43.0, 53.0)), Point::new(40.0, 53.0)); + assert_eq!(g.snap_plane(Point::new(-4.9, 61.0)), Point::new(0.0, 60.0)); + // The nearer edge lands on a line: the right one (1 from 60) rather than the left (3 from 40). + let b = Rect::new(10.0, 10.0, 34.0, 20.0); + assert_eq!(g.snap_offset(b, Vec2::new(27.0, 30.0)), Vec2::new(26.0, 30.0)); + assert_eq!(g.snap_offset(b, Vec2::new(30.0, 0.0)), Vec2::new(30.0, 0.0), "an edge on a line stays"); + assert_eq!(g.snap_offset(b, Vec2::new(18.0, 0.0)), Vec2::new(18.0, 0.0), "nothing within reach"); + } + + #[test] + fn colours_and_rulers_come_from_the_definition() { + let mut g = grid(); + g.opacity = 40.0; + g.right_color = super::super::Rgb([1, 2, 3]); + assert_eq!(g.line_color(Plane::Right), [1, 2, 3, 102]); + g.units = "inches".into(); + g.cell = 72.0; + let labels: Vec = + g.ruler_overlays(1.0).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + assert_eq!(labels.first().map(String::as_str), Some("0 in")); + assert_eq!(labels.get(1).map(String::as_str), Some("1 in")); + g.scale = [1.0, 4.0]; + let second = g.ruler_overlays(1.0).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).nth(1); + assert_eq!(second.as_deref(), Some("4 in"), "real-world lengths at 1:4"); + } +} diff --git a/crates/tools/src/distort/perspective/widget.rs b/crates/tools/src/distort/perspective/widget.rs new file mode 100644 index 000000000..4af1173e8 --- /dev/null +++ b/crates/tools/src/distort/perspective/widget.rs @@ -0,0 +1,156 @@ +//! The Plane Switching Widget: a small cube in a corner of the document window whose faces pick +//! the active plane (left, horizontal, right) and whose dot picks none. It stays put on screen +//! when the view pans or zooms ([`ScreenFrame`]); without a window (headless) it sits in the first +//! artboard's corner. Perspective Grid Options (double-click the tool) hide it or move it to +//! another corner ([`WidgetOptions`], kept with the preferences). + +use serde::{Deserialize, Serialize}; +use vectorcraft_doc::Document; +use vectorcraft_geom::{Point, Vec2}; + +use super::{PerspectiveGrid, Plane, WidgetGeom, in_quad}; +use crate::{ScreenFrame, ToolContext}; + +/// The widget's size and its margin from the window's edges, in screen pixels. +const SIZE: f64 = 40.0; +const MARGIN: f64 = 10.0; + +/// Where the widget sits. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WidgetCorner { + #[default] + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +impl WidgetCorner { + pub const ALL: [Self; 4] = [Self::TopLeft, Self::TopRight, Self::BottomLeft, Self::BottomRight]; + pub fn id(self) -> &'static str { + match self { + Self::TopLeft => "topLeft", + Self::TopRight => "topRight", + Self::BottomLeft => "bottomLeft", + Self::BottomRight => "bottomRight", + } + } + pub fn label(self) -> &'static str { + match self { + Self::TopLeft => "Top Left", + Self::TopRight => "Top Right", + Self::BottomLeft => "Bottom Left", + Self::BottomRight => "Bottom Right", + } + } + /// By id or label, any case. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim()) || c.label().eq_ignore_ascii_case(s.trim())) + } +} + +/// Perspective Grid Options: whether the Plane Switching Widget shows, and where. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct WidgetOptions { + pub show: bool, + pub position: WidgetCorner, +} + +impl Default for WidgetOptions { + fn default() -> Self { + Self { show: true, position: WidgetCorner::TopLeft } + } +} + +/// Where the widget is: the window it stays put in (none headless) and its corner. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct WidgetPlace<'a> { + pub screen: Option<&'a ScreenFrame>, + pub corner: WidgetCorner, +} + +impl PerspectiveGrid { + /// The widget's geometry (`tol` = document units per screen pixel) at `place`. + pub fn widget_at(&self, doc: &Document, tol: f64, place: WidgetPlace) -> WidgetGeom { + let right = matches!(place.corner, WidgetCorner::TopRight | WidgetCorner::BottomRight); + let bottom = matches!(place.corner, WidgetCorner::BottomLeft | WidgetCorner::BottomRight); + let inset = MARGIN + SIZE / 2.0; + // Headless, the first artboard stands in for the window. + let frame = place.screen.copied().unwrap_or_else(|| { + let ab = super::define::first_artboard(doc); + let tol = tol.max(1e-9); + ScreenFrame { + origin: Point::new(ab.x0, ab.y0), + right: Vec2::new(tol, 0.0), + down: Vec2::new(0.0, tol), + size: (ab.width() / tol, ab.height() / tol), + } + }); + let (w, h) = frame.size; + let c = Vec2::new(if right { w - inset } else { inset }, if bottom { h - inset } else { inset }); + let p = |dx: f64, dy: f64| frame.at(c.x + dx * SIZE, c.y + dy * SIZE); + let faces = vec![ + (Plane::Left, [p(-0.5, -0.25), p(0.0, 0.0), p(0.0, 0.5), p(-0.5, 0.25)]), + (Plane::Right, [p(0.0, 0.0), p(0.5, -0.25), p(0.5, 0.25), p(0.0, 0.5)]), + (Plane::Ground, [p(0.0, -0.5), p(0.5, -0.25), p(0.0, 0.0), p(-0.5, -0.25)]), + ]; + // The dot sits off the cube's corner toward the window's inside. + let (sx, sy) = (if right { -0.62 } else { 0.62 }, if bottom { -0.62 } else { 0.62 }); + (faces, (p(sx, sy), 0.12 * SIZE * tol)) + } + + /// Which widget control is at `p` (see [`Self::widget_at`]). + pub fn widget_hit_at(&self, doc: &Document, tol: f64, place: WidgetPlace, p: Point) -> Option { + let (faces, (c, r)) = self.widget_at(doc, tol, place); + if p.distance(c) <= r * 1.5 { + return Some(Plane::None); + } + faces.into_iter().find(|(_, q)| in_quad(q, p)).map(|(pl, _)| pl) + } + + /// Which widget control the tool context's pointer `p` is on (none while the widget is hidden). + pub fn widget_hit_cx(&self, cx: &ToolContext, p: Point) -> Option { + let corner = cx.plane_widget?; + self.widget_hit_at(cx.doc, cx.tol(1.0), WidgetPlace { screen: cx.screen.as_ref(), corner }, p) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_geom::Rect; + + fn frame(origin: Point, zoom: f64) -> ScreenFrame { + ScreenFrame { origin, right: Vec2::new(1.0 / zoom, 0.0), down: Vec2::new(0.0, 1.0 / zoom), size: (600.0, 400.0) } + } + + #[test] + fn the_widget_stays_put_on_screen() { + let d = Document::new(800.0, 600.0); + let g = PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0)); + for corner in WidgetCorner::ALL { + // Panned and zoomed, the widget is at the same screen pixels. + let (a, b) = (frame(Point::new(0.0, 0.0), 1.0), frame(Point::new(250.0, -40.0), 4.0)); + let (fa, _) = g.widget_at(&d, 1.0, WidgetPlace { screen: Some(&a), corner }); + let (fb, _) = g.widget_at(&d, 0.25, WidgetPlace { screen: Some(&b), corner }); + for ((_, qa), (_, qb)) in fa.iter().zip(&fb) { + for (pa, pb) in qa.iter().zip(qb) { + let px = (*pb - b.origin) * 4.0; + assert!((px - pa.to_vec2()).hypot() < 1e-9, "{corner:?}"); + } + } + // Inside the window, near the chosen corner. + let x = fa[0].1[0].x; + assert!(if matches!(corner, WidgetCorner::TopRight | WidgetCorner::BottomRight) { x > 500.0 } else { x < 100.0 }, "{corner:?}"); + let centre = |q: &[Point; 4]| Point::new(q.iter().map(|p| p.x).sum::() / 4.0, q.iter().map(|p| p.y).sum::() / 4.0); + assert_eq!(g.widget_hit_at(&d, 0.25, WidgetPlace { screen: Some(&b), corner }, centre(&fb[1].1)), Some(Plane::Right)); + } + // Headless, the top-left one is in the first artboard's corner, as before. + let (faces, _) = g.widget(&d, 1.0); + assert_eq!(faces[0].1[0], Point::new(10.0, 20.0)); + assert_eq!(WidgetCorner::parse("Bottom Left"), Some(WidgetCorner::BottomLeft)); + assert_eq!(WidgetCorner::parse("middle"), None); + } +} diff --git a/crates/tools/src/distort/puppet.rs b/crates/tools/src/distort/puppet.rs index 575e29693..648621ba5 100644 --- a/crates/tools/src/distort/puppet.rs +++ b/crates/tools/src/distort/puppet.rs @@ -1,26 +1,42 @@ //! Puppet Warp tool. //! -//! With artwork selected the tool places three pins automatically (spread over the mesh). Click -//! on the artwork adds a pin, Alt-click deletes one, Delete/Backspace removes the selected pin, -//! and dragging a pin warps the art: every drag previews -//! `object.puppetWarp {ids, pins, moved}` (pins = current pin positions, moved = the same list -//! with the dragged pin at the pointer), committed on release as one undo step. The mesh follows -//! the current shape, so successive drags compose. +//! With artwork selected the tool shows pins at once: the ones already placed on it, else +//! automatic pins at the centre and the end of each limb ([`default_pins`]). A click on the artwork +//! adds a pin and selects it; Shift-click adds pins to the selection (or takes them out); dragging +//! a pin moves every selected pin and warps the art; Alt-dragging near (not on) a selected pin +//! turns the art around it, and that pin keeps the turn; Delete/Backspace remove the selected pins. +//! A click off the mesh places nothing and says so. +//! +//! The pins live in the document ([`PuppetPins`]) with the rest shape they started from, so every +//! warp starts from that shape (dragging a pin back restores the original, warps don't stack) and +//! Undo/Redo take the pins back with the art. They stay while the same artwork is selected; a new +//! selection starts afresh. Every change runs `object.puppetWarp {rest: true, ids, pins, moved, +//! angles, expand}` (pins = rest positions, moved = where they are now): adding or deleting pins +//! at once, drags as a preview committed on release, each one undo step. +//! +//! Control bar (`tool.setOption`): `showMesh` (on by default), `expand` (Expand Mesh, points, 0 +//! allowed) and `selectAllPins`. + +use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, Node, NodeId, PuppetPin, PuppetPins}; use vectorcraft_geom::{BezPath, Point, Rect}; -use super::arap::{Mesh, MeshOptions, auto_pins}; -use super::{BLUE, collect_points}; +use super::arap::{self, Mesh, MeshOptions, default_pins}; +use super::{BLUE, collect_points, warp_node_with}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; /// Outlines and bounds of the given objects (what the mesh is built over). pub fn mesh_input(doc: &Document, ids: &[NodeId]) -> Option<(Vec, Rect)> { + let nodes: Vec<&Node> = ids.iter().map(|id| doc.node(*id)).collect::>()?; + outlines_of(&nodes) +} + +fn outlines_of(nodes: &[&Node]) -> Option<(Vec, Rect)> { let mut outlines = vec![]; let mut pts = vec![]; - for id in ids { - let n = doc.node(*id)?; + for n in nodes { collect_points(n, &mut pts); n.walk(&mut |c| { if let Some(p) = c.path_data() { @@ -39,45 +55,283 @@ pub fn mesh_for(doc: &Document, ids: &[NodeId], expand: f64) -> Option { Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() })) } -#[derive(Default)] -pub struct PuppetWarpTool { +/// The mesh over `nodes` (a rest shape). +fn mesh_of(nodes: &[Arc], expand: f64) -> Option { + let refs: Vec<&Node> = nodes.iter().map(|n| n.as_ref()).collect(); + let (o, b) = outlines_of(&refs)?; + Some(Mesh::build(&o, b, MeshOptions { expand, ..Default::default() })) +} + +/// The solver's view of the pins. +fn solver_pins(pins: &[PuppetPin]) -> Vec { + pins.iter().map(|q| arap::Pin { angle: q.angle, ..arap::Pin::new(q.rest, q.at) }).collect() +} + +/// Do the pins leave the art as it is (each where it started, turned by nothing)? +fn at_rest(pins: &[PuppetPin]) -> bool { + pins.iter().all(|q| q.rest == q.at && q.angle.is_none_or(|a| a == 0.0)) +} + +/// A rest shape and the pins placed on it (`None`: none yet). +pub type RestPins = (Vec>, Option>); + +/// The rest shape of `ids` and the pins placed on it: the document's pins when they belong to +/// these objects as the last warp left them; when something else edited them since, the same pins +/// starting again from the objects as they are; `None` pins: none placed yet. +pub fn rest_and_pins(doc: &Document, ids: &[NodeId]) -> Option { + let current: Vec> = ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::>()?; + Some(match doc.puppet.as_deref().filter(|p| p.ids == ids) { + Some(p) if p.holds(doc, ids) => (p.rest.clone(), Some(p.pins.clone())), + Some(p) => (current, Some(p.pins.iter().map(|q| PuppetPin { rest: q.at, at: q.at, angle: q.angle.map(|_| 0.0) }).collect())), + None => (current, None), + }) +} + +/// The Puppet Warp pins in effect on some objects, with the mesh they warp. +pub struct PinSet { + pub ids: Vec, + pub pins: Vec, + /// No pin was placed yet: these are the automatic ones. + pub auto: bool, + pub mesh: Mesh, + /// The mesh vertices as the pins warp them. + pub deformed: Vec, + /// The pins warp the art (else `deformed` is the rest mesh). + warped: bool, +} + +impl PinSet { + /// The pins on `ids` (see [`rest_and_pins`]) with the mesh built with Expand `expand`. + pub fn of(doc: &Document, ids: &[NodeId], expand: f64) -> Option { + let (rest, pins) = rest_and_pins(doc, ids)?; + let mesh = mesh_of(&rest, expand)?; + let auto = pins.is_none(); + let pins = pins.unwrap_or_else(|| default_pins(&mesh).into_iter().map(|p| PuppetPin { rest: p, at: p, angle: None }).collect()); + let warped = !at_rest(&pins); + let deformed = if warped { arap::deform(&mesh, &solver_pins(&pins)) } else { mesh.verts.clone() }; + Some(Self { ids: ids.to_vec(), pins, auto, mesh, deformed, warped }) + } + /// Where on the rest shape the art now at `q` comes from (None: off the mesh). + pub fn rest_of(&self, q: Point) -> Option { + if self.warped { self.mesh.unmap(&self.deformed, q) } else { self.mesh.contains(q).then_some(q) } + } + /// The turn (radians) the art has around pin `i`: the one it holds, else the warp's there. + pub fn turn_of(&self, i: usize) -> f64 { + self.pins.get(i).map_or(0.0, |q| q.angle.or_else(|| self.mesh.turn_at(&self.deformed, q.rest)).unwrap_or(0.0)) + } + /// The `object.puppetWarp` params that set `pins` on these objects. + pub fn params(&self, pins: &[PuppetPin], expand: f64) -> Value { + json!({ + "rest": true, + "ids": crate::json_ids(&self.ids), + "pins": pins.iter().map(|q| json!([q.rest.x, q.rest.y])).collect::>(), + "moved": pins.iter().map(|q| json!([q.at.x, q.at.y])).collect::>(), + "angles": pins.iter().map(|q| q.angle.map(f64::to_degrees)).collect::>(), + "expand": expand, + }) + } + /// The warped mesh as one path (for the overlay). + fn mesh_path(&self) -> BezPath { + let mut bp = BezPath::new(); + for t in &self.mesh.tris { + let [a, b, c] = t.map(|i| self.deformed.get(i).copied().unwrap_or(self.mesh.verts[i])); + bp.move_to(a); + bp.line_to(b); + bp.line_to(c); + bp.close_path(); + } + bp + } +} + +/// Put `pins` on `ids`: warp their rest shape (see [`rest_and_pins`]) so each pin lands where it +/// is now, and keep the pins in the document. With no pins the art stays as it is and the pins +/// start again from it. +pub fn warp_from_rest(doc: &mut Document, ids: &[NodeId], pins: Vec, expand: f64) -> Result<(), String> { + let (rest, _) = rest_and_pins(doc, ids).ok_or("nothing to warp")?; + let rest = if pins.is_empty() { + ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::>>().ok_or("nothing to warp")? + } else { + let mesh = mesh_of(&rest, expand).ok_or("nothing to warp")?; + if let Some(i) = pins.iter().position(|q| !mesh.contains(q.rest)) { + return Err(format!("pin {i} is off the artwork (outside its mesh)")); + } + let deformed = arap::deform(&mesh, &solver_pins(&pins)); + let f = |q: Point| mesh.map(&deformed, q); + for (id, r) in ids.iter().zip(&rest) { + let mut n = Node::clone(r); + warp_node_with(&mut n, &f); + *doc.node_mut(*id).ok_or("the artwork is gone")? = n; + } + rest + }; + let result = ids.iter().map(|id| doc.node_arc(*id).cloned()).collect::>>().ok_or("the artwork is gone")?; + doc.puppet = Some(Arc::new(PuppetPins { ids: ids.to_vec(), rest, result, pins })); + Ok(()) +} + +/// Within this many pixels of a selected pin (and not on it), Alt-dragging turns the art around it. +const TURN_REACH_PX: f64 = 24.0; +/// Radius (pixels) of the dotted circle shown around the pin the art would turn around. +const TURN_RING_PX: f64 = 16.0; +/// The feedback for a click off the mesh. +const OFF_MESH: &str = "Pins go on the artwork"; +const WARN: [u8; 3] = [0xd9, 0x3f, 0x3f]; + +/// A drag in progress. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Gesture { + /// The selected pins move by `to - from`. + Move { from: Point, to: Point }, + /// The art turns around pin `pin`: `base` (radians) is its turn when the drag began, `from` + /// the pointer's angle around it then, `angle` how far the drag has turned it since. + Turn { pin: usize, base: f64, from: f64, angle: f64 }, +} + +/// What the cached pins were computed from (holding the objects keeps the pointers unique). +struct Key { ids: Vec, - pins: Vec, - selected: Option, - drag: Option<(usize, Point)>, - /// Control bar: Show Mesh. - show_mesh: bool, - /// Control bar: Expand Mesh (points). + nodes: Vec>, + puppet: Option>, expand: f64, } -impl PuppetWarpTool { - /// Re-seed pins when the selection changed. - fn sync(&mut self, cx: &ToolContext) { +impl Key { + fn of(cx: &ToolContext, expand: f64) -> Option { let ids = cx.selection.objects.clone(); - if ids == self.ids { - return; - } - self.ids = ids; - self.selected = None; - self.pins = if self.ids.is_empty() { - vec![] - } else { - mesh_for(cx.doc, &self.ids, self.expand_or_default()).map(|m| auto_pins(&m, 3)).unwrap_or_default() + let nodes = ids.iter().map(|id| cx.doc.node_arc(*id).cloned()).collect::>()?; + Some(Self { ids, nodes, puppet: cx.doc.puppet.clone(), expand }) + } + fn same(&self, o: &Key) -> bool { + let ptrs = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y)); + let puppet = match (&self.puppet, &o.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), }; + self.ids == o.ids && ptrs(&self.nodes, &o.nodes) && puppet && self.expand == o.expand } - fn expand_or_default(&self) -> f64 { - if self.expand > 0.0 { self.expand } else { 3.0 } +} + +struct Cached { + key: Key, + set: PinSet, + mesh: BezPath, +} + +pub struct PuppetWarpTool { + /// The objects the selected pins belong to. + ids: Vec, + /// Selected pins (indices into the pins). + selected: Vec, + drag: Option, + /// The pins as the drag began (each preview starts from them). + start: Option>, + /// The pointer and the modifiers held while hovering (for the turn ring). + hover: Option<(Point, Mods)>, + /// Where a click off the mesh was (its feedback shows until the pointer moves on). + off_mesh: Option, + /// Control bar: Show Mesh. + show_mesh: bool, + /// Control bar: Expand Mesh (points, 0 allowed). + expand: f64, + /// The pins and mesh of the selection, recomputed when the art, its pins or Expand change + /// (overlays ask every frame). + cache: Mutex>>, +} + +impl Default for PuppetWarpTool { + fn default() -> Self { + Self { + ids: vec![], + selected: vec![], + drag: None, + start: None, + hover: None, + off_mesh: None, + show_mesh: true, + expand: 3.0, + cache: Mutex::new(None), + } } - fn pin_at(&self, cx: &ToolContext, p: Point) -> Option { +} + +impl PuppetWarpTool { + /// The pins on the selection (None: nothing selected). + fn pins(&self, cx: &ToolContext) -> Option> { + let key = Key::of(cx, self.expand).filter(|k| !k.ids.is_empty())?; + let mut cache = self.cache.lock().ok(); + if let Some(c) = cache.as_deref().and_then(Option::as_ref).filter(|c| c.key.same(&key)) { + return Some(c.clone()); + } + let set = PinSet::of(cx.doc, &key.ids, self.expand)?; + let c = Arc::new(Cached { mesh: set.mesh_path(), key, set }); + if let Some(slot) = cache.as_deref_mut() { + *slot = Some(c.clone()); + } + Some(c) + } + /// The last pins computed (for `options`, which has no document). + fn last_pins(&self) -> Option> { + self.cache.lock().ok().and_then(|c| c.clone()) + } + /// A new selection starts with no pin selected. + fn sync(&mut self, cx: &ToolContext) { + if cx.selection.objects != self.ids { + self.ids = cx.selection.objects.clone(); + self.selected.clear(); + } + } + fn pin_at(cx: &ToolContext, set: &PinSet, p: Point) -> Option { let tol = cx.tol(7.0); - self.pins.iter().enumerate().filter(|(_, q)| q.distance(p) <= tol).min_by(|a, b| a.1.distance(p).total_cmp(&b.1.distance(p))).map(|(i, _)| i) + set.pins.iter().enumerate().map(|(i, q)| (i, q.at.distance(p))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i) } - fn params(&self, i: usize, to: Point) -> Value { - let mut moved = self.pins.clone(); - moved[i] = to; - let pj = |v: &[Point]| Value::Array(v.iter().map(|p| json!([p.x, p.y])).collect()); - json!({"ids": crate::json_ids(&self.ids), "pins": pj(&self.pins), "moved": pj(&moved), "expand": self.expand_or_default()}) + /// The selected pin the art would turn around with Alt held at `p`: near it, not on a pin. + fn turn_pin_at(&self, cx: &ToolContext, set: &PinSet, p: Point) -> Option { + if Self::pin_at(cx, set, p).is_some() { + return None; + } + let reach = cx.tol(TURN_REACH_PX); + self.selected + .iter() + .filter_map(|&i| Some((i, set.pins.get(i)?.at.distance(p)))) + .filter(|(_, d)| *d <= reach) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) + } + /// The pins during gesture `g`. + fn targets(&self, set: &PinSet, g: Gesture) -> Vec { + let mut pins = set.pins.clone(); + for (i, q) in pins.iter_mut().enumerate() { + match g { + Gesture::Move { from, to } if self.selected.contains(&i) => q.at += to - from, + Gesture::Turn { pin, base, angle, .. } if pin == i => q.angle = Some(base + angle), + _ => {} + } + } + pins + } + /// Run `object.puppetWarp` with `pins`, then look at the pins it left. + fn apply(&self, set: &PinSet, pins: &[PuppetPin]) -> Vec { + vec![Action::Exec("object.puppetWarp".into(), set.params(pins, self.expand)), Action::Notify("pins".into())] + } + /// Select pin `i` (Shift: add it to the selection or take it out). → whether it is selected. + fn pick(&mut self, i: usize, shift: bool) -> bool { + match (shift, self.selected.iter().position(|s| *s == i)) { + (true, Some(k)) => { + self.selected.remove(k); + false + } + (true, None) => { + self.selected.push(i); + true + } + // A press on one of several selected pins drags them all. + (false, Some(_)) => true, + (false, None) => { + self.selected = vec![i]; + true + } + } } } @@ -88,100 +342,162 @@ impl Tool for PuppetWarpTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; match ev.kind { - PointerKind::Down => { - self.sync(cx); - if self.ids.is_empty() { - // Click an object to select it; pins appear on the next event. - if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) { - return vec![Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]}))]; - } - return vec![]; - } - if let Some(i) = self.pin_at(cx, p) { - if ev.mods.alt { - self.pins.remove(i); - self.selected = None; - return vec![]; - } - self.selected = Some(i); - self.drag = Some((i, p)); - return vec![Action::Begin("Puppet Warp".into())]; - } - let inside = cx.doc.bounds_of(&self.ids, false).is_some_and(|b| b.inflate(cx.tol(4.0), cx.tol(4.0)).contains(p)); - if inside && !ev.mods.alt { - self.pins.push(p); - self.selected = Some(self.pins.len() - 1); + PointerKind::Move => { + self.hover = Some((p, ev.mods)); + if self.off_mesh.is_some_and(|q| q.distance(p) > cx.tol(6.0)) { + self.off_mesh = None; } vec![] } - PointerKind::Drag => match self.drag { - Some((i, _)) => { - self.drag = Some((i, p)); - vec![Action::Preview("object.puppetWarp".into(), self.params(i, p))] + PointerKind::Down => { + self.sync(cx); + self.off_mesh = None; + let Some(c) = self.pins(cx) else { + // Click an object to select it: its pins appear at once. + if let Some(h) = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) { + return vec![ + Action::Exec("select.set".into(), json!({"ids": [h.top_object(cx.isolation).0]})), + Action::Notify("pins".into()), + ]; + } + return vec![]; + }; + let set = &c.set; + if ev.mods.alt + && let Some(i) = self.turn_pin_at(cx, set, p) + && let Some(q) = set.pins.get(i) + { + self.drag = Some(Gesture::Turn { pin: i, base: set.turn_of(i), from: (p - q.at).atan2(), angle: 0.0 }); + self.start = Some(c.clone()); + return vec![Action::Begin("Puppet Warp".into())]; } - None => vec![], - }, + if let Some(i) = Self::pin_at(cx, set, p) { + if !self.pick(i, ev.mods.shift) { + return vec![]; + } + self.drag = Some(Gesture::Move { from: p, to: p }); + self.start = Some(c.clone()); + return vec![Action::Begin("Puppet Warp".into())]; + } + match set.rest_of(p) { + Some(rest) => { + let mut pins = set.pins.clone(); + pins.push(PuppetPin { rest, at: p, angle: None }); + self.pick(pins.len() - 1, ev.mods.shift); + self.apply(set, &pins) + } + None => { + // Off the mesh: nothing to pin there (the feedback says so). + self.off_mesh = Some(p); + if !ev.mods.shift { + self.selected.clear(); + } + vec![] + } + } + } + PointerKind::Drag => { + let (Some(mut g), Some(c)) = (self.drag, self.start.clone()) else { return vec![] }; + match &mut g { + Gesture::Move { to, .. } => *to = p, + Gesture::Turn { pin, from, angle, .. } => { + let Some(q) = c.set.pins.get(*pin) else { return vec![] }; + *angle = (p - q.at).atan2() - *from; + } + } + self.drag = Some(g); + vec![Action::Preview("object.puppetWarp".into(), c.set.params(&self.targets(&c.set, g), self.expand))] + } PointerKind::Up => match self.drag.take() { - Some((i, _)) => { - self.pins[i] = p; - vec![Action::Commit] + Some(_) => { + self.start = None; + vec![Action::Commit, Action::Notify("pins".into())] } None => vec![], }, - _ => vec![], + PointerKind::DoubleClick => vec![], } } - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if matches!(key, ToolKey::Delete | ToolKey::Backspace) - && let Some(i) = self.selected.take() - && i < self.pins.len() - { - self.pins.remove(i); + /// Delete/Backspace remove the selected pins; with none selected they are the shortcut's + /// (Clear deletes the selected objects). + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + matches!(key, ToolKey::Delete | ToolKey::Backspace) + && self.drag.is_none() + && cx.selection.objects == self.ids + && self.pins(cx).is_some_and(|c| self.selected.iter().any(|i| *i < c.set.pins.len())) + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if !self.claims_key(cx, key) { + return vec![]; } - vec![] + let Some(c) = self.pins(cx) else { return vec![] }; + let gone = std::mem::take(&mut self.selected); + let pins: Vec = c.set.pins.iter().enumerate().filter(|(i, _)| !gone.contains(i)).map(|(_, q)| *q).collect(); + self.apply(&c.set, &pins) } fn overlays(&self, cx: &ToolContext) -> Vec { let mut out = vec![]; - if self.show_mesh - && cx.selection.objects == self.ids - && let Some(m) = mesh_for(cx.doc, &self.ids, self.expand_or_default()) - { - let mut bp = BezPath::new(); - for t in &m.tris { - bp.move_to(m.verts[t[0]]); - bp.line_to(m.verts[t[1]]); - bp.line_to(m.verts[t[2]]); - bp.close_path(); + if let Some(c) = self.pins(cx) { + if self.show_mesh { + out.push(Overlay::Path { path: c.mesh.clone(), color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false }); } - out.push(Overlay::Path { path: bp, color: [0x9a, 0x9a, 0x9a], width: 0.5, dashed: false }); - } - let r = cx.tol(5.0); - for (i, q) in self.pins.iter().enumerate() { - let q = match self.drag { - Some((d, to)) if d == i => to, - _ => *q, + let r = cx.tol(5.0); + for (i, q) in c.set.pins.iter().enumerate() { + let sel = self.selected.contains(&i) && cx.selection.objects == self.ids; + out.push(Overlay::Path { + path: super::ellipse_path(q.at, r, r, 0.0), + color: BLUE, + width: if sel { 3.0 } else { 1.5 }, + dashed: false, + }); + out.push(Overlay::Anchor { p: q.at, color: BLUE, filled: sel, size: 3.0 }); + } + // The dotted circle of a turn: around the pin being turned, or the one Alt would turn. + let ring = match (self.drag, self.hover) { + (Some(Gesture::Turn { pin, .. }), _) => Some(pin), + (None, Some((h, mods))) if mods.alt => self.turn_pin_at(cx, &c.set, h), + _ => None, }; - let circle = super::ellipse_path(q, r, r, 0.0); - out.push(Overlay::Path { path: circle, color: BLUE, width: if self.selected == Some(i) { 3.0 } else { 1.5 }, dashed: false }); - out.push(Overlay::Anchor { p: q, color: BLUE, filled: self.selected == Some(i), size: 3.0 }); + if let Some(q) = ring.and_then(|i| c.set.pins.get(i)) { + let rr = cx.tol(TURN_RING_PX); + out.push(Overlay::Path { path: super::ellipse_path(q.at, rr, rr, 0.0), color: BLUE, width: 1.0, dashed: true }); + } + } + if let Some(p) = self.off_mesh { + out.push(Overlay::Label { p, text: OFF_MESH.into(), color: WARN }); } out } fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor { - match self.pin_at(cx, p) { - Some(_) if mods.alt => Cursor::PenDelete, - Some(_) => Cursor::Move, - None => Cursor::PenAdd, + let Some(c) = self.pins(cx) else { return Cursor::Arrow }; + if mods.alt && self.turn_pin_at(cx, &c.set, p).is_some() { + Cursor::Rotate + } else if Self::pin_at(cx, &c.set, p).is_some() { + Cursor::Move + } else if c.set.rest_of(p).is_some() { + Cursor::PenAdd + } else { + // Off the mesh: no pin goes there. + Cursor::NotAllowed } } fn options(&self) -> Value { - json!({"showMesh": self.show_mesh, "expand": self.expand_or_default(), "pins": self.pins.iter().map(|p| json!([p.x, p.y])).collect::>()}) + let pins = self.last_pins().map(|c| c.set.pins.iter().map(|q| json!([q.at.x, q.at.y])).collect::>()).unwrap_or_default(); + json!({"showMesh": self.show_mesh, "expand": self.expand, "pins": pins, "selected": self.selected}) } fn set_option(&mut self, key: &str, value: &Value) { match key { "showMesh" => self.show_mesh = value.as_bool().unwrap_or(self.show_mesh), - "expand" => self.expand = value.as_f64().unwrap_or(self.expand).clamp(0.0, 1000.0), - "selectAllPins" => self.selected = None, + "expand" => { + if let Some(v) = value.as_f64().filter(|v| v.is_finite()) { + self.expand = v.clamp(0.0, 1000.0); + } + } + // Select All Pins (false: deselect them all). + "selectAllPins" => { + let n = self.last_pins().map_or(0, |c| c.set.pins.len()); + self.selected = if value.as_bool() == Some(false) { vec![] } else { (0..n).collect() }; + } _ => {} } } @@ -190,10 +506,18 @@ impl Tool for PuppetWarpTool { } fn notify(&mut self, cx: &ToolContext, _what: &str) { self.sync(cx); + // Pick up the pins the command left (and drop selected pins that are gone). + let n = self.pins(cx).map_or(0, |c| c.set.pins.len()); + self.selected.retain(|i| *i < n); } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - self.ids.clear(); - self.pins.clear(); + self.selected.clear(); + self.hover = None; + self.off_mesh = None; + if let Ok(mut c) = self.cache.lock() { + *c = None; + } + self.start = None; if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } } } @@ -204,28 +528,222 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + fn alt() -> Mods { + Mods { alt: true, ..Default::default() } + } + + fn shift() -> Mods { + Mods { shift: true, ..Default::default() } + } + + /// The tool on a document, running the `object.puppetWarp` actions it emits the way the + /// engine does (previews on the snapshot taken at Begin). + struct Rig { + doc: Document, + sel: Selection, + base: Option, + tool: PuppetWarpTool, + /// The params of the last command run. + last: Value, + } + + impl Rig { + fn new() -> Self { + let (doc, id) = doc_with_rect(); + Self { doc, sel: Selection { objects: vec![id], ..Default::default() }, base: None, tool: PuppetWarpTool::default(), last: Value::Null } + } + fn run(&mut self, p: &Value) { + let pts = |k: &str| -> Vec { + p[k].as_array().unwrap().iter().map(|q| Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() + }; + let angles = p["angles"].as_array().unwrap().iter().map(|a| a.as_f64().map(f64::to_radians)); + let pins = pts("pins").into_iter().zip(pts("moved")).zip(angles).map(|((rest, at), angle)| PuppetPin { rest, at, angle }).collect(); + let ids: Vec = p["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect(); + assert_eq!(p["rest"], json!(true)); + warp_from_rest(&mut self.doc, &ids, pins, p["expand"].as_f64().unwrap()).unwrap(); + self.last = p.clone(); + } + fn apply(&mut self, acts: Vec) -> Vec { + for a in &acts { + match a { + Action::Begin(_) => self.base = Some(self.doc.clone()), + Action::Preview(_, p) => { + self.doc = self.base.clone().unwrap(); + self.run(p); + } + Action::Commit => self.base = None, + Action::Exec(c, p) if c == "object.puppetWarp" => self.run(p), + Action::Notify(w) => { + let p = paint(); + self.tool.notify(&cx(&self.doc, &self.sel, &p), w); + } + _ => {} + } + } + acts + } + fn ev(&mut self, kind: PointerKind, x: f64, y: f64, mods: Mods) -> Vec { + let p = paint(); + let acts = self.tool.pointer(&cx(&self.doc, &self.sel, &p), &PointerEvent::new(kind, x, y).with_mods(mods)); + self.apply(acts) + } + fn click(&mut self, x: f64, y: f64, mods: Mods) -> Vec { + let mut a = self.ev(PointerKind::Down, x, y, mods); + a.extend(self.ev(PointerKind::Up, x, y, mods)); + a + } + fn drag(&mut self, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec { + let mut a = self.ev(PointerKind::Down, from.0, from.1, mods); + a.extend(self.ev(PointerKind::Drag, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, mods)); + a.extend(self.ev(PointerKind::Drag, to.0, to.1, mods)); + a.extend(self.ev(PointerKind::Up, to.0, to.1, mods)); + a + } + fn key(&mut self, k: ToolKey) -> Vec { + let p = paint(); + let acts = self.tool.key(&cx(&self.doc, &self.sel, &p), k, Mods::default()); + self.apply(acts) + } + fn claims(&self, k: ToolKey) -> bool { + let p = paint(); + self.tool.claims_key(&cx(&self.doc, &self.sel, &p), k) + } + fn overlays(&self) -> Vec { + let p = paint(); + self.tool.overlays(&cx(&self.doc, &self.sel, &p)) + } + fn cursor(&self, x: f64, y: f64, mods: Mods) -> Cursor { + let p = paint(); + self.tool.cursor(&cx(&self.doc, &self.sel, &p), Point::new(x, y), mods) + } + fn pins(&self) -> Vec { + self.doc.puppet.as_ref().map(|p| p.pins.clone()).unwrap_or_default() + } + fn bounds(&self) -> Rect { + self.doc.node(self.sel.objects[0]).unwrap().geometric_bounds().unwrap() + } + } + #[test] fn puppet_tool_autopins_adds_and_drags() { - let (d, id) = doc_with_rect(); - let s = Selection { objects: vec![id], ..Default::default() }; - let p = paint(); - let c = cx(&d, &s, &p); - let mut t = PuppetWarpTool::default(); - // Click inside the rect away from auto pins: adds a pin. - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)); - assert!(acts.is_empty()); - assert_eq!(t.pins.len(), 4, "3 auto pins + 1"); - t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 170.0)); + let mut r = Rig::new(); + // The automatic pins show as soon as there is art selected. + assert_eq!(r.overlays().iter().filter(|o| matches!(o, Overlay::Anchor { .. })).count(), 3, "3 automatic pins"); + // Click inside the rect away from them: adds a pin (one command, the pins kept in the document). + let acts = r.click(130.0, 170.0, Mods::default()); + assert!(matches!(&acts[0], Action::Exec(c, _) if c == "object.puppetWarp"), "{acts:?}"); + assert_eq!(r.pins().len(), 4, "3 auto pins + 1"); + assert_eq!(r.tool.options()["selected"], json!([3])); // Drag the new pin. - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 130.0, 170.0)), vec![Action::Begin("Puppet Warp".into())]); - let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 120.0, 190.0)); - let Action::Preview(cmd, v) = &acts[0] else { panic!() }; + assert_eq!(r.ev(PointerKind::Down, 130.0, 170.0, Mods::default()), vec![Action::Begin("Puppet Warp".into())]); + let acts = r.ev(PointerKind::Drag, 120.0, 190.0, Mods::default()); + let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; assert_eq!(cmd, "object.puppetWarp"); assert_eq!(v["moved"][3], json!([120.0, 190.0])); assert_eq!(v["pins"][3], json!([130.0, 170.0])); - assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 120.0, 190.0)), vec![Action::Commit]); - // Alt-click deletes it. - t.pointer(&c, &PointerEvent::new(PointerKind::Down, 120.0, 190.0).with_mods(Mods { alt: true, ..Default::default() })); - assert_eq!(t.pins.len(), 3); + assert_eq!(r.ev(PointerKind::Up, 120.0, 190.0, Mods::default())[0], Action::Commit); + // Alt-click on a pin doesn't delete it (Alt turns the art around a pin): Delete does. + r.click(120.0, 190.0, alt()); + assert_eq!(r.pins().len(), 4); + assert!(r.claims(ToolKey::Delete)); + r.key(ToolKey::Delete); + assert_eq!(r.pins().len(), 3); + assert!(!r.claims(ToolKey::Delete), "nothing selected: Delete is Clear's"); + } + + #[test] + fn shift_click_selects_several_pins_that_move_together() { + let mut r = Rig::new(); + // Two new pins, the second Shift-added to the selection. + r.click(110.0, 110.0, Mods::default()); + r.click(190.0, 110.0, shift()); + let (a, b) = (r.pins().len() - 2, r.pins().len() - 1); + assert_eq!(r.tool.selected, vec![a, b]); + // Dragging one moves both. + r.ev(PointerKind::Down, 110.0, 110.0, Mods::default()); + let acts = r.ev(PointerKind::Drag, 110.0, 100.0, Mods::default()); + let Action::Preview(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((v["moved"][a].clone(), v["moved"][b].clone()), (json!([110.0, 100.0]), json!([190.0, 100.0]))); + assert_eq!(v["moved"][0], v["pins"][0], "unselected pins stay"); + r.ev(PointerKind::Up, 110.0, 100.0, Mods::default()); + // Shift-click takes a pin out of the selection without dragging. + assert!(r.ev(PointerKind::Down, 190.0, 100.0, shift()).is_empty()); + assert_eq!(r.tool.selected, vec![a]); + // Select All Pins, then delete them all: the art stays as it is. + let before = r.bounds(); + r.tool.set_option("selectAllPins", &json!(true)); + assert_eq!(r.tool.selected, (0..r.pins().len()).collect::>()); + r.key(ToolKey::Backspace); + assert!(r.pins().is_empty()); + assert_eq!(r.bounds(), before); + } + + #[test] + fn alt_drag_near_a_selected_pin_turns_the_art_around_it() { + let mut r = Rig::new(); + r.click(110.0, 150.0, Mods::default()); + let i = r.pins().len() - 1; + // Alt near (not on) the selected pin: the turn ring and cursor. + r.ev(PointerKind::Move, 125.0, 150.0, alt()); + assert_eq!(r.cursor(125.0, 150.0, alt()), Cursor::Rotate); + assert!(r.overlays().iter().any(|o| matches!(o, Overlay::Path { dashed: true, .. })), "dotted ring"); + assert_ne!(r.cursor(125.0, 150.0, Mods::default()), Cursor::Rotate, "only with Alt"); + // Drag a quarter turn around it. + assert_eq!(r.ev(PointerKind::Down, 125.0, 150.0, alt()), vec![Action::Begin("Puppet Warp".into())]); + let acts = r.ev(PointerKind::Drag, 110.0, 165.0, alt()); + let Action::Preview(cmd, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(cmd, "object.puppetWarp"); + assert!((v["angles"][i].as_f64().unwrap() - 90.0).abs() < 1e-6, "{v}"); + assert_eq!(v["moved"][i], v["pins"][i], "the pin stays put"); + assert_eq!(v["angles"][0], Value::Null, "other pins turn freely"); + r.ev(PointerKind::Up, 110.0, 165.0, alt()); + // The turned pin keeps its turn through the next warp. + let q = r.pins()[0].at; + r.drag((q.x, q.y), (q.x + 5.0, q.y), Mods::default()); + assert!((r.last["angles"][i].as_f64().unwrap() - 90.0).abs() < 1e-6, "{}", r.last); + } + + #[test] + fn show_mesh_is_on_by_default_and_expand_may_be_zero() { + let mut t = PuppetWarpTool::default(); + assert_eq!((t.options()["showMesh"].clone(), t.options()["expand"].clone()), (json!(true), json!(3.0))); + t.set_option("expand", &json!(0)); + assert_eq!(t.options()["expand"], json!(0.0)); + t.set_option("expand", &json!(f64::NAN)); + t.set_option("expand", &json!("x")); + assert_eq!(t.options()["expand"], json!(0.0)); + } + + #[test] + fn warps_start_from_the_rest_shape() { + let mut r = Rig::new(); + let rest = r.doc.node(r.sel.objects[0]).cloned().unwrap(); + r.click(190.0, 190.0, Mods::default()); + // Two drags out and back: the second starts from where the first left the pin. + r.drag((190.0, 190.0), (230.0, 230.0), Mods::default()); + assert!(r.bounds().x1 > 215.0, "{:?}", r.bounds()); + r.drag((230.0, 230.0), (190.0, 190.0), Mods::default()); + // Back where it started: the original shape, not a warp of a warp. + let back = r.doc.node(r.sel.objects[0]).unwrap(); + let (a, b) = (rest.path_data().unwrap(), back.path_data().unwrap()); + let worst = a.subpaths[0].anchors.iter().zip(&b.subpaths[0].anchors).map(|(x, y)| x.p.distance(y.p)).fold(0.0, f64::max); + assert!(worst < 0.01, "{worst}"); + } + + #[test] + fn a_click_off_the_mesh_places_no_pin_and_says_so() { + let mut r = Rig::new(); + r.click(130.0, 170.0, Mods::default()); + assert_eq!(r.cursor(400.0, 400.0, Mods::default()), Cursor::NotAllowed); + assert!(r.click(400.0, 400.0, Mods::default()).is_empty()); + assert_eq!(r.pins().len(), 4); + assert!(r.tool.selected.is_empty(), "the click deselects the pins"); + assert!(r.overlays().iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == OFF_MESH))); + r.ev(PointerKind::Move, 450.0, 450.0, Mods::default()); + assert!(!r.overlays().iter().any(|o| matches!(o, Overlay::Label { .. })), "gone once the pointer moves on"); + // The command refuses a pin off the mesh. + let ids = r.sel.objects.clone(); + let off = PuppetPin { rest: Point::new(400.0, 400.0), at: Point::new(400.0, 400.0), angle: None }; + assert!(warp_from_rest(&mut r.doc, &ids, vec![off], 3.0).unwrap_err().contains("off the artwork")); } } diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index 4fa59e2e0..902230111 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -12,6 +12,10 @@ //! Gestures preview `stroke.widthPoint.set {id, t, left, right, index?}` (side widths in points), //! `stroke.widthPoint.copy {id, index, t}` or, for several points, `stroke.widthProfile.set {ids, //! points}`, and commit on release; Delete runs `stroke.widthPoint.remove {id, indices}`. +//! +//! A compound path's stroke is the compound's: its members are edited through it, and the width +//! points show on every subpath of the stroke (one profile runs along each). The cursor says what a +//! drag does: add a width point on a stroke, or move or widen the one under it. use serde_json::json; use vectorcraft_doc::{Document, NodeId, NodeKind, StrokeLayer}; @@ -28,6 +32,9 @@ const SAME_T: f64 = 1e-6; /// Where the pointer is relative to a stroked path. #[derive(Clone, Copy, Debug, PartialEq)] struct Spot { + /// The object whose stroke it is (a compound path for its members). + owner: NodeId, + /// The path under the pointer. id: NodeId, sub: usize, /// Fraction along the subpath. @@ -93,12 +100,31 @@ struct Drag { pub struct WidthTool { hover: Option, drag: Option, - /// Selected width points: the path and their positions t. + /// Selected width points: the stroke's owner and their positions t. selected: Option<(NodeId, Vec)>, } -fn stroke_of(doc: &Document, id: NodeId) -> Option<&StrokeLayer> { - doc.node(id)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none()) +/// The object whose stroke path `id` draws with: its compound path, if it is a member of one. +pub fn stroke_owner(doc: &Document, id: NodeId) -> NodeId { + doc.parent_of(id).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. }))).unwrap_or(id) +} + +/// The visible weighted stroke of `owner`. +fn stroke_of(doc: &Document, owner: NodeId) -> Option<&StrokeLayer> { + doc.node(owner)?.appearance.stroke().filter(|s| s.visible && s.width > 0.0 && !s.paint.is_none()) +} + +/// The paths a stroke owner draws: itself, or a compound path's members. +fn paths_of(doc: &Document, owner: NodeId) -> Vec { + let mut out = vec![]; + if let Some(n) = doc.node(owner) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { guide: false, .. }) { + out.push(c.id); + } + }); + } + out } /// Side widths (points) of a stroke at fraction `t`. @@ -116,7 +142,8 @@ impl WidthTool { } let n = cx.doc.node(id)?; let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None }; - let st = stroke_of(cx.doc, id)?; + let owner = stroke_owner(cx.doc, id); + let st = stroke_of(cx.doc, owner)?; let widest = st.profile.as_ref().map_or(1.0, |pr| pr.points.iter().map(|q| q.1.max(q.2)).fold(1.0, f64::max)); let reach = widest * st.width / 2.0 + cx.tol(4.0); if !path.bounds()?.inflate(reach, reach).contains(p) { @@ -127,10 +154,11 @@ impl WidthTool { return None; } let (left, right) = sides_at(st, t); - Some((d, Spot { id, sub, t, p: q, n: left_normal(tan), left, right })) + Some((d, Spot { owner, id, sub, t, p: q, n: left_normal(tan), left, right })) }; let best_of = |ids: &mut dyn Iterator| ids.filter_map(consider).min_by(|a, b| a.0.total_cmp(&b.0)); - if let Some(b) = best_of(&mut cx.selection.objects.iter().copied()) { + // The selected paths first (a selected compound path: its members). + if let Some(b) = best_of(&mut cx.selection.objects.iter().flat_map(|id| paths_of(cx.doc, *id))) { return Some(b.1); } let mut all = vec![]; @@ -142,9 +170,9 @@ impl WidthTool { best_of(&mut all.into_iter().rev()).map(|b| b.1) } - /// Width points of `id` on subpath `sub`. - fn points_of(cx: &ToolContext, id: NodeId, sub: usize) -> Vec { - let Some(st) = stroke_of(cx.doc, id) else { return vec![] }; + /// Width points of `owner`'s stroke on subpath `sub` of path `id`. + fn points_of(cx: &ToolContext, owner: NodeId, id: NodeId, sub: usize) -> Vec { + let Some(st) = stroke_of(cx.doc, owner) else { return vec![] }; let Some(pr) = &st.profile else { return vec![] }; let Some(NodeKind::Path { path, .. }) = cx.doc.node(id).map(|n| &n.kind) else { return vec![] }; let Some(sp) = path.subpaths.get(sub) else { return vec![] }; @@ -158,11 +186,20 @@ impl WidthTool { .collect() } + /// Width points of `owner`'s stroke on every subpath it draws. + fn all_points(cx: &ToolContext, owner: NodeId) -> Vec { + let subpaths = |id: NodeId| match cx.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Path { path, .. }) => path.subpaths.len(), + _ => 0, + }; + paths_of(cx.doc, owner).into_iter().flat_map(|id| (0..subpaths(id)).flat_map(move |sub| Self::points_of(cx, owner, id, sub))).collect() + } + /// The width point part nearest to `p` within a few pixels: a centre (`None`) or the end of a /// left (`Some(true)`) or right (`Some(false)`) handle. fn hit(cx: &ToolContext, spot: &Spot, p: Point) -> Option<(WPoint, Option)> { let tol = cx.tol(5.0); - Self::points_of(cx, spot.id, spot.sub) + Self::points_of(cx, spot.owner, spot.id, spot.sub) .into_iter() .flat_map(|w| [(w, None, w.p), (w, Some(true), w.p + w.n * w.left), (w, Some(false), w.p - w.n * w.right)]) .map(|(w, part, q)| (q.distance(p), w, part)) @@ -217,7 +254,7 @@ impl WidthTool { /// The command previewing drag `d`. fn preview(d: &Drag) -> Action { - let id = d.spot.id.0; + let id = d.spot.owner.0; if let Some(g) = &d.group { let (dt, dl, dr) = (d.t - d.spot.t, (d.left - d.spot.left) / g.half, (d.right - d.spot.right) / g.half); let mut pts: Vec<[f64; 3]> = g @@ -260,8 +297,8 @@ impl Tool for WidthTool { PointerKind::DoubleClick => { let Some(spot) = Self::spot_at(cx, p) else { return vec![] }; let Some((w, _)) = Self::hit(cx, &spot, p) else { return vec![] }; - self.selected = Some((spot.id, vec![w.t])); - vec![Action::Dialog("widthPoint".into(), json!({"id": spot.id.0, "index": w.index}))] + self.selected = Some((spot.owner, vec![w.t])); + vec![Action::Dialog("widthPoint".into(), json!({"id": spot.owner.0, "index": w.index}))] } PointerKind::Down => { let Some(spot) = Self::spot_at(cx, p) else { @@ -278,20 +315,20 @@ impl Tool for WidthTool { Some(left) => Mode::Width(ev.mods.alt.then_some(left)), }; if ev.mods.shift { - if !self.toggle(spot.id, w.t) { + if !self.toggle(spot.owner, w.t) { return vec![]; } - } else if mode == Mode::Copy || !self.is_selected(spot.id, w.t) { - self.selected = Some((spot.id, vec![w.t])); + } else if mode == Mode::Copy || !self.is_selected(spot.owner, w.t) { + self.selected = Some((spot.owner, vec![w.t])); } - let others = Self::points_of(cx, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect(); - let group = self.group(cx, spot.id, mode); + let others = Self::points_of(cx, spot.owner, spot.id, spot.sub).into_iter().filter(|o| o.index != w.index).collect(); + let group = self.group(cx, spot.owner, mode); let spot = Spot { t: w.t, p: w.p, n: w.n, left: w.left, right: w.right, ..spot }; Drag { spot, index: Some(w.index), mode, left: w.left, right: w.right, t: w.t, group, others, moved: false } } None => { let side = (p - spot.p).dot(spot.n) >= 0.0; - self.selected = Some((spot.id, vec![spot.t])); + self.selected = Some((spot.owner, vec![spot.t])); let mode = Mode::Width(ev.mods.alt.then_some(side)); Drag { spot, index: None, mode, left: spot.left, right: spot.right, t: spot.t, group: None, others: vec![], moved: false } } @@ -337,11 +374,11 @@ impl Tool for WidthTool { let dt = d.t - d.spot.t; self.selected = match (d.group.is_some(), d.moved, self.selected.take()) { // A click on one of several selected points selects just that one. - (true, false, _) if !ev.mods.shift => Some((d.spot.id, vec![d.spot.t])), + (true, false, _) if !ev.mods.shift => Some((d.spot.owner, vec![d.spot.t])), // Points moved together stay selected. (true, _, Some((id, ts))) if d.mode == Mode::Move => Some((id, ts.into_iter().map(|t| (t + dt).clamp(0.0, 1.0)).collect())), (true, _, sel) => sel, - (false, _, _) => Some((d.spot.id, vec![d.t])), + (false, _, _) => Some((d.spot.owner, vec![d.t])), }; vec![Action::Commit] } @@ -349,6 +386,13 @@ impl Tool for WidthTool { }, } } + /// Delete/Backspace remove the selected width points; with none selected they are the + /// shortcut's (Clear deletes the selected objects). + fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool { + matches!(key, ToolKey::Delete | ToolKey::Backspace) + && self.drag.is_none() + && self.selected.as_ref().is_some_and(|(id, _)| !self.selected_indices(cx, *id).is_empty()) + } fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if !matches!(key, ToolKey::Delete | ToolKey::Backspace) { return vec![]; @@ -365,9 +409,10 @@ impl Tool for WidthTool { let mut out = vec![]; let r = cx.tol(4.0); let focus = self.drag.as_ref().map(|d| d.spot).or(self.hover); - let show = |id: NodeId, sub: usize, out: &mut Vec| { - for w in Self::points_of(cx, id, sub) { - let sel = self.is_selected(id, w.t); + // Every subpath's width points of the stroke under the pointer, and of the selected ones'. + let show = |owner: NodeId, out: &mut Vec| { + for w in Self::all_points(cx, owner) { + let sel = self.is_selected(owner, w.t); out.push(Overlay::Line { a: w.p + w.n * w.left, b: w.p - w.n * w.right, color: BLUE, dashed: false }); out.push(Overlay::Path { path: diamond(w.p, r), color: BLUE, width: if sel { 2.5 } else { 1.0 }, dashed: false }); out.push(Overlay::Handle { p: w.p + w.n * w.left, color: BLUE }); @@ -375,12 +420,12 @@ impl Tool for WidthTool { } }; if let Some(s) = focus { - show(s.id, s.sub, &mut out); + show(s.owner, &mut out); } - if let Some((id, _)) = &self.selected - && focus.is_none_or(|s| s.id != *id) + if let Some((owner, _)) = &self.selected + && focus.is_none_or(|s| s.owner != *owner) { - show(*id, 0, &mut out); + show(*owner, &mut out); } if let Some(d) = &self.drag { let q = d.spot.p; @@ -393,8 +438,16 @@ impl Tool for WidthTool { } out } - fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { - Cursor::Crosshair + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.drag.is_some() { + return Cursor::WidthPoint; + } + // The stroke under the pointer, as the last move found it (no search every frame). + match self.hover { + Some(spot) if Self::hit(cx, &spot, p).is_some() => Cursor::WidthPoint, + Some(_) => Cursor::WidthAdd, + None => Cursor::Width, + } } fn busy(&self) -> bool { self.drag.is_some() @@ -410,6 +463,7 @@ mod tests { use super::*; use crate::testutil::*; use vectorcraft_doc::{Appearance, Node, Selection, WidthProfile}; + use vectorcraft_geom::Point; use vectorcraft_geom::{PathData, SubPath}; fn doc_line() -> (Document, NodeId) { @@ -547,6 +601,94 @@ mod tests { assert_eq!((cmd, v["t"].as_f64(), v["index"].as_u64()), ("stroke.widthPoint.set", Some(0.5), Some(1))); } + /// A compound path of two 200 pt lines (y = 200 and 300) with a 10 pt stroke on the compound + /// (its members carry none), a width point at t = 0.5 → (doc, compound, second member). + fn doc_compound() -> (Document, NodeId, NodeId) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (c, a, b) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = |id, y| { + let path = PathData::single(SubPath::polyline(&[Point::new(100.0, y), Point::new(300.0, y)], false)); + std::sync::Arc::new(Node::path(id, path, Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0))) + }; + let mut comp = Node::new(c, NodeKind::Compound { children: vec![line(a, 200.0), line(b, 300.0)], rule: Default::default() }); + comp.appearance = Appearance::default_art(); + let st = comp.appearance.stroke_mut().unwrap(); + st.width = 10.0; + st.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (1.0, 1.0, 1.0)] }); + d.insert(Some(l), 0, comp).unwrap(); + (d, c, b) + } + + #[test] + fn compound_path_strokes_are_edited_through_the_compound() { + let (d, c, b) = doc_compound(); + assert_eq!(stroke_owner(&d, b), c); + let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint()); + let cx = cx(&d, &s, &p); + let mut t = WidthTool::default(); + // Drag out from the second member: the compound's stroke gets the width point. + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 301.0, Mods::default())), vec![Action::Begin("Width Point".into())]); + let acts = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 320.0, Mods::default())); + let (cmd, v) = preview(&acts); + assert_eq!((cmd, &v["id"]), ("stroke.widthPoint.set", &json!(c.0))); + t.pointer(&cx, &ev(PointerKind::Up, 150.0, 320.0, Mods::default())); + // The width point in the middle of the first member selects (and deletes) the compound's. + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Up, 200.0, 200.0, Mods::default())); + assert!(t.claims_key(&cx, ToolKey::Delete)); + assert_eq!( + t.key(&cx, ToolKey::Delete, Mods::default()), + vec![Action::Exec("stroke.widthPoint.remove".into(), json!({"id": c.0, "indices": [1]}))] + ); + } + + #[test] + fn width_points_show_on_every_subpath() { + let (d, c, _) = doc_compound(); + let (s, p) = (Selection { objects: vec![c], ..Default::default() }, paint()); + let cx = cx(&d, &s, &p); + let mut t = WidthTool::default(); + let handles = |t: &WidthTool| t.overlays(&cx).iter().filter(|o| matches!(o, Overlay::Handle { .. })).count(); + // Hovering one member shows the 3 width points on both (two handle ends each). + t.pointer(&cx, &ev(PointerKind::Move, 150.0, 302.0, Mods::default())); + assert_eq!(handles(&t), 3 * 2 * 2); + // A selected point shows them on every subpath when the pointer is elsewhere. + t.pointer(&cx, &ev(PointerKind::Down, 200.0, 300.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Move, 450.0, 450.0, Mods::default())); + assert_eq!(handles(&t), 3 * 2 * 2); + // One path of two subpaths: both. + let (mut d2, id) = doc_line(); + let n = d2.node_mut(id).unwrap(); + if let NodeKind::Path { path, .. } = &mut n.kind { + path.subpaths.push(SubPath::polyline(&[Point::new(100.0, 260.0), Point::new(300.0, 260.0)], false)); + } + n.appearance.stroke_mut().unwrap().profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)] }); + let s2 = Selection { objects: vec![id], ..Default::default() }; + let cx2 = crate::testutil::cx(&d2, &s2, &p); + let mut t = WidthTool::default(); + t.pointer(&cx2, &ev(PointerKind::Down, 200.0, 260.0, Mods::default())); + t.pointer(&cx2, &ev(PointerKind::Up, 200.0, 260.0, Mods::default())); + t.pointer(&cx2, &ev(PointerKind::Move, 450.0, 450.0, Mods::default())); + let ends: Vec = t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Handle { p, .. } = o { Some(*p) } else { None }).collect(); + assert!(ends.iter().any(|q| q.y < 230.0) && ends.iter().any(|q| q.y > 230.0), "both subpaths: {ends:?}"); + } + + #[test] + fn the_cursor_says_what_a_drag_does() { + let (d, _) = doc_three_points(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = WidthTool::default(); + assert_eq!(t.cursor(&c, Point::new(400.0, 400.0), Mods::default()), Cursor::Width); + t.pointer(&c, &ev(PointerKind::Move, 170.0, 201.0, Mods::default())); + assert_eq!(t.cursor(&c, Point::new(170.0, 201.0), Mods::default()), Cursor::WidthAdd, "on the stroke: adds a point"); + t.pointer(&c, &ev(PointerKind::Move, 150.0, 200.0, Mods::default())); + assert_eq!(t.cursor(&c, Point::new(150.0, 200.0), Mods::default()), Cursor::WidthPoint, "on a width point"); + assert_eq!(t.cursor(&c, Point::new(150.0, 195.0), Mods::default()), Cursor::WidthPoint, "on a handle end"); + } + #[test] fn double_click_on_a_width_point_opens_width_point_edit() { let (d, id) = doc_three_points(); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index a45e5c69c..4e30ed002 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -11,17 +11,20 @@ pub mod bbox; pub mod builder; pub mod catalog; pub mod corners; +pub mod cut; pub mod direct; pub mod distort; pub mod draw2; pub mod extra; pub mod guides; pub mod meshblend; +pub mod meshedit; pub mod params; pub mod pen; pub mod place; pub mod printtiling; pub mod select; +pub mod settings; pub mod shape; pub mod slice; pub mod symbolism; @@ -32,7 +35,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use vectorcraft_color::Paint; use vectorcraft_doc::{Document, NodeId, Selection, Unit}; -use vectorcraft_geom::{BezPath, Point, Rect}; +use vectorcraft_geom::{BezPath, Point, Rect, Vec2}; pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info}; @@ -90,6 +93,15 @@ impl PointerEvent { self.mods = m; self } + /// The pen `pressure` of a pointer event given as JSON (control channel, MCP): 0..1, default 1. + pub fn json_pressure(e: &Value) -> f32 { + e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32) + } + /// How long the pointer then holds still, in seconds, from a JSON pointer event's `holdMs` + /// (0..60000; default 0): the time [`Tool::tick`] gets. + pub fn json_hold(e: &Value) -> f64 { + e.get("holdMs").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(0.0, |ms| ms.clamp(0.0, 60_000.0) / 1000.0) + } } /// Keys tools care about. @@ -109,6 +121,9 @@ pub enum ToolKey { Tab, Home, End, + /// A digit key 0–9 (5 while dragging with the Perspective Selection tool moves perpendicular + /// to the plane). + Digit(u8), } /// What a tool asks the engine to do. @@ -168,6 +183,26 @@ impl Default for PaintDefaults { } } +/// The document window on screen, in document coordinates: widgets that stay put on screen (the +/// Plane Switching Widget) are placed with it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScreenFrame { + /// The window's top-left corner. + pub origin: Point, + /// One screen pixel to the right and one down. + pub right: Vec2, + pub down: Vec2, + /// The window's size in screen pixels. + pub size: (f64, f64), +} + +impl ScreenFrame { + /// The document point `x`, `y` screen pixels from the window's top-left corner. + pub fn at(&self, x: f64, y: f64) -> Point { + self.origin + self.right * x + self.down * y + } +} + /// Read-only context a tool sees. pub struct ToolContext<'a> { pub doc: &'a Document, @@ -215,6 +250,10 @@ pub struct ToolContext<'a> { pub slices_hidden: bool, /// View → Lock Slices: the Slice Selection tool leaves locked slices alone. pub slices_locked: bool, + /// The document window (none headless): screen-fixed widgets sit in it. + pub screen: Option, + /// Where the Plane Switching Widget sits (Perspective Grid Options); None while it's hidden. + pub plane_widget: Option, } impl ToolContext<'_> { @@ -260,6 +299,8 @@ pub enum Overlay { Highlight { quad: [Point; 4], color: [u8; 4] }, /// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected. Swatch { p: Point, color: [u8; 4], selected: bool }, + /// A hairline in a translucent colour (perspective gridlines), RGBA. + GridLine { a: Point, b: Point, color: [u8; 4] }, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -296,6 +337,18 @@ pub enum Cursor { Slice, /// The Slice Selection tool: the arrow with a slice badge. SliceSelect, + /// The Width tool away from strokes. + Width, + /// The Width tool over a stroke: a drag adds a width point. + WidthAdd, + /// The Width tool over a width point or a handle end: a drag moves or widens it. + WidthPoint, + /// The Blend tool away from art: a crosshair with a hollow square. + Blend, + /// The Blend tool over an object it can blend: a crosshair with a filled square. + BlendObject, + /// The Blend tool over an anchor point (the blend starts there): a crosshair with a target. + BlendAnchor, } /// A tool state machine. @@ -337,6 +390,21 @@ pub trait Tool: Send { fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] } + /// A command from outside the tool ran (a menu, a panel, an agent): finish work it ended (the + /// Type tool stops editing text the command took out of the selection). + fn after_command(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } + /// Time passes while the pointer button is held (`dt` seconds since the last tick, whether or + /// not the pointer moved): tools that keep working while the brush holds still (Twirl, Pucker, + /// Bloat) act on it. The host supplies the time, so tests and agents drive it exactly. + fn tick(&mut self, _cx: &ToolContext, _dt: f64) -> Vec { + vec![] + } + /// Does the tool want [`Tool::tick`]s now (the host keeps time only then)? + fn wants_ticks(&self) -> bool { + false + } } /// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id. @@ -346,7 +414,7 @@ pub fn create(id: &str) -> Box { "directSelection" => Box::new(direct::DirectSelectionTool::new(false)), "groupSelection" => Box::new(direct::DirectSelectionTool::new(true)), "pen" => Box::new(pen::PenTool::default()), - "type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)), + "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath" => Box::new(text::TypeTool::new(id)), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), // Not in the toolbar: `file.place.queue` loads it. "place" => Box::new(place::PlaceTool::default()), @@ -359,6 +427,7 @@ pub fn create(id: &str) -> Box { .or_else(|| extra::create(other)) .or_else(|| slice::create(other)) .or_else(|| printtiling::create(other)) + .or_else(|| cut::create(other)) .unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))), } } @@ -423,6 +492,8 @@ pub(crate) mod testutil { paste_plain_text: false, slices_hidden: false, slices_locked: false, + screen: None, + plane_widget: Some(Default::default()), } } } diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 7bfd167c7..5958b5509 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -1,17 +1,21 @@ //! Blend tool (W) and Mesh tool (U). //! -//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`. Double-click opens -//! Blend Options. Clicking empty canvas resets. +//! - Blend: click object A, then object B → `object.blend.make {ids: [A, B]}`; clicking an anchor +//! point of a path blends from that point (`starts`). Each further click adds the object as one +//! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens +//! Blend Options (dialog `blendOptions`). Clicking empty canvas starts over. //! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through -//! the click); click inside a mesh → `object.mesh.addLine` (new point takes the current fill -//! colour unless Shift); drag a mesh point → `object.mesh.movePoint` previews; Alt-click a mesh -//! point → `object.mesh.deletePoint`. +//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's +//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the +//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point → +//! `object.mesh.deletePoint`. use serde_json::json; use vectorcraft_color::Paint; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; +use crate::meshedit::{MeshEdit, grid_of}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; @@ -29,9 +33,48 @@ fn hit_top(cx: &ToolContext, p: Point) -> Option<(NodeId, NodeId)> { Some((h.top_object(cx.isolation), h.leaf)) } +/// The dialog the Blend tool's double-click, Alt-click and toolbar button open. +const BLEND_OPTIONS: &str = "blendOptions"; + +/// What the Blend tool picks: an object and the anchor of its first subpath clicked, if any. +type Pick = (NodeId, Option); + #[derive(Default)] pub struct BlendTool { - first: Option, + first: Option, + /// A blend was just made: the next click adds to it (the selected blend). + chained: bool, +} + +/// The anchor of the first subpath of path or compound path `id` (its first member's) nearest +/// `p`, within the click tolerance. +fn anchor_at(cx: &ToolContext, id: NodeId, p: Point) -> Option { + let n = cx.doc.node(id)?; + let path = match &n.kind { + NodeKind::Path { path, .. } => path, + NodeKind::Compound { children, .. } => children.first()?.path_data()?, + _ => return None, + }; + let tol = cx.tol(5.0); + let d = |a: &vectorcraft_geom::Anchor| a.p.distance(p); + let (i, a) = path.subpaths.first()?.anchors.iter().enumerate().min_by(|x, y| d(x.1).total_cmp(&d(y.1)))?; + (d(a) <= tol).then_some(i) +} + +/// The object the Blend tool picks at `p` (the top object hit) and the anchor clicked. +fn pick(cx: &ToolContext, p: Point) -> Option { + let (top, _) = hit_top(cx, p)?; + Some((top, anchor_at(cx, top, p))) +} + +impl BlendTool { + /// The object picked first: the clicked one, or the blend just made. + fn first(&self, cx: &ToolContext) -> Option { + self.first.or_else(|| match cx.selection.objects.as_slice() { + [b] if self.chained && cx.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })) => Some((*b, None)), + _ => None, + }) + } } impl Tool for BlendTool { @@ -39,23 +82,29 @@ impl Tool for BlendTool { "blend" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + let options = |t: &mut Self| { + (t.first, t.chained) = (None, false); + vec![Action::Dialog(BLEND_OPTIONS.into(), json!({}))] + }; match ev.kind { - PointerKind::DoubleClick => { - self.first = None; - vec![Action::Dialog("object.blend.options".into(), json!({}))] - } - PointerKind::Down => match (self.first, hit_top(cx, ev.pos)) { + PointerKind::DoubleClick => options(self), + PointerKind::Down if ev.mods.alt => options(self), + PointerKind::Down => match (self.first(cx), pick(cx, ev.pos)) { (_, None) => { - self.first = None; + (self.first, self.chained) = (None, false); vec![] } - (None, Some((top, _))) => { - self.first = Some(top); + (None, Some((top, start))) => { + self.first = Some((top, start)); vec![Action::Exec("select.set".into(), json!({"ids": [top.0]}))] } - (Some(a), Some((b, _))) if a != b && cx.doc.node(a).is_some() => { - self.first = None; - vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))] + (Some((a, sa)), Some((b, sb))) if a != b && cx.doc.node(a).is_some() => { + (self.first, self.chained) = (None, true); + let mut p = json!({"ids": [a.0, b.0]}); + if sa.is_some() || sb.is_some() { + p["starts"] = json!([sa, sb]); + } + vec![Action::Exec("object.blend.make".into(), p)] } (Some(_), Some(_)) => vec![], }, @@ -63,34 +112,47 @@ impl Tool for BlendTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { - let Some(b) = self.first.and_then(|a| cx.doc.node(a)).and_then(|n| n.geometric_bounds()) else { return vec![] }; - vec![Overlay::Anchor { p: b.center(), color: FEEDBACK, filled: true, size: 6.0 }] + let Some((id, start)) = self.first(cx) else { return vec![] }; + let Some(n) = cx.doc.node(id) else { return vec![] }; + let at = match (start, &n.kind) { + (Some(i), NodeKind::Path { path, .. }) => path.subpaths.first().and_then(|s| s.anchors.get(i)).map(|a| a.p), + (Some(i), NodeKind::Compound { children, .. }) => { + children.first().and_then(|c| c.path_data()).and_then(|p| p.subpaths.first()).and_then(|s| s.anchors.get(i)).map(|a| a.p) + } + _ => None, + }; + let Some(p) = at.or_else(|| n.geometric_bounds().map(|b| b.center())) else { return vec![] }; + vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }] } - fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { - Cursor::Crosshair + fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + match pick(cx, p) { + Some((_, Some(_))) => Cursor::BlendAnchor, + Some(_) => Cursor::BlendObject, + None => Cursor::Blend, + } } fn busy(&self) -> bool { self.first.is_some() } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - self.first = None; + (self.first, self.chained) = (None, false); vec![] } } #[derive(Default)] pub struct MeshTool { - drag: Option<(NodeId, usize)>, + edit: MeshEdit, } impl MeshTool { - /// Meshes to consider for point hits: selected ones first, then any mesh in the document. + /// Meshes (gradient meshes and mesh envelopes) to consider for point hits: selected ones + /// first, then any in the document. fn meshes(cx: &ToolContext) -> Vec { - let mut v: Vec = - cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)))).collect(); + let mut v: Vec = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).and_then(grid_of).is_some()).collect(); let mut rest = vec![]; cx.doc.walk(|n| { - if matches!(n.kind, NodeKind::Mesh(_)) { + if grid_of(n).is_some() { rest.push(n.id); } }); @@ -103,12 +165,8 @@ impl MeshTool { v } - fn point_at(cx: &ToolContext, p: Point) -> Option<(NodeId, usize)> { - let tol = cx.tol(5.0); - Self::meshes(cx).into_iter().find_map(|id| match &cx.doc.node(id)?.kind { - NodeKind::Mesh(m) => m.point_near(p, tol).map(|i| (id, i)), - _ => None, - }) + fn grab_at(&self, cx: &ToolContext, p: Point) -> Option { + self.edit.hit(cx, &Self::meshes(cx), p) } } @@ -120,13 +178,14 @@ impl Tool for MeshTool { let p = ev.pos; match ev.kind { PointerKind::Down => { - if let Some((id, index)) = Self::point_at(cx, p) { - if ev.mods.alt { - return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": id.0, "index": index}))]; + if let Some(g) = self.grab_at(cx, p) { + if ev.mods.alt && g.handle.is_none() { + self.edit.unfocus(); + return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))]; } - self.drag = Some((id, index)); - return vec![Action::Begin("Move Mesh Point".into())]; + return self.edit.press(g); } + self.edit.unfocus(); let Some((top, leaf)) = hit_top(cx, p) else { return vec![] }; let color = match (&cx.paint.fill, ev.mods.shift) { (Paint::Solid { color, .. }, false) => Some(color.to_hex()), @@ -141,6 +200,9 @@ impl Tool for MeshTool { } return vec![Action::Exec("object.mesh.addLine".into(), params)]; } + Some(NodeKind::Envelope { .. }) if cx.doc.node(id).and_then(grid_of).is_some() => { + return vec![Action::Exec("object.mesh.addLine".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; + } Some(NodeKind::Path { guide: false, .. }) | Some(NodeKind::Compound { .. }) => { return vec![Action::Exec("object.mesh.create".into(), json!({"ids": [id.0], "at": [p.x, p.y]}))]; } @@ -149,14 +211,8 @@ impl Tool for MeshTool { } vec![] } - PointerKind::Drag => match self.drag { - Some((id, index)) => vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": id.0, "index": index, "x": p.x, "y": p.y}))], - None => vec![], - }, - PointerKind::Up => match self.drag.take() { - Some(_) => vec![Action::Commit], - None => vec![], - }, + PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(), + PointerKind::Up => self.edit.release().unwrap_or_default(), _ => vec![], } } @@ -170,20 +226,22 @@ impl Tool for MeshTool { } } } + out.extend(self.edit.overlays(cx)); out } fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor { - match Self::point_at(cx, p) { - Some(_) if mods.alt => Cursor::PenDelete, + match self.grab_at(cx, p) { + Some(g) if mods.alt && g.handle.is_none() => Cursor::PenDelete, Some(_) => Cursor::Move, None => Cursor::PenAdd, } } fn busy(&self) -> bool { - self.drag.is_some() + self.edit.dragging() } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - if self.drag.take().is_some() { vec![Action::Commit] } else { vec![] } + self.edit.unfocus(); + self.edit.release().unwrap_or_default() } } @@ -208,6 +266,58 @@ mod tests { assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0]}))]); } + #[test] + fn blend_tool_anchor_clicks_and_options() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let b = d.alloc_id(); + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(300.0, 100.0, 350.0, 150.0)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, r, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = create("blend").unwrap(); + // Over an anchor, over an object, away from art. + assert_eq!(t.cursor(&c, Point::new(100.0, 100.0), Mods::default()), Cursor::BlendAnchor); + assert_eq!(t.cursor(&c, Point::new(150.0, 150.0), Mods::default()), Cursor::BlendObject); + assert_eq!(t.cursor(&c, Point::new(250.0, 400.0), Mods::default()), Cursor::Blend); + // The rectangle's first anchor is its top left, b's third its bottom right. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 99.0)); + assert!(t.busy()); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 350.0, 150.0)); + assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [a.0, b.0], "starts": [0, 2]}))]); + // Alt-click and double-click open Blend Options. + let alt = Mods { alt: true, ..Default::default() }; + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)), + vec![Action::Dialog("blendOptions".into(), json!({}))] + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::DoubleClick, 10.0, 10.0)), vec![Action::Dialog("blendOptions".into(), json!({}))]); + } + + #[test] + fn blend_tool_next_click_adds_to_the_blend_just_made() { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let g = d.alloc_id(); + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let (k1, k2) = (d.alloc_id(), d.alloc_id()); + let blend = vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 0.0), key(k2, 100.0)], spec: Default::default() }); + d.insert(Some(l), 1, blend).unwrap(); + let mut s = Selection::default(); + let p = paint(); + let mut t = BlendTool { first: None, chained: true }; + s.set([g]); + let c = cx(&d, &s, &p); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(acts, vec![Action::Exec("object.blend.make".into(), json!({"ids": [g.0, a.0]}))]); + // Away from art: start over. + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)); + assert!(t.first(&c).is_none()); + } + #[test] fn mesh_tool_click_path_creates_mesh() { let (d, a) = doc_with_rect(); diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs new file mode 100644 index 000000000..3a6a53feb --- /dev/null +++ b/crates/tools/src/meshedit.rs @@ -0,0 +1,183 @@ +//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct +//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a +//! point focuses it: its handles show and can be dragged too. + +use std::borrow::Cow; + +use serde_json::json; +use vectorcraft_doc::live::{GradientMesh, envelope_grid}; +use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind}; +use vectorcraft_geom::Point; + +use crate::{Action, Overlay, ToolContext}; + +const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff]; + +/// The editable grid of `n`: a gradient mesh, or a mesh envelope's grid (not while its contents +/// are being edited). +pub fn grid_of(n: &Node) -> Option> { + match &n.kind { + NodeKind::Mesh(m) => Some(Cow::Borrowed(m)), + NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows, cols, points, handles }, editing: false, .. } => { + envelope_grid(*rows, *cols, points, handles).map(Cow::Owned) + } + _ => None, + } +} + +/// What a press on a mesh grabs: the mesh, the point and (on the focused point) a handle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MeshGrab { + pub id: NodeId, + pub index: usize, + pub handle: Option, +} + +/// Point and handle drags on meshes. +#[derive(Default)] +pub struct MeshEdit { + /// The point last clicked: its handles show and can be dragged. + focus: Option<(NodeId, usize)>, + drag: Option, +} + +impl MeshEdit { + /// The handle of the focused point, else the mesh point, under `p` among the meshes `ids` + /// (editable ones, in order). + pub fn hit(&self, cx: &ToolContext, ids: &[NodeId], p: Point) -> Option { + let tol = cx.tol(5.0); + if let Some((id, index)) = self.focus + && ids.contains(&id) + && let Some(grid) = cx.doc.node(id).and_then(grid_of) + && let Some(q) = grid.points.get(index) + && let Some(h) = (0..4).find(|h| q.handles[*h].hypot() > 1e-6 && (q.p + q.handles[*h]).distance(p) <= tol) + { + return Some(MeshGrab { id, index, handle: Some(h) }); + } + ids.iter().filter(|id| cx.doc.is_editable(**id)).find_map(|id| { + let index = grid_of(cx.doc.node(*id)?)?.point_near(p, tol)?; + Some(MeshGrab { id: *id, index, handle: None }) + }) + } + + /// Start dragging `grab` (its point takes the focus). + pub fn press(&mut self, grab: MeshGrab) -> Vec { + self.focus = Some((grab.id, grab.index)); + self.drag = Some(grab); + vec![Action::Begin("Move Mesh Point".into())] + } + + /// The preview of a drag to `p` (`None` when nothing is being dragged). + pub fn drag_to(&self, p: Point) -> Option> { + let g = self.drag?; + let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y}); + if let Some(h) = g.handle { + params["handle"] = json!(h); + } + Some(vec![Action::Preview("object.mesh.movePoint".into(), params)]) + } + + /// End a drag (`None` when nothing was being dragged). + pub fn release(&mut self) -> Option> { + self.drag.take().map(|_| vec![Action::Commit]) + } + + pub fn dragging(&self) -> bool { + self.drag.is_some() + } + + /// Forget the focused point. + pub fn unfocus(&mut self) { + self.focus = None; + } + + /// The focused point's handles (lines and ends). + pub fn overlays(&self, cx: &ToolContext) -> Vec { + let Some((id, index)) = self.focus else { return vec![] }; + let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return vec![] }; + let Some(q) = grid.points.get(index) else { return vec![] }; + let mut out = vec![Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 }]; + for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) { + out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false }); + out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK }); + } + out + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::testutil::*; + use crate::{PointerEvent, PointerKind, Tool}; + use vectorcraft_doc::live::{EnvelopeOptions, grid_points}; + use vectorcraft_doc::{Document, Selection}; + use vectorcraft_geom::{Affine, Rect}; + + /// The rectangle of [`doc_with_rect`] (100..200 square) in a 1×1 mesh envelope. + fn doc_with_envelope() -> (Document, NodeId) { + let (mut d, a) = doc_with_rect(); + let rect = d.node(a).cloned().unwrap(); + d.remove(a).unwrap(); + let id = d.alloc_id(); + let kind = EnvelopeKind::Mesh { rows: 1, cols: 1, points: grid_points(Rect::new(100.0, 100.0, 200.0, 200.0), 1, 1), handles: vec![] }; + let env = NodeKind::Envelope { + content: vec![Arc::new(rect)], + kind, + fidelity: 50.0, + editing: false, + options: EnvelopeOptions::NEW, + frame: Affine::IDENTITY, + }; + let l = d.layers[0].id; + d.insert(Some(l), 0, Node::new(id, env)).unwrap(); + (d, id) + } + + #[test] + fn the_mesh_tool_drags_envelope_points_and_adds_lines() { + let (d, e) = doc_with_envelope(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = crate::meshblend::create("mesh").unwrap(); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())]); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0)), + vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 1, "x": 210.0, "y": 90.0}))] + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 210.0, 90.0)), vec![Action::Commit]); + // The clicked point shows its handles (the smooth mesh's) and they can be dragged. + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Handle { .. }))); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 200.0, 133.33333)), vec![Action::Begin("Move Mesh Point".into())]); + let drag = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 220.0, 140.0)); + assert!(matches!(&drag[..], [Action::Preview(id, p)] if id == "object.mesh.movePoint" && p["handle"] == json!(2)), "{drag:?}"); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 220.0, 140.0)); + // Inside the envelope: a new row and column. + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 160.0)), + vec![Action::Exec("object.mesh.addLine".into(), json!({"id": e.0, "x": 150.0, "y": 160.0}))] + ); + } + + #[test] + fn direct_selection_drags_a_selected_envelopes_points() { + let (d, e) = doc_with_envelope(); + let mut s = Selection::default(); + let p = paint(); + let mut t = crate::direct::DirectSelectionTool::new(false); + // Not selected: its points aren't handles to grab. + let c = cx(&d, &s, &p); + assert_ne!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]); + t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 200.0)); + s.set([e]); + let c = cx(&d, &s, &p); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 200.0)), vec![Action::Begin("Move Mesh Point".into())]); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 90.0, 210.0)), + vec![Action::Preview("object.mesh.movePoint".into(), json!({"id": e.0, "index": 2, "x": 90.0, "y": 210.0}))] + ); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Commit]); + } +} diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index 70411f9c2..da8371142 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -3,7 +3,8 @@ //! Click adds a corner anchor; click-drag adds a smooth anchor with symmetric handles (Alt-drag //! breaks the handles); Shift constrains to 45°. Clicking the first anchor closes the path. //! Enter/Esc (or switching tools) ends the path. Clicking the end of a selected open path continues -//! it. The rubber-band preview shows the next segment. +//! it. The rubber-band preview shows the next segment. On a selected blend's spine a click adds a +//! point (on a point no key object sits on: deletes it). use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind}; @@ -68,6 +69,9 @@ impl Tool for PenTool { self.drag = Some((p, false)); return vec![Action::Begin("Pen".into()), Action::Preview("path.appendAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]; } + if let Some(acts) = spine_click(cx, p, tol) { + return acts; + } // Continue a selected open path when clicking on one of its ends. if let Some((_, first, last, _)) = active_path(cx) && (p.distance(last) <= tol || p.distance(first) <= tol) @@ -149,6 +153,13 @@ impl Tool for PenTool { vec![Overlay::Path { path: bp, color: c, width: 1.0, dashed: false }] } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if !self.drawing { + match spine_click(cx, p, cx.tol(5.0)).as_deref() { + Some([Action::Exec(c, _)]) if c == "object.blend.spine.removeAnchor" => return Cursor::PenDelete, + Some([_]) => return Cursor::PenAdd, + _ => {} + } + } if let Some((_, first, last, _)) = active_path(cx) { if self.drawing && p.distance(first) <= cx.tol(5.0) { return Cursor::PenClose; @@ -161,6 +172,25 @@ impl Tool for PenTool { } } +/// A click on a selected blend's spine: delete the point under `p` when no key object sits on it +/// (a key's point does nothing), else add one where the spine passes within `tol`. +fn spine_click(cx: &ToolContext, p: Point, tol: f64) -> Option> { + for (id, path) in crate::direct::spines(cx).into_iter().filter(|(id, _)| cx.selection.contains(*id)) { + let Some(NodeKind::Blend { children, spec }) = cx.doc.node(id).map(|n| &n.kind) else { continue }; + let keys = vectorcraft_doc::live::blend_spine(children, spec).and_then(|(_, a)| a).unwrap_or_default(); + if let Some((_, ai, _)) = path.anchors().find(|(_, _, a)| a.p.distance(p) <= tol) { + if keys.contains(&ai) { + return Some(vec![]); + } + return Some(vec![Action::Exec("object.blend.spine.removeAnchor".into(), json!({"id": id.0, "anchor": ai}))]); + } + if path.nearest(p).is_some_and(|n| n.4 <= tol) { + return Some(vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": id.0, "x": p.x, "y": p.y}))]); + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +209,31 @@ mod tests { assert!(matches!(&a[1], Action::Preview(c, _) if c == "path.create")); assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)), vec![Action::Commit]); } + + #[test] + fn clicking_a_selected_blend_spine_adds_a_point() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let key = |id: NodeId, x: f64| { + let r = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x, 300.0, x + 20.0, 320.0)); + std::sync::Arc::new(vectorcraft_doc::Node::path(id, r, vectorcraft_doc::Appearance::default_art())) + }; + let (g, k1, k2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert( + Some(l), + 1, + vectorcraft_doc::Node::new(g, NodeKind::Blend { children: vec![key(k1, 100.0), key(k2, 200.0)], spec: Default::default() }), + ) + .unwrap(); + let mut s = Selection::default(); + s.set([g]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = PenTool::default(); + assert_eq!(t.cursor(&cx, Point::new(160.0, 311.0), Mods::default()), Cursor::PenAdd); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 160.0, 311.0)); + assert_eq!(a, vec![Action::Exec("object.blend.spine.addAnchor".into(), json!({"id": g.0, "x": 160.0, "y": 310.0}))]); + // A key's own point is left alone (the click snaps to the spine line). + assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 310.0)), vec![]); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 6adf8283f..872f03134 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1,10 +1,12 @@ //! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains), //! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°), //! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode. +//! The bounding box stands at the selection's own angle after a rotation, so its handles scale +//! along the objects' axes. use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; use vectorcraft_doc::hit::{hit_test, marquee}; +use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; @@ -22,7 +24,7 @@ enum State { }, Scaling { handle: Handle, - rect: Rect, + bx: OrientedBox, }, Rotating { center: Point, @@ -57,6 +59,28 @@ pub fn selection_bounds(cx: &ToolContext) -> Option { cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds) } +/// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool +/// shows and drags (rotated after a rotation). +pub fn selection_box(cx: &ToolContext) -> Option { + cx.doc.oriented_bounds(&cx.selection.objects, cx.preview_bounds) +} + +/// What of the bounding box is under the pointer. +enum BoxHit { + Handle(Handle), + /// Just outside a corner. + Rotate, +} + +/// The bounding-box handle or rotate zone under `p`. +fn box_hit(cx: &ToolContext, b: &OrientedBox, p: Point) -> Option { + let (tol, lp) = (cx.tol(5.0), b.to_local(p)); + if let Some(h) = hit_handle(b.rect, lp, tol) { + return Some(BoxHit::Handle(h)); + } + in_rotate_zone(b.rect, lp, tol, cx.tol(18.0)).map(|_| BoxHit::Rotate) +} + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) @@ -98,16 +122,18 @@ impl Tool for SelectionTool { return vec![]; } if cx.show_bbox - && let Some(r) = selection_bounds(cx) + && let Some(bx) = selection_box(cx) { - let tol = cx.tol(5.0); - if let Some(h) = hit_handle(r, p, tol) { - self.state = State::Scaling { handle: h, rect: r }; - return vec![Action::Begin("Scale".into())]; - } - if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { - self.state = State::Rotating { center: r.center(), start: p }; - return vec![Action::Begin("Rotate".into())]; + match box_hit(cx, &bx, p) { + Some(BoxHit::Handle(handle)) => { + self.state = State::Scaling { handle, bx }; + return vec![Action::Begin("Scale".into())]; + } + Some(BoxHit::Rotate) => { + self.state = State::Rotating { center: bx.center(), start: p }; + return vec![Action::Begin("Rotate".into())]; + } + None => {} } } // 2. Objects. @@ -164,11 +190,12 @@ impl Tool for SelectionTool { out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out } - (PointerKind::Drag, State::Scaling { handle, rect, .. }) => { - let a = scale_for_drag(rect, handle, p, m.shift, m.alt); - let nr = a.transform_rect_bbox(rect); + (PointerKind::Drag, State::Scaling { handle, bx }) => { + // Scale in the box's own frame: along the objects' axes when it is rotated. + let a = scale_for_drag(bx.rect, handle, bx.to_local(p), m.shift, m.alt); + let nr = a.transform_rect_bbox(bx.rect); self.measure = Some((p, cx.size_label(nr.width(), nr.height()))); - vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(a), "copy": false }))] + vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }))] } (PointerKind::Drag, State::Rotating { center, start, .. }) => { let (a, deg) = rotate_for_drag(center, start, p, m.shift); @@ -234,7 +261,7 @@ impl Tool for SelectionTool { fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { match self.state { State::Rotating { .. } => return Cursor::Rotate, - State::Scaling { handle, .. } => return handle_cursor(handle), + State::Scaling { handle, bx } => return handle_cursor(handle, bx.angle), State::Moving { began: true, .. } => return Cursor::Arrow, State::Corner(_) => return Cursor::CornerRadius, _ => {} @@ -243,14 +270,12 @@ impl Tool for SelectionTool { return Cursor::CornerRadius; } if cx.show_bbox - && let Some(r) = selection_bounds(cx) + && let Some(bx) = selection_box(cx) { - let tol = cx.tol(5.0); - if let Some(h) = hit_handle(r, p, tol) { - return handle_cursor(h); - } - if in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() { - return Cursor::Rotate; + match box_hit(cx, &bx, p) { + Some(BoxHit::Handle(h)) => return handle_cursor(h, bx.angle), + Some(BoxHit::Rotate) => return Cursor::Rotate, + None => {} } } if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { @@ -263,7 +288,11 @@ impl Tool for SelectionTool { } } -fn handle_cursor(h: Handle) -> Cursor { +/// The resize cursor for handle `h` of a box turned by `angle` (counter-clockwise degrees): the +/// handle that sits where `h` appears on screen, in 45° steps. +fn handle_cursor(h: Handle, angle: f64) -> Cursor { + let steps = if angle.is_finite() { (angle / 45.0).round() as i64 } else { 0 }; + let h = Handle::ALL.get((h as i64 - steps).rem_euclid(8) as usize).copied().unwrap_or(h); match h { Handle::Top | Handle::Bottom => Cursor::ResizeV, Handle::Left | Handle::Right => Cursor::ResizeH, @@ -339,6 +368,34 @@ mod tests { assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]); } + #[test] + fn turned_box_handles_turn_and_scale_along_the_object() { + let (mut d, id) = doc_with_rect(); + let c = Point::new(150.0, 150.0); + let turn = Affine::translate(c.to_vec2()) * Affine::rotate(-std::f64::consts::FRAC_PI_4) * Affine::translate(-c.to_vec2()); + d.node_mut(id).unwrap().transform(turn, false); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + // The Right handle sits up and to the right, where a diagonal cursor fits it. + let k = 50.0 * std::f64::consts::FRAC_1_SQRT_2; + let right = Point::new(150.0 + k, 150.0 - k); + let mut t = SelectionTool::default(); + assert_eq!(t.cursor(&cx, right, Mods::default()), Cursor::ResizeNeSw); + // The page box's corner is no handle any more. + assert_ne!(t.cursor(&cx, Point::new(150.0 + 2.0 * k, 150.0 - 2.0 * k), Mods::default()), Cursor::ResizeNeSw); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, right.x, right.y)), vec![Action::Begin("Scale".into())]); + let a = t.pointer(&cx, &ev(PointerKind::Drag, right.x + k, right.y - k)); + let Action::Preview(_, v) = &a[0] else { panic!("{a:?}") }; + // Doubling the width along 45° about the left side: the left handle stays put. + let m: Vec = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + let m = Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]]); + let left = Point::new(150.0 - k, 150.0 + k); + assert!((m * left).distance(left) < 1e-9); + assert!((m * right).distance(Point::new(right.x + k, right.y - k)) < 1e-9); + } + #[test] fn click_empty_deselects() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs new file mode 100644 index 000000000..ef6a1ccfa --- /dev/null +++ b/crates/tools/src/settings.rs @@ -0,0 +1,74 @@ +//! Tool options that persist: a tool keeps them when another tool is chosen and they are saved +//! with the preferences, as the reference app keeps each tool's options. Options that are the +//! state of an interaction (pins, a reference point, the text being edited, the files a place +//! cursor holds) aren't listed: they start afresh. +//! +//! Each row names the tools it covers, the store their values live in and the option keys. A row +//! with a shared store gives every tool it lists one set of values: the Liquify tools share their +//! Global Brush Dimensions, the Symbolism tools their brush. Otherwise each tool has its own store, +//! named after its id. + +/// (tools, the store they share (None: each tool's own), option keys). +type Row = (&'static [&'static str], Option<&'static str>, &'static [&'static str]); + +/// The Liquify tools. +pub const LIQUIFY: &[&str] = &["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"]; + +const SYMBOLISM: &[&str] = + &["symbolSprayer", "symbolShifter", "symbolScruncher", "symbolSizer", "symbolSpinner", "symbolStainer", "symbolScreener", "symbolStyler"]; + +const ROWS: &[Row] = &[ + // Global Brush Dimensions. + (LIQUIFY, Some("liquify"), &["width", "height", "angle", "intensity", "usePressure", "showBrush"]), + (LIQUIFY, None, &["detail", "simplify", "simplifyOn", "rate", "complexity", "horizontal", "vertical", "affectAnchors", "affectIn", "affectOut"]), + (SYMBOLISM, Some("symbolism"), &["diameter", "intensity", "density"]), + (&["mirrorCut"], None, &["axis", "keep"]), + (&["puppetWarp"], None, &["showMesh", "expand"]), + (&["arc"], None, &["closed"]), + (&["spiral"], None, &["decay", "segments", "clockwise"]), + (&["rectangularGrid"], None, &["rows", "columns"]), + (&["polarGrid"], None, &["concentric", "radial"]), + (&["pencil", "paintbrush"], None, &["fidelity", "fill", "editWithin", "closeWithin"]), + (&["smooth"], None, &["fidelity"]), + (&["blobBrush", "eraser"], None, &["size"]), + (&["polygon"], None, &["sides"]), + (&["star"], None, &["points"]), + (&["freeTransform"], None, &["constrain"]), + (&["artboard"], None, &["moveArt"]), +]; + +/// The stores `tool`'s persistent options live in, each with the option keys it holds. +pub fn stores(tool: &str) -> impl Iterator + '_ { + ROWS.iter().filter_map(move |(tools, shared, keys)| { + let id = tools.iter().find(|t| **t == tool)?; + Some((shared.unwrap_or(id), *keys)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_listed_option_is_one_the_tool_reports() { + for (tools, _, keys) in ROWS { + for id in *tools { + assert!(crate::tool_info(id).is_some(), "{id} is a tool"); + let opts = crate::create(id).options(); + for k in *keys { + assert!(opts.get(*k).is_some(), "{id} reports `{k}` in {opts}"); + } + } + } + } + + #[test] + fn liquify_tools_share_the_global_brush() { + let warp: Vec<_> = stores("warp").collect(); + let twirl: Vec<_> = stores("twirl").collect(); + assert_eq!(warp[0], twirl[0]); + assert_eq!(warp[0].0, "liquify"); + assert_eq!((warp[1].0, twirl[1].0), ("warp", "twirl")); + assert_eq!(stores("selection").count(), 0); + } +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index f8c77e005..e97ae4bd1 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -2,7 +2,8 @@ //! //! Type: click places point type, drag draws an area-type frame, clicking into existing text //! places the caret. Area Type / Type on a Path: click a path to turn it into a text frame or a -//! baseline (`text.createInPath`). +//! baseline (`text.createInPath`). Point type placed by a click and left empty is discarded when +//! editing ends (`text.discardEmpty`); frames keep their shape. //! //! While editing: caret movement by character / word (Cmd or Alt) / line (Up/Down) / line ends //! (Home/End; Cmd = whole text), Shift extends the selection, drag selects, double-click selects a @@ -39,9 +40,12 @@ struct Typing { #[derive(Default)] pub struct TypeTool { + vertical: bool, mode: Mode, /// Text object being edited. editing: Option, + /// The point type a click just placed (edited now): discarded if empty when editing ends. + fresh: Option, /// Caret and selection anchor (byte offsets into the plain text). caret: usize, anchor: usize, @@ -62,11 +66,11 @@ pub struct TypeTool { impl TypeTool { pub fn new(id: &str) -> Self { let mode = match id { - "areaType" => Mode::Area, - "typeOnPath" => Mode::OnPath, + "areaType" | "verticalAreaType" => Mode::Area, + "typeOnPath" | "verticalTypeOnPath" => Mode::OnPath, _ => Mode::Type, }; - Self { mode, ..Default::default() } + Self { mode, vertical: id.starts_with("vertical"), ..Default::default() } } fn text<'a>(cx: &'a ToolContext, id: NodeId) -> Option<&'a TextObject> { @@ -114,8 +118,16 @@ impl TypeTool { if self.typing.take().is_some() { vec![Action::Commit] } else { vec![] } } - fn finish(&mut self) -> Vec { - let out = self.commit(); + /// Stop editing. Point type a click placed that is still empty is discarded, with the steps + /// since its click (`text.discardEmpty`). + fn finish(&mut self, cx: &ToolContext) -> Vec { + let mut out = self.commit(); + if let Some(id) = self.fresh.take() + && self.editing == Some(id) + && Self::text(cx, id).is_some_and(|t| matches!(t.kind, TextKind::Point) && edit::runs_len(&t.runs) == 0) + { + out.push(Action::Exec("text.discardEmpty".into(), json!({"id": id.0}))); + } self.editing = None; self.selecting = false; self.goal_x = None; @@ -170,6 +182,7 @@ impl TypeTool { fn start_editing(&mut self, id: NodeId, caret: usize) { self.editing = Some(id); + self.fresh = None; self.caret = caret; self.anchor = caret; self.goal_x = None; @@ -206,7 +219,7 @@ impl TypeTool { } fn on_up(&mut self, cx: &ToolContext, start: Point) -> Vec { - let mut out = self.finish(); + let mut out = self.finish(cx); // Click into existing text: place the caret. if let Some(h) = hit_test(cx.doc, start, cx.hit_options()) && let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) @@ -224,16 +237,20 @@ impl TypeTool { let on_path = self.mode == Mode::OnPath; if let Some(pid) = Self::path_at(cx, start, !on_path) { let mode = if on_path { "onPath" } else { "area" }; - out.push(Action::Exec("text.createInPath".into(), json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y]}))); + out.push(Action::Exec( + "text.createInPath".into(), + json!({"path": pid.0, "mode": mode, "text": "", "at": [start.x, start.y], "vertical": self.vertical}), + )); out.push(Action::Notify("text.editNew".into())); return out; } } let area = drag.map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); - let params = match area { + let mut params = match area { Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}), None => json!({"x": start.x, "y": start.y, "text": ""}), }; + params["vertical"] = json!(self.vertical); out.push(Action::Exec("text.create".into(), params)); out.push(Action::Notify("text.editNew".into())); out @@ -282,10 +299,13 @@ fn common_affixes(a: &str, b: &str) -> (usize, usize) { impl Tool for TypeTool { fn id(&self) -> &'static str { - match self.mode { - Mode::Type => "type", - Mode::Area => "areaType", - Mode::OnPath => "typeOnPath", + match (self.mode, self.vertical) { + (Mode::Type, false) => "type", + (Mode::Type, true) => "verticalType", + (Mode::Area, false) => "areaType", + (Mode::Area, true) => "verticalAreaType", + (Mode::OnPath, false) => "typeOnPath", + (Mode::OnPath, true) => "verticalTypeOnPath", } } fn busy(&self) -> bool { @@ -385,12 +405,23 @@ impl Tool for TypeTool { let (a, b) = self.sel(); let word = mods.cmd || mods.alt; let lay = self.layout(&t); + let key = if lay.vertical { + match key { + ToolKey::Up => ToolKey::Left, + ToolKey::Down => ToolKey::Right, + ToolKey::Right => ToolKey::Up, + ToolKey::Left => ToolKey::Down, + other => other, + } + } else { + key + }; let vertical = matches!(key, ToolKey::Up | ToolKey::Down); if !vertical { self.goal_x = None; } match key { - ToolKey::Escape => self.finish(), + ToolKey::Escape => self.finish(cx), ToolKey::Enter => self.replace(cx, "\n", None), ToolKey::Tab => self.replace(cx, "\t", None), ToolKey::Backspace | ToolKey::Delete if a != b => self.replace(cx, "", None), @@ -425,7 +456,7 @@ impl Tool for TypeTool { ToolKey::Up | ToolKey::Down => { let d = if key == ToolKey::Up { -1 } else { 1 }; let from = if a != b && !mods.shift { if d < 0 { a } else { b } } else { self.caret }; - let x = self.goal_x.unwrap_or_else(|| vectorcraft_text::caret_position(&lay, from).0.x); + let x = self.goal_x.unwrap_or_else(|| lay.logical_point(vectorcraft_text::caret_position(&lay, from).0).x); let to = if mods.cmd { // Cmd+Up/Down: paragraph start / end. let p = edit::paragraph_at(&text, from); @@ -454,8 +485,21 @@ impl Tool for TypeTool { _ => vec![], } } - fn deactivate(&mut self, _cx: &ToolContext) -> Vec { - self.finish() + fn deactivate(&mut self, cx: &ToolContext) -> Vec { + self.finish(cx) + } + /// Editing ends when a command takes the text out of the selection (Deselect, another object + /// picked in the Layers panel) or removes it. + fn after_command(&mut self, cx: &ToolContext) -> Vec { + let Some(id) = self.editing else { return vec![] }; + let mut cur = Some(id).filter(|i| cx.doc.node(*i).is_some()); + while let Some(c) = cur { + if cx.selection.contains(c) { + return vec![]; + } + cur = cx.doc.parent_of(c); + } + self.finish(cx) } /// After `text.create` / `text.createInPath` the engine selects the new object; edit it. fn notify(&mut self, cx: &ToolContext, what: &str) { @@ -463,6 +507,7 @@ impl Tool for TypeTool { && let Some(id) = cx.selection.objects.first().copied() { self.start_editing(id, 0); + self.fresh = Self::text(cx, id).filter(|t| matches!(t.kind, TextKind::Point)).map(|_| id); } } /// `{editing, start, end, caret, anchor, typing}` — the Character panel styles `start..end`. diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index 16fabcd44..42747181c 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -218,3 +218,12 @@ fn area_and_path_tools_convert_clicked_paths() { let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, 150.0, 100.0)); assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}"); } + +#[test] +fn vertical_tools_preserve_their_ids_and_create_vertical_text() { + for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] { + let tool = TypeTool::new(id); + assert_eq!(tool.id(), id); + assert!(tool.vertical); + } +} diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index df1351215..869e3c6de 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -14,7 +14,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::target_overlays; use crate::bbox::rotate_for_drag; use crate::guides::snap_pick; -use crate::select::{matrix_json, selection_bounds}; +use crate::select::{matrix_json, selection_bounds, selection_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -90,7 +90,7 @@ impl TransformTool { } /// The effective reference point: the custom one if it belongs to the current selection, - /// otherwise the selection centre. + /// otherwise the centre of its bounding box (rotated with rotated objects). pub fn reference_point(&self, cx: &ToolContext) -> Option { if let Some((p, ids)) = &self.origin && *ids == cx.selection.objects @@ -98,7 +98,7 @@ impl TransformTool { { return Some(*p); } - selection_bounds(cx).map(|b| b.center()) + selection_box(cx).map(|b| b.center()) } /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index dc2b1d265..df08cfeee 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ vectorcraft-tools = { workspace = true } vectorcraft-engine = { workspace = true } vectorcraft-svg = { workspace = true } vectorcraft-effects = { workspace = true } +vectorcraft-plugins = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } @@ -31,3 +32,4 @@ log = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } +wat = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 86ea9d13a..ca328b03d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -92,6 +92,22 @@ fn temp_tool_id() -> egui::Id { egui::Id::new("canvas-temp-tool") } +/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch +/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none +/// until a pen reports it). A mouse presses fully (1). +fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { + let id = egui::Id::new("canvas-pressure"); + let force = ui.input(|i| { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) + }); + let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::(id)) }).unwrap_or(1.0); + ui.data_mut(|d| d.insert_temp(id, p)); + p +} + pub fn mods(m: egui::Modifiers, space: bool) -> Mods { Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space } } @@ -127,7 +143,11 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); - if app.session.active().is_none() { + // A document that opened, closed or became active since Home was chosen replaces it. + if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) { + app.ui.home = None; + } + if app.session.active().is_none() || app.ui.home.is_some() { home(app, ui, full); return; } @@ -374,6 +394,9 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::RemoveStop => C::NotAllowed, Cursor::Slice => C::Crosshair, Cursor::SliceSelect => C::Default, + Cursor::Width | Cursor::WidthAdd => C::Crosshair, + Cursor::WidthPoint => C::Move, + Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair, } } @@ -432,7 +455,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: app.select_tool("selection"); kind = Drag::TempSelect; } - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; dispatch(app, &ev, view); kind }; @@ -469,9 +492,16 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::Tool | Drag::TempSelect => { if pointer.delta() != egui::Vec2::ZERO { - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } + // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts + // a quarter second at most. + if app.session.tool_wants_ticks() { + let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25)); + let r = app.session.tool_tick(dt, view); + apply_requests(app, r); + } } } } else { @@ -495,7 +525,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::Tool | Drag::TempSelect | Drag::Art { .. } => { if !matches!(d, Drag::Art { .. }) { - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } if matches!(d, Drag::TempSelect | Drag::Art { temp: true }) @@ -558,6 +588,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result crate::dialogs::open_tool_dialog(app, &kind, p), vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t), + vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), } } } @@ -754,18 +785,36 @@ fn c32(rgb: [u8; 3]) -> Color32 { /// Outline of a node for highlighting (paths, compound children, text/image bounds). fn node_outline(n: &Node) -> BezPath { let mut bp = BezPath::new(); - n.walk(&mut |c| match &c.kind { + walk_drawn(n, &mut |c| match &c.kind { NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { if let Some(b) = c.geometric_bounds() { bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); } } + // An envelope shows its mesh (or top object), not its content. + NodeKind::Envelope { .. } => { + if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) { + bp.extend(lines.to_bezpath()); + } + } _ => {} }); bp } +/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the +/// envelope shows its mesh instead). +fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { + f(n); + if matches!(n.kind, NodeKind::Envelope { .. }) { + return; + } + for c in n.children().into_iter().flatten() { + walk_drawn(c, f); + } +} + /// The topmost editable object under document point `p` at `zoom` (3 px tolerance). fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false }; @@ -992,7 +1041,13 @@ fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { /// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard. const PRINT_TILING: Color32 = Color32::from_gray(96); -fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { +fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { + // The Selection tool's bounding box (rotated with the objects after a rotation). + let show_box = app.session.tool_id() == "selection" + && app.ui.view.bounding_box + && app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty()); + let bbox = if show_box { app.selection_box() } else { None }; + let app = &*app; let Some(st) = app.session.active() else { return }; let tool = app.session.tool_id(); let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature"); @@ -1002,8 +1057,14 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { let partial = st.selection.partial(*id); // Path outlines. stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color)); - // Anchors (and handles for selected anchors in direct mode). - n.walk(&mut |c| { + // Anchors (and handles for selected anchors in direct mode); a mesh envelope's points. + walk_drawn(n, &mut |c| { + if let Some((_, Some(grid))) = vectorcraft_doc::live::envelope_overlay(c) { + for q in &grid.points { + anchor_square(p, xf.to_screen(q.p), color, false, if direct { 5.0 } else { 4.0 }); + } + return; + } let NodeKind::Path { path, .. } = &c.kind else { return }; for (si, ai, a) in path.anchors() { let sel = match partial { @@ -1044,6 +1105,20 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { p.circle_filled(o, 2.5, color); } } + // The spine of each selected blend (or of the blend a selected key object belongs to). + let mut spines = vec![]; + for id in &st.selection.objects { + let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. })); + let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue }; + spines.push(b); + let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue }; + let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue }; + let color = c32(st.doc.layer_color(b)); + stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color)); + for (_, _, a) in path.anchors() { + anchor_square(p, xf.to_screen(a.p), color, false, if direct { 5.0 } else { 4.0 }); + } + } // Live Corners widgets (Selection / Direct Selection on a single live rectangle). if matches!(tool, "selection" | "directSelection") && app.ui.view.corner_widgets @@ -1057,12 +1132,11 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { } } // Bounding box with handles (Selection tool). - if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() { - let Some(b) = app.session.transform_bounds(&st.selection.objects) else { return }; + if let Some(b) = bbox { let color = c32(st.doc.layer_color(st.selection.objects[0])); - p.add(Shape::closed_line(xf.quad(b), Stroke::new(1.0, color))); + p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color))); for h in vectorcraft_tools::bbox::Handle::ALL { - let c = xf.to_screen(h.pos(b)); + let c = xf.to_screen(b.to_doc() * h.pos(b.rect)); let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0)); p.rect_filled(hr, 0.0, Color32::WHITE); p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside); @@ -1118,6 +1192,10 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) { p.rect_filled(r, CornerRadius::same(3), t.measure_bg); p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE); } + Overlay::GridLine { a, b, color } => { + let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); + p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c)); + } Overlay::Swatch { p: pt, color, selected } => { // A white disc under the colour shows its opacity; a dark rim keeps it readable on // any art, and the accent ring marks the selected stop. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 42fceecb5..680ce95a7 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,5 +1,7 @@ //! Window chrome: application bar (menus), Control bar, document tabs, status bar. +use std::borrow::Cow; + use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; use vectorcraft_doc::NodeKind; @@ -27,8 +29,9 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); crate::brand::paint_mark(ui, r); ui.add_space(4.0); - if widgets::icon_button(ui, "house", "Home", false, 24.0).clicked() { - crate::dialogs::open_new_document(app); + let on_home = app.ui.home.is_some() || app.session.active().is_none(); + if widgets::icon_button(ui, "house", "Home", on_home, 24.0).clicked() { + app.run("app.home", json!({})).ok(); } ui.add_space(2.0); let menus_end = if app.native_menu { @@ -134,6 +137,8 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); crate::place::control_bar_details(app, ui); + crate::toolbar::control_bar_options(app, ui); + crate::dialogs::envelope::control_bar(app, ui); let shown_stroke = crate::panels::current_stroke(app); let mixed = crate::panels::stroke_mixed(app, ui.ctx()); let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); @@ -197,14 +202,18 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.separator(); // Transform fields. - let b = app.session.active().and_then(|s| app.session.transform_bounds(&s.selection.objects)); - if let Some(b) = b { - for (k, lbl, v) in - [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] - { + // The bounding box, rotated with rotated objects: its centre and its own sides. + if let Some(b) = app.selection_box() { + let c = b.center(); + let link = app.session.prefs.constrain_proportions; + for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", b.rect.width()), ("height", "H:", b.rect.height())] { + // The W/H link sits between W and H. + if k == "height" { + crate::panels::transform::constrain_link(app, ui); + } ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim)); if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 80.0) { - app.run("object.setBounds", json!({k: nv, "reference": 4})).ok(); + app.run("object.setBounds", json!({k: nv, "reference": 4, "proportional": link})).ok(); } } } @@ -237,7 +246,8 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { for (i, d) in app.session.documents().iter().enumerate() { let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0); let title = tab_title(d, zoom, app.ui.view.outline); - let is_active = Some(i) == active; + // On the Home screen no tab is the current one. + let is_active = Some(i) == active && app.ui.home.is_none(); let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim }); let w = galley.size().x + 50.0; let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0)); @@ -265,6 +275,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { app.status(e); } } else if let Some(i) = activate { + app.ui.home = None; app.session.set_active(i); } // Isolation mode breadcrumb bar. @@ -397,15 +408,16 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -/// Contextual hint for the active tool: segments of (text, bold). -fn hint_for(tool: &str) -> &'static [(&'static str, bool)] { - match tool { +/// Contextual hint for the active tool: segments of (text, bold). Keys in bold segments are +/// written as shortcuts are ("Alt+Click"); [`hint_segments`] names them for the platform. +fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { + Some(match tool { "selection" => &[ ("Click", true), (" the object to select | ", false), ("Shift+Click", true), (" to select multiple objects | ", false), - ("Option+Drag", true), + ("Alt+Drag", true), (" the object to duplicate", false), ], "directSelection" => &[ @@ -447,7 +459,7 @@ fn hint_for(tool: &str) -> &'static [(&'static str, bool)] { (" to draw | ", false), ("Shift+Drag", true), (" to constrain proportions | ", false), - ("Option+Drag", true), + ("Alt+Drag", true), (" from center | ", false), ("Click", true), (" for exact size", false), @@ -457,14 +469,14 @@ fn hint_for(tool: &str) -> &'static [(&'static str, bool)] { (" to set the reference point | ", false), ("Drag", true), (" to transform | ", false), - ("Option+Click", true), + ("Alt+Click", true), (" for exact values", false), ], "hand" => &[("Drag", true), (" to pan the view", false)], "zoom" => &[ ("Click", true), (" to zoom in | ", false), - ("Option+Click", true), + ("Alt+Click", true), (" to zoom out | ", false), ("Drag", true), (" to zoom into an area", false), @@ -479,8 +491,91 @@ fn hint_for(tool: &str) -> &'static [(&'static str, bool)] { ], "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], "artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)], - _ => &[("Press ", false), ("Cmd+Shift+/", true), (" to search every command", false)], - } + "width" => &[ + ("Drag", true), + (" a stroke to add a width point | ", false), + ("Alt+Drag", true), + (" to change one side | ", false), + ("Shift+Click", true), + (" to select more points | ", false), + ("Double-click", true), + (" a point to edit it | ", false), + ("Delete", true), + (" to remove points", false), + ], + "puppetWarp" => &[ + ("Click", true), + (" the art to add a pin | ", false), + ("Shift+Click", true), + (" to select more pins | ", false), + ("Drag", true), + (" a pin to warp | ", false), + ("Alt+Drag", true), + (" near a selected pin to rotate | ", false), + ("Delete", true), + (" to remove pins", false), + ], + "warp" => &[ + ("Drag", true), + (" across paths to push them along | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "twirl" | "pucker" | "bloat" => &[ + ("Click or drag", true), + (" over paths, and hold still to keep going | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "scallop" | "crystallize" | "wrinkle" => &[ + ("Click or drag", true), + (" over paths to roughen their outlines | ", false), + ("Alt+Drag", true), + (" to size the brush (", false), + ("Shift", true), + (" keeps its proportions)", false), + ], + "perspectiveSelection" => &[ + ("Drag", true), + (" to move in perspective | ", false), + ("Alt+Drag", true), + (" to copy | ", false), + ("5", true), + (" while dragging to move perpendicular to the plane | ", false), + ("Drag a handle", true), + (" to scale in perspective", false), + ], + "blend" => &[ + ("Click", true), + (" an object, then another to blend them | ", false), + ("Click an anchor point", true), + (" to blend from it | ", false), + ("Alt+Click", true), + (" to set spacing and orientation", false), + ], + _ => return None, + }) +} + +/// The hint bar's segments for `tool`, with keys named as the menus name them (Alt and Ctrl on +/// Windows and Linux, ⌥ and ⌘ on macOS). Tools without a hint point to Search Commands. +fn hint_segments(tool: &str) -> Vec<(Cow<'static, str>, bool)> { + let search; + let segments = match hint_for(tool) { + Some(s) => s, + None => { + search = match menus::shortcut_of("help.commandPalette") { + Some(sc) => [("Press ", false), (sc, true), (" to search every command", false)], + None => [("Use ", false), ("Help › Search Commands…", true), (" to search every command", false)], + }; + &search + } + }; + segments.iter().map(|&(text, bold)| (if bold { Cow::Owned(menus::pretty_shortcut(text)) } else { Cow::Borrowed(text) }, bold)).collect() } pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -495,9 +590,9 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text); ui.add_space(6.0); let mut job = egui::text::LayoutJob::default(); - for (txt, bold) in hint_for(app.session.tool_id()) { - let font = if *bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; - job.append(txt, 0.0, egui::TextFormat { font_id: font, color: if *bold { t.text_strong } else { t.text }, ..Default::default() }); + for (txt, bold) in hint_segments(app.session.tool_id()) { + let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; + job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } ui.label(job); }); @@ -509,6 +604,27 @@ mod tests { use serde_json::json; use vectorcraft_engine::Session; + #[test] + fn hints_name_keys_as_the_menus_do() { + let text = |tool: &str| super::hint_segments(tool).into_iter().map(|(s, _)| s).collect::(); + for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id) { + let hint = text(tool); + assert!(!hint.contains("Option"), "{tool}: {hint}"); + assert!(cfg!(target_os = "macos") || !hint.contains("Cmd"), "{tool}: {hint}"); + } + let pretty = crate::menus::pretty_shortcut; + assert!(text("selection").contains(&pretty("Alt+Drag"))); + assert!(text("rectangle").contains(&pretty("Alt+Drag"))); + assert!(text("zoom").contains(&pretty("Alt+Click"))); + assert!(text("rotate").contains(&pretty("Alt+Click"))); + assert!(text("eyedropper").contains(&pretty("Alt+Click"))); + assert!(text("perspectiveSelection").contains(&pretty("Alt+Drag")) && text("perspectiveSelection").contains("perpendicular")); + assert!(text("paintbrush").contains(&pretty("Cmd+Shift+/")), "the Search Commands shortcut"); + if !cfg!(target_os = "macos") { + assert!(text("zoom").contains("Alt+Click") && text("paintbrush").contains("Ctrl+Shift+/")); + } + } + #[test] fn the_tab_title_names_the_opacity_mask_while_it_is_edited() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a2105e721..ddb46dbbe 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -144,7 +144,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques vectorcraft_geom::Point::new(x, y) }; let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); - crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: 1.0 }, view); + crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view); + let hold = PointerEvent::json_hold(e); + if hold > 0.0 { + let r = app.session.tool_tick(hold, view); + crate::canvas::apply_requests(app, r); + } } ctx.request_repaint(); wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "tool": app.session.tool_id()}))) @@ -160,10 +165,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques command: b("cmd"), }; app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); - app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); + // Typed text comes with the press, so it shares the key's frame and modifiers. if let Some(t) = s("text") { app.synthetic.push(egui::Event::Text(t.to_string())); } + app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }); ctx.request_repaint(); ok(Value::Null) } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 7027e1309..7ac1903b2 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -133,6 +133,33 @@ fn slice(p: &Painter, o: Pos2) { line(p, b + vec2(8.0, 6.0), b + vec2(12.0, 12.0)); } +/// The Width tool: the hollow arrow with a stroke that swells in the middle (a width point), +/// plus a badge: `+` over a stroke (a drag adds a point), a bar across the swell over a width point +/// (a drag moves or widens it). +fn width(p: &Painter, o: Pos2, badge: &str) { + arrow(p, o, true); + let b = o + vec2(11.0, 18.0); + let top: Vec = (0..=8) + .map(|i| { + let t = i as f32 / 8.0; + b + vec2(12.0 * t, -3.5 * (std::f32::consts::PI * t).sin()) + }) + .collect(); + let mut lens = top.clone(); + lens.extend(top.iter().rev().map(|q| pos2(q.x, 2.0 * b.y - q.y))); + p.add(Shape::closed_line(lens.clone(), Stroke::new(3.0, HALO))); + p.add(Shape::closed_line(lens, Stroke::new(1.2, INK))); + match badge { + "+" => { + let c = b + vec2(16.0, -6.0); + line(p, c - vec2(3.0, 0.0), c + vec2(3.0, 0.0)); + line(p, c - vec2(0.0, 3.0), c + vec2(0.0, 3.0)); + } + "point" => line(p, b + vec2(6.0, -6.0), b + vec2(6.0, 6.0)), + _ => {} + } +} + fn ibeam(p: &Painter, o: Pos2) { line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0)); line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0)); @@ -140,6 +167,21 @@ fn ibeam(p: &Painter, o: Pos2) { line(p, o + vec2(-2.0, 3.0), o + vec2(2.0, 3.0)); } +/// The Blend tool: a crosshair with a square below right of the hotspot, hollow away from art, +/// filled over an object; over an anchor point a ringed dot (the blend starts there). +fn blend(p: &Painter, o: Pos2, badge: Cursor) { + crosshair(p, o); + let b = o + vec2(9.0, 9.0); + if badge == Cursor::BlendAnchor { + p.circle_stroke(b + vec2(3.5, 3.5), 3.5, Stroke::new(3.0, HALO)); + p.circle_stroke(b + vec2(3.5, 3.5), 3.5, Stroke::new(1.2, INK)); + p.circle_filled(b + vec2(3.5, 3.5), 1.4, INK); + return; + } + let fill = if badge == Cursor::BlendObject { INK } else { HALO }; + poly(p, vec![b, b + vec2(7.0, 0.0), b + vec2(7.0, 7.0), b + vec2(0.0, 7.0)], fill, INK); +} + /// Paint cursor `c` at `p` on the given (foreground) painter. Returns false for cursors that should /// stay system cursors (hand, zoom, busy states). pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { @@ -170,6 +212,10 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool { arrow(painter, p, false); slice_badge(painter, p + vec2(11.0, 14.0)); } + Cursor::Width => width(painter, p, ""), + Cursor::WidthAdd => width(painter, p, "+"), + Cursor::WidthPoint => width(painter, p, "point"), + Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => blend(painter, p, c), _ => return false, } let _ = pos2; diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs new file mode 100644 index 000000000..83af48e2e --- /dev/null +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -0,0 +1,213 @@ +//! Object → Blend → Blend Options (also the Blend tool's double-click, Alt-click and toolbar +//! button): Spacing (Smooth Color, Specified Steps or Specified Distance) with its value and +//! Orientation (Align to Page or Align to Path), opened on the selected blend's options and +//! previewed live on it; OK keeps the preview as one undo step (`object.blend.options`). With no +//! blend selected it sets the options new blends start with. +//! +//! Fields: `spacing` (`smooth` | `steps` | `distance`), `steps` (1..1000), `distance` (pt), +//! `orientation` (`page` | `path`), `preview`, and `__target` (`blend` or `defaults`, from +//! `object.blend.info`). + +use egui::{CornerRadius, Sense, Stroke, Vec2, pos2, vec2}; +use serde_json::{Value, json}; + +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Blend Options. +pub const KIND: &str = "blendOptions"; + +const CMD: &str = "object.blend.options"; + +/// Width of the label column. +const LABEL_W: f32 = 82.0; + +/// Spacing modes: (value, label). +const SPACING: [(&str, &str); 3] = [("smooth", "Smooth Color"), ("steps", "Specified Steps"), ("distance", "Specified Distance")]; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Blend Options".into(), body, confirm, preview: true, min_width: 300.0, ..DialogSpec::FORM }; + +/// Open Blend Options on the selected blend's options (else those new blends start with). +pub fn open(app: &mut VectorcraftApp) -> Result { + let mut fields = app.session.execute("object.blend.info", &json!({})).map_err(|e| e.to_string())?; + let target = fields["target"].clone(); + let Some(o) = fields.as_object_mut() else { return Err("no blend options".into()) }; + o.retain(|k, _| matches!(k.as_str(), "spacing" | "steps" | "distance" | "orientation")); + o.insert("__target".into(), target); + o.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +/// Does the dialog edit a blend (rather than the options new blends start with)? +fn on_blend(d: &Dialog) -> bool { + d.str("__target") == "blend" +} + +/// `object.blend.options` parameters from the fields. +fn params(d: &Dialog) -> Value { + let mut p = json!({"spacing": d.str("spacing"), "orientation": d.str("orientation")}); + match d.str("spacing").as_str() { + "steps" => p["steps"] = json!(d.f64("steps", 5.0).round().clamp(1.0, 1000.0)), + "distance" => p["distance"] = json!(d.f64("distance", 10.0)), + _ => {} + } + p +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + let mode = d.str("spacing"); + let shown = SPACING.iter().find(|(v, _)| *v == mode).map_or(SPACING[0].1, |(_, l)| l); + let labels = SPACING.map(|(_, l)| l); + widgets::label_row(ui, "Spacing:", LABEL_W, |ui| { + if let Some((value, _)) = widgets::dropdown(ui, "blend-spacing", shown, &labels, 150.0).and_then(|i| SPACING.get(i)) { + d.fields.insert("spacing".into(), json!(value)); + } + ui.add_space(6.0); + match mode.as_str() { + "distance" => { + form::length(ui, d, "distance", unit, 72.0); + } + // Smooth Color picks the count itself: the field shows the steps, disabled. + m => { + let steps = d.f64("steps", 5.0); + ui.add_enabled_ui(m == "steps", |ui| { + if let Some(n) = widgets::plain_field(ui, "blend-steps", steps, "", 0, 72.0) { + d.fields.insert("steps".into(), json!(n.round().clamp(1.0, 1000.0))); + } + }); + } + } + }); + ui.add_space(6.0); + widgets::label_row(ui, "Orientation:", LABEL_W, |ui| { + let path = d.str("orientation") == "path"; + for (value, along, tip) in [("page", false, "Align to Page"), ("path", true, "Align to Path")] { + if orientation_button(ui, along, path == along, tip) { + d.fields.insert("orientation".into(), json!(value)); + } + } + }); + if on_blend(d) { + let p = params(d); + form::preview(app, ui, d, "Blend Options", CMD, p); + } + false +} + +/// An orientation toggle drawn in code: three bars on a curve, upright (Align to Page) or square +/// to the curve (Align to Path), in a pressed well when `selected`. Returns clicked. +fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, tip: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(28.0), Sense::click()); + let bg = if selected { + t.tool_active + } else if resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, CornerRadius::same(3), bg); + let color = if selected { t.text } else { t.icon }; + // An arch through the button: y = c + a·x² with x in -1..1. + let c = rect.center() + vec2(0.0, -2.0); + let at = |x: f32| pos2(c.x + 9.0 * x, c.y + 6.0 * x * x); + let curve: Vec = (0..=12).map(|i| at(-1.0 + i as f32 / 6.0)).collect(); + ui.painter().add(egui::Shape::line(curve, Stroke::new(1.0, color))); + for x in [-0.8f32, 0.0, 0.8] { + let p = at(x); + // Normal to the arch at x (pointing up), or straight up. + let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) }; + ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color)); + } + resp.on_hover_text(tip).clicked() +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if on_blend(d) { form::commit_preview(app, CMD, params(d)) } else { run_and_close(app, CMD, params(d)) } +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::NodeKind; + use vectorcraft_doc::live::{BlendOrientation, BlendSpacing}; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn spec(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::BlendSpec { + match &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Blend { spec, .. } => spec.clone(), + _ => panic!("not a blend"), + } + } + + #[test] + fn opens_on_the_blend_previews_and_keeps_smooth_color() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let id = app.run("object.blend.make", json!({"smooth": true})).unwrap()["id"].as_u64().unwrap(); + // The menu item opens the dialog on the blend's own options. + crate::menus::invoke(&mut app, CMD, json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("spacing"), "smooth"); + // OK without changes keeps Smooth Color (it used to become 5 steps). + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(spec(&app, id).spacing, BlendSpacing::SmoothColor); + // Specified Steps and Align to Path preview on the blend; Cancel rolls them back. + app.run("ui.blendOptions", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("spacing".into(), json!("steps")); + d.fields.insert("steps".into(), json!(3)); + d.fields.insert("orientation".into(), json!("path")); + frame(&mut app); + assert_eq!(spec(&app, id).spacing, BlendSpacing::Steps(3), "previewed"); + crate::dialogs::cancel(&mut app); + assert_eq!(spec(&app, id).spacing, BlendSpacing::SmoothColor); + // OK keeps them as one undo step. + app.run("ui.blendOptions", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("spacing".into(), json!("distance")); + d.fields.insert("distance".into(), json!(25)); + frame(&mut app); + let depth = app.session.doc().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(spec(&app, id).spacing, BlendSpacing::Distance(25.0)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), depth + 1); + } + + #[test] + fn with_nothing_selected_it_sets_what_new_blends_start_with() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("ui.blendOptions", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("__target"), "defaults"); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("spacing".into(), json!("steps")); + d.fields.insert("steps".into(), json!(7)); + d.fields.insert("orientation".into(), json!("path")); + frame(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("shape.rectangle", json!({"x": 200, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("select.all", json!({})).unwrap(); + let id = app.run("object.blend.make", json!({})).unwrap()["id"].as_u64().unwrap(); + let s = spec(&app, id); + assert_eq!((s.spacing, s.orientation), (BlendSpacing::Steps(7), BlendOrientation::AlignToPath)); + } +} diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 42e5c3760..83dd3a627 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -36,5 +36,12 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let cmd = d.str("__command"); let params = form::params(d); app.ui.dialog = None; + // A tool's click-to-size shape (Flare) goes on the active perspective plane while the grid shows. + let at = |x: &str, y: &str| Some(vectorcraft_geom::Point::new(params.get(x)?.as_f64()?, params.get(y)?.as_f64()?)); + if let Some((c, p)) = + at("cx", "cy").or_else(|| at("x", "y")).and_then(|pt| vectorcraft_engine::perspective_click(&app.session, &cmd, ¶ms, pt)) + { + return app.run(&c, p); + } app.run(&cmd, params) } diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index c7b3a88d9..071370c25 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -9,10 +9,13 @@ use serde_json::{Map, Value, json}; +use vectorcraft_color::Color; + use super::{DialogSpec, form}; -use crate::VectorcraftApp; +use crate::panels::c32; use crate::state::Dialog; use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, ..DialogSpec::FORM }; @@ -112,7 +115,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (id, relative) = (d.str("__effect"), d.bool("relative")); - let changed = form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()); + // Plug-in effects get fields from their parameter schema; colour adjustments their sliders. + let changed = match vectorcraft_plugins::effect::installed(&id) { + Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params), + None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d), + None => form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(&id, k, relative), app.session.general_unit()), + }; ui.add_space(6.0); let mut pv = d.bool("preview"); let pv_changed = ui.checkbox(&mut pv, "Preview").changed(); @@ -128,6 +136,168 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } +/// A slider of a colour adjustment dialog: (effect, field, label, range, rail colour at 0..1). +type Slider = (&'static str, &'static str, &'static str, (f64, f64), fn(f32) -> egui::Color32); + +fn rgb(r: f32, g: f32, b: f32) -> egui::Color32 { + c32(&Color::rgb(r, g, b)) +} + +fn grey_rail(x: f32) -> egui::Color32 { + rgb(x, x, x) +} + +fn hue_rail(x: f32) -> egui::Color32 { + c32(&Color::from_hsb(((x - 0.5) * 360.0).rem_euclid(360.0), 0.8, 0.9)) +} + +fn saturation_rail(x: f32) -> egui::Color32 { + c32(&Color::from_hsb(0.0, x, 0.9)) +} + +fn temperature_rail(x: f32) -> egui::Color32 { + rgb(0.35 + 0.65 * x, 0.55 + 0.05 * x, 1.0 - 0.8 * x) +} + +fn tint_rail(x: f32) -> egui::Color32 { + rgb(0.3 + 0.6 * x, 0.85 - 0.55 * x, 0.3 + 0.6 * x) +} + +const SLIDERS: [Slider; 12] = [ + ("adjust.brightnessContrast", "brightness", "Brightness", (-100.0, 100.0), grey_rail), + ("adjust.brightnessContrast", "contrast", "Contrast", (-100.0, 100.0), grey_rail), + ("adjust.hueSaturation", "hue", "Hue", (-180.0, 180.0), hue_rail), + ("adjust.hueSaturation", "saturation", "Saturation", (-100.0, 100.0), saturation_rail), + ("adjust.hueSaturation", "lightness", "Lightness", (-100.0, 100.0), grey_rail), + ("adjust.levels", "inputBlack", "Input Black", (0.0, 255.0), grey_rail), + ("adjust.levels", "inputWhite", "Input White", (0.0, 255.0), grey_rail), + ("adjust.levels", "outputBlack", "Output Black", (0.0, 255.0), grey_rail), + ("adjust.levels", "outputWhite", "Output White", (0.0, 255.0), grey_rail), + ("adjust.shiftToColor", "amount", "Amount", (0.0, 100.0), grey_rail), + ("adjust.temperatureTint", "temperature", "Temperature", (-100.0, 100.0), temperature_rail), + ("adjust.temperatureTint", "tint", "Tint", (-100.0, 100.0), tint_rail), +]; + +/// The colour adjustment dialogs (Effect → Color Adjustments): a slider per amount, then the +/// channel as a menu and the other values (Curves' points, Levels' gamma, the target colour) in +/// the same label column, and the options. Returns whether the values differ from those last +/// previewed. +fn adjust_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { + const LABEL_W: f32 = 84.0; + let id = d.str("__effect"); + for (_, key, label, (min, max), rail) in SLIDERS.iter().filter(|s| s.0 == id) { + form::slider_w(ui, d, (key, label, LABEL_W), *min..=*max, "", rail); + } + if d.fields.contains_key("channel") { + form::choice(ui, d, "channel", "Channel", (LABEL_W, 120.0), &[("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]); + } + if d.fields.contains_key("points") { + ui.horizontal(|ui| { + ui.add_space(LABEL_W + ui.spacing().item_spacing.x); + curve_graph(ui, d); + }); + } + for (key, label, width) in [("points", "Points", 220.0), ("color", "Color", 120.0)] { + if d.fields.contains_key(key) { + widgets::label_row(ui, label, LABEL_W, |ui| { + form::text(ui, d, key, width); + }); + } + } + if d.fields.contains_key("gamma") { + widgets::label_row(ui, "Gamma", LABEL_W, |ui| { + let mut g = d.f64("gamma", 1.0); + if ui.add(egui::DragValue::new(&mut g).speed(0.01).range(0.1..=10.0).max_decimals(2)).changed() { + d.fields.insert("gamma".into(), json!(g)); + } + }); + } + for (key, label) in [("colorize", "Colorize"), ("preserveLightness", "Preserve Lightness")] { + if d.fields.contains_key(key) { + form::check(ui, d, key, label); + } + } + let params = form::params(d); + let changed = d.fields.get(form::PREVIEWED) != Some(¶ms); + d.fields.insert(form::PREVIEWED.into(), params); + changed +} + +/// Curves' graph of its `points` (input across, output up, 0..255): drag a point to move it +/// between its neighbours, press on the graph away from the points to add one there, drag one +/// off the graph to remove it (two always stay). +fn curve_graph(ui: &mut egui::Ui, d: &mut Dialog) { + const SIZE: f32 = 200.0; + /// How near (screen points) a press must be to take a point. + const GRAB: f32 = 8.0; + let t = Tokens::get(ui.ctx()); + let mut pts = vectorcraft_effects::curve_points(d.fields.get("points")); + let (rect, resp) = ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::click_and_drag()); + let screen = |(x, y): (f32, f32)| egui::pos2(rect.left() + x * SIZE, rect.bottom() - y * SIZE); + let unit = |p: egui::Pos2| (((p.x - rect.left()) / SIZE).clamp(0.0, 1.0), ((rect.bottom() - p.y) / SIZE).clamp(0.0, 1.0)); + let held = ui.id().with("curve-point"); + let mut grabbed: Option = ui.data(|m| m.get_temp(held)).flatten(); + let mut changed = false; + if let Some(p) = resp.interact_pointer_pos() + && resp.drag_started() + { + let near = + pts.iter().enumerate().map(|(i, q)| (i, screen(*q).distance(p))).filter(|(_, dist)| *dist <= GRAB).min_by(|a, b| a.1.total_cmp(&b.1)); + grabbed = match near { + Some((i, _)) => Some(i), + None => { + let q = unit(p); + let i = pts.partition_point(|a| a.0 < q.0); + pts.insert(i, q); + changed = true; + Some(i) + } + }; + } + if let (Some(i), Some(p)) = (grabbed, resp.interact_pointer_pos()) + && resp.dragged() + { + if !rect.expand(24.0).contains(p) && pts.len() > 2 { + pts.remove(i.min(pts.len() - 1)); + grabbed = None; + } else { + let step = 1.0 / 255.0; + let lo = if i == 0 { 0.0 } else { pts.get(i - 1).map_or(0.0, |a| a.0 + step) }; + let hi = pts.get(i + 1).map_or(1.0, |a| a.0 - step); + let (x, y) = unit(p); + if let Some(q) = pts.get_mut(i) { + *q = (x.clamp(lo, hi.max(lo)), y); + } + } + changed = true; + } + if resp.drag_stopped() { + grabbed = None; + } + ui.data_mut(|m| m.insert_temp(held, grabbed)); + // The graph: quarter grid, the identity line, the curve and its points. + let painter = ui.painter_at(rect.expand(4.0)); + painter.rect_filled(rect, 2.0, t.input); + let faint = egui::Stroke::new(1.0, t.input_border); + for k in 1..4 { + let f = k as f32 / 4.0; + painter.line_segment([screen((f, 0.0)), screen((f, 1.0))], faint); + painter.line_segment([screen((0.0, f)), screen((1.0, f))], faint); + } + painter.line_segment([screen((0.0, 0.0)), screen((1.0, 1.0))], faint); + let curve: Vec = (0..=64).map(|k| k as f32 / 64.0).map(|x| screen((x, vectorcraft_effects::curve_at(&pts, x)))).collect(); + painter.add(egui::Shape::line(curve, egui::Stroke::new(1.5, t.text))); + for (i, q) in pts.iter().enumerate() { + let fill = if Some(i) == grabbed { t.accent } else { t.panel }; + painter.circle(screen(*q), 4.0, fill, egui::Stroke::new(1.5, t.text)); + } + painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + if changed { + let text: Vec = pts.iter().map(|(x, y)| format!("{},{}", (x * 255.0).round(), (y * 255.0).round())).collect(); + d.fields.insert("points".into(), json!(text.join(" "))); + } +} + /// What OK runs: `effect.setParams` on the edited effect (`__index`), else `effect.apply` of the /// dialog's effect with its values on the target item. fn command(d: &Dialog) -> (&'static str, Value) { diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs new file mode 100644 index 000000000..06516902f --- /dev/null +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -0,0 +1,491 @@ +//! Object → Envelope Distort's dialogs, previewed live on the canvas; OK keeps the preview as one +//! undo step: +//! +//! - **Warp Options** (`envelopeWarp`): Make with Warp, or Reset with Warp for a selected envelope +//! (`reset`). Fields `style` (one of the 15 warp styles), `horizontal` (false: Vertical), +//! `bend`, `h` and `v` (%, -100..100). +//! - **Envelope Mesh** (`envelopeMesh`): Make with Mesh, or Reset with Mesh (`reset`, with +//! `maintainShape`). Fields `rows`, `cols` (1..50). +//! - **Envelope Options** (`envelopeOptions`): the selected envelopes' options, or with none +//! selected the options new envelopes get (no preview then). Fields `antiAlias`, +//! `preserveShape` (`clippingMask` | `transparency`), `fidelity` (0..100), +//! `distortAppearance`, `distortLinearGradients`, `distortPatternFills`. +//! +//! The Control bar's envelope controls ([`control_bar`]) live here too. +//! +//! Every dialog also has `preview`. They open with the selected envelope's values +//! (`object.envelope.info`); the menu items and shortcuts of Make with Warp and Make with Mesh +//! open the Reset variant while an envelope is selected, as the reference app's do. + +use serde_json::{Value, json}; +use vectorcraft_doc::{EnvelopeKind, NodeKind}; +use vectorcraft_render::effects::WARP_STYLES; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kinds. +pub const WARP: &str = "envelopeWarp"; +pub const MESH: &str = "envelopeMesh"; +pub const OPTIONS: &str = "envelopeOptions"; + +const MAKE_WARP: &str = "object.envelope.makeWithWarp"; +const RESET_WARP: &str = "object.envelope.resetWithWarp"; +const MAKE_MESH: &str = "object.envelope.makeWithMesh"; +const RESET_MESH: &str = "object.envelope.resetWithMesh"; +const OPTIONS_CMD: &str = "object.envelope.options"; + +/// Width of the label column. +const LABEL_W: f32 = 84.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, preview: true, min_width: 340.0, ..DialogSpec::FORM }; + +fn heading(d: &Dialog) -> String { + match d.kind.as_str() { + WARP => "Warp Options", + MESH if d.bool("reset") => "Reset Envelope Mesh", + MESH => "Envelope Mesh", + _ => "Envelope Options", + } + .into() +} + +/// Does the menu item of command `id` open one of these dialogs? +pub fn opens(id: &str) -> bool { + matches!(id, MAKE_WARP | RESET_WARP | MAKE_MESH | RESET_MESH | OPTIONS_CMD) +} + +/// Open the dialog command `id`'s menu item opens, with the selected envelope's values. +pub fn open(app: &mut VectorcraftApp, id: &str) -> Result { + let info = app.session.execute("object.envelope.info", &json!({})).map_err(|e| e.to_string())?; + let envelope = !info["id"].is_null(); + if matches!(id, RESET_WARP | RESET_MESH) && !envelope { + return Err("select an envelope".into()); + } + let num = |k: &str, d: f64| info[k].as_f64().unwrap_or(d); + let (kind, fields) = match id { + MAKE_WARP | RESET_WARP => { + let warp = info["type"] == "warp"; + let own = |k: &str, d: f64| if warp { num(k, d) } else { d }; + let style = if warp { info["style"].clone() } else { json!("arc") }; + let horizontal = !warp || info["horizontal"] != false; + ( + WARP, + json!({"reset": envelope, "style": style, "horizontal": horizontal, "bend": own("bend", 50.0), "h": own("h", 0.0), "v": own("v", 0.0), "preview": true}), + ) + } + MAKE_MESH | RESET_MESH => { + let mesh = info["type"] == "mesh"; + let own = |k: &str| if mesh { num(k, 4.0) } else { 4.0 }; + (MESH, json!({"reset": envelope, "rows": own("rows"), "cols": own("cols"), "maintainShape": true, "preview": true})) + } + OPTIONS_CMD => { + let mut f = json!({"__envelope": envelope, "preview": envelope}); + for k in ["antiAlias", "preserveShape", "fidelity", "distortAppearance", "distortLinearGradients", "distortPatternFills"] { + f[k] = info[k].clone(); + } + (OPTIONS, f) + } + _ => return Err(format!("`{id}` has no envelope dialog")), + }; + app.ui.dialog = Some(Dialog::new(kind, fields)); + Ok(Value::Null) +} + +/// The command OK runs, its parameters and the preview's undo label. +fn command(d: &Dialog) -> (&'static str, Value, &'static str) { + let reset = d.bool("reset"); + match d.kind.as_str() { + WARP => { + let p = json!({"style": d.str("style"), "horizontal": d.bool("horizontal"), "bend": d.f64("bend", 50.0), "h": d.f64("h", 0.0), "v": d.f64("v", 0.0)}); + if reset { (RESET_WARP, p, "Reset with Warp") } else { (MAKE_WARP, p, "Make Envelope") } + } + MESH => { + let p = json!({"rows": d.f64("rows", 4.0), "cols": d.f64("cols", 4.0)}); + if reset { + let mut p = p; + p["maintainShape"] = json!(d.bool("maintainShape")); + (RESET_MESH, p, "Reset with Mesh") + } else { + (MAKE_MESH, p, "Make Envelope") + } + } + _ => { + let mut p = json!({"fidelity": d.f64("fidelity", 50.0), "preserveShape": d.str("preserveShape")}); + for k in ["antiAlias", "distortAppearance", "distortLinearGradients", "distortPatternFills"] { + p[k] = json!(d.bool(k)); + } + (OPTIONS_CMD, p, "Envelope Options") + } + } +} + +fn set(d: &mut Dialog, key: &str, v: Value) { + d.fields.insert(key.into(), v); +} + +/// A −100..100 % slider row. +fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let rail = Tokens::get(ui.ctx()).input_border; + form::slider_w(ui, d, (key, label, LABEL_W), -100.0..=100.0, "%", &move |_| rail); +} + +/// A whole number field row clamped to 1..50. +fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + widgets::label_row(ui, label, LABEL_W, |ui| { + if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) { + set(d, key, json!(n.round().clamp(1.0, 50.0))); + } + }); +} + +fn warp_body(ui: &mut egui::Ui, d: &mut Dialog) { + let styles: Vec<(&str, &str)> = WARP_STYLES.iter().map(|(id, label)| (*id, label.trim_end_matches('…'))).collect(); + form::choice(ui, d, "style", "Style:", (LABEL_W, 160.0), &styles); + let horizontal = d.bool("horizontal"); + widgets::label_row(ui, "", LABEL_W, |ui| { + for (h, label) in [(true, "Horizontal"), (false, "Vertical")] { + if widgets::radio(ui, label, horizontal == h, true) { + set(d, "horizontal", json!(h)); + } + ui.add_space(10.0); + } + }); + percent(ui, d, "bend", "Bend:"); + ui.add_space(6.0); + widgets::subheader(ui, "Distortion"); + percent(ui, d, "h", "Horizontal:"); + percent(ui, d, "v", "Vertical:"); +} + +fn mesh_body(ui: &mut egui::Ui, d: &mut Dialog) { + count(ui, d, "rows", "Rows:"); + count(ui, d, "cols", "Columns:"); + if d.bool("reset") { + ui.add_space(4.0); + form::check(ui, d, "maintainShape", "Maintain Envelope Shape"); + } +} + +fn options_body(ui: &mut egui::Ui, d: &mut Dialog) { + widgets::subheader(ui, "Rasters"); + ui.horizontal(|ui| { + ui.add_space(12.0); + form::check(ui, d, "antiAlias", "Anti-Alias"); + }); + ui.add_space(4.0); + widgets::subheader(ui, "Preserve Shape Using:"); + let clip = d.str("preserveShape") != "transparency"; + ui.horizontal(|ui| { + ui.add_space(12.0); + for (value, label) in [("clippingMask", "Clipping Mask"), ("transparency", "Transparency")] { + if widgets::radio(ui, label, clip == (value == "clippingMask"), true) { + set(d, "preserveShape", json!(value)); + } + ui.add_space(10.0); + } + }); + ui.add_space(4.0); + let rail = Tokens::get(ui.ctx()).input_border; + form::slider_w(ui, d, ("fidelity", "Fidelity:", LABEL_W), 0.0..=100.0, "", &move |_| rail); + ui.add_space(4.0); + form::check(ui, d, "distortAppearance", "Distort Appearance"); + // Gradients and patterns bend only with the appearance. + let appearance = d.bool("distortAppearance"); + for (key, label) in [("distortLinearGradients", "Distort Linear Gradients"), ("distortPatternFills", "Distort Pattern Fills")] { + let on = d.bool(key); + ui.horizontal(|ui| { + ui.add_space(20.0); + if widgets::check(ui, label, on, appearance) { + set(d, key, json!(!on)); + } + }); + } +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + match d.kind.as_str() { + WARP => warp_body(ui, d), + MESH => mesh_body(ui, d), + _ => options_body(ui, d), + } + // With no envelope selected, Envelope Options sets the defaults: nothing to preview. + if d.kind != OPTIONS || d.bool("__envelope") { + let (cmd, p, label) = command(d); + form::preview(app, ui, d, label, cmd, p); + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (cmd, p, _) = command(d); + form::commit_preview(app, cmd, p) +} + +/// What the Control bar shows for the selected envelope. +#[derive(Clone, Debug, PartialEq)] +enum Bar { + Warp { style: String, bend: f64, h: f64, v: f64, horizontal: bool }, + Mesh { rows: u32, cols: u32 }, + TopObject, +} + +/// The selected envelope (or the one whose contents are selected), whether its contents are being +/// edited, and its settings. +fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> { + let st = app.session.active()?; + let env = st.selection.objects.first().and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id))?; + let NodeKind::Envelope { kind, editing, .. } = &env.kind else { return None }; + let bar = match kind { + EnvelopeKind::Warp { style, bend, h, v, horizontal } => { + Bar::Warp { style: style.clone(), bend: *bend, h: *h, v: *v, horizontal: *horizontal } + } + EnvelopeKind::Mesh { rows, cols, .. } => Bar::Mesh { rows: *rows, cols: *cols }, + EnvelopeKind::TopObject { .. } => Bar::TopObject, + }; + Some((*editing, bar)) +} + +/// The Control bar's envelope controls (as in the reference app) while an envelope, or content +/// whose envelope is being edited, is selected: Edit Envelope / Edit Contents; a warp's style, +/// orientation, bend and distortions, or a mesh's rows and columns; Reset Envelope Shape; and +/// Envelope Options. +pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + let Some((editing, bar)) = bar_of(app) else { return }; + let mut run: Option<(&str, Value)> = None; + ui.separator(); + for (contents, icon, tip) in [(false, "dc-mesh", "Edit Envelope"), (true, "shapes", "Edit Contents")] { + if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents { + run = Some(("object.envelope.editContents", json!({ "editing": contents }))); + } + } + ui.add_space(4.0); + let field = |ui: &mut egui::Ui, key: &str, label: &str, value: f64, suffix: &str| { + widgets::dim_label(ui, label); + widgets::plain_field(ui, ("cb-envelope", key), value, suffix, 0, 46.0) + }; + match &bar { + Bar::Warp { style, bend, h, v, horizontal } => { + let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect(); + let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…')); + widgets::dim_label(ui, "Style:"); + if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) { + run = Some((OPTIONS_CMD, json!({ "style": id }))); + } + for (h, label) in [(true, "Horizontal"), (false, "Vertical")] { + if widgets::radio(ui, label, *horizontal == h, true) { + run = Some((OPTIONS_CMD, json!({ "horizontal": h }))); + } + } + for (key, label, value) in [("bend", "Bend:", *bend), ("h", "H:", *h), ("v", "V:", *v)] { + if let Some(n) = field(ui, key, label, value, "%") { + run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) }))); + } + } + } + Bar::Mesh { rows, cols } => { + for (key, label, value) in [("rows", "Rows:", *rows), ("cols", "Columns:", *cols)] { + if let Some(n) = field(ui, key, label, f64::from(value), "") { + run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true }))); + } + } + } + Bar::TopObject => {} + } + // Reset Envelope Shape: an unbent warp, a flat mesh. + let reset = match bar { + Bar::Warp { .. } => Some((OPTIONS_CMD, json!({"bend": 0, "h": 0, "v": 0}))), + Bar::Mesh { .. } => Some((RESET_MESH, json!({"maintainShape": false}))), + Bar::TopObject => None, + }; + if let Some(r) = reset + && widgets::flat_button(ui, "Reset", 50.0).on_hover_text("Reset Envelope Shape").clicked() + { + run = Some(r); + } + if widgets::icon_button(ui, "dc-options", "Envelope Options", false, 24.0).clicked() { + crate::menus::invoke(app, OPTIONS_CMD, json!({})); + } + if let Some((cmd, p)) = run + && let Err(e) = app.run(cmd, p) + { + app.status(e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::live::EnvelopeKind; + use vectorcraft_doc::{NodeId, NodeKind}; + use vectorcraft_engine::Session; + + fn app_with_rect() -> (VectorcraftApp, NodeId) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + (app, NodeId(id)) + } + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + fn envelope(app: &VectorcraftApp) -> (NodeId, vectorcraft_doc::Node) { + let st = app.session.doc().unwrap(); + let id = st.selection.objects[0]; + (id, st.doc.node(id).unwrap().clone()) + } + + #[test] + fn warp_dialog_previews_and_ok_keeps_one_step() { + let (mut app, rect) = app_with_rect(); + crate::menus::invoke(&mut app, MAKE_WARP, json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| (d.kind.as_str(), d.fields["reset"].clone())), Some((WARP, json!(false)))); + frame(&mut app); + // The preview made the envelope already. + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { .. })); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("style".into(), json!("flag")); + d.fields.insert("horizontal".into(), json!(false)); + d.fields.insert("bend".into(), json!(-30)); + frame(&mut app); + let undo = app.session.doc().unwrap().history.undo.len(); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + let (_, n) = envelope(&app); + match &n.kind { + NodeKind::Envelope { kind: EnvelopeKind::Warp { style, bend, horizontal, .. }, content, .. } => { + assert_eq!((style.as_str(), *bend, *horizontal), ("flag", -30.0, false)); + assert_eq!(content[0].id, rect); + } + k => panic!("{k:?}"), + } + // With the envelope selected, the same item resets it, starting from its values. + crate::menus::invoke(&mut app, MAKE_WARP, json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["reset"].clone(), d.fields["style"].clone(), d.fields["bend"].clone()), (json!(true), json!("flag"), json!(-30.0))); + crate::dialogs::cancel(&mut app); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. }), "cancel rolls back"); + } + + #[test] + fn reset_items_take_the_make_items_place_while_an_envelope_is_selected() { + let labels = |app: &VectorcraftApp| -> Vec { + crate::menus::menu_entries(app) + .into_iter() + .filter(|e| e.path.last().map(String::as_str) == Some("Envelope Distort")) + .map(|e| e.label) + .collect() + }; + let (mut app, _) = app_with_rect(); + let before = labels(&app); + assert!(before.contains(&"Make with Warp…".to_string()) && !before.contains(&"Reset with Warp…".to_string()), "{before:?}"); + app.run(MAKE_MESH, json!({})).unwrap(); + let after = labels(&app); + assert!(after.contains(&"Reset with Warp…".to_string()) && after.contains(&"Reset with Mesh…".to_string()), "{after:?}"); + assert!(!after.contains(&"Make with Warp…".to_string()) && !after.contains(&"Make with Mesh…".to_string()), "{after:?}"); + assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_ok()); + app.run("select.none", json!({})).unwrap(); + assert!(app.run("ui.menuDialog", json!({"command": RESET_WARP})).is_err(), "nothing to reset"); + } + + #[test] + fn reset_mesh_dialog_switches_the_kind_and_cancel_rolls_back() { + let (mut app, _) = app_with_rect(); + app.run(MAKE_WARP, json!({"style": "arc", "bend": 50})).unwrap(); + app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(MESH)); + frame(&mut app); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 4, cols: 4, .. }, .. }), "previewed"); + crate::dialogs::cancel(&mut app); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Warp { .. }, .. })); + app.run("ui.menuDialog", json!({"command": RESET_MESH})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), json!(2)); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(envelope(&app).1.kind, NodeKind::Envelope { kind: EnvelopeKind::Mesh { rows: 2, cols: 4, .. }, .. })); + } + + #[test] + fn options_dialog_starts_from_the_envelope_and_without_one_sets_the_defaults() { + let (mut app, _) = app_with_rect(); + app.run(MAKE_WARP, json!({})).unwrap(); + app.run("object.envelope.options", json!({"fidelity": 80, "distortPatternFills": true})).unwrap(); + crate::menus::invoke(&mut app, OPTIONS_CMD, json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.fields["fidelity"].clone(), d.fields["distortPatternFills"].clone()), (json!(80.0), json!(true))); + d.fields.insert("preserveShape".into(), json!("transparency")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + let info = app.session.execute("object.envelope.info", &json!({})).unwrap(); + assert_eq!((info["preserveShape"].clone(), info["fidelity"].clone()), (json!("transparency"), json!(80.0))); + // Nothing selected: the defaults for new envelopes, no preview. + app.run("select.none", json!({})).unwrap(); + crate::menus::invoke(&mut app, OPTIONS_CMD, json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.fields["preview"], json!(false)); + d.fields.insert("distortAppearance".into(), json!(false)); + frame(&mut app); + assert!(!app.session.in_interaction()); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.execute("object.envelope.info", &json!({})).unwrap()["distortAppearance"], json!(false)); + } + + #[test] + fn edit_contents_reads_edit_envelope_while_editing() { + let (mut app, _) = app_with_rect(); + app.run(MAKE_WARP, json!({})).unwrap(); + let label = |app: &VectorcraftApp| crate::menus::dynamic_label(app, "object.envelope.editContents", "Edit Contents"); + assert_eq!(label(&app), "Edit Contents"); + app.run("object.envelope.editContents", json!({})).unwrap(); + assert_eq!(label(&app), "Edit Envelope"); + assert!(crate::menus::shortcut_of("object.envelope.editContents").is_none(), "its reference shortcut is Paste in Place's here"); + } + + /// One Control bar frame with `events`; the rectangles of its 24 pt icon buttons, left to right. + fn bar_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1800.0, 600.0))); + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect, ..Default::default() }, |ui| crate::chrome::control_bar(app, ui)); + out.textures_delta.clear(); + let mut r: Vec = ctx.viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == egui::vec2(24.0, 24.0)).map(|w| w.rect).collect() + }); + r.sort_by(|a, b| a.left().total_cmp(&b.left())); + r + } + + #[test] + fn the_control_bar_switches_to_edit_contents_and_back() { + let (mut app, rect) = app_with_rect(); + app.run(MAKE_WARP, json!({"style": "arch"})).unwrap(); + assert!(bar_of(&app).is_some_and(|(editing, bar)| !editing && matches!(bar, Bar::Warp { ref style, .. } if style == "arch"))); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let click = |app: &mut VectorcraftApp, at: egui::Pos2| { + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + bar_frame(app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); + bar_frame(app, &ctx, vec![button(false)]); + }; + bar_frame(&mut app, &ctx, vec![]); + let buttons = bar_frame(&mut app, &ctx, vec![]); + // Edit Envelope, Edit Contents come first. + click(&mut app, buttons[1].center()); + assert_eq!(app.session.doc().unwrap().selection.objects, vec![rect], "Edit Contents selects the content"); + assert!(bar_of(&app).is_some_and(|(editing, _)| editing), "the content keeps the envelope's controls"); + let buttons = bar_frame(&mut app, &ctx, vec![]); + click(&mut app, buttons[0].center()); + assert!(bar_of(&app).is_some_and(|(editing, _)| !editing)); + // A mesh envelope shows its rows and columns instead. + app.run(RESET_MESH, json!({"rows": 3, "cols": 2})).unwrap(); + assert_eq!(bar_of(&app).map(|b| b.1), Some(Bar::Mesh { rows: 3, cols: 2 })); + bar_frame(&mut app, &ctx, vec![]); + } +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 8a20a3d41..b77483a29 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -205,6 +205,48 @@ pub(super) fn param_fields(ui: &mut egui::Ui, d: &mut Dialog, is_length: &dyn Fn changed } +/// Editor for a plug-in's parameters from its schema (plug-in filter and effect dialogs): numbers +/// within their range, whole numbers, checkboxes and dropdowns, in declaration order. Returns +/// true when a value changed. +pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String, vectorcraft_plugins::ParamSpec)]) -> bool { + use vectorcraft_plugins::ParamSpec; + let t = Tokens::get(ui.ctx()); + let mut changed = false; + egui::Grid::new("plugin-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + for (k, spec) in specs { + ui.label(egui::RichText::new(humanize(k)).color(t.text)); + let cur = d.fields.get(k).cloned().unwrap_or_else(|| spec.default_value()); + let new = match spec { + ParamSpec::Number { min, max, .. } => { + let mut x = cur.as_f64().unwrap_or(*min).clamp(*min, *max); + let speed = ((max - min) / 200.0).clamp(0.01, 10.0); + ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(speed).max_decimals(2)).changed().then(|| json!(x)) + } + ParamSpec::Int { min, max, .. } => { + let mut x = cur.as_i64().unwrap_or(*min).clamp(*min, *max); + ui.add(egui::DragValue::new(&mut x).range(*min..=*max).speed(0.2)).changed().then(|| json!(x)) + } + ParamSpec::Bool { .. } => { + let mut b = cur.as_bool().unwrap_or(false); + ui.checkbox(&mut b, "").changed().then(|| json!(b)) + } + ParamSpec::Choice { options, .. } => { + let labels: Vec<&str> = options.iter().map(String::as_str).collect(); + crate::widgets::dropdown(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0) + .and_then(|i| options.get(i)) + .map(|o| json!(o)) + } + }; + if let Some(v) = new { + d.fields.insert(k.clone(), v); + changed = true; + } + ui.end_row(); + } + }); + changed +} + /// A field label from its camelCase key (`miterLimit` → "Miter Limit:"). pub(super) fn humanize(k: &str) -> String { let mut s = String::new(); @@ -244,13 +286,25 @@ pub(super) fn slider( range: std::ops::RangeInclusive, suffix: &str, track: &dyn Fn(f32) -> egui::Color32, +) { + slider_w(ui, d, (key, label, SLIDER_LABEL), range, suffix, track); +} + +/// [`slider`] with its label column `label_w` wide: (`key`, `label`, `label_w`). +pub(super) fn slider_w( + ui: &mut egui::Ui, + d: &mut Dialog, + (key, label, label_w): (&str, &str, f32), + range: std::ops::RangeInclusive, + suffix: &str, + track: &dyn Fn(f32) -> egui::Color32, ) { let t = Tokens::get(ui.ctx()); let (min, max) = (*range.start(), *range.end()); let v = d.f64(key, 0.0).clamp(min, max); let mut new = None; ui.horizontal(|ui| { - ui.add_sized([SLIDER_LABEL, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text))); + ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(label).color(t.text))); if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) { new = Some((min + x as f64 * (max - min)).round()); } diff --git a/crates/ui-egui/src/dialogs/halftone.rs b/crates/ui-egui/src/dialogs/halftone.rs new file mode 100644 index 000000000..c28cd6b11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/halftone.rs @@ -0,0 +1,53 @@ +//! Object → Vector Halftone: the dot shape, the screen frequency and angle, mono (in one colour) +//! or CMYK screens, Invert, Clip to Art and Keep Original, previewed live on the canvas; OK keeps +//! the preview as one undo step (`object.vectorHalftone`). +//! +//! Fields: `shape`, `frequency`, `angle`, `mode`, `color`, `invert`, `clip`, `keepOriginal` (the +//! command's parameters) and `preview`. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::{DialogSpec, form}; +use crate::panels::c32; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Vector Halftone. +pub const KIND: &str = "vectorHalftone"; + +const CMD: &str = "object.vectorHalftone"; + +/// Width of the label column. +const LABEL_W: f32 = 84.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Vector Halftone".into(), body, confirm, preview: true, ..DialogSpec::FORM }; + +/// The dialog's fields as it opens: the command's defaults, previewed. +pub fn fields() -> Value { + json!({"shape": "circle", "frequency": 20, "angle": 45, "mode": "mono", "color": "#000000", "invert": false, "clip": true, "keepOriginal": false, "preview": true}) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let shapes = [("circle", "Circle"), ("ellipse", "Ellipse"), ("square", "Square"), ("diamond", "Diamond"), ("line", "Line")]; + form::choice(ui, d, "shape", "Shape", (LABEL_W, 140.0), &shapes); + form::slider_w(ui, d, ("frequency", "Frequency", LABEL_W), 1.0..=150.0, " lpi", &|x| c32(&Color::gray(1.0 - x))); + form::slider_w(ui, d, ("angle", "Angle", LABEL_W), -90.0..=90.0, "°", &|x| c32(&Color::gray(0.2 + 0.6 * x))); + form::choice(ui, d, "mode", "Screens", (LABEL_W, 140.0), &[("mono", "Mono"), ("cmyk", "CMYK")]); + if d.str("mode") != "cmyk" { + widgets::label_row(ui, "Color", LABEL_W, |ui| { + form::text(ui, d, "color", 120.0); + }); + } + ui.add_space(4.0); + for (key, label) in [("invert", "Invert"), ("clip", "Clip to Art"), ("keepOriginal", "Keep Original")] { + form::check(ui, d, key, label); + } + let p = form::params(d); + form::preview(app, ui, d, "Vector Halftone", CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, form::params(d)) +} diff --git a/crates/ui-egui/src/dialogs/liquify.rs b/crates/ui-egui/src/dialogs/liquify.rs new file mode 100644 index 000000000..630ae81cb --- /dev/null +++ b/crates/ui-egui/src/dialogs/liquify.rs @@ -0,0 +1,227 @@ +//! Liquify Tool Options (double-click a Liquify tool: Warp, Twirl, Pucker, Bloat, Scallop, +//! Crystallize, Wrinkle): the Global Brush Dimensions the seven tools share, the tool's own options +//! and Show Brush Size. OK sets them with `tool.setOption {tool, values}` (they last across tool +//! switches and are saved with the preferences). +//! +//! Fields: `tool`; `width`, `height` (points), `angle` (degrees), `intensity` (%), `usePressure`; +//! `detail` (1–10); `simplify` (1–100) and `simplifyOn` (Warp, Twirl, Pucker, Bloat); `rate` +//! (Twirl, −180–180°); `complexity` (0–15), `affectAnchors`, `affectIn`, `affectOut` (Scallop, +//! Crystallize, Wrinkle); `horizontal`, `vertical` (Wrinkle, %); `showBrush`. + +use serde_json::{Map, Value, json}; +use vectorcraft_tools::distort::liquify::LiquifyKind; + +use super::document_setup::check; +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the Liquify Tool Options. +pub const KIND: &str = "liquifyOptions"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// Width of the label column. +const LABEL_W: f32 = 104.0; + +/// The options shown as percentages (the tool keeps them as 0..1). +const PERCENT: [&str; 3] = ["intensity", "horizontal", "vertical"]; + +/// The options the dialog edits (the tool's options; `tool` names it). +const KEYS: [&str; 16] = [ + "width", + "height", + "angle", + "intensity", + "usePressure", + "detail", + "simplify", + "simplifyOn", + "rate", + "complexity", + "horizontal", + "vertical", + "affectAnchors", + "affectIn", + "affectOut", + "showBrush", +]; + +fn kind(d: &Dialog) -> LiquifyKind { + LiquifyKind::parse(&d.str("tool")).unwrap_or(LiquifyKind::Warp) +} + +fn heading(d: &Dialog) -> String { + format!("{} Tool Options", kind(d).label()) +} + +/// Open the options of Liquify tool `tool` with its current values. +pub fn open(app: &mut VectorcraftApp, tool: &str) -> Result { + let k = LiquifyKind::parse(tool).ok_or_else(|| format!("`{tool}` isn't a Liquify tool"))?; + let o = app.session.tool_options_of(k.id()); + let mut fields: Map = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect(); + for key in PERCENT { + let v = fields.get(key).and_then(Value::as_f64).unwrap_or(0.0); + fields.insert(key.into(), json!((v * 100.0).round())); + } + fields.insert("tool".into(), json!(k.id())); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(json!({ "dialog": KIND })) +} + +/// A slider row for the whole number `d.fields[key]`. +fn slider(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), range: std::ops::RangeInclusive, suffix: &str) { + let rail = Tokens::get(ui.ctx()).input_border; + form::slider_w(ui, d, (key, label, LABEL_W), range, suffix, &|_| rail); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let k = kind(d); + let unit = app.session.general_unit(); + widgets::subheader(ui, "Global Brush Dimensions"); + ui.add_space(4.0); + for (key, label) in [("width", "Width:"), ("height", "Height:")] { + widgets::label_row(ui, label, LABEL_W, |ui| { + form::length(ui, d, key, unit, 110.0); + }); + } + widgets::label_row(ui, "Angle:", LABEL_W, |ui| { + if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) { + d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0))); + } + }); + let pressure = d.bool("usePressure"); + widgets::label_row(ui, "Intensity:", LABEL_W, |ui| { + ui.add_enabled_ui(!pressure, |ui| { + if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) { + d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0))); + } + }); + }); + widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "usePressure", "Use Pressure Pen")); + ui.add_space(10.0); + widgets::subheader(ui, &format!("{} Options", k.label())); + ui.add_space(4.0); + if k == LiquifyKind::Twirl { + slider(ui, d, ("rate", "Twirl Rate:"), -180.0..=180.0, "°"); + } + if k == LiquifyKind::Wrinkle { + slider(ui, d, ("horizontal", "Horizontal:"), 0.0..=100.0, "%"); + slider(ui, d, ("vertical", "Vertical:"), 0.0..=100.0, "%"); + } + if k.has_affects() { + slider(ui, d, ("complexity", "Complexity:"), 0.0..=15.0, ""); + } + slider(ui, d, ("detail", "Detail:"), 1.0..=10.0, ""); + if k.simplifies() { + widgets::label_row(ui, "", LABEL_W, |ui| check(ui, d, "simplifyOn", "Simplify")); + let on = d.bool("simplifyOn"); + ui.add_enabled_ui(on, |ui| slider(ui, d, ("simplify", "Simplify:"), 1.0..=100.0, "")); + } else { + ui.add_space(4.0); + widgets::label_row(ui, "Brush Affects:", LABEL_W, |ui| { + ui.vertical(|ui| { + for (key, label) in [("affectAnchors", "Anchor Points"), ("affectIn", "In Tangent Handles"), ("affectOut", "Out Tangent Handles")] { + check(ui, d, key, label); + } + }); + }); + } + ui.add_space(10.0); + check(ui, d, "showBrush", "Show Brush Size"); + let hint = format!("{} with the tool sizes the brush", crate::menus::pretty_shortcut("Alt+Drag")); + ui.label(egui::RichText::new(hint).size(11.5).color(Tokens::get(ui.ctx()).text_dim)); + false +} + +/// OK: set the tool's options (percentages back to 0..1). +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut values: Map = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + for key in PERCENT { + if values.contains_key(key) { + values.insert(key.into(), json!(d.f64(key, 0.0) / 100.0)); + } + } + run_and_close(app, "tool.setOption", json!({ "tool": kind(d).id(), "values": values })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app + } + + #[test] + fn each_tool_shows_its_options_and_ok_sets_them() { + let mut app = app(); + app.select_tool("twirl"); + app.run("tool.options", json!({"tool": "twirl"})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + for label in [ + "Twirl Tool Options", + "Global Brush Dimensions", + "Width:", + "Height:", + "Angle:", + "Intensity:", + "Use Pressure Pen", + "Twirl Options", + "Twirl Rate:", + "Detail:", + "Simplify", + "Show Brush Size", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + assert!(!text.contains("Brush Affects:") && !text.contains("Complexity:"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.f64("intensity", 0.0), d.f64("rate", 0.0), d.bool("showBrush")), (50.0, 40.0, true)); + for (k, v) in + [("width", json!(80)), ("intensity", json!(30)), ("rate", json!(-120)), ("simplifyOn", json!(false)), ("showBrush", json!(false))] + { + d.fields.insert(k.into(), v); + } + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let o = app.session.tool_options(); + assert_eq!((o["width"].as_f64(), o["intensity"].as_f64(), o["rate"].as_f64()), (Some(80.0), Some(0.3), Some(-120.0))); + assert_eq!((o["simplifyOn"].as_bool(), o["showBrush"].as_bool()), (Some(false), Some(false))); + // The brush is shared: the Warp tool has it too, though it is not the active tool. + app.run("tool.options", json!({"tool": "warp"})).unwrap(); + assert_eq!((app.ui.dialog.as_ref().unwrap().f64("width", 0.0), app.ui.dialog.as_ref().unwrap().f64("intensity", 0.0)), (80.0, 30.0)); + } + + #[test] + fn scallop_crystallize_and_wrinkle_have_brush_affects_and_no_simplify() { + let mut app = app(); + for (tool, extra) in [("scallop", None), ("crystallize", None), ("wrinkle", Some("Horizontal:"))] { + app.run("tool.options", json!({"tool": tool})).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + for label in ["Complexity:", "Detail:", "Brush Affects:", "Anchor Points", "In Tangent Handles", "Out Tangent Handles"] { + assert!(text.contains(label), "{tool}: {label} in {text}"); + } + assert!(!text.contains("Simplify"), "{tool}: {text}"); + assert_eq!(text.contains("Horizontal:"), extra.is_some(), "{tool}"); + app.ui.dialog.as_mut().unwrap().fields.insert("affectIn".into(), json!(false)); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.tool_options_of(tool)["affectIn"], json!(false), "{tool}"); + } + } + + #[test] + fn type_under_the_brush_is_reported_in_the_status_bar() { + let mut app = app(); + app.run("text.create", json!({"x": 100, "y": 100, "text": "Hi"})).unwrap(); + app.run("select.set", json!({"ids": []})).unwrap(); + app.select_tool("bloat"); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 105.0, 95.0), view); + assert!(app.ui.status.contains("Bloat left 1 object under the brush"), "{}", app.ui.status); + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index a775978aa..94c5e53d8 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -9,6 +9,7 @@ mod about; mod all_tools; mod artboard_options; +pub mod blend_options; pub mod color_balance; pub mod color_guide_options; mod color_picker; @@ -18,6 +19,7 @@ mod document_setup; pub mod dxf_import; pub mod dxf_options; mod effect; +pub mod envelope; pub mod eps_options; pub mod expand; mod export_as; @@ -29,7 +31,9 @@ pub mod flattener_presets; mod form; mod gradient_stop; pub mod graphic_style_options; +pub mod halftone; pub mod import_pdf; +pub mod liquify; pub mod missing_links; pub mod new_color_group; mod new_document; @@ -38,8 +42,13 @@ pub mod office_export; pub mod package; mod path_ops; pub mod pdf_presets; +pub mod perspective_grid; +pub mod perspective_options; +pub mod perspective_plane; +pub mod perspective_presets; pub mod place; pub mod placement_options; +pub mod plugin; mod png_options; pub mod print; pub mod print_presets; @@ -90,6 +99,10 @@ use crate::{VectorcraftApp, widgets}; type DialogResult = Result; +/// The shared dialog frame's inner margin, and the least room it leaves at the window's edges. +const MARGIN: i8 = 22; +const EDGE_GAP: f32 = 8.0; + /// How a dialog draws and applies itself. Specs start from [`DialogSpec::FORM`] and override what /// differs. pub(crate) struct DialogSpec { @@ -241,6 +254,15 @@ registry! { Print: [print::KIND] => print::SPEC, PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC, PrintPresets: [print_presets::KIND] => print_presets::SPEC, + Plugin: [plugin::KIND] => plugin::SPEC, + VectorHalftone: [halftone::KIND] => halftone::SPEC, + PerspectiveGrid: [perspective_grid::KIND] => perspective_grid::SPEC, + Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, + LiquifyOptions: [liquify::KIND] => liquify::SPEC, + PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC, + PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC, + BlendOptions: [blend_options::KIND] => blend_options::SPEC, + PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -296,12 +318,12 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { .resizable(false) .title_bar(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, -40.0]) - .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(MARGIN))) .show(ctx, |ui| { - ui.set_min_width(spec.min_width); - if let Some(w) = spec.max_width { - ui.set_max_width(w); - } + // Never wider than the window (a large UI scale in a small window): the text wraps. + let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP); + ui.set_min_width(spec.min_width.min(room)); + ui.set_max_width(spec.max_width.map_or(room, |w| w.min(room))); ui.label(egui::RichText::new(heading.as_str()).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); cancel = (spec.body)(app, ui, &mut d); @@ -381,3 +403,9 @@ mod tests_print_presets; #[cfg(test)] mod tests_print_advanced; + +#[cfg(test)] +mod tests_scale; + +#[cfg(test)] +mod tests_perspective; diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 441f24809..47150f0ae 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -97,7 +97,9 @@ fn sync(app: &VectorcraftApp, d: &mut Dialog) { let preset = d.str("preset"); match d.fields.get(APPLIED).and_then(Value::as_object) { Some(a) if a.get("preset").and_then(Value::as_str) == Some(preset.as_str()) => { - if a.iter().any(|(k, v)| k != "preset" && d.fields.get(k) != Some(v)) { + // Unchanged fields keep the preset (falling through would apply it again). + let edited = a.iter().any(|(k, v)| k != "preset" && d.fields.get(k) != Some(v)); + if edited { d.fields.insert("preset".into(), json!("")); d.fields.remove(APPLIED); } diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs new file mode 100644 index 000000000..92123945c --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -0,0 +1,338 @@ +//! View → Perspective Grid → Define Grid…: the grid's type, units and scale, gridline spacing, +//! viewing angle and distance, horizon height and third vanishing point, and the planes' gridline +//! colours and opacity, prefilled from the grid (`perspective.grid.get`). OK runs +//! `perspective.grid.define`: one undo step, nothing when nothing changed. +//! +//! The Preset menu loads a preset's fields (it reads [Custom] once they differ); Save Preset… +//! asks for a name and saves the fields as a preset (`perspective.presets.save`), then comes back. +//! The same dialog is View → Perspective Grid → Save Grid as Preset… and the preset editor of +//! Edit → Perspective Grid Presets (New…, Edit…), whose OK returns to the presets. +//! +//! Fields: those of `perspective.grid.define` (lengths are real-world lengths in `units`; `name` +//! the preset), plus `__mode` (`save`, `edit` or none for Define Grid), `__from` (`define`: Save +//! Preset… returns to Define Grid) and `__original` (the saved preset an edit changes). + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_tools::distort::perspective::{GridDefinition, Rgb}; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Define Perspective Grid. +pub const KIND: &str = "perspectiveGrid"; + +/// The label column's width. +const LABEL: f32 = 140.0; +/// A value field's width. +const FIELD: f32 = 120.0; + +/// The unit the lengths were last shown in. +const SHOWN: &str = "__units"; +/// What the dialog is: Define Grid (none), `save` (Save Grid as Preset) or `edit` (the preset +/// editor). +const MODE: &str = "__mode"; +const FROM: &str = "__from"; +const ORIGINAL: &str = "__original"; + +/// The Preset menu's entry for fields no preset has. +const CUSTOM: &str = "[Custom]"; + +/// The lengths among the fields: real-world lengths in `units`. +const LENGTHS: [&str; 3] = ["gridline", "distance", "horizonHeight"]; + +/// The scale choices: (label, [artboard, real world]). +const SCALES: [(&str, [f64; 2]); 9] = [ + ("1:1", [1.0, 1.0]), + ("1:2", [1.0, 2.0]), + ("1:4", [1.0, 4.0]), + ("1:8", [1.0, 8.0]), + ("1:12", [1.0, 12.0]), + ("1:24", [1.0, 24.0]), + ("1:48", [1.0, 48.0]), + ("1:96", [1.0, 96.0]), + ("1:100", [1.0, 100.0]), +]; + +const TYPES: [&str; 3] = ["One Point Perspective", "Two Point Perspective", "Three Point Perspective"]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |d| { + match d.str(MODE).as_str() { + "save" => "Save Grid as Preset", + "edit" => "Perspective Grid Preset", + _ => "Define Perspective Grid", + } + .into() + }, + body, + confirm, + min_width: 420.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Open the dialog on `def`'s fields as `mode` (`""`, `save` or `edit`). +fn open_on(app: &mut VectorcraftApp, mut def: Value, mode: &str) { + def[SHOWN] = def["units"].clone(); + def[MODE] = json!(mode); + app.ui.dialog = Some(Dialog::new(KIND, def)); +} + +/// The active document's grid as Define Grid fields. +fn grid_fields(app: &mut VectorcraftApp) -> Result { + Ok(app.session.execute("perspective.grid.get", &json!({})).map_err(|e| e.to_string())?["define"].take()) +} + +/// Open Define Grid on the active document's grid. +pub fn open(app: &mut VectorcraftApp) -> Result { + let fields = grid_fields(app)?; + open_on(app, fields, ""); + Ok(Value::Null) +} + +/// View → Perspective Grid → Save Grid as Preset…: the grid's fields under a new preset name. +pub fn open_save(app: &mut VectorcraftApp) -> Result { + let mut fields = grid_fields(app)?; + fields["name"] = json!(app.session.new_perspective_preset_name()); + open_on(app, fields, "save"); + Ok(Value::Null) +} + +/// The preset editor: the saved preset `original` (Edit…), or a new preset starting from `base` +/// (New…). Its OK returns to Edit → Perspective Grid Presets. +pub fn open_edit(app: &mut VectorcraftApp, original: Option<&str>, base: &str) -> Result<(), String> { + let name = original.unwrap_or(base); + let mut def = json!(app.session.perspective_preset(name).ok_or_else(|| format!("no perspective grid preset named `{name}`"))?); + match original { + Some(n) => def[ORIGINAL] = json!(n), + None => def["name"] = json!(app.session.new_perspective_preset_name()), + } + open_on(app, def, "edit"); + Ok(()) +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = form::params(d); + match d.str(MODE).as_str() { + "save" | "edit" => { + if let Some(original) = d.fields.get(ORIGINAL).and_then(Value::as_str) { + params["newName"] = params["name"].take(); + params["name"] = json!(original); + } + let r = app.run("perspective.presets.save", params)?; + let name = r["name"].as_str().unwrap_or_default().to_string(); + if d.str(MODE) == "edit" { + super::perspective_presets::open(app, Some(&name)); + } else if d.str(FROM) == "define" { + // Back to Define Grid, on the preset just saved. + if let Some(back) = app.ui.dialog.as_mut() { + back.fields.remove(MODE); + back.fields.remove(FROM); + back.fields.insert("name".into(), json!(name)); + } + } else { + app.ui.dialog = None; + } + Ok(r) + } + _ => { + let r = app.run("perspective.grid.define", params); + if r.is_ok() { + app.ui.dialog = None; + } + r + } + } +} + +/// The unit the lengths are in. +fn unit(d: &Dialog) -> Unit { + Unit::named(&d.str("units")).unwrap_or_default() +} + +fn kind(d: &Dialog) -> u8 { + d.fields.get("kind").and_then(Value::as_u64).map_or(2, |k| k.clamp(1, 3) as u8) +} + +fn scale(d: &Dialog) -> [f64; 2] { + let v = d.fields.get("scale").and_then(Value::as_array); + let at = |i: usize| v.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(1.0); + [at(0), at(1)] +} + +/// A length field (`key`, in `unit`) `width` wide. Returns true when it changed. +fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool { + let pt = d.fields.get(key).and_then(Value::as_f64).map(|v| unit.to_pt(v)); + let Some(v) = widgets::num_field(ui, ("persp-len", key), pt, unit, width) else { return false }; + d.fields.insert(key.into(), json!(unit.from_pt(v))); + true +} + +/// One of the third vanishing point's coordinates (`i`: 0 = x, 1 = y). +fn third_vp(ui: &mut egui::Ui, d: &mut Dialog, i: usize, unit: Unit, enabled: bool) { + let mut v = d.fields.get("thirdVp").and_then(Value::as_array).cloned().unwrap_or_else(|| vec![json!(0), json!(0)]); + v.resize(2, json!(0)); + let pt = v.get(i).and_then(Value::as_f64).map(|x| unit.to_pt(x)); + ui.label(if i == 0 { "X:" } else { "Y:" }); + if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field(ui, ("persp-vp3", i), pt, unit, 84.0)).inner { + v[i] = json!(unit.from_pt(x)); + d.fields.insert("thirdVp".into(), Value::Array(v)); + } +} + +/// A plane's gridline colour: a chip and its hex. +fn color(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let t = Tokens::get(ui.ctx()); + let cur = Rgb::parse(&d.str(key)); + widgets::label_row(ui, label, LABEL, |ui| { + let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover()); + if let Some(Rgb([r, g, b])) = cur { + ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(r, g, b)); + } + ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + let hex = cur.map(|c| c.hex()).unwrap_or_default(); + if let Some(c) = widgets::hex_field(ui, ("persp-color", key), hex.trim_start_matches('#')).as_deref().and_then(Rgb::parse) { + d.fields.insert(key.into(), json!(c.hex())); + } + }); +} + +/// The Name row: in Define Grid the Preset menu (choosing a preset loads its fields) and Save +/// Preset…, else the preset's name. +fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + if !d.str(MODE).is_empty() { + widgets::label_row(ui, "Name:", LABEL, |ui| { + form::text(ui, d, "name", 200.0); + }); + return; + } + let presets = app.session.perspective_presets(); + let name = d.str("name"); + // The fields no longer the named preset's: [Custom]. + let fields: Option = serde_json::from_value(form::params(d)).ok(); + let current = presets.iter().find(|p| p.name == name).filter(|p| fields.as_ref().is_some_and(|f| f.same(p))); + if current.is_none() && !name.is_empty() { + d.fields.insert("name".into(), json!("")); + } + let shown = current.map_or(CUSTOM, |p| p.name.as_str()); + let names: Vec<&str> = std::iter::once(CUSTOM).chain(presets.iter().map(|p| p.name.as_str())).collect(); + widgets::label_row(ui, "Preset:", LABEL, |ui| { + if let Some(p) = widgets::dropdown(ui, "persp-preset", shown, &names, 200.0).and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) { + let mut fields = json!(p); + fields[SHOWN] = fields["units"].clone(); + if let Some(o) = fields.as_object() { + d.fields.extend(o.clone()); + } + } + if widgets::flat_button(ui, "Save Preset…", 96.0).on_hover_text("Save these settings as a preset").clicked() { + d.fields.insert(MODE.into(), json!("save")); + d.fields.insert(FROM.into(), json!("define")); + d.fields.insert("name".into(), json!(app.session.new_perspective_preset_name())); + } + }); + ui.add_space(8.0); +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + name_row(app, ui, d); + widgets::subheader(ui, "Perspective Grid Settings"); + ui.add_space(6.0); + let k = kind(d); + widgets::label_row(ui, "Type:", LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "persp-type", TYPES[usize::from(k - 1)], &TYPES, 200.0) { + d.fields.insert("kind".into(), json!(i + 1)); + } + }); + let units: Vec<(&str, &str)> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| (u.key(), u.label())).collect(); + form::choice(ui, d, "units", "Units:", (LABEL, 200.0), &units); + // A new unit (the menu or `ui.dialog.set`) keeps the lengths: they are converted. + let unit = unit(d); + let before = Unit::named(&d.str(SHOWN)).unwrap_or(unit); + d.fields.insert(SHOWN.into(), json!(unit.key())); + if unit != before { + let f = before.points() / unit.points(); + for key in LENGTHS { + if let Some(v) = d.fields.get(key).and_then(Value::as_f64) { + d.fields.insert(key.into(), json!(v * f)); + } + } + if let Some(v) = d.fields.get("thirdVp").and_then(Value::as_array) { + let v: Vec = v.iter().map(|x| x.as_f64().unwrap_or(0.0) * f).collect(); + d.fields.insert("thirdVp".into(), json!(v)); + } + } + let sc = scale(d); + let named = SCALES.iter().find(|(_, s)| *s == sc).map(|(l, _)| *l); + let mut labels: Vec<&str> = SCALES.iter().map(|(l, _)| *l).collect(); + labels.push("Custom"); + widgets::label_row(ui, "Scale:", LABEL, |ui| { + if let Some(i) = widgets::dropdown(ui, "persp-scale", named.unwrap_or("Custom"), &labels, 200.0) { + // Custom starts from the scale shown. + let s = SCALES.get(i).map_or(sc, |(_, s)| *s); + d.fields.insert("scale".into(), json!(s)); + } + }); + if named.is_none() { + widgets::label_row(ui, "", LABEL, |ui| { + for (i, label) in ["Artboard:", "Real World:"].into_iter().enumerate() { + ui.label(egui::RichText::new(label).color(t.text_dim)); + if let Some(v) = widgets::plain_field(ui, ("persp-scale", i), sc[i], "", 3, 56.0).filter(|v| *v > 0.0 && v.is_finite()) { + let mut s = sc; + s[i] = v; + d.fields.insert("scale".into(), json!(s)); + } + } + }); + } + widgets::label_row(ui, "Gridline every:", LABEL, |ui| { + length(ui, d, "gridline", unit, FIELD); + }); + if sc[0] != sc[1] { + let real = unit.number(unit.to_pt(sc[1] / sc[0])); + let note = format!("1 {s} on the artboard stands for {real} {s} in the scene.", s = unit.suffix()); + widgets::label_row(ui, "", LABEL, |ui| { + ui.label(egui::RichText::new(note).size(11.5).color(t.text_dim)); + }); + } + ui.add_space(4.0); + widgets::label_row(ui, "Viewing Angle:", LABEL, |ui| { + let a = d.f64("angle", 45.0); + if let Some(v) = ui.add_enabled_ui(k != 1, |ui| widgets::plain_field(ui, "persp-angle", a, "°", 2, FIELD)).inner { + d.fields.insert("angle".into(), json!(v)); + } + }); + widgets::label_row(ui, "Viewing Distance:", LABEL, |ui| { + length(ui, d, "distance", unit, FIELD); + }); + widgets::label_row(ui, "Horizon Height:", LABEL, |ui| { + length(ui, d, "horizonHeight", unit, FIELD); + }); + widgets::label_row(ui, "Third Vanishing Point:", LABEL, |ui| { + third_vp(ui, d, 0, unit, k == 3); + third_vp(ui, d, 1, unit, k == 3); + }); + ui.add_space(12.0); + widgets::subheader(ui, "Grid Color & Opacity"); + ui.add_space(6.0); + color(ui, d, "leftColor", "Left Grid:"); + color(ui, d, "rightColor", "Right Grid:"); + color(ui, d, "groundColor", "Horizontal Grid:"); + // Opacity: a slider and its field, in the label column the rows above use. + widgets::label_row(ui, "Opacity:", LABEL, |ui| { + let v = d.f64("opacity", 50.0).clamp(0.0, 100.0); + let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8); + let mut new = + widgets::color_slider(ui, "persp-opacity", (v / 100.0) as f32, form::SLIDER_WIDTH, &track).0.map(|x| (f64::from(x) * 100.0).round()); + if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) { + new = Some(x.round().clamp(0.0, 100.0)); + } + if let Some(n) = new.filter(|n| *n != v) { + d.fields.insert("opacity".into(), json!(n)); + } + }); + false +} diff --git a/crates/ui-egui/src/dialogs/perspective_options.rs b/crates/ui-egui/src/dialogs/perspective_options.rs new file mode 100644 index 000000000..21936c441 --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_options.rs @@ -0,0 +1,42 @@ +//! Perspective Grid Options (double-click the Perspective Grid tool): whether the Plane Switching +//! Widget shows and which corner of the window it sits in. OK runs `perspective.widget.options` +//! (the options live in the preferences). +//! +//! Fields: `show` (bool) and `position` (`topLeft`, `topRight`, `bottomLeft`, `bottomRight`). + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::widget::WidgetCorner; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Perspective Grid Options. +pub const KIND: &str = "perspectiveGridOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| "Perspective Grid Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM }; + +/// Open the dialog with the current options. +pub fn open(app: &mut VectorcraftApp) { + app.ui.dialog = Some(Dialog::new(KIND, json!(app.session.prefs.perspective_widget))); +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let r = app.run("perspective.widget.options", form::params(d)); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let on = d.bool("show"); + if widgets::check(ui, "Show Active Plane Widget", on, true) { + d.fields.insert("show".into(), json!(!on)); + } + ui.add_space(8.0); + let corners: Vec<(&str, &str)> = WidgetCorner::ALL.iter().map(|c| (c.id(), c.label())).collect(); + ui.add_enabled_ui(on, |ui| form::choice(ui, d, "position", "Widget Position:", (110.0, 160.0), &corners)); + false +} diff --git a/crates/ui-egui/src/dialogs/perspective_plane.rs b/crates/ui-egui/src/dialogs/perspective_plane.rs new file mode 100644 index 000000000..4ac217fe0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_plane.rs @@ -0,0 +1,149 @@ +//! Perspective plane options: double-clicking a plane widget of the perspective grid (or +//! `ui.perspectivePlane {plane}`) moves that plane to an exact location, alone, with the objects on +//! it, or with copies of them. OK runs `perspective.plane.move`. +//! +//! Fields: `plane` (`left`, `right` or `ground`), `location` (points along the plane's normal; 0 +//! is its place in the grid's definition), `objects` (`none`, `move` or `copy`). + +use serde_json::{Value, json}; +use vectorcraft_tools::distort::perspective::{PerspectiveGrid, Plane}; + +use super::swatch_options::{grid, label}; +use super::{DialogSpec, run_and_close}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of the plane options. +pub const KIND: &str = "perspectivePlane"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 300.0, ..DialogSpec::FORM }; + +/// What becomes of the objects on the plane: (`objects` value, label). +const OBJECTS: [(&str, &str); 3] = [("none", "Do Not Move"), ("move", "Move All Objects"), ("copy", "Copy All Objects")]; + +fn heading(d: &Dialog) -> String { + match Plane::parse(&d.str("plane")) { + Some(Plane::Right) => "Right Plane", + Some(Plane::Ground) => "Horizontal Plane", + _ => "Left Plane", + } + .into() +} + +/// Open the options of `plane` (`{plane}`; default the active plane), filled in with its location. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let doc = &app.session.active().ok_or("no document")?.doc; + let g = PerspectiveGrid::current(doc); + let plane = match p.get("plane").and_then(Value::as_str) { + Some(s) => Plane::parse(s).ok_or("plane must be left, right or ground")?, + None => g.plane, + }; + if plane == Plane::None { + return Err("no active perspective plane".into()); + } + let fields = json!({"plane": plane.id(), "location": g.offset(plane), "objects": "none"}); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let unit = app.session.general_unit(); + grid(ui, |ui| { + label(ui, "Location:"); + if let Some(v) = widgets::num_field(ui, "pp-location", Some(d.f64("location", 0.0)), unit, 120.0) { + d.fields.insert("location".into(), json!(v)); + } + ui.end_row(); + }); + ui.add_space(6.0); + let cur = d.str("objects"); + for (value, text) in OBJECTS { + if widgets::radio(ui, text, cur == value || (cur.is_empty() && value == "none"), true) { + d.fields.insert("objects".into(), json!(value)); + } + } + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let objects = Some(d.str("objects")).filter(|o| OBJECTS.iter().any(|(v, _)| v == o)).unwrap_or_else(|| "none".into()); + let p = json!({"plane": d.str("plane"), "offset": d.f64("location", 0.0), "objects": objects}); + run_and_close(app, "perspective.plane.move", p) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + use vectorcraft_tools::{PointerEvent, PointerKind}; + + fn shown(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // A new window lays itself out in its first frame and paints in the next. + ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())).textures_delta.clear(); + let mut out = ctx.run_ui(Default::default(), |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + #[test] + fn double_clicking_a_plane_widget_opens_its_options_and_ok_moves_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.run("perspective.grid.preset", json!({"kind": 2})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.run("perspective.attach", json!({"ids": [id], "plane": "right"})).unwrap(); + let g = PerspectiveGrid::current(&app.session.active().unwrap().doc); + let (_, w) = g.plane_widgets().into_iter().find(|(p, _)| *p == Plane::Right).unwrap(); + app.select_tool("perspectiveGrid"); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::DoubleClick, w.x, w.y), view); + let d = app.ui.dialog.clone().expect("the plane's options opened"); + assert_eq!((d.kind.as_str(), d.str("plane").as_str(), d.f64("location", 1.0)), (KIND, "right", 0.0)); + let texts = shown(&mut app); + for s in ["Right Plane", "Location:", "Do Not Move", "Move All Objects", "Copy All Objects"] { + assert!(texts.iter().any(|t| t == s), "{s} in {texts:?}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("location".into(), json!(40)); + d.fields.insert("objects".into(), json!("copy")); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let doc = &app.session.active().unwrap().doc; + assert_eq!(PerspectiveGrid::current(doc).right_offset, 40.0); + assert_eq!(doc.layers[0].children().unwrap().len(), 2, "copied"); + // The UI command opens it too; unknown planes are refused. + app.run("ui.perspectivePlane", json!({"plane": "ground"})).unwrap(); + assert_eq!(heading(app.ui.dialog.as_ref().unwrap()), "Horizontal Plane"); + assert!(app.run("ui.perspectivePlane", json!({"plane": "up"})).is_err()); + } + + #[test] + fn a_click_to_size_rectangle_goes_on_the_active_plane() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app.run("perspective.grid.preset", json!({"kind": 2})).unwrap(); + app.run("perspective.plane.set", json!({"plane": "right"})).unwrap(); + app.select_tool("rectangle"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 470.0, 300.0), view); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("rectangle")); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.journal.last().unwrap().0, "perspective.draw"); + let st = app.session.active().unwrap(); + let id = *st.selection.objects.first().unwrap(); + assert_eq!(vectorcraft_tools::distort::perspective::attachment(st.doc.node(id).unwrap()).map(|a| a.0), Some(Plane::Right)); + } +} diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs new file mode 100644 index 000000000..52d8c2e6e --- /dev/null +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -0,0 +1,224 @@ +//! Edit → Perspective Grid Presets…: every preset in a list (the built-in views, protected, then +//! the saved ones) with the selected one's settings beside it; New… (a preset starting from the +//! selected one), Edit… (saved presets), Delete, Import… and Export… (the selected preset) under +//! the list. New… and Edit… open the preset editor (the Define Grid dialog, mode `edit`), whose OK +//! comes back here. +//! +//! Also View → Perspective Grid → One/Two/Three Point Perspective: the built-in views of the type +//! and slots for the saved presets of that type ([`SLOT`]). +//! +//! Field: `selected` (a preset's name). + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::perspgrid::{PRESET_EXTS, PRESET_FORMAT}; +use vectorcraft_tools::distort::perspective::GridDefinition; +use vectorcraft_tools::distort::perspective::define::{BUILTINS, is_builtin}; + +use super::DialogSpec; +use crate::menus::Item; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Perspective Grid Presets. +pub const KIND: &str = "perspectiveGridPresets"; + +/// The id prefix of the saved-preset slots in the One/Two/Three Point Perspective menus: +/// `ui.perspectiveUserPreset.` is the n-th saved preset of that type. +pub const SLOT: &str = "ui.perspectiveUserPreset"; + +/// Saved-preset slots per type. +pub const SLOTS: usize = 5; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| "Perspective Grid Presets".into(), + body, + confirm: |app, _| { + app.ui.dialog = None; + Ok(Value::Null) + }, + ok: None, + min_width: 600.0, + max_width: Some(600.0), + ..DialogSpec::FORM +}; + +/// Open the presets manager on `selected` (default: the first preset). +pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) { + app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(BUILTINS[0].0) }))); +} + +/// What a button asked for, done once the list and the details are drawn. +pub(super) enum Action { + Select(String), + New, + Edit, + Delete, + Import, + Export, +} + +/// The settings rows of a preset: (label, value). +fn details(p: &GridDefinition) -> Vec<(&'static str, String)> { + let u = p.unit(); + let len = |v: f64| u.format(u.to_pt(v)); + let mut rows = vec![ + ("Type:", ["One Point", "Two Point", "Three Point"].get(usize::from(p.kind.clamp(1, 3) - 1)).copied().unwrap_or_default().to_string()), + ("Units:", u.label().to_string()), + ("Scale:", format!("{}:{}", Unit::Points.number(p.scale[0]), Unit::Points.number(p.scale[1]))), + ("Gridline every:", len(p.gridline)), + ]; + if p.kind != 1 { + rows.push(("Viewing Angle:", format!("{:.1}°", p.angle))); + } + rows.extend([("Viewing Distance:", len(p.distance)), ("Horizon Height:", len(p.horizon_height))]); + if p.kind == 3 { + rows.push(("Third Vanishing Point:", format!("{}, {}", len(p.third_vp[0]), len(p.third_vp[1])))); + } + rows.push(("Opacity:", format!("{:.0}%", p.opacity))); + rows +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let presets = app.session.perspective_presets(); + let selected = d.str("selected"); + let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(selected.trim())).unwrap_or(0); + let Some(preset) = presets.get(i) else { return false }; + let builtin = is_builtin(&preset.name); + let mut act = None; + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(260.0); + widgets::dim_label(ui, "Presets:"); + widgets::list_box(ui, |ui| { + egui::ScrollArea::vertical().id_salt("perspective-presets").min_scrolled_height(240.0).max_height(240.0).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.set_min_height(240.0); + for (k, p) in presets.iter().enumerate() { + if ui.selectable_label(k == i, &p.name).clicked() { + act = Some(Action::Select(p.name.clone())); + } + } + }); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() { + act = Some(Action::New); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() { + act = Some(Action::Edit); + } + if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() { + act = Some(Action::Delete); + } + if widgets::flat_button(ui, "Import…", 60.0).clicked() { + act = Some(Action::Import); + } + if widgets::flat_button(ui, "Export…", 60.0).on_hover_text("Save the selected preset to a file").clicked() { + act = Some(Action::Export); + } + }); + }); + ui.add_space(18.0); + ui.vertical(|ui| { + ui.set_width(280.0); + ui.label(egui::RichText::new(&preset.name).color(t.text_strong).strong()); + ui.add_space(8.0); + egui::Grid::new("perspective-preset-details").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { + for (label, value) in details(preset) { + ui.label(egui::RichText::new(label).color(t.text_dim)); + ui.label(egui::RichText::new(value).color(t.text)); + ui.end_row(); + } + }); + if builtin { + ui.add_space(6.0); + ui.label(egui::RichText::new("Built-in presets don't change: New… starts an editable copy.").color(t.text_dim).size(11.5)); + } + }); + }); + let name = preset.name.clone(); + if let Some(a) = act + && let Err(e) = run(app, d, a, &name) + { + app.status(e); + } + false +} + +fn select(d: &mut Dialog, name: &str) { + d.fields.insert("selected".into(), json!(name)); +} + +pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> { + match act { + Action::Select(name) => select(d, &name), + // The editor replaces this dialog; its OK comes back here. + Action::New | Action::Edit => { + let original = matches!(act, Action::Edit).then_some(current); + super::perspective_grid::open_edit(app, original, current)?; + *d = app.ui.dialog.take().ok_or("no preset editor")?; + } + Action::Delete => { + let i = app.session.prefs.perspective_presets.iter().position(|p| p.name == current); + app.run("perspective.presets.delete", json!({ "name": current }))?; + // The one above it takes its place (the last built-in view above the first saved one). + let saved = &app.session.prefs.perspective_presets; + let next = + i.and_then(|i| i.checked_sub(1)).and_then(|i| saved.get(i)).map_or(BUILTINS[BUILTINS.len() - 1].0, |p| p.name.as_str()).to_string(); + select(d, &next); + } + Action::Import => { + // On the web the picked file arrives later and imports through `io::open_bytes`. + if let Some(f) = app.services.open_async.as_mut() { + f(); + return Ok(()); + } + let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; + let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?; + let r = app.run("perspective.presets.import", json!({ "path": path }))?; + if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { + select(d, first); + } + } + Action::Export => { + crate::io::save_command_output(app, "perspective.presets.export", PRESET_FORMAT, json!({ "names": [current] }))?; + } + } + Ok(()) +} + +// ---------- the One/Two/Three Point Perspective menus ---------- + +/// The saved preset a slot id (`ui.perspectiveUserPreset2.1`) stands for. +pub fn slot_preset(app: &VectorcraftApp, id: &str) -> Option { + let (kind, n) = id.strip_prefix(SLOT)?.split_once('.')?; + let (kind, n): (u8, usize) = (kind.parse().ok()?, n.parse().ok()?); + app.session.prefs.perspective_presets.iter().filter(|p| p.kind == kind).nth(n.checked_sub(1)?).map(|p| p.name.clone()) +} + +/// How many saved presets the slots list (the native menu is rebuilt when this changes). +pub fn listed_slots(app: &VectorcraftApp) -> usize { + (1..=3u8).map(|k| app.session.prefs.perspective_presets.iter().filter(|p| p.kind == k).count().min(SLOTS)).sum() +} + +/// View → Perspective Grid → One/Two/Three Point Perspective (`kind` 1–3): the built-in views of +/// the type, then the saved presets' slots (`slots`, the type's ids). +pub fn menu(kind: u8, slots: [&'static str; SLOTS]) -> Vec { + let mut items: Vec = BUILTINS + .iter() + .filter(|b| b.1 == kind) + .enumerate() + .map(|(i, b)| { + // The normal view is the type's preset (`kind`), as before named presets. + let p = if i == 0 { json!({ "kind": kind }) } else { json!({ "name": b.0 }) }; + Item::Cmd(b.0, "perspective.grid.preset", p) + }) + .collect(); + items.extend(slots.map(|id| Item::Cmd("User Preset", id, Value::Null))); + items +} diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs new file mode 100644 index 000000000..81699993c --- /dev/null +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -0,0 +1,104 @@ +//! WebAssembly plug-ins in the menus and their generated dialogs. +//! +//! Object › Plug-ins lists the installed object filters (`plugin.dialog {id}`), then Install +//! Plug-in… and Reload Plug-ins; Effect › Plug-ins lists the live-effect plug-ins, which open the +//! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per +//! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live +//! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs +//! at once. + +use serde_json::{Map, Value, json}; +use vectorcraft_plugins::{Kind, registry}; + +use super::{DialogSpec, form}; +use crate::VectorcraftApp; +use crate::menus::Item; +use crate::state::Dialog; + +/// The dialog kind of plug-in filters. +pub const KIND: &str = "plugin"; + +const CMD: &str = "plugin.run"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |d| d.str("__label"), body, confirm, preview: true, max_width: Some(420.0), ..DialogSpec::FORM }; + +/// A menu label for plug-in `name`: with an ellipsis when it opens a dialog. +fn label(name: &str, has_params: bool) -> &'static str { + let dots = if has_params && !name.ends_with('…') { "…" } else { "" }; + registry::intern(&format!("{name}{dots}")) +} + +/// Object › Plug-ins: the installed object filters, then Install Plug-in… and Reload Plug-ins. +pub fn object_menu() -> Vec { + let mut items: Vec = registry::list() + .iter() + .filter(|p| p.manifest().kind == Kind::Filter) + .map(|p| Item::Cmd(label(&p.manifest().name, !p.manifest().params.is_empty()), "plugin.dialog", json!({ "id": p.id() }))) + .collect(); + if items.is_empty() { + items.push(Item::Header("No filter plug-ins installed")); + } + items.extend([ + Item::Sep, + Item::Cmd("Install Plug-in…", "ui.installPlugin", Value::Null), + Item::Cmd("Reload Plug-ins", "plugin.reload", Value::Null), + ]); + items +} + +/// Effect › Plug-ins: the live-effect plug-ins (`None` while none is installed). Effects without +/// options apply at once, as built-in ones do. +pub fn effect_menu() -> Option { + let items: Vec = vectorcraft_effects::plugin_effects() + .into_iter() + .map(|e| match e.defaults.as_object().is_some_and(|o| o.is_empty()) { + true => Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id })), + false => Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })), + }) + .collect(); + (!items.is_empty()).then_some(Item::Sub("Plug-ins", items)) +} + +/// `plugin.dialog {id, params?}`: run filter `id` at once when it takes no parameters or `params` +/// are given, else open its dialog prefilled with its defaults. +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let id = p.get("id").and_then(Value::as_str).ok_or("missing `id` (see plugin.list)")?; + let plugin = registry::get(id).ok_or_else(|| format!("no plug-in {id:?} is installed"))?; + let m = plugin.manifest(); + if m.kind != Kind::Filter { + return Err(format!("{} is a live effect (Effect › Plug-ins)", m.name)); + } + let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); + if m.params.is_empty() || given.is_some() { + return app.run(CMD, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)})); + } + let mut fields: Map = m.defaults(); + fields.insert("__plugin".into(), json!(id)); + fields.insert("__label".into(), json!(m.name.trim_end_matches('…'))); + fields.insert("preview".into(), json!(true)); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + Ok(Value::Null) +} + +fn params(d: &Dialog) -> Value { + json!({"id": d.str("__plugin"), "params": form::params(d)}) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + // A plug-in uninstalled while its dialog is open closes it. + let Some(plugin) = registry::get(&d.str("__plugin")) else { return true }; + let m = plugin.manifest(); + if !m.description.is_empty() { + ui.label(egui::RichText::new(&m.description).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + ui.add_space(8.0); + } + form::schema_fields(ui, d, &m.params); + let (label, p) = (d.str("__label"), params(d)); + form::preview(app, ui, d, &label, CMD, p); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + form::commit_preview(app, CMD, params(d)) +} diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index c1ca0c7e6..75a8a4f75 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -38,5 +38,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}), ), }; - run_and_close(app, id, params) + // On the active plane while the perspective grid shows, sized in plane units from the click. + match vectorcraft_engine::perspective_click(&app.session, id, ¶ms, vectorcraft_geom::Point::new(x, y)) { + Some((id, params)) => run_and_close(app, &id, params), + None => run_and_close(app, id, params), + } } diff --git a/crates/ui-egui/src/dialogs/tests_perspective.rs b/crates/ui-egui/src/dialogs/tests_perspective.rs new file mode 100644 index 000000000..ee9332297 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_perspective.rs @@ -0,0 +1,227 @@ +//! Perspective Grid dialogs: Define Grid, the presets manager and the tool options. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_tools::distort::perspective::PerspectiveGrid; + +use crate::{VectorcraftApp, theme}; + +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 800, "height": 600})).unwrap(); + app +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +fn field(app: &VectorcraftApp, k: &str) -> Value { + app.ui.dialog.as_ref().and_then(|d| d.fields.get(k).cloned()).unwrap_or_default() +} + +fn set(app: &mut VectorcraftApp, k: &str, v: Value) { + app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(k.into(), v); +} + +fn grid(app: &VectorcraftApp) -> PerspectiveGrid { + PerspectiveGrid::effective(&app.session.active().unwrap().doc) +} + +#[test] +fn define_grid_opens_on_the_grid_and_applies_its_fields() { + let mut app = app(); + app.run("perspective.grid.preset", json!({"kind": 3})).unwrap(); + let g = grid(&app); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); + assert_eq!(field(&app, "kind"), json!(3), "prefilled from the grid, not a fixed type"); + assert!((field(&app, "gridline").as_f64().unwrap() - g.cell).abs() < 1e-9); + frame(&mut app); + // A new unit converts the lengths shown. + set(&mut app, "units", json!("inches")); + frame(&mut app); + assert!((field(&app, "gridline").as_f64().unwrap() - g.cell / 72.0).abs() < 1e-9); + // OK with nothing changed leaves the grid as it was. + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), None); + assert_eq!((grid(&app).cell, grid(&app).vp_left, grid(&app).kind), (g.cell, g.vp_left, 3)); + // A new viewing angle and colour apply in one undo step. + let undo = app.session.active().unwrap().history.undo.len(); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + set(&mut app, "angle", json!(30)); + set(&mut app, "rightColor", json!("#112233")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + let n = grid(&app); + assert!((n.viewing_angle() - 30.0).abs() < 1e-9); + assert_eq!(n.right_color.hex(), "#112233"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + // A refused value keeps the dialog open. + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + set(&mut app, "distance", json!(-5)); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); +} + +#[test] +fn define_grid_is_a_view_menu_item() { + let app = app(); + let entries = crate::menus::menu_entries(&app); + let e = entries.iter().find(|e| e.command.as_deref() == Some("ui.perspectiveGridDialog")).expect("View › Perspective Grid › Define Grid…"); + assert!(e.path.iter().any(|p| p == "Perspective Grid"), "{:?}", e.path); + assert!(e.enabled); +} + +#[test] +fn define_grid_loads_and_saves_presets() { + let mut app = app(); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + frame(&mut app); + assert_eq!(field(&app, "name"), json!("[2P-Normal View]")); + // Choosing a preset (an agent sets its fields; the menu loads them) keeps its name while the + // fields are its; a changed field makes the fields [Custom]. + set(&mut app, "angle", json!(20)); + frame(&mut app); + assert_eq!(field(&app, "name"), json!("")); + // Save Preset… asks for a name; OK saves the fields and comes back to Define Grid on it. + app.ui.dialog.as_mut().unwrap().fields.insert("__mode".into(), json!("save")); + set(&mut app, "__from", json!("define")); + set(&mut app, "name", json!("Shallow")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some(super::perspective_grid::KIND)); + assert_eq!((field(&app, "name"), field(&app, "__mode")), (json!("Shallow"), Value::Null)); + assert_eq!(app.session.prefs.perspective_presets[0].angle, 20.0); + frame(&mut app); + assert_eq!(field(&app, "name"), json!("Shallow"), "the fields are the saved preset's"); + super::confirm(&mut app).unwrap(); + assert_eq!(grid(&app).name, "Shallow"); + // View › Perspective Grid › Save Grid as Preset…: the grid under a new name. + app.run("ui.savePerspectivePreset", json!({})).unwrap(); + assert_eq!(field(&app, "name"), json!("Perspective Preset 1")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), None); + assert_eq!(app.session.prefs.perspective_presets.len(), 2); +} + +#[test] +fn the_presets_manager_creates_edits_and_deletes_presets() { + use super::perspective_presets::{self, Action}; + let mut app = app(); + app.run("ui.perspectivePresetsDialog", json!({"selected": "[3P-Normal View]"})).unwrap(); + frame(&mut app); + let press = |app: &mut VectorcraftApp, act: Action, current: &str| { + let mut d = app.ui.dialog.take().unwrap(); + perspective_presets::run(app, &mut d, act, current).unwrap(); + app.ui.dialog = Some(d); + frame(app); + }; + // New… opens the editor on a copy of the selected preset; its OK comes back here. + press(&mut app, Action::New, "[3P-Normal View]"); + assert_eq!((kind(&app), field(&app, "kind")), (Some(super::perspective_grid::KIND), json!(3))); + set(&mut app, "name", json!("Canyon")); + set(&mut app, "opacity", json!(90)); + super::confirm(&mut app).unwrap(); + assert_eq!((kind(&app), field(&app, "selected")), (Some(perspective_presets::KIND), json!("Canyon"))); + // Edit… renames it. + press(&mut app, Action::Edit, "Canyon"); + set(&mut app, "name", json!("Gorge")); + super::confirm(&mut app).unwrap(); + assert_eq!(field(&app, "selected"), json!("Gorge")); + assert_eq!(app.session.prefs.perspective_presets.iter().map(|p| p.name.as_str()).collect::>(), ["Gorge"]); + assert_eq!(app.session.prefs.perspective_presets[0].opacity, 90.0); + // Its type's menu lists it in a slot, which applies it. + assert_eq!(crate::menus::dynamic_label(&app, "ui.perspectiveUserPreset3.1", ""), "Gorge"); + assert!(crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset3.2") && crate::menus::hidden_slot(&app, "ui.perspectiveUserPreset2.1")); + app.run("ui.perspectiveUserPreset3.1", json!({})).unwrap(); + assert_eq!(grid(&app).name, "Gorge"); + // Delete selects the one above it. + press(&mut app, Action::Delete, "Gorge"); + assert!(app.session.prefs.perspective_presets.is_empty()); + assert_eq!(field(&app, "selected"), json!("[3P-Low View]")); + assert!(app.run("ui.perspectiveUserPreset3.1", json!({})).is_err()); +} + +#[test] +fn perspective_preset_menus_list_the_views_of_each_type() { + let app = app(); + let entries = crate::menus::menu_entries(&app); + let under = |sub: &str| entries.iter().filter(|e| e.path.iter().any(|p| p == sub)).map(|e| e.label.clone()).collect::>(); + assert_eq!(under("One Point Perspective"), ["[1P-Normal View]", "[1P-Low View]", "[1P-High View]"]); + assert_eq!(under("Three Point Perspective"), ["[3P-Normal View]", "[3P-Low View]"]); + let presets = entries.iter().find(|e| e.command.as_deref() == Some("ui.perspectivePresetsDialog")).unwrap(); + assert_eq!((presets.label.as_str(), presets.path.first().map(String::as_str)), ("Perspective Grid Presets…", Some("Edit"))); +} + +#[test] +fn perspective_grid_view_toggles_flip_their_labels_and_checks() { + use crate::menus::{checked, dynamic_label}; + let mut app = app(); + assert_eq!(dynamic_label(&app, "perspective.grid.show", ""), "Show Grid"); + assert_eq!(checked(&app, "perspective.grid.snap", &Value::Null), Some(true), "Snap to Grid is on by default"); + app.run("perspective.grid.show", json!({})).unwrap(); + app.run("perspective.grid.rulers", json!({})).unwrap(); + app.run("perspective.grid.lock", json!({})).unwrap(); + app.run("perspective.grid.lockStation", json!({})).unwrap(); + app.run("perspective.grid.snap", json!({})).unwrap(); + assert_eq!( + ["perspective.grid.show", "perspective.grid.rulers", "perspective.grid.lock"].map(|id| dynamic_label(&app, id, "")), + ["Hide Grid", "Hide Rulers", "Unlock Grid"] + ); + assert_eq!(checked(&app, "perspective.grid.lockStation", &Value::Null), Some(true)); + assert_eq!(checked(&app, "perspective.grid.snap", &Value::Null), Some(false)); + let entries = crate::menus::menu_entries(&app); + let labels: Vec<&str> = entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Perspective Grid")).map(|e| e.label.as_str()).collect(); + assert_eq!(&labels[..6], ["Hide Grid", "Hide Rulers", "Snap to Grid", "Unlock Grid", "Lock Station Point", "Define Grid…"]); +} + +#[test] +fn double_clicking_the_tool_opens_perspective_grid_options() { + let mut app = app(); + let r = app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap(); + assert_eq!(r["dialog"], json!(super::perspective_options::KIND)); + assert_eq!((field(&app, "show"), field(&app, "position")), (json!(true), json!("topLeft"))); + frame(&mut app); + set(&mut app, "position", json!("bottomLeft")); + frame(&mut app); + super::confirm(&mut app).unwrap(); + assert_eq!(kind(&app), None); + assert_eq!(app.session.prefs.perspective_widget.position.id(), "bottomLeft"); + app.run("tool.options", json!({"tool": "perspectiveGrid"})).unwrap(); + set(&mut app, "show", json!(false)); + super::confirm(&mut app).unwrap(); + assert!(!app.session.prefs.perspective_widget.show); +} + +#[test] +fn digit_keys_switch_planes_and_the_widget_follows_the_window() { + let mut app = app(); + app.run("perspective.grid.preset", json!({"kind": 2})).unwrap(); + let key = |app: &mut VectorcraftApp, k: egui::Key| { + let ctx = egui::Context::default(); + let press = egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + let input = egui::RawInput { events: vec![press], ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); + }; + key(&mut app, egui::Key::Num3); + assert_eq!(grid(&app).plane.id(), "right"); + key(&mut app, egui::Key::Num2); + assert_eq!(grid(&app).plane.id(), "ground"); + // The window the widget stays in: none before the canvas is laid out. + app.canvas_rect = Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0))); + let f = app.screen_frame().unwrap(); + assert_eq!(f.size, (800.0, 600.0)); + let z = app.view().unwrap().zoom; + assert!((f.right.x - 1.0 / z).abs() < 1e-9 && f.right.y.abs() < 1e-9); + assert_eq!(app.view_info().screen, Some(f)); +} diff --git a/crates/ui-egui/src/dialogs/tests_scale.rs b/crates/ui-egui/src/dialogs/tests_scale.rs new file mode 100644 index 000000000..efe9bdc10 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_scale.rs @@ -0,0 +1,41 @@ +//! The shared dialog frame at every UI scale (Preferences → User Interface → UI Scaling zooms the +//! whole interface, so a window holds fewer points). + +use serde_json::json; +use vectorcraft_engine::Session; + +use super::*; + +/// The save prompt's rect after a few frames in a window of `physical` pixels at UI scale `zoom`. +fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, zoom: f32) -> (egui::Rect, egui::Rect) { + ctx.set_zoom_factor(zoom); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, physical / zoom); + for _ in 0..10 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear(); + } + let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", crate::unsaved::KIND)))).unwrap(); + (rect, screen) +} + +#[test] +fn the_save_prompt_fits_its_content_and_the_window_at_every_ui_scale() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + for name in ["Untitled-1", "Quarterly campaign poster, final revision with the printer's notes"] { + app.ui.dialog = Some(Dialog::new(crate::unsaved::KIND, json!({"index": 0, "name": name, "then": "close"}))); + // Scaled up and down while the prompt is open, in a full-size and in the smallest window. + for (w, h, zoom) in + [(1440.0, 900.0, 1.0), (1440.0, 900.0, 2.0), (1440.0, 900.0, 1.5), (800.0, 500.0, 2.0), (800.0, 500.0, 1.0), (1440.0, 900.0, 0.75)] + { + let (rect, screen) = prompt(&mut app, &ctx, egui::vec2(w, h), zoom); + let at = format!("{name}: {w} × {h} px at {zoom}×: {rect:?} in {screen:?}"); + assert!(screen.contains_rect(rect), "inside the window, {at}"); + // As tall as its text and buttons (no empty band around the buttons). + assert!(rect.height() < 200.0, "{at}"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index a56e35fdd..80d88588d 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -56,6 +56,20 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { } return; } + // The Blend tool's double-click and Alt-click: Blend Options. + super::blend_options::KIND => { + if let Err(e) = super::blend_options::open(app) { + app.status(e); + } + return; + } + // Double-clicking a plane widget of the perspective grid: that plane's options. + super::perspective_plane::KIND => { + if let Err(e) = super::perspective_plane::open(app, &p) { + app.status(e); + } + return; + } // Double-clicking a slice with the Slice Selection tool: Slice Options. super::slices::OPTIONS => { if let Err(e) = super::slices::open_options(app) { diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 67ef44ce2..f6232ed8b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -62,19 +62,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border))) .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; - for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { - if gi > 0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); - ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); - } - for id in group.iter() { - let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; - let open = app.ui.open_panel.as_deref() == Some(*id); - if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + // The icons scroll in a window too short for them. + widgets::strip_scroll(ui, "icon_column", |ui| { + for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() { + if gi > 0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); + ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider)); + } + for id in group.iter() { + let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue }; + let open = app.ui.open_panel.as_deref() == Some(*id); + if widgets::icon_button(ui, icon, label, open, 30.0).clicked() { + app.ui.open_panel = if open { None } else { Some(id.to_string()) }; + } } } - } + }); }); } @@ -115,3 +118,34 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.open_panel = None; } } + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use super::*; + use crate::toolbar::tests::{wheel, widget_rects}; + + #[test] + fn the_icon_column_scrolls_in_a_short_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut frame = |time: f64, events| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(900.0, 400.0)); + let input = egui::RawInput { time: Some(time), events, screen_rect: Some(screen), ..Default::default() }; + ctx.run_ui(input, |ui| show(&mut app, ui)).textures_delta.clear(); + widget_rects(&ctx, vec2(30.0, 30.0)) + }; + let icons = frame(0.0, vec![]); + assert!(icons.last().unwrap().bottom() > 400.0, "the last panel icon starts below the window"); + frame(0.1, wheel(icons[0].center(), 2000.0)); + let mut icons = vec![]; + for k in 2..40 { + icons = frame(f64::from(k) * 0.1, vec![]); + } + assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last()); + } +} diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index a1dc4fc57..6d6758cbb 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -75,12 +75,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { }); ui.add_space(10.0); widgets::subheader(ui, "Replace With Font"); - let families = vectorcraft_text::FontDb::global().families(); let fam = d.str("family"); ui.horizontal(|ui| { - let names: Vec<&str> = families.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "ff-family", &fam, &names, 220.0) { - d.fields.insert("family".into(), json!(families[i])); + if let Some(f) = widgets::font_dropdown(ui, "ff-family", &fam, 220.0) { + d.fields.insert("family".into(), json!(f)); d.fields.insert("style".into(), json!("")); } let styles = vectorcraft_text::FontDb::global().styles(&d.str("family")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 000000000..188c730de --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,153 @@ +//! Interface translations. Command ids, document text and file names remain stable. +//! Untranslated labels fall back to English so coverage can grow incrementally. Every entry is a +//! label VectorCraft's menus show (tested), so labels from other apps don't creep in. + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, +} + +impl Language { + pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + _ => None, + } + } + + pub fn tr(self, text: &str) -> &str { + if self == Self::Ja + && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) + { + return japanese; + } + text + } +} + +const JAPANESE: &[(&str, &str)] = &[ + ("Object", "オブジェクト"), + ("Effect", "効果"), + ("Settings…", "環境設定…"), + ("Type", "書式"), + ("Select", "選択"), + ("Window", "ウィンドウ"), + ("Language", "表示言語"), + ("Save As…", "別名で保存…"), + ("New…", "新規…"), + ("Horizontal", "横書き"), + ("Vertical", "縦書き"), + ("Type Orientation", "組み方向"), + ("Layers", "レイヤー"), + ("History", "履歴"), + ("Properties", "プロパティ"), + ("Color", "カラー"), + ("Tools", "ツール"), + ("Zoom In", "ズームイン"), + ("Zoom Out", "ズームアウト"), + ("Copy", "コピー"), + ("Cut", "切り取り"), + ("Paste", "貼り付け"), + ("Deselect", "選択を解除"), + ("Export", "書き出し"), + ("Export As…", "形式を指定して書き出し…"), + ("File", "ファイル"), + ("Edit", "編集"), + ("View", "表示"), + ("Help", "ヘルプ"), + ("Preferences…", "環境設定…"), + ("Open…", "開く…"), + ("Save", "保存"), + ("Revert", "保存済みの状態に戻す"), + ("Print…", "印刷…"), + ("Undo", "取り消し"), + ("Redo", "やり直し"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translations_are_unique_and_preserve_unknown_text() { + for (i, (en, ja)) in JAPANESE.iter().enumerate() { + assert!(!ja.is_empty()); + assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); + assert_eq!(Language::En.tr(en), *en); + } + assert_eq!(Language::Ja.tr("File"), "ファイル"); + assert_eq!(Language::Ja.tr("日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(Language::parse("xx"), None); + } + + #[test] + fn every_translated_label_is_a_menu_label() { + fn walk(items: &[crate::menus::Item], out: &mut Vec<&'static str>) { + for i in items { + match i { + crate::menus::Item::Cmd(l, ..) | crate::menus::Item::Todo(l, _) | crate::menus::Item::Header(l) => out.push(l), + crate::menus::Item::Sub(l, children) => { + out.push(l); + walk(children, out); + } + crate::menus::Item::Sep => {} + } + } + } + let mut labels = vec![]; + for (title, items) in crate::menus::menu_tree() { + labels.push(title); + walk(&items, &mut labels); + } + for (en, _) in JAPANESE { + assert!(labels.contains(en), "`{en}` isn't a VectorCraft menu label"); + } + } + + #[test] + fn language_command_validates_and_persists_without_a_document() { + let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "ja"})).unwrap().unwrap(); + assert_eq!(app.ui.language, Language::Ja); + assert_eq!(crate::menus::checked(&app, "app.language", &serde_json::json!({"lang": "ja"})), Some(true)); + assert!(crate::menus::run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "xx"})).unwrap().is_err()); + assert_eq!(app.ui.language, Language::Ja); + let saved = serde_json::to_string(&app.ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::Ja); + } + + #[test] + fn japanese_glyphs_are_available_without_system_fonts() { + if !vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese()) { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + let families: Vec<_> = fonts.definitions().families.keys().cloned().collect(); + for family in families { + let font = egui::FontId::new(13.0, family); + for ch in "日本語ファイル編集".chars() { + assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}"); + } + } + }); + } +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 546cd609c..1bd751460 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -99,12 +99,14 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-join-round", include_bytes!("../../../assets/icons/dc-join-round.svg")), ("dc-knife", include_bytes!("../../../assets/icons/dc-knife.svg")), ("dc-line", include_bytes!("../../../assets/icons/dc-line.svg")), + ("dc-line-cut", include_bytes!("../../../assets/icons/dc-line-cut.svg")), ("dc-list-view", include_bytes!("../../../assets/icons/dc-list-view.svg")), ("dc-live-bucket", include_bytes!("../../../assets/icons/dc-live-bucket.svg")), ("dc-live-select", include_bytes!("../../../assets/icons/dc-live-select.svg")), ("dc-mask-none", include_bytes!("../../../assets/icons/dc-mask-none.svg")), ("dc-measure", include_bytes!("../../../assets/icons/dc-measure.svg")), ("dc-mesh", include_bytes!("../../../assets/icons/dc-mesh.svg")), + ("dc-mirror-cut", include_bytes!("../../../assets/icons/dc-mirror-cut.svg")), ("dc-new-fill", include_bytes!("../../../assets/icons/dc-new-fill.svg")), ("dc-new-item", include_bytes!("../../../assets/icons/dc-new-item.svg")), ("dc-new-stroke", include_bytes!("../../../assets/icons/dc-new-stroke.svg")), @@ -137,6 +139,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-pucker", include_bytes!("../../../assets/icons/dc-pucker.svg")), ("dc-puppet", include_bytes!("../../../assets/icons/dc-puppet.svg")), ("dc-rearrange", include_bytes!("../../../assets/icons/dc-rearrange.svg")), + ("dc-rect-cut", include_bytes!("../../../assets/icons/dc-rect-cut.svg")), ("dc-rect-grid", include_bytes!("../../../assets/icons/dc-rect-grid.svg")), ("dc-reference-point", include_bytes!("../../../assets/icons/dc-reference-point.svg")), ("dc-remove-brush", include_bytes!("../../../assets/icons/dc-remove-brush.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index d4c2d52fd..2cf8b6b48 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -64,6 +64,9 @@ pub fn tool_icon(name: &str) -> &'static str { "tool-eraser" => "eraser", "tool-scissors" => "scissors", "tool-knife" => "dc-knife", + "tool-mirror-cut" => "dc-mirror-cut", + "tool-line-cut" => "dc-line-cut", + "tool-rect-cut" => "dc-rect-cut", "tool-rotate" => "rotate-ccw", "tool-reflect" => "flip-horizontal-2", "tool-scale" => "scaling", diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 73c5da31c..4feef1f45 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,14 +15,22 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"]; /// Open bytes of any readable format as a new document (templates open untitled); swatch and -/// graphic style library files open in the library panel and flattener, PDF and print presets -/// files are imported. +/// graphic style library files open in the library panel and flattener, PDF, print and perspective +/// grid presets files are imported. pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option) -> Result<(), String> { let ext = fileio::extension(name); + // A WebAssembly plug-in is installed. + if vectorcraft_engine::cmd::plugin::EXTS.contains(&ext.as_str()) { + let r = + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?; + app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name))); + return Ok(()); + } let presets = [ (vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"), (vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"), (vectorcraft_engine::cmd::printpresets::PRESET_EXTS, "print.presets.import", "print presets"), + (vectorcraft_engine::cmd::perspgrid::PRESET_EXTS, "perspective.presets.import", "perspective grid presets"), ]; if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) { let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?; @@ -65,6 +73,19 @@ fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result) -> Result { + let path = match path { + Some(p) => p, + None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), + None => pick_open(app, &FilePick { filters: vec![("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?, + }; + let r = app.run("plugin.install", json!({ "path": path }))?; + app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); + Ok(r) +} + /// File → Open… pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { if let Some(f) = app.services.open_async.as_mut() { @@ -134,12 +155,25 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: } /// Where a file goes when no path was given: the suggested name on the web (a download), else the -/// save panel's choice. +/// save panel's choice, given the suggested name's extension when it names no file type (a name +/// typed without one). fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result { if is_web(app) { return Ok(pick.name.clone()); } - app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into()) + let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?; + Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true)) +} + +/// `path` as typed in a save panel: kept when it ends in `ext` or its extension names a format +/// `keeps` takes, else with `.ext` added. +fn with_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let has = fileio::extension(path); + if ext.is_empty() || has.eq_ignore_ascii_case(ext) || fileio::format_for_name(path).is_some_and(keeps) { + path.to_string() + } else { + format!("{path}.{ext}") + } } /// The active document's file name with extension `ext`, and its folder. @@ -172,10 +206,7 @@ pub fn remember_view(app: &mut VectorcraftApp) { /// A path typed in a save panel: kept when its extension names a save format (the panel's file /// type), else `f`'s extension is added. fn with_save_extension(path: &str, f: &Format) -> String { - match fileio::format_for_name(path) { - Some(g) if SAVE_FORMATS.contains(&g.id) => path.to_string(), - _ => format!("{path}.{}", f.extensions[0]), - } + with_extension(path, f.extensions[0], |g| SAVE_FORMATS.contains(&g.id)) } /// Write every file of an export (one per artboard, linked images) for the destination `path`. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1befbd13a..e4b87c9eb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod cursors; pub mod dialogs; pub mod dock; pub mod find_font; +pub mod i18n; pub mod icon_data; pub mod icons; pub mod io; @@ -33,10 +34,13 @@ pub mod sysclip; pub mod theme; pub mod titlebar; pub mod toolbar; +mod ui_fonts; pub mod unsaved; pub mod widgets; pub mod workspaces; +#[cfg(test)] +mod tests_adjust; #[cfg(test)] mod tests_aisave; #[cfg(test)] @@ -44,41 +48,63 @@ mod tests_background; #[cfg(test)] mod tests_clipboard; #[cfg(test)] +mod tests_cut; +#[cfg(test)] +mod tests_distortkeys; +#[cfg(test)] mod tests_docsetup; #[cfg(test)] +mod tests_fonts; +#[cfg(test)] +mod tests_home; +#[cfg(test)] mod tests_labels; #[cfg(test)] mod tests_nativeoptions; #[cfg(test)] +mod tests_numfields; +#[cfg(test)] mod tests_overprint; #[cfg(test)] mod tests_paintchips; #[cfg(test)] +mod tests_pastechords; +#[cfg(test)] mod tests_pdfoutput; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_plugins; +#[cfg(test)] mod tests_printps; #[cfg(test)] mod tests_printtiling; #[cfg(test)] +mod tests_puppetwarp; +#[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_recovery; #[cfg(test)] mod tests_save; #[cfg(test)] +mod tests_saveext; +#[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgsave; #[cfg(test)] +mod tests_synthetic; +#[cfg(test)] mod tests_sysclip; #[cfg(test)] mod tests_sysclip_emf; #[cfg(test)] mod tests_transparencygrid; +#[cfg(test)] +mod tests_widthtool; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; @@ -182,6 +208,8 @@ pub struct CanvasCache { pub slices: Option, /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). pub print_tiling: Option, + /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). + pub selection_box: Option<((u64, u64, bool), Option)>, } /// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). @@ -237,6 +265,8 @@ pub struct VectorcraftApp { pub synthetic: Vec, styled: bool, fonts_ready: bool, + /// Installed fonts added to the UI's for characters its own fonts lack (CJK names…). + ui_fonts: ui_fonts::UiFonts, frame: u64, last_time: f64, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. @@ -267,6 +297,10 @@ pub struct VectorcraftApp { pub background: background::Background, /// Data Recovery's timer and startup question ([`recovery`]). pub recovery: recovery::Timer, + /// Modifiers the keyboard holds (from the host's input), given back after synthetic input. + host_modifiers: egui::Modifiers, + /// Synthetic input set the modifiers egui holds (see [`Self::raw_input_hook`]). + synthetic_modifiers: bool, } /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. @@ -274,6 +308,8 @@ const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { pub fn new(mut session: Session, services: Services) -> Self { + // The font menus and the first file opened need the installed fonts: catalog them now. + vectorcraft_text::FontDb::global().scan_in_background(); if let Some(store) = &services.recovery_store { session.recovery.set_store(store.clone()); } @@ -292,6 +328,7 @@ impl VectorcraftApp { worker_started: false, slices: None, print_tiling: None, + selection_box: None, }, perf: Perf::default(), integrated_titlebar: false, @@ -309,6 +346,7 @@ impl VectorcraftApp { synthetic: vec![], styled: false, fonts_ready: false, + ui_fonts: Default::default(), frame: 0, last_time: 0.0, canvas_rect: None, @@ -320,6 +358,8 @@ impl VectorcraftApp { paste_chord: Default::default(), background: Default::default(), recovery: Default::default(), + host_modifiers: Default::default(), + synthetic_modifiers: false, } } @@ -354,9 +394,23 @@ impl VectorcraftApp { show_bbox: self.ui.view.bounding_box, snap_to_point: self.ui.view.snap_to_point, corner_widgets: self.ui.view.corner_widgets, + screen: self.screen_frame(), } } + /// The canvas on screen in document coordinates (none before it is laid out). + pub fn screen_frame(&self) -> Option { + let (rect, view) = (self.canvas_rect?, self.view()?); + let xf = canvas::Xf::new(rect, view); + let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); + Some(vectorcraft_tools::ScreenFrame { + origin: xf.to_doc(rect.left_top()), + right: px(1.0, 0.0), + down: px(0.0, 1.0), + size: (f64::from(rect.width()), f64::from(rect.height())), + }) + } + /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { @@ -550,6 +604,23 @@ pub fn now_ms() -> f64 { } } +impl VectorcraftApp { + /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The + /// canvas and the transform fields read it every frame: it is measured once per revision. + pub fn selection_box(&mut self) -> Option { + let st = self.session.active()?; + let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); + if let Some((k, b)) = self.canvas.selection_box + && k == key + { + return b; + } + let b = self.session.transform_box(&st.selection.objects); + self.canvas.selection_box = Some((key, b)); + b + } +} + /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are /// called from the host's `eframe::App` impl. impl VectorcraftApp { @@ -652,20 +723,52 @@ impl VectorcraftApp { place::drop_files(self, files, target); } - /// Inject synthetic events (one press/release step per frame). + /// Inject synthetic events (one press/release step per frame). Handlers read the modifiers + /// egui holds (`i.modifiers`), so a synthetic key or button holds its own for the frames it + /// spans (a drag's moves included); the keyboard's come back after. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if self.synthetic.is_empty() { - return; + for e in &raw.events { + match e { + egui::Event::ModifiersChanged(m) => self.host_modifiers = *m, + egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE, + _ => {} + } } + let Some(first) = self.synthetic.first() else { + if std::mem::take(&mut self.synthetic_modifiers) { + raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)); + } + return; + }; // Pointer events go one per frame so egui sees presses, drags and releases as real input; // keyboard sequences go up to the key release. - let n = match self.synthetic[0] { + let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { raw.events.push(egui::Event::PointerMoved(*p)); } + let (now, later) = self.synthetic.split_at(n.min(self.synthetic.len())); + // This frame's key or button, else the button a drag holds down (released later). + let held = now + .iter() + .find_map(|e| match e { + egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } => Some(*modifiers), + _ => None, + }) + .or_else(|| match later.iter().find(|e| matches!(e, egui::Event::PointerButton { .. })) { + Some(egui::Event::PointerButton { pressed: false, modifiers, .. }) => Some(*modifiers), + _ => None, + }); + match held { + Some(m) => { + raw.events.push(egui::Event::ModifiersChanged(m)); + self.synthetic_modifiers = true; + } + None if std::mem::take(&mut self.synthetic_modifiers) => raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)), + None => {} + } raw.events.extend(self.synthetic.drain(..n)); } @@ -713,6 +816,7 @@ impl VectorcraftApp { if self.custom_titlebar { titlebar::resize_zones(ui); } + self.ui_fonts.frame(&ctx); self.perf.frame_ms = now_ms() - t0; let _ = json!(null); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e7fcc540a..e00ff3400 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -13,6 +13,7 @@ use crate::VectorcraftApp; use crate::io; use crate::state::{DockTab, ICON_PANELS, next_zoom}; use crate::theme::{self, Brightness, Tokens}; +use crate::widgets; #[derive(Clone, Debug)] pub enum Item { @@ -49,6 +50,7 @@ use Item::Sep; /// UI-level commands: (id, label, shortcut, params doc). pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ + ("app.language", "Interface Language", "", "{lang: en|ja} — persistent interface language"), ("file.open", "Open…", "Cmd+O", "{path?}"), ( "file.save", @@ -110,6 +112,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), ("shortcuts.set", "Set Keyboard Shortcut", "", "{id: command id or tool:, shortcut: \"Cmd+Shift+K\" | \"\" (none) | null (default), force?}"), @@ -168,7 +171,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.workspace.list", "List Workspaces", "", "{}"), ("window.newWindow", "New Window", "", "{}"), ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), - ("tool.setOption", "Tool Option", "", "{key, value}"), + ( + "tool.setOption", + "Tool Option", + "", + "{key, value} | {values: {key: value…}}, tool?: id (default: the active tool) → the tool's options (`{}` reads them). The options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, drawing tools…) last across tool switches and are saved with the preferences; another tool's are stored for when it is chosen", + ), ( "effect.dialog", "Effect…", @@ -226,7 +234,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true})", + "{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values})", ), ( "ui.colorGuideOptions", @@ -359,7 +367,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.menuDialog", "Menu Dialog", "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates)", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone|object.envelope.makeWithWarp|object.envelope.resetWithWarp|object.envelope.makeWithMesh|object.envelope.resetWithMesh|object.envelope.options} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone, envelopeWarp, envelopeMesh, envelopeOptions; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates; the envelope dialogs start from the selected envelope, Make opens as Reset (`reset: true`) while one is selected)", ), ( "ui.widthPointEdit", @@ -528,10 +536,148 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{name?: a saved preset to edit | preset?: the preset a new one starts from (default [Default])} open the preset editor (dialog `printPreset`: the Print dialog's settings fields plus `name`); OK runs print.presets.save and returns to Print Presets", ), + ( + "plugin.dialog", + "Plug-in…", + "", + "{id: an object filter plug-in (plugin.list), params?: {…}} Object › Plug-ins: run the filter at once when it takes no parameters or params are given (plugin.run), else open its dialog (dialog `plugin`: a field per parameter, `__plugin` the id, live preview; OK runs plugin.run as one undo step)", + ), + ( + "ui.installPlugin", + "Install Plug-in…", + "", + "{path?: a .wasm file} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too)", + ), + ( + "ui.perspectiveGridDialog", + "Define Perspective Grid…", + "", + "{} open View › Perspective Grid › Define Grid (dialog `perspectiveGrid`, prefilled from the grid: the fields of perspective.grid.define); OK runs perspective.grid.define", + ), + ( + "ui.perspectivePresetsDialog", + "Perspective Grid Presets…", + "", + "{selected?} open Edit › Perspective Grid Presets (dialog `perspectiveGridPresets`, field `selected`): New… and Edit… open the preset editor (dialog `perspectiveGrid` with `__mode` edit), whose OK runs perspective.presets.save and comes back; Delete, Import… and Export… run perspective.presets.*", + ), + ( + "ui.savePerspectivePreset", + "Save Grid as Preset…", + "", + "{} open View › Perspective Grid › Save Grid as Preset (dialog `perspectiveGrid` with `__mode` save, `name` a new preset name): OK runs perspective.presets.save with the fields", + ), + ( + "ui.perspectiveUserPreset1.1", + "One Point Perspective Preset 1", + "", + "{} apply the 1. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.2", + "One Point Perspective Preset 2", + "", + "{} apply the 2. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.3", + "One Point Perspective Preset 3", + "", + "{} apply the 3. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.4", + "One Point Perspective Preset 4", + "", + "{} apply the 4. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset1.5", + "One Point Perspective Preset 5", + "", + "{} apply the 5. saved 1-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.1", + "Two Point Perspective Preset 1", + "", + "{} apply the 1. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.2", + "Two Point Perspective Preset 2", + "", + "{} apply the 2. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.3", + "Two Point Perspective Preset 3", + "", + "{} apply the 3. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.4", + "Two Point Perspective Preset 4", + "", + "{} apply the 4. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset2.5", + "Two Point Perspective Preset 5", + "", + "{} apply the 5. saved 2-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.1", + "Three Point Perspective Preset 1", + "", + "{} apply the 1. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.2", + "Three Point Perspective Preset 2", + "", + "{} apply the 2. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.3", + "Three Point Perspective Preset 3", + "", + "{} apply the 3. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.4", + "Three Point Perspective Preset 4", + "", + "{} apply the 4. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.perspectiveUserPreset3.5", + "Three Point Perspective Preset 5", + "", + "{} apply the 5. saved 3-point perspective grid preset (perspective.grid.preset)", + ), + ( + "ui.blendOptions", + "Blend Options…", + "", + "{} open Blend Options (Object › Blend › Blend Options…, the Blend tool's double-click, Alt-click and toolbar button; dialog `blendOptions`: spacing smooth|steps|distance, steps, distance (pt), orientation page|path, preview) on the selected blend's options, previewed live: OK runs object.blend.options as one undo step; with no blend selected it sets what new blends start with", + ), + ( + "ui.perspectivePlane", + "Perspective Plane Options…", + "", + "{plane?: left|right|ground (default: the active plane)} open the plane's options, as double-clicking its plane widget does (dialog `perspectivePlane`: location (pt along the plane's normal), objects: none|move|copy): OK runs perspective.plane.move", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + if id == "app.language" { + let language = p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse); + let Some(language) = language else { return Some(Err("lang must be en or ja".into())) }; + app.ui.language = language; + return Some(Ok(json!(language))); + } if let Some(r) = crate::panels::character::intercept_text_command(app, id) { return Some(r); } @@ -548,6 +694,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + app.ui.home = Some(home_key(app)); + Ok(Value::Null) + } "file.open" => match s("path") { Some(path) => io::open_path(app, &path).map(|_| Value::Null), None => io::open_dialog(app).map(|_| Value::Null), @@ -768,11 +918,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err(format!("unknown tool `{t}`")), None => Err("missing `tool`".into()), }, - "tool.setOption" => { - let k = s("key").unwrap_or_default(); - app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null)); - Ok(app.session.tool_options()) - } + "tool.setOption" => app.session.set_tool_option_cmd(p), "effect.dialog" => crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), "ui.paramDialog" => { @@ -866,12 +1012,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::expand::open(app), "attributes.openUrl" => crate::panels::attributes::open_url(app, p), "ui.spotColors" => crate::dialogs::spot_colors::open(app), - "ui.menuDialog" => match s("command").as_deref().and_then(menu_dialog) { - Some((kind, fields)) => { - app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); - Ok(Value::Null) - } - None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + "ui.menuDialog" => match s("command").as_deref() { + Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c), + c => match c.and_then(menu_dialog) { + Some((kind, fields)) => { + app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); + Ok(Value::Null) + } + None => Err("`command` must be a command whose menu item opens a dialog (see ui.menuDialog)".into()), + }, }, "ui.widthPointEdit" => crate::dialogs::width_point::open(app, p), "ui.colorGuideLimit" => crate::panels::color_guide::set_limit(app, p), @@ -939,6 +1088,20 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::print::open_preset(app, p), + "plugin.dialog" => crate::dialogs::plugin::open(app, p), + "ui.installPlugin" => io::install_plugin(app, s("path")), + "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), + "ui.perspectivePresetsDialog" => { + crate::dialogs::perspective_presets::open(app, s("selected").as_deref()); + Ok(Value::Null) + } + "ui.savePerspectivePreset" => crate::dialogs::perspective_grid::open_save(app), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => match crate::dialogs::perspective_presets::slot_preset(app, id) { + Some(name) => app.run("perspective.grid.preset", json!({ "name": name })), + None => Err("no such perspective grid preset".into()), + }, + "ui.blendOptions" => crate::dialogs::blend_options::open(app), + "ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p), _ => return None, }; Some(r) @@ -946,8 +1109,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Option { + if id == "app.language" { + return Some(p.get("lang").and_then(Value::as_str).and_then(crate::i18n::Language::parse) == Some(app.ui.language)); + } let v = &app.ui.view; Some(match id { + "type.orientation.vertical" | "type.orientation.horizontal" => { + let st = app.session.active()?; + let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) { + Some(vectorcraft_engine::doc::NodeKind::Text(t)) => Some(t), + _ => None, + })?; + t.vertical == (id == "type.orientation.vertical") + } "view.outline" => v.outline, "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, @@ -986,6 +1160,8 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.proofColors" => vectorcraft_render::proof::view().proof_colors, "view.overprintPreview" => vectorcraft_render::proof::view().overprint, "view.proofSetup" => p.get("target").and_then(Value::as_str) == Some(vectorcraft_render::proof::view().setup.target.id().as_str()), + "perspective.grid.snap" => perspective_grid(app)?.snap, + "perspective.grid.lockStation" => perspective_grid(app)?.lock_station, "file.documentColorMode" | "object.convertDocumentColorMode" => { let cmyk = app.session.active().is_some_and(|d| d.doc.color_mode == vectorcraft_engine::doc::ColorMode::Cmyk); p.get("mode").and_then(Value::as_str) == Some(if cmyk { "cmyk" } else { "rgb" }) @@ -994,6 +1170,11 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { }) } +/// The active document's perspective grid (View → Perspective Grid toggles). +fn perspective_grid(app: &VectorcraftApp) -> Option { + app.session.active().map(|d| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&d.doc)) +} + /// Label for toggles whose text flips (Outline/Preview, Hide/Show …). pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { let v = &app.ui.view; @@ -1031,6 +1212,17 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if id.starts_with("file.openRecent") => recent_slot(app, id) .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) .unwrap_or_else(|| "—".into()), + // Edit Contents reads Edit Envelope while an envelope's contents are being edited. + "object.envelope.editContents" => { + let editing = app.session.active().is_some_and(|st| { + st.selection + .objects + .first() + .and_then(|id| vectorcraft_doc::live::envelope_of(&st.doc, *id)) + .is_some_and(|e| matches!(e.kind, vectorcraft_doc::NodeKind::Envelope { editing: true, .. })) + }); + if editing { "Edit Envelope" } else { "Edit Contents" }.into() + } "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), id if id.starts_with(crate::panels::swatches::USER_SLOT) => { @@ -1039,6 +1231,12 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) } + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) + } + "perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(), + "perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(), + "perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(), _ => label.into(), } } @@ -1050,6 +1248,18 @@ fn hidden_when_disabled(id: &str) -> bool { || id.starts_with("file.openRecent") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT) + || id.starts_with(crate::dialogs::perspective_presets::SLOT) +} + +/// Menu items another item stands in for right now: Envelope Distort's Reset with Warp and Reset +/// with Mesh take the place of Make with Warp and Make with Mesh while an envelope is selected. +fn swapped_out(app: &VectorcraftApp, id: &str) -> bool { + let reset = match id { + "object.envelope.makeWithWarp" | "object.envelope.makeWithMesh" => false, + "object.envelope.resetWithWarp" | "object.envelope.resetWithMesh" => true, + _ => return false, + }; + enabled(app, "object.envelope.release") != reset } /// File → Open Recent Files slots (Preferences → File Handling shows 0–30 of them). @@ -1104,9 +1314,44 @@ pub fn listed_slots(app: &VectorcraftApp) -> usize { use vectorcraft_engine::cmd::{stylelib, swatchlib}; let user = |libs: Vec| libs.iter().filter(|l| l.category == "user").count().min(10); let views = app.session.active().map_or(0, |d| d.doc.views.len().min(10)); - views + io::recent_files(app).len().min(RECENT_IDS.len()) + user(swatchlib::libraries(&app.session)) + user(stylelib::libraries(&app.session)) + views + + io::recent_files(app).len().min(RECENT_IDS.len()) + + user(swatchlib::libraries(&app.session)) + + user(stylelib::libraries(&app.session)) + + crate::dialogs::perspective_presets::listed_slots(app) } +/// Changes whenever a plug-in is installed or removed: Object › Plug-ins and Effect › Plug-ins +/// list them, so a menu built once (the native one) is rebuilt. +pub fn plugin_revision() -> u64 { + vectorcraft_plugins::registry::revision() +} + +/// View → Perspective Grid → One/Two/Three Point Perspective: the saved-preset slots of each type. +const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] = [ + [ + "ui.perspectiveUserPreset1.1", + "ui.perspectiveUserPreset1.2", + "ui.perspectiveUserPreset1.3", + "ui.perspectiveUserPreset1.4", + "ui.perspectiveUserPreset1.5", + ], + [ + "ui.perspectiveUserPreset2.1", + "ui.perspectiveUserPreset2.2", + "ui.perspectiveUserPreset2.3", + "ui.perspectiveUserPreset2.4", + "ui.perspectiveUserPreset2.5", + ], + [ + "ui.perspectiveUserPreset3.1", + "ui.perspectiveUserPreset3.2", + "ui.perspectiveUserPreset3.3", + "ui.perspectiveUserPreset3.4", + "ui.perspectiveUserPreset3.5", + ], +]; + /// Type → Recent Fonts slots. const RECENT_FONT_IDS: [&str; 10] = [ "type.recentFont1", @@ -1178,6 +1423,12 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "file.exportSelection" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "css.copy" | "css.exportFile" => app.session.active().is_some(), "print.printerSetup" => app.services.print.as_ref().is_some_and(|s| s.has_setup()), + "plugin.dialog" => app.session.active().is_some(), + "ui.perspectiveGridDialog" | "ui.savePerspectivePreset" => app.session.active().is_some(), + id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { + app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() + } + "ui.perspectivePlane" => app.session.active().is_some(), _ => true, } } @@ -1199,6 +1450,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Join Our Discord", "help.discord"), Sep, c("Settings…", "edit.preferences"), + sub("Language", vec![cp("English", "app.language", json!({"lang": "en"})), cp("日本語", "app.language", json!({"lang": "ja"}))]), Sep, sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), Sep, @@ -1313,7 +1565,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"), c("Print Presets…", "ui.printPresetsDialog"), c("PDF Presets…", "ui.pdfPresetsDialog"), - cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})), + c("Perspective Grid Presets…", "ui.perspectivePresetsDialog"), Sep, c("Color Settings…", "edit.colorSettings"), c("Assign Profile…", "edit.assignProfile"), @@ -1387,6 +1639,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "object.createObjectMosaic", json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), ), + c("Vector Halftone…", "object.vectorHalftone"), c("Create Trim Marks", "object.createTrimMarks"), c("Flatten Transparency…", "ui.flattenTransparencyDialog"), Sep, @@ -1451,7 +1704,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Make", "object.blend.make"), c("Release", "object.blend.release"), Sep, - cp("Blend Options…", "object.blend.options", json!({"steps": 5})), + c("Blend Options…", "object.blend.options"), Sep, c("Expand", "object.blend.expand"), Sep, @@ -1463,22 +1716,29 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { sub( "Envelope Distort", vec![ - cp( - "Make with Warp…", - "object.envelope.makeWithWarp", - json!({"style": "arc", "bend": 50, "h": 0, "v": 0, "horizontal": true}), - ), - cp("Make with Mesh…", "object.envelope.makeWithMesh", json!({"rows": 4, "cols": 4})), + // While an envelope is selected, Reset takes Make's place (`swapped_out`). + c("Make with Warp…", "object.envelope.makeWithWarp"), + c("Reset with Warp…", "object.envelope.resetWithWarp"), + c("Make with Mesh…", "object.envelope.makeWithMesh"), + c("Reset with Mesh…", "object.envelope.resetWithMesh"), c("Make with Top Object", "object.envelope.makeWithTopObject"), Sep, c("Release", "object.envelope.release"), - cp("Envelope Options…", "object.envelope.options", json!({"fidelity": 50})), + c("Envelope Options…", "object.envelope.options"), c("Expand", "object.envelope.expand"), Sep, c("Edit Contents", "object.envelope.editContents"), ], ), - sub("Perspective", vec![c("Attach to Active Plane", "perspective.attach"), c("Release with Perspective", "perspective.release")]), + sub( + "Perspective", + vec![ + c("Attach to Active Plane", "perspective.attach"), + c("Release with Perspective", "perspective.release"), + c("Move Plane to Match Object", "perspective.plane.matchObject"), + c("Edit Text", "perspective.editText"), + ], + ), sub( "Live Paint", vec![ @@ -1537,6 +1797,8 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { cp("As Multiple Assets", "assets.add", json!({"multiple": true})), ], ), + Sep, + sub("Plug-ins", crate::dialogs::plugin::object_menu()), ], ), ( @@ -1594,7 +1856,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Fill with Placeholder Text", "type.fillPlaceholder"), Sep, c("Show Hidden Characters", "type.hiddenCharacters"), - sub("Type Orientation", vec![todo("Horizontal"), todo("Vertical")]), + sub("Type Orientation", vec![c("Horizontal", "type.orientation.horizontal"), c("Vertical", "type.orientation.vertical")]), ], ), ( @@ -1725,11 +1987,17 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "Perspective Grid", vec![ c("Show Grid", "perspective.grid.show"), + c("Show Rulers", "perspective.grid.rulers"), + c("Snap to Grid", "perspective.grid.snap"), + c("Lock Grid", "perspective.grid.lock"), + c("Lock Station Point", "perspective.grid.lockStation"), Sep, - cp("One Point Perspective", "perspective.grid.preset", json!({"kind": 1})), - cp("Two Point Perspective", "perspective.grid.preset", json!({"kind": 2})), - cp("Three Point Perspective", "perspective.grid.preset", json!({"kind": 3})), - cp("Define Grid…", "perspective.grid.set", json!({"kind": 2, "cell": 20, "distance": 300})), + c("Define Grid…", "ui.perspectiveGridDialog"), + sub("One Point Perspective", crate::dialogs::perspective_presets::menu(1, PERSPECTIVE_SLOTS[0])), + sub("Two Point Perspective", crate::dialogs::perspective_presets::menu(2, PERSPECTIVE_SLOTS[1])), + sub("Three Point Perspective", crate::dialogs::perspective_presets::menu(3, PERSPECTIVE_SLOTS[2])), + Sep, + c("Save Grid as Preset…", "ui.savePerspectivePreset"), ], ), c("Show Grid", "view.grid"), @@ -1845,9 +2113,9 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { .ui(ui, |ui| { for (i, (title, items)) in tree.iter().enumerate() { let text = if i == 0 { - egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) + egui::RichText::new(app.ui.language.tr(title)).font(theme::semibold(13.0)).color(t.text) } else { - egui::RichText::new(*title).size(13.0).color(t.text) + egui::RichText::new(app.ui.language.tr(title)).size(13.0).color(t.text) }; ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)); } @@ -1860,10 +2128,13 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { end } -/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt. +/// A top-level menu's popup: as wide as its widest item (label plus shortcut), at least 230 pt; +/// it scrolls when it is taller than the window. fn menu_body(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { - ui.set_min_width(230.0); - render_items(app, ui, items, clicked); + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(230.0); + render_items(app, ui, items, clicked); + }); } fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked: &mut Option<(String, Value)>) { @@ -1874,17 +2145,19 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked ui.separator(); } Item::Header(h) => { - ui.label(egui::RichText::new(*h).size(11.0).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr(h)).size(11.0).color(t.text_dim)); } Item::Sub(label, children) => { - ui.menu_button(*label, |ui| { - ui.set_min_width(200.0); - render_items(app, ui, children, clicked); + ui.menu_button(app.ui.language.tr(label), |ui| { + widgets::menu_scroll(ui, |ui| { + ui.set_min_width(200.0); + render_items(app, ui, children, clicked); + }); }); } Item::Todo(label, sc) => { ui.add_enabled_ui(false, |ui| { - ui.add(egui::Button::new(*label).shortcut_text(pretty_shortcut(sc))); + ui.add(egui::Button::new(app.ui.language.tr(label)).shortcut_text(pretty_shortcut(sc))); }) .response .on_disabled_hover_text("Coming soon — tracked in the parity plan"); @@ -1892,10 +2165,11 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked Item::Cmd(label, id, p) => { let en = enabled(app, id); // Unused saved-view and recent-file slots are hidden (only the real ones are listed). - if !en && hidden_when_disabled(id) { + if (!en && hidden_when_disabled(id)) || swapped_out(app, id) { continue; } let label = dynamic_label(app, id, label); + let label = app.ui.language.tr(&label).to_string(); let sc = item_shortcut(id, p).map(pretty_shortcut).unwrap_or_default(); let chk = checked(app, id, p); let text = match chk { @@ -1913,6 +2187,12 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked } } +/// What the Home screen remembers when it opens (`app.home`): the active document and the +/// document count. When either changes, the Home screen gives way to the document. +pub(crate) fn home_key(app: &VectorcraftApp) -> (Option, usize) { + (app.session.active().map(|d| d.uid), app.session.documents().len()) +} + fn label_of(it: &Item) -> &'static str { match it { Item::Cmd(l, _, _) => l, @@ -1925,7 +2205,7 @@ fn label_of(it: &Item) -> &'static str { pub fn click_target(label: &str, id: &str, p: &Value) -> (String, Value) { let dialog = label.ends_with('…') && p.as_object().is_some_and(|o| !o.is_empty()) - && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode"); + && !matches!(id, "effect.dialog" | "window.panel" | "window.brightness" | "view.screenMode" | "plugin.dialog"); if dialog { return ("ui.paramDialog".into(), json!({"command": id, "label": label.trim_end_matches('…'), "params": p})); } @@ -1945,6 +2225,7 @@ fn menu_dialog(id: &str) -> Option<(&'static str, Value)> { "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), "object.path.simplify" => ("simplify", json!({"tolerance": "1 pt"})), "object.path.splitIntoGrid" => ("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"})), + "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), _ => return None, }) } @@ -1957,6 +2238,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { app.ui.dialog = Some(crate::state::Dialog::new(kind, fields)); return; } + // Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection. + if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::envelope::open(app, id) { + app.status(e); + } + return; + } // Repeat Options: a dialog with the selected repeat's current values. if id == "object.repeat.options" && p.as_object().is_none_or(|o| o.is_empty()) @@ -2032,6 +2320,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Blend Options…: its dialog, on the selected blend's options. + if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) { + if let Err(e) = crate::dialogs::blend_options::open(app) { + app.status(e); + } + return; + } // Expand…: its dialog. if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) { if let Err(e) = crate::dialogs::expand::open(app) { @@ -2095,7 +2390,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec { fn walk(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { - Item::Cmd(_, id, _) if hidden_when_disabled(id) && !enabled(app, id) => {} + Item::Cmd(_, id, _) if (hidden_when_disabled(id) && !enabled(app, id)) || swapped_out(app, id) => {} Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), @@ -2181,25 +2476,35 @@ fn insert_items(list: &[(&'static str, &'static str)]) -> Vec { list.iter().map(|(l, c)| cp(l, "type.insert", json!({ "char": c }))).collect() } -/// Type → Font: one item per installed family (labels interned once so the per-frame menu build -/// doesn't allocate forever). +/// Type → Font: one item per available family, the installed fonts included. Built again only when +/// the fonts change (the menu tree is built every frame); labels are interned once each, so +/// rebuilding doesn't allocate forever. fn font_items() -> Vec { - static NAMES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); - let fams = vectorcraft_text::FontDb::global().families(); - let Ok(mut names) = NAMES.lock() else { return vec![] }; - fams.iter() - .map(|f| { - let label: &'static str = match names.iter().find(|n| **n == f.as_str()) { - Some(n) => n, - None => { - let n: &'static str = Box::leak(f.clone().into_boxed_str()); - names.push(n); - n - } - }; - cp(label, "text.setStyle", json!({ "font": label })) - }) - .collect() + static NAMES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeSet::new()); + static ITEMS: std::sync::Mutex<(u64, Vec)> = std::sync::Mutex::new((u64::MAX, Vec::new())); + let db = vectorcraft_text::FontDb::global(); + // Read first: fonts that load meanwhile make the next frame build the list again. + let generation = db.generation(); + let fams = db.family_list(); + let (Ok(mut names), Ok(mut items)) = (NAMES.lock(), ITEMS.lock()) else { return vec![] }; + if items.0 != generation { + let list = fams + .iter() + .map(|f| { + let label: &'static str = match names.get(f.as_str()) { + Some(n) => n, + None => { + let n: &'static str = Box::leak(f.clone().into_boxed_str()); + names.insert(n); + n + } + }; + cp(label, "text.setStyle", json!({ "font": label })) + }) + .collect(); + *items = (generation, list); + } + items.1.clone() } /// The Effect menu, built from the effects catalogue (vector effects), plus raster effects. @@ -2214,8 +2519,19 @@ fn effect_menu() -> Vec { Item::Header("Vector Effects"), ]; // Submenus (and Crop Marks, an item of its own) in the reference app's order. - let order = - ["3D and Materials", "Convert to Shape", "Crop Marks", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; + let order = [ + "3D and Materials", + "Color Adjustments", + "Convert to Shape", + "Crop Marks", + "Distort & Transform", + "Path", + "Pathfinder", + "Stylize", + "SVG Filters", + "Warp", + "Blur", + ]; let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); for sub_name in order { if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { @@ -2232,6 +2548,8 @@ fn effect_menu() -> Vec { }) .collect(); if sub_name == "Blur" { + // Live-effect plug-ins close the vector effects. + out.extend(crate::dialogs::plugin::effect_menu()); if !items.is_empty() { out.push(Sep); out.push(Item::Header("Raster Effects")); @@ -2366,14 +2684,13 @@ mod tests { fn walk(items: &[Item], bad: &mut Vec) { for it in items { match it { - Item::Cmd(_, id, _) => { + Item::Cmd(_, id, _) if vectorcraft_engine::find_command(id).is_none() && !UI_COMMANDS.iter().any(|c| c.0 == *id) && !id.starts_with("object.path.") - && *id != "type.createOutlines" - { - bad.push(id.to_string()); - } + && *id != "type.createOutlines" => + { + bad.push(id.to_string()); } Item::Sub(_, ch) => walk(ch, bad), _ => {} @@ -2428,4 +2745,36 @@ mod tests { let w = ctx.memory(|m| m.area_rect(id)).unwrap().width(); assert!((230.0..340.0).contains(&w), "File menu is {w} pt wide"); } + + #[test] + fn long_menus_scroll_inside_a_short_window() { + // The Window menu is taller than a 600 pt window: it stops above the bottom edge and + // scrolls instead of running off screen. + let app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let tree = menu_tree(); + let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap(); + let id = egui::Id::new("test-long-menu"); + let rect = |height: f32| { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height)); + for _ in 0..3 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + egui::Area::new(id).fixed_pos(egui::pos2(300.0, 30.0)).show(ui.ctx(), |ui| { + egui::containers::menu::menu_style(ui.style_mut()); + ui.with_layout(egui::Layout::top_down_justified(egui::Align::Min), |ui| menu_body(&app, ui, window, &mut None)); + }); + }); + out.textures_delta.clear(); + } + ctx.memory(|m| m.area_rect(id)).unwrap() + }; + let short = rect(600.0); + assert!(short.bottom() <= 600.0, "Window menu ends at {} in a 600 pt window", short.bottom()); + // With room to spare it shows whole, taller than the short window allowed. + let tall = rect(3000.0); + assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height()); + assert!(tall.bottom() < 3000.0); + } } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 8c5b6423a..2f4822eaa 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -133,19 +133,25 @@ fn click_item(app: &mut VectorcraftApp, ctx: &egui::Context, n: &Node, sel: Sel, set_pstate(ctx, "ap-multi", (Some(n.id), set)); } -fn catalog() -> &'static [(String, String, Vec)] { - static C: OnceLock)>> = OnceLock::new(); - C.get_or_init(|| { - vectorcraft_effects::effect_catalog() - .into_iter() - .map(|e| (e.id.to_string(), e.label.trim_end_matches('…').to_string(), e.menu.iter().map(|s| s.to_string()).collect())) - .collect() - }) +/// An effect as the fx menu lists it: (id, label without the ellipsis, Effect-menu path). +fn catalog_entry(e: vectorcraft_effects::EffectInfo) -> (String, String, Vec) { + (e.id.to_string(), e.label.trim_end_matches('…').to_string(), e.menu.iter().map(|s| s.to_string()).collect()) } -/// Display label of an effect id ("stylize.dropShadow" → "Drop Shadow"). +fn catalog() -> &'static [(String, String, Vec)] { + static C: OnceLock)>> = OnceLock::new(); + C.get_or_init(|| vectorcraft_effects::effect_catalog().into_iter().map(catalog_entry).collect()) +} + +/// Display label of an effect id ("stylize.dropShadow" → "Drop Shadow"; plug-in effects by their +/// plug-in's name). pub fn effect_label(id: &str) -> String { - catalog().iter().find(|c| c.0 == id).map(|c| c.1.clone()).unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()) + catalog() + .iter() + .find(|c| c.0 == id) + .map(|c| c.1.clone()) + .or_else(|| vectorcraft_effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string())) + .unwrap_or_else(|| id.rsplit('.').next().unwrap_or(id).to_string()) } /// "Opacity: Default" or "Opacity: 50% Multiply". @@ -704,7 +710,7 @@ fn has_options(id: &str) -> bool { /// edits go through `effect.setParams`. fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: usize, e: &Effect) { let t = Tokens::get(ui.ctx()); - let defaults = vectorcraft_effects::effect_catalog().into_iter().find(|c| c.id == e.id).map(|c| c.defaults).unwrap_or(Value::Null); + let defaults = vectorcraft_effects::default_params(&e.id).unwrap_or(Value::Null); let mut params = defaults.as_object().cloned().unwrap_or_default(); if let Some(cur) = e.params.as_object() { for (key, v) in cur { @@ -861,7 +867,8 @@ fn delete_selected(app: &mut VectorcraftApp, ui: &Ui, node: Option<&Node>, sel: /// opens the effect dialog. pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { let mut groups: Vec<(String, Vec<(String, String)>)> = vec![]; - for (id, label, menu) in catalog() { + let plugins: Vec<_> = vectorcraft_effects::plugin_effects().into_iter().map(catalog_entry).collect(); + for (id, label, menu) in catalog().iter().chain(&plugins) { let g = menu.get(1).cloned().unwrap_or_else(|| "Other".into()); match groups.iter_mut().find(|(n, _)| *n == g) { Some((_, v)) => v.push((id.clone(), label.clone())), @@ -870,12 +877,14 @@ pub(crate) fn fx_menu(app: &mut VectorcraftApp, ui: &mut Ui) { } for (g, items) in groups { ui.menu_button(g, |ui| { - for (id, label) in items { - if ui.button(format!("{label}…")).clicked() { - app.run("effect.dialog", json!({"effect": id})).ok(); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (id, label) in items { + if ui.button(format!("{label}…")).clicked() { + app.run("effect.dialog", json!({"effect": id})).ok(); + ui.close(); + } } - } + }); }); } } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 7f17962ae..829d3da22 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -240,11 +240,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { super::empty_state(ui, "type", "No text selected", "Select a text object to edit its character attributes."); return; }; - let fams = vectorcraft_text::FontDb::global().families(); - let names: Vec<&str> = fams.iter().map(String::as_str).collect(); let w = ui.available_width(); - if let Some(i) = widgets::dropdown(ui, "ch-font", &s.font_family, &names, w - 4.0) { - style(app, json!({"font": names[i]})); + if let Some(f) = widgets::font_dropdown(ui, "ch-font", &s.font_family, w - 4.0) { + style(app, json!({ "font": f })); } let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); @@ -403,10 +401,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { menu_item(ui, l, false, l == "Fractional Widths"); } ui.separator(); + // The installed fonts are always listed; this picks up fonts installed since the app started. #[cfg(not(target_arch = "wasm32"))] - if menu_item(ui, "Show System Fonts", true, vectorcraft_text::FontDb::global().families().len() > 4) { - let n = vectorcraft_text::FontDb::global().load_system_fonts(); - app.ui.status = format!("{n} system font faces available"); + if menu_item(ui, "Refresh Font List", true, false) + && let Ok(r) = app.run("text.rescanFonts", json!({})) + { + app.status(format!("{} font families available", r["families"])); } if menu_item(ui, "Reset Panel", has, false) { style(app, json!({"tracking": 0, "leading": "auto"})); diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index bf722e4eb..9cbbca69f 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -193,12 +193,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .size(11.0) .color(t.text_dim), ); - let fams = db.families(); - let fnames: Vec<&str> = fams.iter().map(String::as_str).collect(); ui.horizontal(|ui| { - if let Some(i) = widgets::dropdown(ui, "gl-family", &family, &fnames, (w * 0.6).max(80.0)) { - let st = db.styles(fnames[i]).into_iter().next().unwrap_or_else(|| "Regular".into()); - set_pstate(ui.ctx(), "gl-font", Some((fnames[i].to_string(), st))); + if let Some(f) = widgets::font_dropdown(ui, "gl-family", &family, (w * 0.6).max(80.0)) { + let st = db.face(&f, "Regular").map_or_else(|| "Regular".into(), |face| face.style.clone()); + set_pstate(ui.ctx(), "gl-font", Some((f, st))); } let styles = db.styles(&family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 2a86bbe34..81985c7b5 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -25,6 +25,18 @@ fn expanded_id() -> egui::Id { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; + // A rename belongs to the document it started in (node ids are per document): drop it when + // another document shows, so it can't rename that document's node with the same id. + let shown_doc = egui::Id::new("layers-doc"); + let before = ui.data(|d| d.get_temp::(shown_doc)); + if before != Some(st.uid) { + ui.data_mut(|d| { + if before.is_some() { + d.remove::<(u64, String)>(egui::Id::new("layers-rename")); + } + d.insert_temp(shown_doc, st.uid); + }); + } let doc = st.doc.clone(); let sel: HashSet = st.selection.objects.iter().copied().collect(); let target = st.selection.target; @@ -202,7 +214,11 @@ fn row( Some((rid, mut buf)) if rid == n.id.0 => { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(font.clone())); - te.request_focus(); + // Focus the field as it opens. Asking every frame took the focus back from Enter, + // Escape or a click elsewhere, so the rename never ended. + if !te.has_focus() && !te.lost_focus() { + te.request_focus(); + } if te.lost_focus() { ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { @@ -587,6 +603,55 @@ mod tests { assert_eq!(st.doc.node_count(), count); } + #[test] + fn renaming_a_layer_commits_on_enter_and_escape_cancels() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + let name = |app: &VectorcraftApp| app.session.doc().unwrap().doc.layers[0].display_name(); + let ctx = egui::Context::default(); + let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let select_all = + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + // What a double-click on the row does: the name field opens with the name. + let open = |app: &mut VectorcraftApp| { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (layer.0, name(app)))); + frame(app, &ctx, vec![], false); + frame(app, &ctx, vec![], false); + }; + open(&mut app); + frame(&mut app, &ctx, vec![select_all.clone(), egui::Event::Text("Sky".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Enter)], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(name(&app), "Sky", "Enter keeps the new name"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("layers-rename"))).is_none(), "and closes the field"); + open(&mut app); + frame(&mut app, &ctx, vec![select_all, egui::Event::Text("Ground".into())], false); + frame(&mut app, &ctx, vec![key(egui::Key::Escape)], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(name(&app), "Sky", "Escape cancels"); + } + + #[test] + fn an_open_rename_does_not_follow_into_another_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let layer = app.session.doc().unwrap().doc.layers[0].id; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![], false); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (layer.0, "Renamed in the first".to_string()))); + frame(&mut app, &ctx, vec![], false); + // A second document, whose first layer has the same node id, comes to the front. + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.layers[0].id, layer); + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, vec![], false); + frame(&mut app, &ctx, vec![enter], false); + frame(&mut app, &ctx, vec![], false); + assert_eq!(app.session.doc().unwrap().doc.layers[0].display_name(), "Layer 1"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("layers-rename"))).is_none()); + } + #[test] fn masks_underline_dashed_and_mask_editing_lists_only_the_mask() { let (mut app, _, [a, _]) = two_rects(); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index f57e97b36..2affee132 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -374,9 +374,11 @@ pub(crate) fn library_items(ui: &mut Ui, libs: &[LibraryRef], current: Option<&s } for s in subs { ui.menu_button(s, |ui| { - for l in libs.iter().filter(|l| l.submenu == Some(s)) { - item(ui, l); - } + crate::widgets::menu_scroll(ui, |ui| { + for l in libs.iter().filter(|l| l.submenu == Some(s)) { + item(ui, l); + } + }); }); } chosen diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 521db16b4..edf4467e4 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -158,8 +158,10 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) { icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); let resp = resp.on_hover_text("Panel menu"); egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(220.0); - panel_menu_items(app, ui, id); + crate::widgets::menu_scroll(ui, |ui| { + ui.set_min_width(220.0); + panel_menu_items(app, ui, id); + }); }); } @@ -453,6 +455,8 @@ mod tests_appearance; #[cfg(test)] mod tests_asset_export; #[cfg(test)] +mod tests_constrain; +#[cfg(test)] mod tests_css_properties; #[cfg(test)] mod tests_effectedit; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index e200e9fb6..c54b4f3b4 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -250,21 +250,27 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) { } pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { - let Some(st) = app.session.active() else { return }; + if app.session.active().is_none() { + return; + } let units = app.session.general_unit(); - let Some(b) = app.session.transform_bounds(&st.selection.objects) else { + // The bounding box, rotated with rotated objects (as in the Transform panel). + let Some(bx) = app.selection_box() else { dim_label(ui, "No Selection"); return; }; + let b = bx.rect; let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); - let rp = vectorcraft_geom::reference_point(b, refi); + let rp = bx.reference_point(refi); section_header(ui, "Transform"); ui.horizontal(|ui| { if let Some(i) = widgets::reference_point(ui, refi) { ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); } ui.add_space(6.0); - let fw = ((ui.available_width() - 44.0) / 2.0).clamp(60.0, 110.0); + let link = app.session.prefs.constrain_proportions; + // Room for the labels and the W/H link. + let fw = ((ui.available_width() - 70.0) / 2.0).clamp(60.0, 110.0); egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { dim_label(ui, "X:"); if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) { @@ -272,7 +278,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { } dim_label(ui, "W:"); if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) { - app.run("object.setBounds", json!({"width": v, "reference": refi})).ok(); + app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok(); } ui.end_row(); dim_label(ui, "Y:"); @@ -281,15 +287,17 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { } dim_label(ui, "H:"); if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) { - app.run("object.setBounds", json!({"height": v, "reference": refi})).ok(); + app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok(); } ui.end_row(); }); + super::transform::constrain_link(app, ui); }); ui.horizontal(|ui| { icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon); - if let Some(a) = widgets::plain_field(ui, "rot", 0.0, "°", 2, 70.0) { - app.run("object.rotate", json!({"angle": a})).ok(); + // The bounding box's angle: a new value turns the selection to it. + if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, 70.0) { + app.run("object.rotate", json!({"angle": a, "absolute": true})).ok(); } ui.add_space(10.0); if widgets::icon_button(ui, "flip-horizontal-2", "Flip Along Horizontal Axis", false, 24.0).clicked() { @@ -374,10 +382,8 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { }; let s = tx.first_style(); section_header(ui, "Character"); - let fams = vectorcraft_text::FontDb::global().families(); - let names: Vec<&str> = fams.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "font", &s.font_family, &names, ui.available_width() - 4.0) { - app.run("text.setStyle", json!({"font": names[i]})).ok(); + if let Some(f) = widgets::font_dropdown(ui, "font", &s.font_family, ui.available_width() - 4.0) { + app.run("text.setStyle", json!({ "font": f })).ok(); } ui.horizontal(|ui| { dim_label(ui, "Size"); diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs new file mode 100644 index 000000000..c074b3ff4 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -0,0 +1,74 @@ +//! The W/H link (Constrain Width and Height Proportions) in the Transform panel, the Properties +//! panel's Transform section and the Control bar: one preference, passed on as `proportional`. + +use egui::{Event, Key, Modifiers, PointerButton}; +use serde_json::json; + +use super::tests_appearance::{frame_raw, run}; +use super::*; + +/// What draws a set of size fields. +type Draw = fn(&mut VectorcraftApp, &mut Ui); + +/// A selected 100 × 50 pt rectangle. +fn rect_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 400, "height": 400})); + let id = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 50}))["id"].clone(); + run(&mut app, "select.set", json!({ "ids": [id] })); + app +} + +fn size(app: &VectorcraftApp) -> (f64, f64) { + let st = app.session.active().unwrap(); + let b = st.doc.bounds_of(&st.selection.objects, false).unwrap(); + (b.width().round(), b.height().round()) +} + +/// Type `value` into the W field (the one showing "100 pt") of what `draw` shows, and press Enter. +fn set_width(app: &mut VectorcraftApp, draw: Draw, value: &str) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut time = 0.0; + let mut frame = |app: &mut VectorcraftApp, events: Vec| { + time += 1.0; + frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, draw) + }; + let shown = frame(app, vec![]); + let at = shown.iter().find(|(s, _)| s == "100 pt").unwrap_or_else(|| panic!("no W field in {shown:?}")).1.center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(app, vec![Event::PointerMoved(at), button(true), button(false)]); + // Focus selected the whole field: typing replaces it. + frame(app, vec![]); + frame(app, vec![Event::Text(value.into())]); + let enter = Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + frame(app, vec![enter]); +} + +#[test] +fn the_link_keeps_proportions_in_every_size_field() { + let surfaces: [(&str, Draw); 3] = + [("Transform panel", transform::show), ("Properties", properties::transform_section), ("Control bar", crate::chrome::control_bar)]; + for (name, draw) in surfaces { + // Off: W alone. + let mut app = rect_app(); + set_width(&mut app, draw, "200"); + assert_eq!(size(&app), (200.0, 50.0), "{name}, unlinked"); + // On: H follows. + let mut app = rect_app(); + run(&mut app, "prefs.set", json!({"key": "constrainProportions", "value": true})); + set_width(&mut app, draw, "200"); + assert_eq!(size(&app), (200.0, 100.0), "{name}, linked"); + } +} + +#[test] +fn the_link_is_a_preference_agents_can_read_and_set() { + let mut app = rect_app(); + assert_eq!(run(&mut app, "prefs.get", json!({"key": "constrainProportions"})), json!(false)); + run(&mut app, "prefs.set", json!({"key": "constrainProportions", "value": true})); + assert!(app.session.prefs.constrain_proportions); + // What the fields send with the link on. + run(&mut app, "object.setBounds", json!({"height": 100, "reference": 4, "proportional": true})); + assert_eq!(size(&app), (200.0, 100.0)); +} diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 276ab79f8..54c061d0e 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -23,16 +23,19 @@ pub fn constrained(w: f64, h: f64, new_w: Option, new_h: Option) -> (f pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let Some(st) = app.session.active() else { + if app.session.active().is_none() { widgets::dim_label(ui, "No document"); return; - }; + } let units = app.session.general_unit(); - let bounds = app.session.transform_bounds(&st.selection.objects); + // The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference + // point on the page. + let bx = app.selection_box(); + let bounds = bx.map(|b| b.rect); let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); - let link: bool = pstate(ui.ctx(), "xf-link"); + let link = app.session.prefs.constrain_proportions; let has = bounds.is_some(); - let rp = bounds.map(|b| vectorcraft_geom::reference_point(b, refi)); + let rp = bx.map(|b| b.reference_point(refi)); ui.horizontal(|ui| { if let Some(i) = widgets::reference_point(ui, refi) { ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); @@ -60,20 +63,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.end_row(); }); }); - if widgets::icon_button(ui, if link { "link" } else { "link-2-off" }, "Constrain Width and Height Proportions", link, 22.0).clicked() { - set_pstate(ui.ctx(), "xf-link", !link); - } + constrain_link(app, ui); }); ui.add_space(4.0); let origin = rp.map(|p| json!([p.x, p.y])); ui.horizontal(|ui| { ui.add_enabled_ui(has, |ui| { icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text("Rotate"); - // Rotation is applied relative to the current orientation (0° shown after each apply). - if let Some(a) = widgets::spin_plain(ui, "xfp-rot", 0.0, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) - && a != 0.0 + // The bounding box's angle: a new value turns the selection to it. + let angle = bx.map_or(0.0, |b| b.angle); + if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) + && a != angle { - app.run("object.rotate", json!({"angle": a, "origin": origin})).ok(); + app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin})).ok(); } ui.add_space(4.0); icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text("Shear"); @@ -135,6 +137,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the +/// `constrainProportions` preference, which the size fields pass on as `proportional`. +pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) { + let on = app.session.prefs.constrain_proportions; + if widgets::icon_button(ui, if on { "link" } else { "link-2-off" }, "Constrain Width and Height Proportions", on, 22.0).clicked() { + set_pref(app, "constrainProportions", !on); + } +} + /// Toggle a boolean preference (through `prefs.set`, as agents do). pub fn set_pref(app: &mut VectorcraftApp, key: &str, on: bool) { if let Err(e) = app.run("prefs.set", json!({ "key": key, "value": on })) { @@ -204,6 +215,21 @@ mod tests { assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds")); } + #[test] + fn rotation_field_shows_the_angle_the_box_keeps() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap(); + app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap(); + let shown = texts(&mut app, show); + // The angle stays, and W/H are the rectangle's own sides. + assert!(shown.iter().any(|t| t == "45°"), "{shown:?}"); + assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}"); + app.run("object.resetBoundingBox", json!({})).unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}"); + } + #[test] fn constrain_proportions() { assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0)); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 303259a92..265b69b04 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -195,9 +195,8 @@ pub fn format(sc: &KeyboardShortcut) -> String { Key::Quote => "'", Key::Slash => "/", Key::Backslash => "\\", - Key::Equals => "=", + Key::Equals | Key::Plus => "=", Key::Minus => "-", - Key::Plus => "+", Key::Backtick => "~", Key::Comma => "Comma", Key::Period => "Period", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c98b8d612..3cbd512ed 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -8,6 +8,8 @@ use vectorcraft_tools::{Mods, ToolKey}; use crate::VectorcraftApp; /// Parse "Cmd+Shift+]" into an egui shortcut. `Cmd` is Command on macOS and Ctrl elsewhere. +/// `+` and `=` name one chord key ([`Key::Equals`]): `+` is Shift+`=` on many layouts, so a +/// `Cmd+=` chord also answers `+` however it is typed (see [`consume`]). pub fn parse(s: &str) -> Option { if s.is_empty() { return None; @@ -31,9 +33,8 @@ pub fn parse(s: &str) -> Option { "'" => Key::Quote, "/" => Key::Slash, "\\" => Key::Backslash, - "=" => Key::Equals, + "=" | "+" => Key::Equals, "-" => Key::Minus, - "+" => Key::Plus, "Delete" => Key::Delete, "Backspace" => Key::Backspace, "Tab" => Key::Tab, @@ -69,9 +70,26 @@ pub(crate) fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str, serde_json v } +/// Consume a press of `sc`. A `=` chord also takes [`Key::Plus`], which is how `+` arrives from +/// the numpad and from layouts where it has its own key (Shift+`=` arrives as either; extra Shift +/// is ignored). `native`: the system menu already handles the chord as written, so only that +/// alias is left to match here. +pub(crate) fn consume(i: &mut egui::InputState, sc: &KeyboardShortcut, native: bool) -> bool { + (!native && i.consume_shortcut(sc)) || (sc.logical_key == Key::Equals && i.consume_key(sc.modifiers, Key::Plus)) +} + /// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.) const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste]; +/// The paste command whose V chord `held` holds (Paste in Place for Cmd+Shift+V by default), else +/// Paste: egui reports every Cmd+V chord as a paste, never as its key. +pub(crate) fn paste_command(held: Modifiers) -> &'static str { + all_shortcuts() + .into_iter() + .find(|(sc, id, _)| sc.logical_key == Key::V && id.starts_with("edit.paste") && held.matches_logically(sc.modifiers)) + .map_or("edit.paste", |(_, id, _)| id) +} + /// Keyboard pastes of something other than text. egui swallows a paste chord's key press and /// sends a Paste event only when the clipboard holds text, so a paste key released without its /// press (or a Paste event) reported was a paste of a bitmap or a PDF. @@ -84,21 +102,26 @@ pub(crate) struct PasteChord { } impl PasteChord { - /// Follow one frame's `events` → whether they hold a paste egui sent no event for. - fn textless_paste(&mut self, events: &[egui::Event]) -> bool { - let mut fire = false; + /// Follow one frame's `events` → the modifiers of a paste egui sent no event for (the chord's, + /// as its key is released). + pub(crate) fn textless_paste(&mut self, events: &[egui::Event]) -> Option { + let mut fire = None; for e in events { match e { egui::Event::Paste(_) => self.pasted = true, // A release that comes while the window is away is never seen. egui::Event::WindowFocused(false) => *self = Self::default(), egui::Event::Key { key, pressed: true, .. } if PASTE_KEYS.contains(key) && !self.pressed.contains(key) => self.pressed.push(*key), - egui::Event::Key { key, pressed: false, .. } if PASTE_KEYS.contains(key) => match self.pressed.iter().position(|k| k == key) { - Some(i) => { - self.pressed.swap_remove(i); + egui::Event::Key { key, pressed: false, modifiers, .. } if PASTE_KEYS.contains(key) => { + match self.pressed.iter().position(|k| k == key) { + Some(i) => { + self.pressed.swap_remove(i); + } + // Unless a Paste event came (the guard takes it either way). + None if !std::mem::take(&mut self.pasted) => fire = Some(*modifiers), + None => {} } - None => fire |= !std::mem::take(&mut self.pasted), - }, + } _ => {} } } @@ -155,20 +178,22 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Editing keys with modifiers, clipboard and Cmd+A. crate::panels::character::route_type_input(app, ctx); // Enter was already delivered above as ToolKey::Enter (newline). - let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| i.consume_shortcut(sc))); + let fire = all_shortcuts().into_iter().filter(|(sc, ..)| sc.modifiers.command).find(|(sc, ..)| ctx.input_mut(|i| consume(i, sc, false))); if let Some((_, id, p)) = fire { crate::menus::invoke(app, id, p); } return; } - // Clipboard keys arrive as events, not key presses (except where the native menu has them). + // Clipboard keys arrive as events, not key presses (except where the native menu has them); + // the chord held says which paste. let mut clip = vec![]; ctx.input_mut(|i| { + let held = i.modifiers; i.events.retain(|e| { let (id, text) = match e { egui::Event::Copy => ("edit.copy", None), egui::Event::Cut => ("edit.cut", None), - egui::Event::Paste(t) => ("edit.paste", Some(t.clone())), + egui::Event::Paste(t) => (paste_command(held), Some(t.clone())), _ => return true, }; if app.native_shortcuts.contains(id) { @@ -179,8 +204,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { }) }); // Only the system clipboard service reads what isn't text (the native menu has its own Paste). - if textless_paste && app.services.system_clipboard.is_some() && !app.native_shortcuts.contains("edit.paste") { - clip.push(("edit.paste", None)); + if let Some(id) = textless_paste.map(paste_command) + && app.services.system_clipboard.is_some() + && !app.native_shortcuts.contains(id) + { + clip.push((id, None)); } for (id, text) in clip { app.clipboard_in = text; @@ -192,6 +220,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let _ = app.session.tool_key(tk, Mods::default(), view); } } + // Digits (5 while dragging with the Perspective Selection tool), once per press. + let digits: Vec = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key), + _ => None, + }) + .collect() + }); + for d in digits { + let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view); + } return; } // Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop: @@ -211,6 +252,28 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } } + // Digits the active tool or the perspective grid takes (1–4 pick the plane while it shows). + if !(m.command || m.alt || m.ctrl) { + let digits: Vec<(u8, Key)> = ctx.input(|i| { + i.events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, .. } => digit_of(*key).map(|d| (d, *key)), + _ => None, + }) + .collect() + }); + for (d, k) in digits { + if app.session.tool_claims_key(ToolKey::Digit(d), view) && ctx.input_mut(|i| i.consume_key(m, k)) { + // The digit's text is the key's too: no single-key shortcut sees it. + let text = d.to_string(); + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text))); + let r = app.session.tool_key(ToolKey::Digit(d), crate::canvas::mods(m, false), view); + crate::canvas::apply_requests(app, r); + return; + } + } + } // Command shortcuts. let mut fire = None; for (sc, id, p) in all_shortcuts() { @@ -220,10 +283,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { if plain && (sc.logical_key.name().len() == 1 || matches!(sc.logical_key, Key::Slash | Key::Comma | Key::Period)) { continue; } - if app.native_shortcuts.contains(id) { - continue; - } - if ctx.input_mut(|i| i.consume_shortcut(&sc)) { + let native = app.native_shortcuts.contains(id); + if ctx.input_mut(|i| consume(i, &sc, native)) { fire = Some((id, p)); break; } @@ -271,6 +332,12 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// The digit a number-row or keypad key types. +fn digit_of(k: Key) -> Option { + const DIGITS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9]; + DIGITS.iter().position(|d| *d == k).map(|i| i as u8) +} + #[cfg(test)] mod tests { use super::*; @@ -325,6 +392,56 @@ mod tests { assert_eq!(fill(&app).color().unwrap().to_hex(), "#336699", "`,` applies the last colour"); } + #[test] + fn plus_typed_any_way_zooms_in() { + assert_eq!(parse("Cmd++"), parse("Cmd+="), "`+` and `=` are one chord key"); + assert_eq!(crate::shortcut_editor::chord_from_event(Key::Plus, Modifiers::COMMAND).as_deref(), Some("Cmd+="), "a recorded `+` too"); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let zoom = |app: &mut VectorcraftApp| app.view_mut().unwrap().zoom; + let press = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + // `=`, Shift+`=` (reported as `=` or as `+`), and the numpad `+` / a layout's own `+` key. + for (key, m) in [(Key::Equals, Modifiers::COMMAND), (Key::Equals, cmd_shift), (Key::Plus, cmd_shift), (Key::Plus, Modifiers::COMMAND)] { + let before = zoom(&mut app); + frame(&mut app, vec![press(key, m)]); + assert!(zoom(&mut app) > before, "{key:?} with {m:?} zooms in"); + } + let before = zoom(&mut app); + frame(&mut app, vec![press(Key::Minus, Modifiers::COMMAND)]); + assert!(zoom(&mut app) < before, "Cmd+- zooms out"); + // The system menu handles `Cmd+=` itself; the `+` key is still matched here. + app.native_shortcuts.insert("view.zoomIn".into()); + let before = zoom(&mut app); + frame(&mut app, vec![press(Key::Equals, Modifiers::COMMAND)]); + assert_eq!(zoom(&mut app), before, "left to the system menu"); + frame(&mut app, vec![press(Key::Plus, Modifiers::COMMAND)]); + assert!(zoom(&mut app) > before); + } + + #[test] + fn digit_keys_reach_a_dragging_tool_once_per_press() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + app.session.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone(); + app.session.execute("perspective.attach", &json!({"ids": [id], "plane": "right"})).unwrap(); + app.select_tool("perspectiveSelection"); + let v = app.view_info(); + let c = + app.session.doc().unwrap().doc.node(vectorcraft_engine::doc::NodeId(id.as_u64().unwrap())).unwrap().geometric_bounds().unwrap().center(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, c.x + 30.0, c.y), v).unwrap(); + let key = |repeat| egui::Event::Key { key: Key::Num5, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }; + // A held key repeats: only its first press toggles. + frame(&mut app, vec![key(false), key(true)]); + let preview = app.session.doc().unwrap().interaction.as_ref().unwrap().preview.clone().unwrap(); + assert_eq!((preview.0.as_str(), &preview.1["perpendicular"]), ("perspective.move", &json!(true))); + assert_eq!(digit_of(Key::Num0), Some(0)); + assert_eq!(digit_of(Key::A), None); + } + #[test] fn parses() { let s = parse("Cmd+Shift+]").unwrap(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7a7d13032..e718e1b6b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -207,6 +207,8 @@ impl Dialog { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct UiState { + #[serde(default)] + pub language: crate::i18n::Language, pub brightness: Brightness, pub dock_tab: DockTab, /// Icon panel currently popped out of the collapsed column. @@ -298,6 +300,29 @@ pub struct UiState { /// never used). #[serde(default)] pub dxf_import: Value, + /// The Home screen is shown over the open documents (`app.home`): the active document's uid + /// and the document count when it opened. Choosing a tab, or a document opening, closing or + /// becoming active, leaves it. + #[serde(skip)] + pub home: Option<(Option, usize)>, + /// The desktop window's size, position and maximized state, saved when the app quits and + /// restored at the next launch (the desktop host reads and writes it; none on the web). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window: Option, +} + +/// The desktop window's geometry, kept across launches. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct WindowGeometry { + /// Top-left corner of the window frame, in physical pixels on the desktop (none where the + /// system doesn't tell windows where they are). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pos: Option<[i32; 2]>, + /// Size of the window's contents in logical pixels (points at 100% UI scaling). + pub size: [f32; 2], + /// The window was maximized; `pos` and `size` are where un-maximizing puts it. + #[serde(default)] + pub maximized: bool, } impl UiState { @@ -319,6 +344,7 @@ impl UiState { impl Default for UiState { fn default() -> Self { Self { + language: crate::i18n::Language::default(), brightness: Brightness::MediumDark, dock_tab: DockTab::Properties, open_panel: None, @@ -359,6 +385,8 @@ impl Default for UiState { dxf_options: Value::Null, eps_options: Value::Null, dxf_import: Value::Null, + home: None, + window: None, } } } diff --git a/crates/ui-egui/src/tests_adjust.rs b/crates/ui-egui/src/tests_adjust.rs new file mode 100644 index 000000000..8ab1056e4 --- /dev/null +++ b/crates/ui-egui/src/tests_adjust.rs @@ -0,0 +1,128 @@ +//! The colour adjustment effect dialogs (Effect → Color Adjustments) and Object → Vector Halftone +//! in the menus. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs, menus, tests_labels::painted_text}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app +} + +fn dialog_text(app: &mut VectorcraftApp) -> String { + painted_text(app, |app, ui| dialogs::show(app, ui.ctx())) +} + +#[test] +fn the_effect_menu_lists_the_adjustments_and_the_object_menu_the_halftone() { + let tree = menus::menu_tree(); + let items = |menu: &str| format!("{:?}", tree.iter().find(|(t, _)| *t == menu).unwrap().1); + let effect = items("Effect"); + for label in ["Color Adjustments", "Brightness/Contrast…", "Curves…", "Hue/Saturation…", "Levels…", "Shift to Color…", "Temperature/Tint…"] + { + assert!(effect.contains(label), "{label}"); + } + assert!(items("Object").contains("Vector Halftone…")); +} + +#[test] +fn an_adjustment_dialog_has_sliders_and_previews_live() { + let mut app = app(); + app.run("effect.dialog", json!({"effect": "adjust.hueSaturation"})).unwrap(); + let text = dialog_text(&mut app); + for s in ["Hue/Saturation", "Hue", "Saturation", "Lightness", "Colorize", "Preview"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + assert!(app.session.in_interaction(), "the preview runs"); + // A value set from outside (ui.dialog.set) previews too. + app.ui.dialog.as_mut().unwrap().fields.insert("hue".into(), json!(90)); + dialog_text(&mut app); + let before = app.session.doc().unwrap().history.undo.len(); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), before + 1, "one undo step"); + let st = app.session.active().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + assert_eq!(n.appearance.effects[0].id, "adjust.hueSaturation"); + assert_eq!(n.appearance.effects[0].params["hue"], 90.0); +} + +#[test] +fn levels_and_curves_show_their_channel_and_values() { + let mut app = app(); + app.run("effect.dialog", json!({"effect": "adjust.levels"})).unwrap(); + let text = dialog_text(&mut app); + for s in ["Input Black", "Output White", "Channel", "RGB", "Gamma"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + app.ui.dialog = None; + let _ = app.session.cancel_interaction(); + app.run("effect.dialog", json!({"effect": "adjust.curves"})).unwrap(); + let text = dialog_text(&mut app); + assert!(text.lines().any(|l| l == "Points") && text.contains("0,0 128,128 255,255"), "{text}"); +} + +#[test] +fn dragging_on_the_curves_graph_adds_and_moves_a_point() { + use egui::{Event, PointerButton, Pos2, Rect, vec2}; + let mut app = app(); + app.run("effect.dialog", json!({"effect": "adjust.curves"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 1000.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let input = egui::RawInput { events, screen_rect, ..Default::default() }; + ctx.run_ui(input, |ui| dialogs::show(app, ui.ctx())).textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let graph = + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.rect.size() == vec2(200.0, 200.0)).map(|w| w.rect)); + let graph = graph.expect("the curve graph"); + // Press above the identity line a quarter of the way in and drag up: a new point, raised. + let at = |x: f32, y: f32| Pos2::new(graph.left() + x * 200.0, graph.bottom() - y * 200.0); + let (from, to) = (at(0.25, 0.35), at(0.25, 0.6)); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![Event::PointerMoved(from), button(from, true)]); + for k in 1..=5 { + frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 5.0))]); + } + frame(&mut app, vec![button(to, false)]); + let points = app.ui.dialog.as_ref().unwrap().str("points"); + let pts: Vec<(f32, f32)> = vectorcraft_effects::curve_points(Some(&json!(points))); + assert_eq!(pts.len(), 4, "{points}"); + let (x, y) = pts[1]; + assert!((x - 0.25).abs() < 0.01 && (y - 0.6).abs() < 0.01, "{points}"); +} + +#[test] +fn the_halftone_menu_item_opens_a_previewing_dialog() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#000000"})).unwrap(); + let id = app.session.active().unwrap().selection.objects[0]; + menus::invoke(&mut app, "object.vectorHalftone", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::halftone::KIND)); + // Agents open it the same way. + app.ui.dialog = None; + app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::halftone::KIND)); + let text = dialog_text(&mut app); + for s in ["Vector Halftone", "Shape", "Circle", "Frequency", "Angle", "Screens", "Mono", "Color", "Clip to Art", "Keep Original"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + assert!(app.session.in_interaction(), "the preview runs"); + assert!(app.session.active().unwrap().doc.node(id).is_none(), "the preview shows the halftone in place of the art"); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("cmyk")); + assert!(!dialog_text(&mut app).lines().any(|l| l == "Color"), "CMYK screens have their own inks"); + let undo = app.session.doc().unwrap().history.undo.len(); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().name.as_deref(), Some("Vector Halftone")); +} diff --git a/crates/ui-egui/src/tests_cut.rs b/crates/ui-egui/src/tests_cut.rs new file mode 100644 index 000000000..1c26490ce --- /dev/null +++ b/crates/ui-egui/src/tests_cut.rs @@ -0,0 +1,38 @@ +//! The cutting tools in the UI: toolbar entries, icons and Mirror & Cut's Control bar options. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, chrome, icons, tests_labels::painted_text}; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app +} + +#[test] +fn the_cut_tools_have_their_own_icons_and_toolbar_slot() { + for (tool, icon) in [("mirrorCut", "dc-mirror-cut"), ("lineCut", "dc-line-cut"), ("rectCut", "dc-rect-cut")] { + let info = vectorcraft_tools::tool_info(tool).unwrap(); + assert_eq!(icons::tool_icon(info.icon), icon); + assert!(icons::exists(icon), "{icon}"); + assert!(crate::toolbar::BASIC.iter().flat_map(|(_, slots)| slots.iter()).any(|s| s.contains(&tool) && s.contains(&"knife"))); + } +} + +#[test] +fn the_control_bar_shows_mirror_and_cut_options() { + let mut app = app(); + app.run("tool.select", json!({"tool": "lineCut"})).unwrap(); + assert!(!painted_text(&mut app, chrome::control_bar).contains("Axis:"), "only Mirror & Cut has options"); + app.run("tool.select", json!({"tool": "mirrorCut"})).unwrap(); + let text = painted_text(&mut app, chrome::control_bar); + for s in ["Axis:", "Free", "Keep:", "Left"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + app.run("tool.setOption", json!({"key": "axis", "value": "horizontal"})).unwrap(); + app.run("tool.setOption", json!({"key": "keep", "value": "bottom"})).unwrap(); + let text = painted_text(&mut app, chrome::control_bar); + assert!(text.lines().any(|l| l == "Horizontal") && text.lines().any(|l| l == "Bottom"), "{text}"); +} diff --git a/crates/ui-egui/src/tests_distortkeys.rs b/crates/ui-egui/src/tests_distortkeys.rs new file mode 100644 index 000000000..5513d81c9 --- /dev/null +++ b/crates/ui-egui/src/tests_distortkeys.rs @@ -0,0 +1,83 @@ +//! Delete and Backspace with the Width and Puppet Warp tools, through the shortcut routing: the +//! selected width point or pin goes, and with nothing of the tool's selected the keys clear the +//! selected art as usual. + +use egui::{Key, Modifiers}; +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use crate::VectorcraftApp; + +/// A 300×300 document with a selected 10 pt line from (100, 200) to (300, 200) → its id. +fn app_with_line() -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.line", json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap()["id"].as_u64().unwrap(); + app.run("stroke.set", json!({"weight": 10})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, id) +} + +/// One frame pressing `key` through the shortcut handler. +fn press(app: &mut VectorcraftApp, key: Key) { + let ctx = egui::Context::default(); + let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn click(app: &mut VectorcraftApp, x: f64, y: f64) { + let v = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + app.session.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } +} + +fn exists(app: &VectorcraftApp, id: u64) -> bool { + app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).is_some() +} + +fn width_points(app: &VectorcraftApp, id: u64) -> Value { + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).cloned().unwrap(); + json!(n.appearance.stroke().unwrap().profile.as_ref().map(|p| p.points.len())) +} + +#[test] +fn delete_and_backspace_remove_the_selected_width_point_then_clear() { + for key in [Key::Delete, Key::Backspace] { + let (mut app, id) = app_with_line(); + app.run("stroke.widthPoint.set", json!({"id": id, "t": 0.5, "left": 12, "right": 12})).unwrap(); + assert_eq!(width_points(&app, id), json!(3)); + app.select_tool("width"); + // A click on the width point's centre selects it; the key removes it, not the line. + click(&mut app, 200.0, 200.0); + press(&mut app, key); + assert!(exists(&app, id), "{key:?} kept the line"); + assert_eq!(width_points(&app, id), json!(2), "{key:?} removed the width point"); + // Nothing of the tool's is selected now: the key clears the selected line. + press(&mut app, key); + assert!(!exists(&app, id), "{key:?} with no width point selected clears the selection"); + } +} + +#[test] +fn delete_and_backspace_remove_the_selected_pin_then_clear() { + for key in [Key::Delete, Key::Backspace] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool("puppetWarp"); + let pins = |app: &VectorcraftApp| app.session.tool_options()["pins"].as_array().map_or(0, Vec::len); + // A click on the art away from the automatic pins adds a pin and selects it. + click(&mut app, 112.0, 188.0); + let n = pins(&app); + assert!(n > 1, "{n} pins"); + press(&mut app, key); + assert!(exists(&app, id), "{key:?} kept the art"); + assert_eq!(pins(&app), n - 1, "{key:?} removed the pin"); + press(&mut app, key); + assert!(!exists(&app, id), "{key:?} with no pin selected clears the selection"); + } +} diff --git a/crates/ui-egui/src/tests_fonts.rs b/crates/ui-egui/src/tests_fonts.rs new file mode 100644 index 000000000..aa59143f6 --- /dev/null +++ b/crates/ui-egui/src/tests_fonts.rs @@ -0,0 +1,80 @@ +//! The font menus list the installed fonts without asking for them (#36), and the font dropdown +//! searches them. + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_text::{FontDb, system_font_dirs}; + +use crate::tests_labels::shapes_text; +use crate::{VectorcraftApp, menus, widgets}; + +#[test] +fn type_font_menu_lists_every_available_family() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let fonts = |app: &VectorcraftApp| -> Vec { + menus::menu_entries(app).into_iter().filter(|e| e.path == ["Type", "Font"]).map(|e| e.label).collect() + }; + let labels = fonts(&app); + assert_eq!(labels, FontDb::global().families()); + let installed = FontDb::with_font_dirs(system_font_dirs()); + installed.load_system_fonts(); + let missing: Vec = installed.families().into_iter().filter(|f| !labels.contains(f)).collect(); + assert!(missing.is_empty(), "installed but not in Type › Font: {missing:?}"); + // The menu is built every frame, from a list built once. + assert_eq!(fonts(&app), labels); +} + +#[test] +fn the_font_dropdown_searches_the_families_and_enter_picks_the_first_match() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Each frame: what it painted, where the dropdown is and what it picked. + let frame = |events: Vec| { + let input = + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }; + let (mut at, mut picked) = (egui::Pos2::ZERO, None); + let mut out = ctx.run_ui(input, |ui| { + at = ui.next_widget_position(); + picked = widgets::font_dropdown(ui, "test-font", "Inter", 220.0); + }); + out.textures_delta.clear(); + (shapes_text(&out), at, picked) + }; + let (closed, at, _) = frame(vec![]); + assert_eq!(closed.trim(), "Inter"); + let click = at + egui::vec2(40.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + // The popup sizes itself invisibly in its first frame, then the list scrolls (animated). + for _ in 0..30 { + frame(vec![]); + } + // The families painted in the list (the button shows the current one, Inter). + let families = FontDb::global().families(); + let listed = |text: &str| -> Vec { text.lines().skip(1).filter(|l| families.iter().any(|f| f == l)).map(str::to_string).collect() }; + let (open, ..) = frame(vec![]); + assert!(listed(&open).len() >= families.len().min(5), "{open}"); + assert!(listed(&open).iter().any(|f| f == "Inter"), "the current font in view: {open}"); + // The search field has the focus: typing filters the list. + frame(vec![egui::Event::Text("source serif".into())]); + let (filtered, ..) = frame(vec![]); + let shown = listed(&filtered); + assert!(shown.iter().any(|f| f == "Source Serif 4"), "{filtered}"); + assert!(shown.iter().all(|f| f.to_lowercase().contains("source serif")), "{shown:?}"); + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let (_, _, picked) = frame(vec![enter(true), enter(false)]); + assert_eq!(picked.as_deref(), Some("Source Serif 4")); + let (closed, ..) = frame(vec![]); + assert_eq!(closed.trim(), "Inter", "the list closes"); +} + +#[test] +fn the_character_panel_menu_refreshes_the_font_list() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + // The installed fonts are always listed: the menu offers to look for new ones instead. + let text = crate::tests_labels::painted_text(&mut app, crate::panels::character::menu); + assert!(text.contains("Refresh Font List") && !text.contains("System Fonts"), "{text}"); +} diff --git a/crates/ui-egui/src/tests_home.rs b/crates/ui-egui/src/tests_home.rs new file mode 100644 index 000000000..0fd8f0caf --- /dev/null +++ b/crates/ui-egui/src/tests_home.rs @@ -0,0 +1,49 @@ +//! The Home icon shows the Home screen over open documents; Cmd+N opens the New Document dialog. + +use serde_json::json; + +use crate::{VectorcraftApp, canvas}; + +fn app_with_doc() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app +} + +/// Draw one canvas frame; whether it drew the document (else the Home screen). +fn shows_document(app: &mut VectorcraftApp) -> bool { + app.canvas_rect = None; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + app.canvas_rect.is_some() +} + +#[test] +fn home_shows_over_open_documents_until_a_document_is_chosen() { + let mut app = app_with_doc(); + assert!(shows_document(&mut app)); + app.run("app.home", json!({})).unwrap(); + assert!(!shows_document(&mut app), "Home replaces the canvas"); + assert_eq!(app.session.documents().len(), 1, "the document stays open"); + assert!(app.ui.dialog.is_none(), "Home is not the New Document dialog"); + // Choosing the document's tab returns to it. + app.ui.home = None; + assert!(shows_document(&mut app)); + // A new document (from Home's presets, New… or Open) replaces Home too. + app.run("app.home", json!({})).unwrap(); + app.run("file.new", json!({})).unwrap(); + assert!(shows_document(&mut app)); + assert!(app.ui.home.is_none()); +} + +#[test] +fn cmd_n_opens_the_new_document_dialog() { + assert_eq!(crate::shortcut_editor::command_for_key("Cmd+N"), Some("file.newDialog")); + let mut app = app_with_doc(); + app.run("file.newDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("newDocument")); + assert_eq!(app.session.documents().len(), 1, "no document is made until the dialog's OK"); +} diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index a34535fbd..49167f6c7 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -16,6 +16,16 @@ fn app() -> VectorcraftApp { /// Every string painted by `f` in a headless frame (app fonts installed), one per line. The second /// of two frames: windows size themselves invisibly in their first. pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); + out.textures_delta.clear(); + shapes_text(&out) +} + +/// Every string a frame painted, one per line. +pub(crate) fn shapes_text(out: &egui::FullOutput) -> String { fn collect(s: &Shape, out: &mut String) { match s { Shape::Text(t) => { @@ -26,11 +36,6 @@ pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut Vect _ => {} } } - let ctx = egui::Context::default(); - crate::theme::install_fonts(&ctx); - ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear(); - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); - out.textures_delta.clear(); let mut text = String::new(); for c in &out.shapes { collect(&c.shape, &mut text); diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs new file mode 100644 index 000000000..716f3517e --- /dev/null +++ b/crates/ui-egui/src/tests_numfields.rs @@ -0,0 +1,127 @@ +//! Numeric fields: math typed before the unit suffix, and the whole text (unit included) selected +//! on focus or double-click so typing replaces it. + +use std::cell::Cell; + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, vec2}; +use vectorcraft_doc::Unit; + +use crate::widgets; + +/// Headless frames of one field: its rect and id as last drawn. +struct Field { + ctx: egui::Context, + time: f64, + rect: Cell, + id: Cell, +} + +impl Field { + fn new() -> Self { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + Self { ctx, time: 0.0, rect: Cell::new(Rect::NOTHING), id: Cell::new(egui::Id::NULL) } + } + + fn frame(&mut self, events: Vec, draw: &dyn Fn(&mut egui::Ui) -> Option) -> Option { + self.time += 0.05; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))); + let input = egui::RawInput { events, time: Some(self.time), screen_rect, ..Default::default() }; + let mut got = None; + let mut out = self.ctx.run_ui(input, |ui| { + self.id.set(ui.id().with("f")); + got = draw(ui); + self.rect.set(ui.min_rect()); + }); + out.textures_delta.clear(); + got + } + + fn num(&mut self, events: Vec) -> Option { + self.frame(events, &|ui| widgets::num_field(ui, "f", Some(100.0), Unit::Pixels, 80.0)) + } + + fn click(&self, count: usize) -> Vec { + let at = self.rect.get().center(); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + std::iter::once(Event::PointerMoved(at)).chain((0..count).flat_map(|_| [button(true), button(false)])).collect() + } + + /// The selected character range of the field's text. + fn selection(&self) -> Option<(usize, usize)> { + let st = egui::TextEdit::load_state(&self.ctx, self.id.get())?; + let r = st.cursor.char_range()?.as_sorted_char_range(); + Some((r.start.0, r.end.0)) + } +} + +fn enter() -> Event { + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn math_before_the_unit_suffix_evaluates_in_every_unit() { + assert_eq!(Unit::Pixels.parse("1080/2 px"), Some(540.0)); + assert_eq!(Unit::Points.parse("1080/2 px"), Some(540.0)); + assert_eq!(Unit::Pixels.parse("100*3 px"), Some(300.0)); + for u in Unit::ALL { + let shown = u.format(u.to_pt(12.0)); + // "12 mm" with "*2" typed after the number, before the suffix. + let typed = shown.replacen(&u.number(u.to_pt(12.0)), "12*2", 1); + let v = u.parse(&typed).unwrap_or_else(|| panic!("{u:?}: `{typed}`")); + assert!((v - u.to_pt(24.0)).abs() < 1e-9, "{u:?}: `{typed}` → {v}"); + let typed = shown.replacen(&u.number(u.to_pt(12.0)), "12/4", 1); + let v = u.parse(&typed).unwrap_or_else(|| panic!("{u:?}: `{typed}`")); + assert!((v - u.to_pt(3.0)).abs() < 1e-9, "{u:?}: `{typed}` → {v}"); + } + // The suffix measures the expression; units given in it still win. + assert_eq!(Unit::Points.parse("1in/2 mm"), Some(36.0)); + assert_eq!(Unit::Points.parse("10/0 px"), None); + // Unitless fields. + assert_eq!(vectorcraft_doc::parse_number("45*2"), Some(90.0)); + assert_eq!(vectorcraft_doc::parse_number(" 100 / 4 "), Some(25.0)); + assert_eq!(vectorcraft_doc::parse_number("+5"), Some(5.0)); + assert_eq!(vectorcraft_doc::parse_number("-5"), Some(-5.0)); + assert_eq!(vectorcraft_doc::parse_number("3 mm"), None); + assert_eq!(vectorcraft_doc::parse_number("inf"), None); +} + +#[test] +fn focus_selects_the_whole_field_so_typing_replaces_it() { + let mut f = Field::new(); + f.num(vec![]); + f.num(f.click(1)); + f.num(vec![]); + assert_eq!(f.selection(), Some((0, "100 px".len())), "the unit is selected too"); + f.num(vec![Event::Text("1080/2".into())]); + assert_eq!(f.num(vec![enter()]), Some(540.0)); +} + +#[test] +fn double_click_selects_the_whole_field() { + let mut f = Field::new(); + f.num(vec![]); + f.num(f.click(1)); + f.num(vec![]); + // A click in the focused field (not part of a double-click) places the caret; a double-click + // takes everything again. + f.time += 1.0; + f.num(f.click(1)); + f.num(vec![]); + assert!(f.selection().is_some_and(|(a, b)| a == b), "{:?}", f.selection()); + f.num(f.click(2)); + f.num(vec![]); + assert_eq!(f.selection(), Some((0, "100 px".len()))); +} + +#[test] +fn plain_fields_do_math_with_their_suffix() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", 10.0, "°", 2, 80.0); + f.frame(vec![], &draw); + f.frame(f.click(1), &draw); + f.frame(vec![], &draw); + assert_eq!(f.selection(), Some((0, "10°".chars().count()))); + f.frame(vec![Event::Text("45*2°".into())], &draw); + assert_eq!(f.frame(vec![enter()], &draw), Some(90.0)); +} diff --git a/crates/ui-egui/src/tests_pastechords.rs b/crates/ui-egui/src/tests_pastechords.rs new file mode 100644 index 000000000..fd6d96804 --- /dev/null +++ b/crates/ui-egui/src/tests_pastechords.rs @@ -0,0 +1,75 @@ +//! The paste chords (egui reports every Cmd+V chord as a paste, not as its key) and the default +//! shortcuts (no chord does two things). + +use std::collections::BTreeMap; + +use egui::{Event, Key, Modifiers}; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, shortcut_editor, shortcuts}; + +/// One headless frame of the shortcut handler (and the app's logic, which publishes copies) with +/// `modifiers` held and `events`. +fn frame(app: &mut VectorcraftApp, modifiers: Modifiers, events: Vec) -> egui::FullOutput { + let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect(); + let mut out = egui::Context::default().run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + shortcuts::handle(app, ui.ctx()); + app.logic(ui.ctx()); + }); + out.textures_delta.clear(); + out +} + +fn last_object_bounds(app: &VectorcraftApp) -> (f64, f64) { + let st = app.session.active().unwrap(); + let id = st.doc.layers[0].children().unwrap().last().unwrap().id; + let b = st.doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0) +} + +#[test] +fn cmd_shift_v_pastes_in_place() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 30})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let out = frame(&mut app, Modifiers::COMMAND, vec![Event::Copy]); + let svg = out + .platform_output + .commands + .iter() + .find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .unwrap(); + // The text egui read from the clipboard comes with the chord still held. + frame(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, vec![Event::Paste(svg.clone())]); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2); + assert_eq!(last_object_bounds(&app), (10.0, 20.0), "Cmd+Shift+V pastes in place"); + frame(&mut app, Modifiers::COMMAND, vec![Event::Paste(svg)]); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 3); + assert_ne!(last_object_bounds(&app), (10.0, 20.0), "Cmd+V pastes offset or centred"); +} + +#[test] +fn a_paste_takes_the_command_of_the_v_chord_held() { + let cmd = Modifiers::COMMAND; + assert_eq!(shortcuts::paste_command(cmd), "edit.paste"); + assert_eq!(shortcuts::paste_command(cmd | Modifiers::SHIFT), "edit.pasteInPlace"); + assert_eq!(shortcuts::paste_command(cmd | Modifiers::ALT), "edit.pasteWithoutFormatting"); + assert_eq!(shortcuts::paste_command(cmd | Modifiers::ALT | Modifiers::SHIFT), "edit.pasteOnAllArtboards"); + // The Paste key, and Shift+Insert, paste. + assert_eq!(shortcuts::paste_command(Modifiers::NONE), "edit.paste"); + assert_eq!(shortcuts::paste_command(Modifiers::SHIFT), "edit.paste"); + // What isn't text: only the key's release is reported, with the chord's modifiers. + let mut chord = shortcuts::PasteChord::default(); + let release = Event::Key { key: Key::V, physical_key: None, pressed: false, repeat: false, modifiers: cmd | Modifiers::SHIFT }; + assert_eq!(chord.textless_paste(&[release]), Some(cmd | Modifiers::SHIFT)); +} + +#[test] +fn no_two_commands_tools_or_panels_share_a_default_shortcut() { + assert_eq!(shortcut_editor::all_conflicts(&BTreeMap::new()), vec![]); +} diff --git a/crates/ui-egui/src/tests_plugins.rs b/crates/ui-egui/src/tests_plugins.rs new file mode 100644 index 000000000..7cd254f1d --- /dev/null +++ b/crates/ui-egui/src/tests_plugins.rs @@ -0,0 +1,115 @@ +//! WebAssembly plug-ins in the UI (#121): Object › Plug-ins and Effect › Plug-ins, the generated +//! filter dialog with its live preview, plug-in effects in the effect dialog, and installing +//! `.wasm` files through File › Open. + +use serde_json::{Value, json}; +use vectorcraft_engine::Session; +use vectorcraft_plugins::wat as tpl; + +use crate::state::Dialog; +use crate::{VectorcraftApp, dialogs, io, menus, theme}; + +const DESATURATE: &[u8] = include_bytes!("../../plugins/tests/fixtures/desaturate.wasm"); +const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let mut out = ctx.run_ui(Default::default(), |ui| dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn red_rect(app: &mut VectorcraftApp) -> vectorcraft_doc::NodeId { + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + vectorcraft_doc::NodeId(id) +} + +fn fill_rgb(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> [f32; 3] { + app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint().color().unwrap().to_rgb_uncalibrated() +} + +fn entry<'a>(entries: &'a [menus::MenuEntry], path: &[&str], label: &str) -> Option<&'a menus::MenuEntry> { + entries.iter().find(|e| e.path == path && e.label == label) +} + +#[test] +fn plugins_are_listed_in_the_object_and_effect_menus() { + let mut app = app(); + let before = menus::plugin_revision(); + io::open_bytes(&mut app, "desaturate.wasm", DESATURATE, None).unwrap(); + let effect = tpl::module(&tpl::manifest("test.ui.echo", "effect", r#"{"wiggle":{"type":"bool"}}"#), tpl::ECHO, "", 1); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(effect).unwrap())})).unwrap(); + assert_ne!(menus::plugin_revision(), before, "the native menu is rebuilt"); + let entries = menus::menu_entries(&app); + let filter = entry(&entries, &["Object", "Plug-ins"], "Desaturate…").expect("filter listed"); + assert_eq!((filter.command.as_deref(), &filter.params), (Some("plugin.dialog"), &json!({"id": DESATURATE_ID}))); + assert!(entry(&entries, &["Object", "Plug-ins"], "Install Plug-in…").is_some()); + assert!(entry(&entries, &["Object", "Plug-ins"], "Reload Plug-ins").is_some()); + let fx = entry(&entries, &["Effect", "Plug-ins"], "Test test.ui.echo…").expect("effect listed"); + assert_eq!((fx.command.as_deref(), &fx.params), (Some("effect.dialog"), &json!({"effect": "plugin.test.ui.echo"}))); + // Menu clicks on items with params don't turn into the generic parameter dialog. + assert_eq!(menus::click_target("Desaturate…", "plugin.dialog", &filter.params).0, "plugin.dialog"); +} + +#[test] +fn filter_dialog_previews_and_commits_one_undo_step() { + let mut app = app(); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)})).unwrap(); + let id = red_rect(&mut app); + let undo = app.session.doc().unwrap().history.undo.len(); + menus::invoke(&mut app, "plugin.dialog", json!({"id": DESATURATE_ID})); + let d = app.ui.dialog.as_ref().expect("dialog open"); + assert_eq!((d.kind.as_str(), d.str("__label"), d.fields.get("amount")), ("plugin", "Desaturate".to_string(), Some(&json!(100.0)))); + // Agents set fields like `ui.dialog.set` does; the frame previews them. + app.ui.dialog.as_mut().unwrap().fields.insert("amount".into(), json!(50)); + frame(&mut app); + assert!(app.session.in_interaction(), "previewing"); + let [r, g, _] = fill_rgb(&app, id); + assert!(r < 1.0 && g > 0.0, "half desaturated on the canvas: {r} {g}"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Desaturate"); + // Cancel rolls the preview back. + menus::invoke(&mut app, "plugin.dialog", json!({"id": DESATURATE_ID})); + frame(&mut app); + let previewed = fill_rgb(&app, id); + dialogs::cancel(&mut app); + assert_ne!(fill_rgb(&app, id), previewed); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // With params, the menu command runs at once. + app.run("plugin.dialog", json!({"id": DESATURATE_ID, "params": {"amount": 100}})).unwrap(); + let [r, g, b] = fill_rgb(&app, id); + assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6); + assert!(app.run("plugin.dialog", json!({"id": "test.ui.missing"})).is_err()); +} + +#[test] +fn plugin_effects_open_the_effect_dialog_with_their_schema() { + let mut app = app(); + let src = + tpl::module(&tpl::manifest("test.ui.fx", "effect", r#"{"mode":{"type":"choice","options":["a","b"],"default":"b"}}"#), tpl::ECHO, "", 1); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap(); + let id = red_rect(&mut app); + app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx"})).unwrap(); + let d: &Dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("__label"), d.str("mode")), ("effect", "Test test.ui.fx".to_string(), "b".to_string())); + frame(&mut app); + dialogs::confirm(&mut app).unwrap(); + let fx = &app.session.doc().unwrap().doc.node(id).unwrap().appearance.effects; + assert_eq!((fx[0].id.as_str(), &fx[0].params), ("plugin.test.ui.fx", &json!({"mode": "b"}))); + assert_eq!(crate::panels::appearance::effect_label("plugin.test.ui.fx"), "Test test.ui.fx"); + // Editing it again starts from its values. + app.run("effect.dialog", json!({"effect": "plugin.test.ui.fx", "index": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get("mode"), Some(&Value::from("b"))); +} diff --git a/crates/ui-egui/src/tests_puppetwarp.rs b/crates/ui-egui/src/tests_puppetwarp.rs new file mode 100644 index 000000000..1ad830bc4 --- /dev/null +++ b/crates/ui-egui/src/tests_puppetwarp.rs @@ -0,0 +1,35 @@ +//! The Puppet Warp tool in the UI: its Control bar (Expand, Show Mesh, Select All Pins) and hint. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, chrome, tests_labels::painted_text}; + +#[test] +fn the_control_bar_shows_puppet_warp_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 200, "height": 100})).unwrap(); + app.run("tool.select", json!({"tool": "puppetWarp"})).unwrap(); + let text = painted_text(&mut app, chrome::control_bar); + for s in ["Expand:", "3 pt", "Show Mesh", "Select All Pins"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + // The options it sets: Expand may be 0, Show Mesh turns off. + let opts = app.run("tool.setOption", json!({"key": "expand", "value": 0})).unwrap(); + assert_eq!(opts["expand"], json!(0.0)); + let opts = app.run("tool.setOption", json!({"key": "showMesh", "value": false})).unwrap(); + assert_eq!(opts["showMesh"], json!(false)); + assert!(painted_text(&mut app, chrome::control_bar).lines().any(|l| l == "0 pt")); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + assert!(!painted_text(&mut app, chrome::control_bar).contains("Show Mesh"), "only with Puppet Warp"); +} + +#[test] +fn the_hint_bar_explains_puppet_warp() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "puppetWarp"})).unwrap(); + let text = painted_text(&mut app, chrome::hint_bar).replace('\n', ""); + assert!(text.contains("add a pin") && text.contains("rotate") && text.contains("Delete"), "{text}"); +} diff --git a/crates/ui-egui/src/tests_saveext.rs b/crates/ui-egui/src/tests_saveext.rs new file mode 100644 index 000000000..e1a896b62 --- /dev/null +++ b/crates/ui-egui/src/tests_saveext.rs @@ -0,0 +1,74 @@ +//! A name typed in a save panel without an extension gets the chosen format's: Save, Save As, +//! Save a Copy, Export, Export Selection and other writes through a save panel. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{FilePick, Services, VectorcraftApp, io}; + +type Written = Rc>>; + +/// An app with a document whose save panel answers `typed` and whose writer records the paths. +fn saving_app(typed: &'static str) -> (VectorcraftApp, Written) { + let written = Written::default(); + let w = written.clone(); + let services = Services { + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })), + pick_save: Some(Box::new(move |_: &FilePick| Some(typed.to_string()))), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + (app, written) +} + +fn last(w: &Written) -> String { + w.borrow().last().cloned().unwrap_or_default() +} + +#[test] +fn saves_add_the_format_extension_to_a_bare_name() { + let (mut app, w) = saving_app("/tmp/Untitled-1"); + app.run("file.save", json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/Untitled-1.vectorcraft"); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/Untitled-1.vectorcraft")); + let (mut app, w) = saving_app("/tmp/copy"); + app.run("file.saveCopy", json!({"format": "svg", "svg": {}})).unwrap(); + assert_eq!(last(&w), "/tmp/copy.svg"); + let (mut app, w) = saving_app("/tmp/as"); + app.run("file.saveAs", json!({"format": "pdf", "options": {}})).unwrap(); + assert_eq!(last(&w), "/tmp/as.pdf"); +} + +#[test] +fn exports_add_the_format_extension_to_a_bare_name() { + let (mut app, w) = saving_app("/tmp/art"); + io::export(&mut app, Some("png"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/art.png"); + app.run("select.all", json!({})).unwrap(); + app.run("document.exportSelection", json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/art.png", "Export Selection: its default format"); + io::save_command_output(&mut app, "document.export", "txt", json!({"format": "txt"})).unwrap(); + assert_eq!(last(&w), "/tmp/art.txt"); +} + +#[test] +fn typed_extensions_are_kept() { + let (mut app, w) = saving_app("/tmp/art.svg"); + io::export(&mut app, Some("svg"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/art.svg"); + let (mut app, w) = saving_app("/tmp/My.Poster.SVG"); + io::export(&mut app, Some("svg"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/My.Poster.SVG"); + // A dot in the name isn't an extension. + let (mut app, w) = saving_app("/tmp/v1.2"); + io::export(&mut app, Some("png"), None, &json!({})).unwrap(); + assert_eq!(last(&w), "/tmp/v1.2.png"); +} diff --git a/crates/ui-egui/src/tests_synthetic.rs b/crates/ui-egui/src/tests_synthetic.rs new file mode 100644 index 000000000..84962e40f --- /dev/null +++ b/crates/ui-egui/src/tests_synthetic.rs @@ -0,0 +1,118 @@ +//! Control-channel input (`ui.key`, `ui.click`, `ui.drag`) holds its modifiers in egui's input +//! state for the frames it spans, so handlers reading `i.modifiers` see them as they see the +//! keyboard's. + +use egui::Modifiers; +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; +use crate::control::{ControlRequest, Outcome}; + +/// Send one control request (it must succeed). +pub(crate) fn control(app: &mut VectorcraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (req, _rx) = ControlRequest::new(method, params); + let Outcome::Done(r) = crate::control::handle(app, ctx, &req) else { panic!("not done") }; + assert_eq!(r["ok"], true, "{r}"); +} + +/// Frames through the host's input hook until the synthetic queue is drained, and one more (the +/// keyboard's modifiers come back); `draw` lays out what the input reaches. → the modifiers +/// held in each frame. +pub(crate) fn run_synthetic(app: &mut VectorcraftApp, ctx: &egui::Context, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) -> Vec { + let mut held = vec![]; + loop { + let last = app.synthetic.is_empty(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&mut raw); + let mut out = ctx.run_ui(raw, |ui| { + held.push(ui.input(|i| i.modifiers)); + draw(app, ui); + }); + out.textures_delta.clear(); + if last { + return held; + } + } +} + +fn keys(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + crate::shortcuts::handle(app, ui.ctx()); +} + +/// A document with one selected 10 × 10 square at (10, 10) → (app, its id). +fn one_square() -> (VectorcraftApp, NodeId) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + (app, NodeId(id)) +} + +fn top_left(app: &VectorcraftApp, id: NodeId) -> (f64, f64) { + let b = app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + (b.x0, b.y0) +} + +fn object_count(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().doc.layers[0].children().unwrap().len() +} + +#[test] +fn shift_arrow_nudges_ten_points_and_alt_arrow_copies() { + let (mut app, id) = one_square(); + let ctx = egui::Context::default(); + control(&mut app, &ctx, "ui.key", json!({"key": "ArrowDown", "shift": true})); + let held = run_synthetic(&mut app, &ctx, keys); + assert_eq!(top_left(&app, id), (10.0, 20.0), "Shift+↓ nudges by 10 pt"); + assert!(held.first().is_some_and(|m| m.shift)); + assert_eq!(held.last(), Some(&Modifiers::NONE), "the keyboard's modifiers come back"); + control(&mut app, &ctx, "ui.key", json!({"key": "ArrowRight", "alt": true})); + run_synthetic(&mut app, &ctx, keys); + assert_eq!(object_count(&app), 2, "Alt+→ nudges a copy"); +} + +#[test] +fn a_typed_key_holds_its_modifiers_for_its_text() { + let (mut app, _) = one_square(); + let ctx = egui::Context::default(); + control(&mut app, &ctx, "ui.key", json!({"key": "B", "shift": true, "text": "B"})); + run_synthetic(&mut app, &ctx, keys); + assert_eq!(app.session.tool_id(), "blobBrush", "Shift+B, not B"); +} + +#[test] +fn a_drag_holds_its_modifiers_from_the_press_to_the_release() { + let (mut app, _) = one_square(); + let ctx = egui::Context::default(); + control(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 10, "toX": 50, "toY": 50, "steps": 3, "shift": true})); + let held = run_synthetic(&mut app, &ctx, |_, _| {}); + let shift: Vec = held.iter().map(|m| m.shift).collect(); + // Move to the start, press, three moves, release, then the keyboard's modifiers again. + assert_eq!(shift, [false, true, true, true, true, true, false]); +} + +#[test] +fn a_shift_click_on_a_layers_selection_square_adds_to_the_selection() { + let (mut app, a) = one_square(); + let b = NodeId(app.run("shape.rectangle", json!({"x": 40, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + let ctx = egui::Context::default(); + // Rows top down: the layer, then `b`; a row's selection square is 17 pt right of its target circle. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::panels::layers::show(&mut app, ui)); + out.textures_delta.clear(); + let mut circles: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Circle(cs) if cs.radius == 5.0 => Some(cs.center), + _ => None, + }) + .collect(); + circles.sort_by(|p, q| p.y.total_cmp(&q.y)); + let square = circles[1] + egui::vec2(17.0, 0.0); + control(&mut app, &ctx, "ui.click", json!({"x": square.x, "y": square.y, "shift": true})); + run_synthetic(&mut app, &ctx, crate::panels::layers::show); + assert_eq!(app.session.active().unwrap().selection.objects, vec![a, b], "added, not replaced"); +} diff --git a/crates/ui-egui/src/tests_widthtool.rs b/crates/ui-egui/src/tests_widthtool.rs new file mode 100644 index 000000000..4a59ccc9c --- /dev/null +++ b/crates/ui-egui/src/tests_widthtool.rs @@ -0,0 +1,29 @@ +//! The Width tool in the UI: its cursors and hint. + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_tools::Cursor; + +use crate::{VectorcraftApp, chrome, tests_labels::painted_text}; + +#[test] +fn the_width_tool_has_its_own_cursors() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.painter().clone(); + for c in [Cursor::Width, Cursor::WidthAdd, Cursor::WidthPoint] { + assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0)), "{c:?} is drawn"); + } + }); + out.textures_delta.clear(); + assert!(out.shapes.len() > 3, "{} shapes", out.shapes.len()); +} + +#[test] +fn the_hint_bar_explains_the_width_tool() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("tool.select", json!({"tool": "width"})).unwrap(); + let text = painted_text(&mut app, chrome::hint_bar).replace('\n', ""); + assert!(text.contains("add a width point") && text.contains("one side") && text.contains("remove points"), "{text}"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e4b95f72f..67a6f84b0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -254,9 +254,28 @@ pub fn install_fonts(ctx: &egui::Context) { let mut mono = vec!["JetBrainsMono".to_string()]; mono.extend(fallback); fonts.families.insert(FontFamily::Name(FONT_MONO.into()), mono); + add_craft_fonts(&mut fonts); ctx.set_fonts(fonts); } +/// The egui name of a craft-fonts face. +pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String { + format!("craft-fonts {} {}", f.family, f.style) +} + +/// The Japanese craft-fonts faces (when built with `CRAFT_FONTS_DIR`) as fallbacks at the end of +/// every family, after the app's own fonts and before the installed fonts `ui_fonts` adds: BIZ +/// UDPGothic first (bold first in the semibold family), then the Mincho faces. +fn add_craft_fonts(fonts: &mut FontDefinitions) { + for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese()) { + fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); + } + for (family, stack) in fonts.families.iter_mut() { + let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into()); + stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name)); + } +} + /// Apply tokens to egui's global style. pub fn apply(ctx: &egui::Context, b: Brightness) { let t = Tokens::for_brightness(b); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2fcd264c0..8d782dfdf 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -29,7 +29,7 @@ pub const BASIC: &[(&str, &[&[&str]])] = &[ "Draw", &[ &["pencil", "smooth", "pathEraser", "join"], - &["eraser", "scissors", "knife"], + &["eraser", "scissors", "knife", "mirrorCut", "lineCut", "rectCut"], &["paintbrush", "blobBrush"], &["pen", "addAnchor", "deleteAnchor", "anchorPoint"], &["curvature"], @@ -106,84 +106,103 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Stroke::new(0.4, t.text_disabled), ); } - let active = app.session.tool_id(); - let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None; - let mut i = 0; - while i < all.len() { - if let Some(cat) = all[i].0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); - let label = if cols == 1 && cat.len() > 6 { format!("{}...", &cat[..4]) } else { cat.to_string() }; - ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text); + // The tools and the controls under them scroll in a window too short for them. + widgets::strip_scroll(ui, "toolbar", |ui| { + let active = app.session.tool_id(); + // The tool button whose long press opened its flyout, until the next press. + let held_id = egui::Id::new("toolbar-held"); + if ui.input(|inp| inp.pointer.any_pressed()) { + ui.data_mut(|d| d.remove::(held_id)); } - // One row = `cols` slots (a category label always starts a new row). - let mut row = vec![i]; - while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() { - row.push(i + row.len()); - } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - if cols == 1 { - ui.add_space((WIDTH - 36.0) / 2.0); - } else { - ui.add_space(2.0); + let held: Option = ui.data(|d| d.get_temp(held_id)); + let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None; + let mut i = 0; + while i < all.len() { + if let Some(cat) = all[i].0 { + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); + let label = if cols == 1 && cat.len() > 6 { format!("{}...", &cat[..4]) } else { cat.to_string() }; + ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text); } - for &k in &row { - let slot = &all[k].1; - let shown_id = if slot.contains(&active) { - active.to_string() + // One row = `cols` slots (a category label always starts a new row). + let mut row = vec![i]; + while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() { + row.push(i + row.len()); + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + if cols == 1 { + ui.add_space((WIDTH - 36.0) / 2.0); } else { - app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string()) - }; - let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue }; - let is_active = slot.contains(&active); - let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag()); - let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5)); - if is_active { - ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active); - } else if resp.hovered() { - ui.painter().rect_filled(well, CornerRadius::same(1), t.hover); + ui.add_space(2.0); } - let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0)); - icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon }); - if slot.len() > 1 { - let c = rect.center() + vec2(12.5, 10.0); - ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE)); + for &k in &row { + let slot = &all[k].1; + let shown_id = if slot.contains(&active) { + active.to_string() + } else { + app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string()) + }; + let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue }; + let is_active = slot.contains(&active); + let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag()); + let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5)); + if is_active { + ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(well, CornerRadius::same(1), t.hover); + } + let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0)); + icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon }); + if slot.len() > 1 { + let c = rect.center() + vec2(12.5, 10.0); + ui.painter().add(egui::Shape::convex_polygon( + vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], + t.icon, + Stroke::NONE, + )); + } + let long_press = resp.is_pointer_button_down_on() + && ui.input(|inp| inp.pointer.press_start_time().is_some_and(|s| inp.time - s > 0.35)); + let alt = ui.input(|inp| inp.modifiers.alt); + if (resp.secondary_clicked() || long_press) && slot.len() > 1 { + open_flyout = Some((slot.clone(), rect)); + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, resp.id)); + } + } else if resp.clicked() && held == Some(resp.id) { + // Releasing the long press that opened the flyout leaves it open. + } else if resp.clicked() && alt && slot.len() > 1 { + let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0); + app.select_tool(slot[(idx + 1) % slot.len()]); + } else if resp.double_clicked() { + app.select_tool(shown.id); + app.run("tool.options", json!({ "tool": shown.id })).ok(); + } else if resp.clicked() { + app.select_tool(shown.id); + } + resp.on_hover_text(tip(shown)); } - let long_press = - resp.is_pointer_button_down_on() && ui.input(|inp| inp.pointer.press_start_time().is_some_and(|s| inp.time - s > 0.35)); - let alt = ui.input(|inp| inp.modifiers.alt); - if (resp.secondary_clicked() || long_press) && slot.len() > 1 { - open_flyout = Some((slot.clone(), rect)); - } else if resp.clicked() && alt && slot.len() > 1 { - let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0); - app.select_tool(slot[(idx + 1) % slot.len()]); - } else if resp.double_clicked() { - app.select_tool(shown.id); - app.run("tool.options", json!({ "tool": shown.id })).ok(); - } else if resp.clicked() { - app.select_tool(shown.id); - } - resp.on_hover_text(tip(shown)); - } - }); - i += row.len(); - } - if let Some((slot, rect)) = open_flyout { - app.ui.flyout = Some(0); - ui.data_mut(|d| { - d.insert_temp(egui::Id::new("flyout-anchor"), rect); - d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::>()); - }); - } - ui.add_space(8.0); - bottom_controls(app, ui, &t); + }); + i += row.len(); + } + if let Some((slot, rect)) = open_flyout { + app.ui.flyout = Some(0); + ui.data_mut(|d| { + d.insert_temp(egui::Id::new("flyout-anchor"), rect); + d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::>()); + }); + } + ui.add_space(8.0); + bottom_controls(app, ui, &t); + }); }); flyout(app, ui.ctx()); } /// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are -/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog; the Print Tiling tool's -/// resets the print tiling. +/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool +/// Options dialog, the Blend tool's Blend +/// Options; the Print Tiling tool's resets the print tiling. pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result { match tool { "gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })), @@ -192,13 +211,52 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result { + crate::dialogs::blend_options::open(app)?; + Ok(json!({ "dialog": crate::dialogs::blend_options::KIND })) + } + "perspectiveGrid" => { + crate::dialogs::perspective_options::open(app); + Ok(json!({ "dialog": crate::dialogs::perspective_options::KIND })) + } // A double click on the Print Tiling tool puts the pages back where the placement puts them. "printTiling" => app.run("print.tiling.set", json!({ "reset": true })), + // The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own). + _ if vectorcraft_tools::settings::LIQUIFY.contains(&tool) => crate::dialogs::liquify::open(app, tool), _ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")), _ => Err(format!("the {tool} tool has no options")), } } +/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps, Puppet +/// Warp's mesh and pins (set through `tool.setOption`). +pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) { + /// (value, label) of each choice. + type Choices = &'static [(&'static str, &'static str)]; + const MIRROR: [(&str, &str, Choices); 2] = [ + ("axis", "Axis:", &[("free", "Free"), ("vertical", "Vertical"), ("horizontal", "Horizontal")]), + ("keep", "Keep:", &[("left", "Left"), ("right", "Right"), ("top", "Top"), ("bottom", "Bottom")]), + ]; + if app.session.tool_id() == "puppetWarp" { + return puppet_warp_options(app, ui); + } + if app.session.tool_id() != "mirrorCut" { + return; + } + let t = Tokens::get(ui.ctx()); + let opts = app.session.tool_options(); + for (key, label, choices) in MIRROR { + ui.label(egui::RichText::new(label).size(12.0).color(t.text)); + let cur = opts[key].as_str().unwrap_or_default(); + let shown = choices.iter().find(|(v, _)| *v == cur).map_or(cur, |(_, l)| *l); + let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect(); + if let Some((value, _)) = widgets::dropdown(ui, ("cb-tool", key), shown, &labels, 96.0).and_then(|i| choices.get(i)) { + app.run("tool.setOption", json!({ "key": key, "value": value })).ok(); + } + } + ui.separator(); +} + fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0)); ui.add_space(5.0); @@ -305,14 +363,36 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { ); if let Some(id) = chosen { app.select_tool(id); - } else if resp.response.clicked_elsewhere() { + } else if resp.response.clicked_elsewhere() && !ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| anchor.contains(p))) { + // A click on the flyout's own tool button (the right-click that opened it, or the end of a + // long press) leaves it to that button. app.ui.flyout = None; } let _ = theme::semibold; } +/// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh +/// and Select All Pins. +fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let opts = app.session.tool_options(); + ui.label(egui::RichText::new("Expand:").size(12.0).color(t.text)); + let unit = app.session.general_unit(); + if let Some(v) = widgets::num_field(ui, ("cb-tool", "expand"), opts["expand"].as_f64(), unit, 64.0) { + app.run("tool.setOption", json!({ "key": "expand", "value": v })).ok(); + } + let show = opts["showMesh"].as_bool().unwrap_or(true); + if widgets::check(ui, "Show Mesh", show, true) { + app.run("tool.setOption", json!({ "key": "showMesh", "value": !show })).ok(); + } + if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() { + app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok(); + } + ui.separator(); +} + #[cfg(test)] -mod tests { +pub(crate) mod tests { use egui::{Event, PointerButton, Pos2}; use vectorcraft_engine::Session; @@ -320,22 +400,58 @@ mod tests { /// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom. fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec) -> Vec { + frame_in(app, ctx, time, events, 1200.0) + } + + /// [`frame`] in a window `height` points tall. + fn frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec, height: f32) -> Vec { let input = egui::RawInput { time: Some(time), events, - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))), + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))), ..Default::default() }; let mut out = ctx.run_ui(input, |ui| show(app, ui)); out.textures_delta.clear(); - let mut r: Vec = ctx.viewport(|vp| { - let size = vec2(36.0, PITCH - 1.0); - vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect() - }); + widget_rects(ctx, vec2(36.0, PITCH - 1.0)) + } + + /// The rects of the last frame's widgets of `size`, top to bottom. + pub(crate) fn widget_rects(ctx: &egui::Context, size: egui::Vec2) -> Vec { + let mut r: Vec = + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()); r.sort_by(|a, b| a.top().total_cmp(&b.top())); r } + /// A mouse wheel turn over `at`, scrolling the content up by `dy` points. + pub(crate) fn wheel(at: Pos2, dy: f32) -> Vec { + let unit = egui::MouseWheelUnit::Point; + vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Default::default() }] + } + + #[test] + fn the_toolbar_scrolls_in_a_short_window() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + // 500 pt: even two columns don't fit, so Edit Toolbar (the last button) starts below the window. + let tools = frame_in(&mut app, &ctx, 0.0, vec![], 500.0); + let edit_toolbar = || widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap(); + assert!(edit_toolbar().bottom() > 500.0); + frame_in(&mut app, &ctx, 0.1, wheel(tools[0].center(), 2000.0), 500.0); + for k in 2..40 { + frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0); + } + assert!(edit_toolbar().bottom() <= 500.0, "scrolled into view: {:?}", edit_toolbar()); + // Back up to the top. + frame_in(&mut app, &ctx, 4.0, wheel(tools[0].center(), -2000.0), 500.0); + for k in 41..80 { + frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0); + } + assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]); + } + /// A double-click at `at`, `time` seconds in (a second apart from the last). fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) { let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; @@ -366,4 +482,42 @@ mod tests { assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color); assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err()); } + + #[test] + fn right_clicking_a_tool_group_opens_its_flyout() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + let buttons = frame(&mut app, &ctx, 0.0, vec![]); + // Selection, Direct Selection, Lasso, then the Shapes group (Rectangle and its sub-tools). + let at = buttons[3].center(); + let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(true)]); + frame(&mut app, &ctx, 1.05, vec![b(false)]); + frame(&mut app, &ctx, 1.1, vec![]); + assert!(app.ui.flyout.is_some(), "the right-click opens the flyout and the same click doesn't close it"); + let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the flyout is shown"); + assert_eq!(app.session.tool_id(), "selection", "a right-click only opens the flyout"); + // Its second row is Rounded Rectangle (30 pt rows). + let row = egui::pos2(menu.left() + 60.0, menu.top() + 45.0); + let p = |pressed| Event::PointerButton { pos: row, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), p(true)]); + frame(&mut app, &ctx, 2.05, vec![p(false)]); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None)); + // A long press opens it too, and releasing the press (a click, being short of 0.8 s) leaves + // it open without choosing the button's tool. + let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 3.0, vec![Event::PointerMoved(at), p(true)]); + frame(&mut app, &ctx, 3.5, vec![]); + assert!(app.ui.flyout.is_some(), "a long press opens the flyout"); + frame(&mut app, &ctx, 3.6, vec![p(false)]); + frame(&mut app, &ctx, 3.7, vec![]); + assert!(app.ui.flyout.is_some(), "releasing the long press leaves the flyout open"); + // A click anywhere else closes it. + let away = egui::pos2(300.0, 1000.0); + let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 4.0, vec![Event::PointerMoved(away), c(true)]); + frame(&mut app, &ctx, 4.05, vec![c(false)]); + assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None)); + } } diff --git a/crates/ui-egui/src/ui_fonts.rs b/crates/ui-egui/src/ui_fonts.rs new file mode 100644 index 000000000..c971a8c6e --- /dev/null +++ b/crates/ui-egui/src/ui_fonts.rs @@ -0,0 +1,243 @@ +//! Fallback fonts for the app's own text: layer and file names (and anything else the UI shows) in +//! Chinese, Japanese, Korean or other scripts the UI fonts lack are drawn in an installed font that +//! covers them. Builds made with craft-fonts (`CRAFT_FONTS_DIR`, all releases) carry Japanese fonts, +//! which `theme::install_fonts` adds after the UI fonts, so Japanese never gets here; nothing else +//! is bundled for them: the first time a frame paints a character the UI fonts +//! don't have, an installed font covering it is looked for (in the background on native) and added +//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs +//! no font loading or memory. On the web, which has no system fonts, such characters stay +//! missing-glyph boxes (Japanese too, unless built with craft-fonts). + +use std::collections::HashSet; +use std::sync::Arc; + +use egui::epaint::Shape; +use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; +use egui::{FontData, FontFamily, FontId, LayerId}; +use vectorcraft_text::{FontDb, FontFace}; + +#[derive(Default)] +pub(crate) struct UiFonts { + /// Characters looked at already: the UI fonts have them, a fallback was added (or is being + /// looked for), or no font has them. + seen: HashSet, + /// The fonts added to egui, by name. + added: HashSet, + /// The faces covering the characters being looked for (native: looked for on a thread). + #[cfg(not(target_arch = "wasm32"))] + pending: Option>>>, +} + +impl UiFonts { + /// After a frame is laid out: add the fallback fonts found since the last frame, and look for + /// fonts covering the characters this frame painted that the UI fonts lack. + pub(crate) fn frame(&mut self, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(faces) = self.pending.as_ref().and_then(|rx| rx.try_recv().ok()) { + self.pending = None; + self.install(ctx, &faces); + } + let missing = self.missing(ctx); + if missing.is_empty() { + return; + } + let find = move || { + let mut faces: Vec> = vec![]; + for c in missing { + if let Some(f) = FontDb::global().face_covering(c) + && !faces.iter().any(|x| x.id() == f.id()) + { + faces.push(f); + } + } + faces + }; + // Native: the installed fonts are read off the UI thread. Characters met meanwhile wait for + // the next search (they aren't marked seen until then). + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + let spawned = std::thread::Builder::new().name("ui-font-fallback".into()).spawn(move || { + // The receiver is gone only when the app is. + let _ = tx.send(find()); + repaint.request_repaint(); + }); + if spawned.is_ok() { + self.pending = Some(rx); + } + } + // The web has only the loaded fonts to look through: quick, on this thread. + #[cfg(target_arch = "wasm32")] + self.install(ctx, &find()); + } + + /// Characters painted this frame that the UI fonts lack and haven't been looked for, now + /// marked seen. None while a search is running. + fn missing(&mut self, ctx: &egui::Context) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if self.pending.is_some() { + return vec![]; + } + let mut layers: Vec = ctx.memory(|m| m.layer_ids().collect()); + if !layers.contains(&LayerId::background()) { + layers.push(LayerId::background()); + } + let mut chars: Vec = vec![]; + let seen = &self.seen; + let mut note = |text: &str| { + // Most UI text is ASCII, which the UI fonts cover. + if !text.is_ascii() { + chars.extend(text.chars().filter(|c| !c.is_ascii() && !c.is_whitespace() && !c.is_control() && !seen.contains(c))); + } + }; + ctx.graphics(|g| { + for l in &layers { + for s in g.get(*l).into_iter().flat_map(|list| list.all_entries()) { + painted_text(&s.shape, &mut note); + } + } + }); + if chars.is_empty() { + return chars; + } + chars.sort_unstable(); + chars.dedup(); + let font = FontId::new(13.0, FontFamily::Proportional); + let missing: Vec = ctx.fonts_mut(|f| chars.iter().copied().filter(|c| !f.has_glyph(&font, *c)).collect()); + self.seen.extend(chars); + missing + } + + /// Add `faces` to every egui family, after the fonts it has. + fn install(&mut self, ctx: &egui::Context, faces: &[Arc]) { + let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + for face in faces { + let name = format!("{} {} #{}", face.family, face.style, face.face_index()); + if !self.added.insert(name.clone()) { + continue; + } + // The face's file data, shared with the process-wide font database (which keeps every + // face it loads for the session) instead of copied: one handle is leaked per font added. + let face: &'static FontFace = Box::leak(Box::new(face.clone())); + let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }; + let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect(); + ctx.add_font(FontInsert { name, data, families }); + } + if !faces.is_empty() { + ctx.request_repaint(); + } + } + + /// Wait for the font search running, if any, and add what it found (tests). + #[cfg(test)] + pub(crate) fn finish(&mut self, ctx: &egui::Context) { + if let Some(faces) = self.pending.take().and_then(|rx| rx.recv().ok()) { + self.install(ctx, &faces); + } + } +} + +/// Call `f` with the text of `shape` and the shapes it holds. +fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) { + match shape { + Shape::Text(t) => f(t.galley.text()), + Shape::Vec(v) => v.iter().for_each(|s| painted_text(s, f)), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Run a frame painting `text`, then the fallback search after it. + fn frame(ctx: &egui::Context, fonts: &mut UiFonts, text: &str) { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.label(text); + fonts.frame(ui.ctx()); + }); + out.textures_delta.clear(); + } + + fn has_glyph(ctx: &egui::Context, c: char) -> bool { + ctx.fonts_mut(|f| f.has_glyph(&FontId::new(13.0, FontFamily::Proportional), c)) + } + + #[test] + fn latin_text_loads_no_fonts() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, "Layer 1 — Café"); + assert!(fonts.pending.is_none() && fonts.added.is_empty()); + } + + #[test] + fn japanese_renders_with_the_craft_fonts_without_installed_fonts() { + if !vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese()) { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let text = "日本語の文字"; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, text); + // Real glyphs (no tofu) in every family from the first frame, with no installed font looked for. + assert!(fonts.pending.is_none() && fonts.added.is_empty(), "no system-font search"); + let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + for family in families { + let stack = ctx.fonts(|f| f.definitions().families.get(&family).cloned().unwrap_or_default()); + assert!(stack.last().is_some_and(|n| n.starts_with("craft-fonts ")), "{family:?}: craft-fonts last: {stack:?}"); + for c in text.chars() { + assert!(ctx.fonts_mut(|f| f.has_glyph(&FontId::new(13.0, family.clone()), c)), "{family:?} draws {c}"); + } + } + // The UI prefers BIZ UDPGothic. + let stack = ctx.fonts(|f| f.definitions().families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); + let first = stack.iter().find(|n| n.starts_with("craft-fonts ")).cloned().unwrap_or_default(); + assert_eq!(first, "craft-fonts BIZ UDPGothic Regular", "{stack:?}"); + } + + #[test] + fn the_ui_works_without_the_craft_fonts() { + // Built either way, the fonts install and Japanese text lays out (as installed-font + // fallbacks or missing-glyph boxes when no font has it). + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, "日本語の文字 Layer 1"); + fonts.finish(&ctx); + frame(&ctx, &mut fonts, "日本語の文字 Layer 1"); + if vectorcraft_text::CRAFT_FONTS.is_empty() { + let names: Vec = ctx.fonts(|f| f.definitions().font_data.keys().cloned().collect()); + assert!(names.iter().all(|n| !n.starts_with("craft-fonts")), "{names:?}"); + } + assert!(has_glyph(&ctx, 'L')); + } + + #[test] + fn cjk_names_get_an_installed_fallback_font() { + let text = "Logo 标志 ロゴ 로고"; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, text); + assert!(!has_glyph(&ctx, '标'), "the UI fonts have no CJK"); + fonts.finish(&ctx); + // The fonts arrive with the next frame. + frame(&ctx, &mut fonts, text); + let installed: Vec = text.chars().filter(|c| !c.is_ascii() && FontDb::global().face_covering(*c).is_some()).collect(); + if installed.is_empty() { + eprintln!("no installed font covers {text}: nothing to check"); + return; + } + for c in installed { + assert!(has_glyph(&ctx, c), "{c} has a glyph"); + } + // Looked for once: a later frame starts no search. + frame(&ctx, &mut fonts, text); + assert!(fonts.pending.is_none()); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3faaa7e3e..4e7e1d491 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -125,11 +125,26 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: .inner }) .inner; + select_all_on_focus(ui, &resp, &buf); let commit = resp.lost_focus() && buf != shown; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if commit { unit.parse(&buf) } else { None } } +/// Numeric fields take the whole text, unit included, when they gain focus or are double-clicked +/// (egui would select one word): typing a number or an expression replaces it. +fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { + if !(resp.gained_focus() || resp.double_clicked()) { + return; + } + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), resp.id) { + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(text.chars().count())); + st.cursor.set_char_range(Some(all)); + st.store(ui.ctx(), resp.id); + ui.ctx().request_repaint(); + } +} + /// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), /// its text kept under `id` while it has focus. Returns the text (as edited) and the response. fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) { @@ -206,8 +221,12 @@ pub fn mixed_field( }) .unwrap_or_default(); let (buf, resp) = recessed_text(ui, id, &shown, width, 1); + select_all_on_focus(ui, &resp, &buf); if resp.lost_focus() && buf != shown { - buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::().ok() + // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. + let suffix = suffix.trim(); + let bare = if suffix.is_empty() { buf.clone() } else { buf.replace(suffix, "") }; + vectorcraft_doc::parse_number(&bare.replace(['%', '°'], "")) } else { None } @@ -390,7 +409,7 @@ pub fn dropdown_with( width: f32, enabled: impl Fn(usize) -> bool, ) -> Option { - combo(ui, id, current, width, |ui| { + combo(ui, id, current, width, false, |ui| { let mut chosen = None; for (i, o) in options.iter().enumerate() { if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, *o)).clicked() { @@ -402,12 +421,14 @@ pub fn dropdown_with( } /// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns -/// the chosen one. +/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open +/// while it is clicked: it closes itself when an option is chosen. fn combo( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, width: f32, + searchable: bool, list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); @@ -419,6 +440,8 @@ fn combo( egui::ComboBox::from_id_salt(ui.id().with(id)) .selected_text(egui::RichText::new(current).size(12.0)) .width(width - 4.0) + .close_behavior(if searchable { egui::PopupCloseBehavior::CloseOnClickOutside } else { egui::PopupCloseBehavior::CloseOnClick }) + .height(if searchable { f32::INFINITY } else { ui.spacing().combo_height }) .show_ui(ui, list) .inner .flatten() @@ -426,6 +449,81 @@ fn combo( .inner } +/// The font family dropdown: every family available (the installed fonts included), with a search +/// field over the list that filters it as you type (Enter picks the first match). Returns the +/// chosen family. +pub fn font_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, width: f32) -> Option { + let state = ui.id().with(&id).with("font-search"); + combo(ui, id, current, width, true, |ui| { + // Each time the list opens: unfiltered, with the current font in view. Typing goes to the + // search field. + let pass = ui.ctx().cumulative_pass_nr(); + // (the last pass the list was drawn in, the pass it opened in) + let (last, opened) = ui.data(|d| d.get_temp::<(u64, u64)>(state)).unwrap_or_default(); + let opened = if last + 1 < pass || last == 0 { pass } else { opened }; + let field = state.with("query"); + ui.data_mut(|d| { + d.insert_temp(state, (pass, opened)); + if opened == pass { + d.insert_temp(field, String::new()); + } + }); + // Its first two passes: a popup sizes itself in the first, invisibly. + let opening = pass <= opened + 1; + if ui.memory(|m| m.focused().is_none()) { + ui.memory_mut(|m| m.request_focus(field.with("edit"))); + } + let query = search_field(ui, field, "Search").to_lowercase(); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let families = vectorcraft_text::FontDb::global().family_list(); + let (mut chosen, mut first) = (None, None); + // The search field stays put over the list as it scrolls. + menu_scroll(ui, |ui| { + for f in families.iter().filter(|f| query.is_empty() || f.to_lowercase().contains(&query)) { + first.get_or_insert(f); + let r = ui.add(egui::Button::selectable(f == current, f.as_str())); + if opening && f == current { + r.scroll_to_me(Some(egui::Align::Center)); + } + if r.clicked() { + chosen = Some(f.clone()); + } + } + }); + match first { + None => { + dim_label(ui, "No matching fonts"); + } + Some(f) if enter && chosen.is_none() => chosen = Some(f.clone()), + _ => {} + } + if chosen.is_some() { + ui.close(); + } + chosen + }) +} + +/// The body of a menu or popup list: as tall as its items up to the bottom of the window, and +/// scrolling only past that, so long menus (Window, Effect, a panel's menu) stay reachable on +/// small windows. +pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + // Room left below the popup's first item, less the popup frame and a small gap. + const BOTTOM_GAP: f32 = 12.0; + const MIN_HEIGHT: f32 = 120.0; + let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).max(MIN_HEIGHT); + // The minimum lets the list grow past a popup's default 400 pt size; it still shrinks to + // its items when they need less. + egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).show(ui, add_contents).inner +} + +/// A column of tool or panel buttons (the toolbar, the dock's icon column) that scrolls when the +/// window is too short for it: with the mouse wheel, or the scroll bar shown while the pointer is +/// over it. +pub fn strip_scroll(ui: &mut Ui, id: &str, add_contents: impl FnOnce(&mut Ui) -> R) -> R { + egui::ScrollArea::vertical().id_salt(id).auto_shrink([false, true]).show(ui, add_contents).inner +} + /// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a /// [`BlendMode::group`] starts. pub fn blend_separator_before(i: usize) -> bool { @@ -435,7 +533,7 @@ pub fn blend_separator_before(i: usize) -> bool { /// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion /// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: Option, width: f32) -> Option { - combo(ui, id, current.map_or("", BlendMode::label), width, |ui| { + combo(ui, id, current.map_or("", BlendMode::label), width, false, |ui| { let mut chosen = None; for (i, m) in BlendMode::ALL.into_iter().enumerate() { if blend_separator_before(i) { @@ -988,6 +1086,7 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: ) }) .inner; + select_all_on_focus(ui, &resp, &buf); ui.data_mut(|d| d.insert_temp(id, buf.clone())); if resp.lost_focus() && buf != shown { let s = buf.trim(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8ccbb7d00..ac4f32770 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -86,6 +86,13 @@ effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setPara `effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard", value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it. +Plug-in dialogs: `engine.execute {command: "plugin.dialog", params: {id}}` (Object › Plug-ins) runs an object filter +plug-in at once when it takes no parameters, else opens the `plugin` dialog: one field per declared parameter (named +after it), `__plugin` (the plug-in id) and `preview`. It previews `plugin.run` on the canvas while open; +`ui.dialog.confirm` keeps the result as one undo step and `ui.dialog.cancel` rolls it back. Live-effect plug-ins open +the `effect` dialog (`effect.dialog {effect: "plugin."}`). `ui.installPlugin {path?}` installs a `.wasm` (without +a path it asks for one); see [plugins.md](plugins.md). + Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, `includeSpotBlacks`); `ui.dialog.set` then `ui.dialog.confirm` runs it on the selection. @@ -222,6 +229,10 @@ Width Point Edit: double-clicking a width point with the Width tool, or `ui.widt `adjustAdjoining`). `ui.dialog.confirm` runs `stroke.widthPoint.set` with them; `ui.dialog.set {field: "discard", value: true}` then confirm (the Delete button) removes the point with `stroke.widthPoint.remove`. +Perspective plane options: double-clicking a plane widget of the perspective grid, or `ui.perspectivePlane {plane}`, +opens the `perspectivePlane` dialog (fields `plane`: left, right or ground; `location`: points along the plane's +normal; `objects`: none, move or copy). `ui.dialog.confirm` runs `perspective.plane.move` with them. + Units: dialog distance fields (Move's `dx`/`dy`, shape sizes, Offset Path's `offset`, Split Into Grid's `gutter`, Artboard Options' sizes, effects' distances, `transformEach`'s `moveH`/`moveV`, New Document's `width`/`height`, the length preferences in `preferences`) show in the General unit (the active document's units, or `unitsGeneral` without a @@ -416,3 +427,108 @@ from)}` opens too: dialog `printPreset`, the Print dialog's settings fields plus `print.presets.save` and returns to Print Presets (a new preset can't take a name in use). Delete, Import… and Export… are `print.presets.delete`, `print.presets.import` and `print.presets.export`; `ui.dialog.confirm` closes. Opening a `.vcprintpresets` file with `app.open` imports its presets. + +Modifiers on synthetic input: `ui.key`, `ui.click` and `ui.drag` take `shift`, `alt`, `ctrl` and `cmd` (Command on +macOS, Ctrl elsewhere), and the app holds them for the frames the input spans, as if the keys were down: a key's +press, its `text` and its release; a click's press and release; a drag from the press through every move to the +release. Every handler sees them (Shift+arrow nudges by the big increment, Alt+arrow nudges a copy, Shift-clicking a +Layers selection square adds to the selection); the keyboard's own modifiers apply again afterwards. + +Cutting tools: Mirror & Cut (`mirrorCut`), Line Cut (`lineCut`) and Rectangle Cut (`rectCut`) cut the selected paths +and compound paths. `ui.tool.select` one, then drag with `ui.pointer`: the cut previews live and the release keeps it +as one undo step (`ui.key` Escape cancels). They run `path.mirrorCut`, `path.lineCut` and `path.rectCut`, which an +agent can call directly. Mirror & Cut's tool options (`tool.setOption`, shown in the Control bar) are `axis` +(`free` | `vertical` | `horizontal`; a constrained axis follows the pointer and a click places it) and `keep` +(`left` | `right` | `top` | `bottom`); Alt on release keeps the other side. + +Puppet Warp (`puppetWarp`): with art selected, the tool shows pins at once (automatic ones at the centre and the end +of each limb until pins are placed). `ui.pointer` clicks on the art add pins (each new pin is selected; Shift-click +adds a pin to the selection or takes it out), dragging a pin moves every selected pin and warps the art, and +Alt-dragging near (not on) a selected pin turns the art around it. A click off the mesh adds nothing: the cursor shows +not-allowed there and the canvas says so. `ui.key` Delete or Backspace removes the selected pins. Adding, deleting and +each drag are one undo step each, running `object.puppetWarp {rest: true, …}`; the pins live in the document, so +Undo/Redo take them back with the art, they stay across tool switches while the selection is unchanged, and another +selection starts afresh. `object.puppetWarp.pins` reads them. Its tool options (`tool.setOption`, shown in the +Control bar) are `expand` (Expand Mesh, points, 0 allowed), `showMesh` (on by default) and `selectAllPins` (`true` +selects every pin, `false` none); `ui.inspect` → `toolOptions` lists the `pins` the tool last showed and the +`selected` ones. + +Colour adjustment effects (Effect → Color Adjustments) open the `effect` dialog like the other effects +(`effect.dialog {effect: "adjust.hueSaturation"}`): its fields are the effect's parameters (sliders for the amounts, +`channel` for Curves and Levels, `points` for Curves, `color` for Shift to Color) and they preview live; set them with +`ui.dialog.set` and `ui.dialog.confirm` to apply (Curves also has a graph: drag its points). Object → Vector +Halftone… (`engine.execute {command: "ui.menuDialog", params: {command: "object.vectorHalftone"}}`) opens the +`vectorHalftone` dialog (fields `shape`, `frequency`, `angle`, `mode`, `color`, `invert`, `clip`, `keepOriginal`, +`preview`): it previews live and `ui.dialog.confirm` keeps the halftone as one undo step. + +Tool options last: `tool.setOption {key, value}` or `{values: {…}}` (and `tool` to set another tool's) sets them, and +the options a tool keeps (Liquify brush and tool options, Mirror & Cut, Puppet Warp, Symbolism, drawing tools) stay +when you switch tools or documents and are saved with the preferences. The Liquify tools share their Global Brush +Dimensions (`width`, `height`, `angle`, `intensity`). + +Perspective grid: View → Perspective Grid → Define Grid… (`ui.perspectiveGridDialog`) opens the `perspectiveGrid` +dialog prefilled from the grid. Its fields are those of `perspective.grid.define` (`kind`, `units`, `scale`, +`gridline`, `angle`, `distance`, `horizonHeight`, `thirdVp`, `leftColor`, `rightColor`, `groundColor`, `opacity`; +lengths in `units`, so setting `units` converts them). `ui.dialog.confirm` runs `perspective.grid.define`; a refused +value keeps the dialog open. + +Object → Envelope Distort's dialogs open through their menu items or `ui.menuDialog {command}`. Make with Warp… +(`object.envelope.makeWithWarp`) opens `envelopeWarp` (fields `style`, `horizontal`, `bend`, `h`, `v`, `preview`, and +`reset`); while an envelope is selected the menu shows Reset with Warp… instead, and the same item, shortcut or +`object.envelope.resetWithWarp` opens it with `reset: true` and the envelope's values. Make with Mesh… / Reset with +Mesh… open `envelopeMesh` (`rows`, `cols`, `reset`, and `maintainShape` when resetting). Envelope Options… +(`object.envelope.options`) opens `envelopeOptions` (`antiAlias`, `preserveShape`, `fidelity`, `distortAppearance`, +`distortLinearGradients`, `distortPatternFills`) with the selected envelope's values, or with nothing selected the +defaults for new envelopes. They preview live while an envelope is involved, and `ui.dialog.confirm` keeps the result +as one undo step. + +Liquify Tool Options: double-clicking a Liquify tool (`tool.options {tool: "twirl"}`) opens a `liquifyOptions` dialog. +Its fields are `tool`, the Global Brush Dimensions `width`, `height` (pt), `angle`, `intensity` (%) and `usePressure`, +then the tool's options `detail`, `simplify` and `simplifyOn` (Warp, Twirl, Pucker, Bloat), `rate` (Twirl), +`complexity`, `affectAnchors`, `affectIn` and `affectOut` (Scallop, Crystallize, Wrinkle), `horizontal` and +`vertical` (Wrinkle, %), and `showBrush`. `ui.dialog.confirm` runs `tool.setOption {tool, values}`. `ui.pointer` +events take `pressure` (0..1, default 1): it is the Liquify intensity while Use Pressure Pen is on. + +`ui.pointer` events also take `holdMs` (0..60000): the pointer then holds still that long, button down, before the +next event. Twirl, Pucker and Bloat keep applying while held (a repeat of the last point every 0.1 s), exactly as +for the same time held with the mouse. Liquify leaves type, symbols, images, graphs, meshes, envelopes, repeats and +blends as they are and says so in the status bar. + +Perspective grid presets: in the `perspectiveGrid` dialog, `name` is the preset whose fields are loaded (setting the +fields of a preset, as the Preset menu does, keeps its name; other changes clear it, which reads [Custom]). Save +Preset… sets `__mode: "save"` and `__from: "define"` with `name` the new preset's name: `ui.dialog.confirm` then saves +the fields as that preset (`perspective.presets.save`) and returns to Define Grid on it. View → Perspective Grid → +Save Grid as Preset… (`ui.savePerspectivePreset`) opens the same dialog in `save` mode; OK saves and closes. Edit → +Perspective Grid Presets… (`ui.perspectivePresetsDialog {selected?}`) opens dialog `perspectiveGridPresets` (field +`selected`): New… and Edit… open the preset editor (`perspectiveGrid` with `__mode: "edit"`, `__original` the preset +edited), whose OK runs `perspective.presets.save` and comes back; Delete, Import… and Export… are +`perspective.presets.delete`, `import` and `export`. View → Perspective Grid → One/Two/Three Point Perspective list +that type's built-in views and the saved presets (`ui.perspectiveUserPreset.`, hidden while empty). Opening a +`.vcperspective` file with `app.open` imports its presets. + +Blend Options (Object › Blend › Blend Options…, `ui.blendOptions`, the Blend tool's double-click, Alt-click and +toolbar button) opens the `blendOptions` dialog on the selected blend's options: `spacing` (`smooth` | `steps` | +`distance`), `steps`, `distance` (pt), `orientation` (`page` | `path`) and `preview`; it previews live and +`ui.dialog.confirm` keeps the change as one undo step. With no blend selected (`__target: "defaults"`) OK sets what +new blends start with. The Blend tool (`ui.tool.select {tool: "blend"}`): `ui.pointer` down on an object, then on +another, blends them; down on an anchor point blends from that point; each further object clicked joins the blend. +A selected blend shows its spine. With Direct Selection (`directSelection`) a `ui.pointer` drag from a spine point +moves it (`object.blend.spine.moveAnchor`, one undo step) and, once a point is clicked, from one of its handles +reshapes the curve; Direct and Group Selection pick a blend's key objects. With the Pen tool a click on a selected +blend's spine adds a point (on a point no key sits on, deletes it). + +Perspective grid view options: View → Perspective Grid lists Show/Hide Grid, Show/Hide Rulers, Snap to Grid +(checked), Lock/Unlock Grid and Lock Station Point (checked); `ui.menu.list` reports the current labels. They run +`perspective.grid.show`, `rulers`, `snap`, `lock` and `lockStation`. + +Perspective grid widgets: the Plane Switching Widget is fixed in a corner of the canvas (`ui.pointer` with +`space: "screen"` reaches it at the same pixels whatever the zoom); a press on it with any tool picks the plane while +the grid shows. `ui.key` with `key` `"1"`…`"4"` picks the left, horizontal, right or no plane. Double-clicking the +Perspective Grid tool (`tool.options {tool: "perspectiveGrid"}`) opens `perspectiveGridOptions` (fields `show`, +`position`: `topLeft`, `topRight`, `bottomLeft`, `bottomRight`); `ui.dialog.confirm` runs `perspective.widget.options`. + +Envelopes on the canvas: `pointer_gesture` with the Mesh tool (`mesh`) or Direct Selection on a selected mesh envelope +drags its points (a press on a point focuses it, and a press on one of the focused point's handle ends drags that +handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is +selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and +distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command. diff --git a/docs/development.md b/docs/development.md index 718271cdb..68ad36f4d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -24,6 +24,40 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop: - **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. - Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter). +## Fonts: craft-fonts (optional build input) + +Font files are never committed to this repository. Fonts shared by the Crafting Apps live in +[storytold/craft-fonts](https://github.com/storytold/craft-fonts); the rules are in craftrules +[`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). To add +a font, add it there. (The Latin fonts in `assets/fonts/` predate the rule and stay.) + +VectorCraft builds, tests and runs without craft-fonts. To embed its Japanese fonts (BIZ UDPGothic +for UI text, Shippori Mincho and BIZ UDMincho for document text), point the `CRAFT_FONTS_DIR` build +option at a checkout: + +```sh +git clone https://github.com/storytold/craft-fonts ../craft-fonts +CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft +CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci # also runs the Japanese-glyph tests +``` + +- `crates/text/build.rs` reads the checkout's `fonts/manifest.txt` and embeds every font it lists as + `vectorcraft_text::CRAFT_FONTS` (empty without `CRAFT_FONTS_DIR`). Give it an absolute path: + build scripts run in the crate's directory (a relative path is taken from the workspace root + as a convenience, but CI and the docs always use absolute paths). Nothing is downloaded, and + craft-fonts is never a `Cargo.toml` dependency. A bad path is a build warning, or an error with + `CRAFT_FONTS_REQUIRED=1` (release builds). +- Web (wasm32) builds embed only BIZ UDPGothic Regular (+4.7 MB of `.wasm`), to stay well under + static hosts' per-file limits (Cloudflare Pages: 25 MiB). Shippori Mincho, which this repo used to + embed everywhere, was 8.7 MB, so the web build is still smaller than before. +- Document text: the Japanese faces join the font database after the bundled fonts (Mincho first), + so they are the fallback for Japanese after the requested font; installed system fonts come after. +- UI: `theme::install_fonts` adds them at the end of every egui family (BIZ UDPGothic first), after + the UI fonts and before the installed fonts `ui_fonts.rs` discovers for other scripts. +- Tests that need Japanese glyphs skip with a message when built without craft-fonts. The FreeBSD + CI job and every release job (`release.yml`) check craft-fonts out at a pinned commit; release + packages carry each embedded font's `OFL-.txt`. + ## Vendor names gate `cargo xtask brands` (part of `cargo xtask ci`) fails when user-visible text names another vendor's products or company: string literals in Rust sources (command labels and params docs, menus, panels, MCP tool definitions), `Cargo.toml` descriptions and packaging files. Comments and test code are not checked. Say "the reference app" or name the feature itself. A line that must keep an old name, such as an alias that files or preferences from earlier versions still use, carries a `brand-ok` comment. diff --git a/docs/mcp.md b/docs/mcp.md index 89040d50e..a7d7d456e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -57,7 +57,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. | | `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. | -| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool. | +| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool). | | `type_text` | `{text}` | Remote only. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. | @@ -133,7 +133,8 @@ lists every option with its default. `encoding` is `utf8`, `utf16` (big-endian a (presentation attributes only; filters, masks, blend modes and embedded fonts left out, symbols as art, each with a warning). `embedFonts: true` embeds the fonts type uses as `@font-face` rules subset to the characters used; a font whose licence (OS/2 `fsType`) forbids subsetting is embedded whole, one that forbids embedding is left out -with a warning, and the type then names faces by numeric `font-weight` (600 for Semibold). `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, +with a warning. Type names its faces by numeric `font-weight` (600 for Semibold, 300 for Light, `bold` for 700), +embedded or not. `svgz` takes the same options and writes the SVG gzipped (`.svgz` files also open, place and paste). `run_command document.save {path: "x.svg", svg: {…}}` saves as SVG (or `.svgz`). Symbols export as one `` with a `` per instance. An instance the def can't stand for is written as its @@ -869,6 +870,28 @@ two points at the same `t` (the width before it, then after it), and the stroke' `document.inspect` reports a path's `strokeOptions.widthPoints` (`[t, left, right]`, factors of half the weight; null for a uniform stroke). `stroke.widthPoint.remove {id, index | indices}` deletes points; `stroke.widthProfile.set {ids, points}` replaces them all, as dragging several Shift-selected points with the Width tool does in one undo step. +With the Width tool, `press_key` Delete or Backspace removes the selected width points (`handledBy: "tool"`); with none +selected the key clears the selected objects as usual (`edit.clear`). The Puppet Warp tool's selected pins go the +same way. +A compound path's stroke is the compound's: width point commands given one of its members edit the compound's +profile, which runs along every subpath (the Width tool shows each subpath's points). + +`object.puppetWarp {ids?, pins, moved, angles?, expand?}` warps art as rigidly as possible so that each pin (a point +on the art) lands on its `moved` point. `angles` (degrees, or null for a free pin; as long as `pins`) holds the turn +the art takes around a pin, as Alt-dragging near a selected pin with the Puppet Warp tool does: one pin with +`angles: [90]` turns the art a quarter turn around it. + +The Puppet Warp tool's pins live in the document while the same art stays selected (never saved; Undo and Redo take +them back with the art). `object.puppetWarp.pins {ids?}` reads them: `pins` (where each sits on the shape the pins +started from), `moved` (where it is now), `angles`, and `auto: true` while none was placed (the tool's automatic pins: +the centre and the end of each limb). Edit `moved`, `angles` or the lists and pass the result back to +`object.puppetWarp`: with `rest: true` the warp always starts from that rest shape, so warps don't stack and putting +a pin back restores the original; every pin must be on the art's mesh, and empty lists remove the pins. + +```json +{"name":"run_command","arguments":{"command":"object.puppetWarp.pins","params":{}}} +{"name":"run_command","arguments":{"command":"object.puppetWarp","params":{"rest":true,"pins":[[250,150],[110,150],[390,150]],"moved":[[250,150],[110,150],[390,90]],"angles":[null,null,null]}}} +``` ```json {"name":"run_command","arguments":{"command":"stroke.widthPoint.set","params":{"id":9,"t":0.5,"left":4,"right":4}}} @@ -1639,3 +1662,330 @@ CMYK one. Settings saved before these options load with their defaults. ```json {"name":"run_command","arguments":{"command":"file.print","params":{"path":"/tmp/proof.pdf","settings":{"advanced":{"overprints":"simulate","flattenerPreset":"High Resolution"},"color":{"profile":"VectorCraft Generic CMYK (SWOP-like)","intent":"perceptual"}}}}} ``` + +### Rotated bounding boxes + +Every object keeps the angle of its own axes (counter-clockwise degrees; `document.inspect` reports it as a node's +`rotation`, and the selection's shared angle as `selectionRotation`, 0 when the selected objects differ). Transforms +turn it with the object while its path geometry is still baked, so after a rotation the bounding box and its handles +stay square to it, as in the reference app. `object.rotate {angle, absolute: true}` turns the selection to an angle +(the Transform and Properties panels' Rotate field), and `object.setBounds` measures width, height and the reference +point along the turned box (x, y stay page coordinates). Objects turned together share the box. A new group starts +square to the page. `object.resetBoundingBox` squares the box again without moving the art. + +```json +{"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}} +``` + +## Empty point type + +Point type the Type tool places with a click and leaves empty is discarded when editing ends: Escape, another tool, +a click elsewhere, switching documents, or a command that takes the text out of the selection (`select.none`, a +`select.set` of other objects). The steps since the click go with it (`text.discardEmpty {id}`), so the document and +its undo history are as if the click never happened. Commands on the edited text (the Character panel's +`text.setRangeStyle {id}`) keep it in editing. Area type dragged as a frame and type in or on a path keep their frame +(Object → Path → Clean Up removes empty text). + +```json +{"name":"run_command","arguments":{"command":"text.discardEmpty","params":{"id":42}}} +``` + +## Constrain proportions + +The link between W and H in the Transform panel, the Properties panel and the Control bar is the +`constrainProportions` preference (`prefs.get` / `prefs.set`). With it on, those fields send `proportional: true` +to `object.setBounds`, which scales the other dimension by the same factor; agents pass `proportional` themselves. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"constrainProportions","value":true}}} +{"name":"run_command","arguments":{"command":"object.setBounds","params":{"width":200,"proportional":true}}} +``` + +## Plug-ins + +WebAssembly plug-ins ([plugins.md](plugins.md)) are installed per process and reached through commands: +`plugin.install {path | dataBase64}` installs one, `plugin.list` / `plugin.info {id}` describe them (parameters, +schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one +undo step; it may change, delete or add objects, and the result is selected). A live-effect plug-in is an effect with +id `plugin.`: `effect.apply`, `effect.setParams` and `effect.expandAppearance` work as for built-in effects, and +`effect.list` lists it. A failing plug-in returns an error and leaves the document as it was. + +```json +{"name":"run_command","arguments":{"command":"plugin.install","params":{"path":"/plugins/desaturate.wasm"}}} +{"name":"run_command","arguments":{"command":"plugin.run","params":{"id":"org.vectorcraft.example.desaturate","params":{"amount":60}}}} +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"plugin.org.example.wobble","params":{"size":4}}}} +``` + +## Colour adjustments + +Effect → Color Adjustments holds live effects that recolour what an object paints +with (fills and strokes, gradient stops, type, gradient meshes, embedded images; on a group or layer every member), +each colour keeping its colour model: `adjust.brightnessContrast`, `adjust.curves` (`points` as `"x,y x,y …"` or +`[[x, y], …]`, 0–255), `adjust.levels`, `adjust.hueSaturation`, `adjust.shiftToColor` and `adjust.temperatureTint` +(`effect.list` documents their parameters). Apply them with `effect.apply` like any effect, or with `item` to recolour +one fill or stroke; they stay editable (`effect.setParams`), export with the adjusted colours (an adjusted image as a +recoloured copy) and become permanent with `effect.expandAppearance`. Patterns and the shadows and glows of raster +effects keep their colours. + +## Vector halftone + +`object.vectorHalftone` turns the selection into vector dots, lines or shapes sized by its +tone (mono, or CMYK screens multiplied over each other), clipped to the art's outline by default: + +```json +{"name":"run_command","arguments":{"command":"object.vectorHalftone","params":{"shape":"circle","frequency":25,"angle":45,"mode":"cmyk"}}} +``` + +## Fonts + +Type can use the bundled fonts, fonts added to the session and the fonts installed on the system (none on the web). +The installed fonts are cataloged once per session (in the background when the app starts, else on the first lookup +by family name), so opening, placing, pasting and importing files find them whatever ran before. `text.fontList` +lists every family available, the installed ones included; with `family` it gives that family's styles (upright +by weight, then italics) and fails when the family isn't available. `text.rescanFonts` (Character panel menu › +Refresh Font List) scans the font folders again, for fonts installed or removed since the app started: type set in a +font that became available redraws in it, without editing the document. + +```json +{"name":"run_command","arguments":{"command":"text.fontList","params":{}}} +{"name":"run_command","arguments":{"command":"text.fontList","params":{"family":"Source Serif 4"}}} +{"name":"run_command","arguments":{"command":"text.rescanFonts","params":{}}} +``` + +## Tool options + +`tool.setOption` reads and sets a tool's options, headless too: `{key, value}` or `{values: {…}}` sets them on the +active tool, `tool` names another one, and `{}` just reads them; the result is the tool's options. The options a +tool keeps last across tool switches (and documents) and are saved with the preferences (`toolSettings` in +`prefs.get`'s full object), as the reference app keeps them: the Liquify tools' brush and tool options, Mirror & Cut's +axis and side, Puppet Warp's mesh, the Symbolism brush, the line, grid, pencil, brush and eraser tools' options, +polygon sides and star points. The Liquify tools share one set of Global Brush Dimensions (width, height, angle, +intensity), and the Symbolism tools one brush. Interaction state (pins, a reference point) starts afresh. + +```json +{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"twirl","values":{"width":60,"rate":90}}}} +``` + +## Perspective grid + +The grid is document data (`perspective.grid.set` edits the model directly). `perspective.grid.get` reads it: +`grid` (the model), `define` (the Define Grid fields), `station` (the viewer: `x` the centre of vision on the +horizon `y`, `distance` from the picture plane, in points) and `defined` (false while the document uses the default +grid). `perspective.grid.define` sets the grid from the Define Grid fields: `kind`, `units`, `scale` +(`[artboard, real world]`), `gridline`, `angle` (the viewing angle, 0–90°), `distance` (the viewing distance), +`horizonHeight`, `thirdVp` (`[x right, y up]` from the centre of vision), `leftColor`, `rightColor`, `groundColor` +(`"#rrggbb"`) and `opacity` (0–100). Lengths are real-world lengths in `units` drawn at `scale`; missing fields keep the +grid's. Only what differs changes (an unchanged OK is no undo step): a new type, angle or distance moves the vanishing +points around the station point, which stays put. In two- and three-point grids the viewer looks at the left plane at +the viewing angle, so the vanishing points sit at `x − distance/tan(angle)` and `x + distance·tan(angle)`. + +```json +{"name":"run_command","arguments":{"command":"perspective.grid.get","params":{}}} +{"name":"run_command","arguments":{"command":"perspective.grid.define","params":{"units":"inches","gridline":0.5,"angle":30,"distance":6,"horizonHeight":3}}} +``` + +Perspective grid presets: `perspective.presets.list` lists the built-in views (`[1P-Normal View]`, `[1P-Low View]`, +`[1P-High View]`, `[2P-Normal View]`, `[2P-Low View]`, `[2P-High View]`, `[3P-Normal View]`, `[3P-Low View]`; +`builtIn: true`, fitted to the first artboard, protected), then the saved ones, each with its Define Grid fields. +`perspective.grid.preset {name}` (or `{kind}`, that type's normal view) resets the grid to one, fitted to the first +artboard. `perspective.presets.save {name?, newName?, preset?, …fields}` saves a preset: just `{name}` saves the +document's grid (View → Perspective Grid → Save Grid as Preset). `perspective.presets.delete {name}`, +`perspective.presets.export {names?, path?}` (a `.vcperspective` JSON file; without `path` it returns `data`) and +`perspective.presets.import {path? | data? | dataBase64?, replace?}` manage them. Saved presets are kept with the +preferences, and a grid whose fields are a preset's is named after it (`name`). + +```json +{"name":"run_command","arguments":{"command":"perspective.grid.preset","params":{"name":"[2P-Low View]"}}} +{"name":"run_command","arguments":{"command":"perspective.presets.save","params":{"name":"Street","units":"feet","scale":[1,48],"gridline":1}}} +``` + +Perspective grid view options (View → Perspective Grid; view state saved with the document, no undo step, each +`{on?: bool}`, no param toggles, → `{on}`): `perspective.grid.lock` (Lock Grid: the grid's widgets can't be dragged), +`perspective.grid.lockStation` (Lock Station Point: dragging one vanishing point turns the view around the station +point, so the other one moves), `perspective.grid.snap` (Snap to Grid, on by default: `perspective.draw` lands the +drawn corners, and `perspective.move` the nearer edge, on gridlines within a quarter cell; both take `snap: false`) +and `perspective.grid.rulers` (Show Rulers: a ruler up the line where the planes meet, in the grid's units at its +scale). Gridlines draw in the Define Grid colours at its opacity. + +Perspective grid widgets: with the Perspective Grid tool (`pointer_gesture`), the vanishing points (Shift keeps the +horizon; with Lock Station Point the other one swings), the horizon, the third vanishing point, the origin, the left +and right ground-level points (24 px from the origin along each wall's ground line: they move the whole grid; Shift +keeps one axis), the left and right extents (separate: `extent` and `extentRight`; Alt drags both, Shift goes by +whole cells), the vertical extent and the cell size widget (on the line where the planes meet, a cell up, or more +cells while they're small) reshape the grid; Lock Grid stops them. The Plane Switching Widget stays put in a corner +of the document window (headless: the first artboard's top-left corner); a press on it picks the plane with any tool +while the grid shows (a perspective tool shows it), and the keys 1–4 (`key` with `"1"`…`"4"`) pick the left, +horizontal, right and no plane. `perspective.widget.options {show?, position?}` (Perspective Grid Options, +double-click the tool) hides it or moves it to `topLeft`, `topRight`, `bottomLeft` or `bottomRight`, kept with the +preferences (`prefs.get`/`prefs.set` key `perspectiveWidget`). + +## Envelopes + +Object → Envelope Distort wraps the selection in a live envelope: `object.envelope.makeWithWarp` (`style`: arc, +arcLower, arcUpper, arch, bulge, shellLower, shellUpper, flag, wave, fish, rise, fisheye, inflate, squeeze or twist; +`bend`, `h` and `v` in % from -100 to 100; `horizontal` or `orientation`), `object.envelope.makeWithMesh` (`rows`, +`cols`: 1–50) and `object.envelope.makeWithTopObject` (the topmost selected path becomes the envelope). +`object.envelope.info` reads the selected envelope's settings (`type`, the warp's or mesh's values, `fidelity` and +the options, `editing`), or with no envelope selected the options new envelopes get. With an envelope selected, +`object.envelope.resetWithWarp` and `object.envelope.resetWithMesh` (`rows`, `cols`, `maintainShape`: true keeps +the current shape) switch its kind and keep its content. `object.envelope.options` sets `fidelity` and the options +(`antiAlias`, `preserveShape`: clippingMask or transparency, `distortAppearance`, `distortLinearGradients`, +`distortPatternFills`; the last two need `distortAppearance`); with no envelope selected it sets them for new +envelopes. New envelopes start with Distort Appearance on, as in the reference app; envelopes saved before these +options existed keep their old look (appearance applied after the distortion). + +`object.envelope.release` gives back the content and the envelope's shape (a grey gradient mesh for warp and mesh +envelopes, the path for a top-object envelope); the content keeps the envelope's opacity, blend mode and opacity mask +(a group around it carries them when there are several objects). `object.envelope.expand`, and `object.expand` with +`object` on, replace envelopes by groups of their distorted content that keep the name, transparency, knockout and +opacity mask. +Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every +export (SVG, PDF, EPS, EMF/WMF, DXF). +Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped +image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art, +and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients +bends linear gradients and Distort Pattern Fills the pattern tiles. A transformed envelope keeps its warp in its own +frame (`kind.frame` in `document.inspect`; rotating a warp envelope turns the warp with it), and move and scale keep +it square to the page. + +```json +{"name":"run_command","arguments":{"command":"object.envelope.makeWithWarp","params":{"style":"arch","bend":40}}} +{"name":"run_command","arguments":{"command":"object.envelope.resetWithMesh","params":{"rows":3,"cols":3,"maintainShape":true}}} +{"name":"run_command","arguments":{"command":"object.envelope.options","params":{"fidelity":80,"distortAppearance":true,"distortLinearGradients":true}}} +{"name":"run_command","arguments":{"command":"object.envelope.info","params":{}}} +``` + +## Liquify tools + +The Liquify tools (`warp`, `twirl`, `pucker`, `bloat`, `scallop`, `crystallize`, `wrinkle`) drag a brush over paths: +the selected ones, or with nothing selected every path the brush passes over. A stroke is one undo step and one +`object.liquify` journal entry, which an agent can also run directly. Their options (`tool.setOption`) are the Global +Brush Dimensions shared by the seven tools (`width`, `height` in pt, `angle`, `intensity` 0..1, `usePressure`, +`showBrush`) and the tool's own: `detail` (1..10); `simplify` (0..100) with `simplifyOn` (Warp, Twirl, Pucker, +Bloat); `rate` (Twirl, -180..180°); `complexity` (0..15) and `affectAnchors`, `affectIn`, `affectOut` (Scallop, +Crystallize, Wrinkle: what the brush moves); `horizontal`, `vertical` (Wrinkle, 0..1). Alt-drag sizes the brush from +its current size (with Shift too it keeps its proportions). With Use Pressure Pen on, each `pointer_gesture` event's +`pressure` (0..1) is the intensity there, and `object.liquify` takes points as `[x, y, pressure]`. + +```json +{"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"bloat","values":{"width":80,"height":80,"usePressure":true}}}} +{"name":"pointer_gesture","arguments":{"tool":"bloat","events":[{"kind":"down","x":280,"y":150,"pressure":0.3},{"kind":"drag","x":280,"y":200,"pressure":0.8},{"kind":"up","x":280,"y":250}]}} +{"name":"run_command","arguments":{"command":"object.liquify","params":{"tool":"scallop","points":[[300,150],[300,250]],"complexity":3,"affectAnchors":false}}} +``` + +Holding the brush still keeps Twirl, Pucker and Bloat working, scaled by the time held: every tenth of a second the +stroke's last point repeats in `object.liquify`'s `points`, one more dab there (other tools ignore repeated points). +Give a `pointer_gesture` event `holdMs` (0..60000) to hold the pointer that long after it, button down; the same +time gives the same result. Liquify reshapes paths only: type, symbols, images, graphs, meshes, and envelopes, +repeats and blends with their contents stay as they are, and guides are never touched. `object.liquify` returns +those under the brush in `skipped` with a `warning`, which the gesture's `requests` carry as `{"status": …}` (the +status bar in the app). A drag applies only the dabs each new sample adds; the result equals applying the whole +stroke at once, so the journal entry replays it exactly. + +```json +{"name":"pointer_gesture","arguments":{"tool":"twirl","events":[{"kind":"down","x":300,"y":200,"holdMs":800},{"kind":"up","x":300,"y":200}]}} +``` + +## Perspective Selection + +Objects in perspective keep their attachment themselves (`perspective` on the object: `plane` and `depth`, the +distance along the plane's normal), so copies, duplicates and pastes stay attached. With the +`perspectiveSelection` tool, a drag moves the selection within its plane (`perspective.move`; Alt copies), the +bounding-box handles scale it in plane space (`perspective.transform`; Shift keeps proportions, Alt scales about the +centre), and `press_key` `5` during a drag switches the move to perpendicular to the plane (press again to switch +back). The arrow keys nudge a selection in perspective by the keyboard increment (`perspective.nudge`; Shift ×10, Alt +copies). Object › Transform › Transform Again repeats the last perspective move or scale in plane space. + +```json +{"name":"pointer_gesture","arguments":{"tool":"perspectiveSelection","events":[{"kind":"down","x":480,"y":410},{"kind":"drag","x":470,"y":405}]}} +{"name":"press_key","arguments":{"key":"5"}} +{"name":"pointer_gesture","arguments":{"events":[{"kind":"up","x":470,"y":405}]}} +{"name":"run_command","arguments":{"command":"perspective.transform","params":{"matrix":[1.5,0,0,1.5,0,0],"copy":true}}} +{"name":"run_command","arguments":{"command":"object.transformAgain","params":{}}} +``` + +Planes move along their normals: `perspective.plane.move {plane, offset | by, objects}` (`leftOffset`, +`rightOffset`, `groundOffset` in the grid). The plane widgets (a diamond on each plane, drawn while the grid shows) +do it with the Perspective Grid and Perspective Selection tools: a plain drag moves the plane alone, Shift-drag moves +the objects on it too, Alt-drag copies them; double-clicking a widget (or `ui.perspectivePlane {plane}`) opens the +`perspectivePlane` dialog (`location` in points, `objects`: none, move or copy). Objects keep their place when the +grid's definition changes, as in the reference app; `perspective.grid.set` with `reproject: true` moves them with it +instead. `perspective.plane.matchObject` (Object › Perspective › Move Plane to Match Object) moves the plane of the +selected object onto it. + +```json +{"name":"run_command","arguments":{"command":"perspective.plane.move","params":{"plane":"right","offset":40,"objects":"move"}}} +{"name":"run_command","arguments":{"command":"perspective.plane.matchObject","params":{}}} +{"name":"run_command","arguments":{"command":"ui.perspectivePlane","params":{"plane":"ground"}}} +``` + +Type and symbol instances attached to a plane stay type and symbol instances: their `perspective` record keeps a +`projection` (a 3 × 3 matrix) the canvas and every export (SVG, PDF, EPS, EMF/WMF, DXF, PNG) draw their outlines +through, so they are really foreshortened and still editable. `perspective.editText` (Object › Perspective › Edit +Text, or double-clicking the type with the Perspective Selection tool) shows the type flat where it is drawn, in +isolation mode, for the Type tool and `text.*` commands; `object.exitIsolation` projects it again. Release with +Perspective keeps their look. Shape, spiral, polar grid and flare drags draw on the active plane while the grid +shows, and so do the click-to-size dialogs (`perspective.draw` with `at`: the click; sizes are plane units). + +```json +{"name":"run_command","arguments":{"command":"perspective.editText","params":{}}} +{"name":"run_command","arguments":{"command":"text.setText","params":{"text":"OPEN"}}} +{"name":"run_command","arguments":{"command":"object.exitIsolation","params":{}}} +{"name":"run_command","arguments":{"command":"perspective.draw","params":{"command":"shape.rectangle","params":{"x":470,"y":300,"width":50,"height":30},"at":[470,300]}}} +``` + +## Blends + +`object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to +what `object.blend.options` set with no blend selected (`object.blend.info` reads them: `target: "defaults"`), +else Smooth Color and Align to Page. `starts` gives, per object, the anchor of its first subpath the blend starts +from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). A blend +among the objects takes the others in as more key objects, keeping its options, name and transparency: + +```json +{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[12,15],"starts":[0,2],"steps":8}}} +{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[20,31]}}} +{"name":"run_command","arguments":{"command":"object.blend.info","params":{}}} +``` + +A blend's spine is the straight lines between its key centres until it is edited. `object.blend.info` lists it +(`spine.anchors`, `keyAnchors`: the point each key sits on). `object.blend.spine.addAnchor {x, y}` adds a point where +the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a point (a key on it moves along; with +`handle: "in" | "out"` it places that handle) and `object.blend.spine.removeAnchor {anchor}` deletes a point no key +sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release +(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result): + +```json +{"name":"run_command","arguments":{"command":"object.blend.spine.addAnchor","params":{"id":40,"x":150,"y":100}}} +{"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} +``` + +How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes +interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale +and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with +different stop counts resample to a common set of stops. Groups pair their members in stacking order (members only +one group has grow out of the other's centre), compound paths stay compound paths, and type, symbol instances and +images interpolate their transforms (type also its colours, stroke weight and size run by run; the words and the +symbol switch halfway). New blends are knockout groups (`object.setProps {knockout}` changes it), drawn on the +canvas as they export. `object.blend.expand` and `object.blend.release` keep the blend's name, transparency, +opacity mask and appearance (Release on a group around the keys and spine when the blend has any), and +`object.expand` with `object: true` expands the blends in the selection. + +## Editing envelopes + +`object.envelope.editContents {editing}` switches between editing the envelope and its contents (the menu item reads +Edit Envelope while the contents are edited; it has no default shortcut, since the reference app's is Paste in Place +here). While the contents are edited, clicks hit the content where it sits undistorted and the Selection tool selects +it. A mesh envelope's points have bezier handles (`kind.handles`, offsets +right/left/down/up; none stored means the smooth mesh through the points, as older files have): +`object.envelope.setMeshPoint {id, index, x, y, handle?}` and `object.mesh.movePoint`, `object.mesh.addLine` and +`object.mesh.deletePoint` edit them like a gradient mesh's (one undo step each). On the canvas a selected envelope +shows its mesh; the Mesh tool and Direct Selection drag its points and the clicked point's handles, and the Mesh tool +adds a row and a column where it clicks inside. The Control bar shows Edit Envelope / Edit Contents, the warp's +style, orientation, bend and distortions (or the mesh's rows and columns), Reset (an unbent warp, a flat mesh) and +Envelope Options. + +```json +{"name":"run_command","arguments":{"command":"object.envelope.editContents","params":{"editing":true}}} +{"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}} +``` diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..1c45e22a5 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,219 @@ +# Plug-ins: the WebAssembly plug-in API (ABI v1) + +VectorCraft does not host native plug-ins built for other illustration apps: that needs unsafe FFI into +arbitrary machine code and cannot work in the browser build. Instead, plug-ins are **WebAssembly modules** +run in a sandbox. One `.wasm` file works on macOS, Windows, Linux and the web. The design follows PhotoCraft's +plug-in API (same sandbox, same manifest and parameter schema), extended for vector documents. + +There are two kinds of plug-in: + +- **Object filters** read the selected paths and compound paths (geometry, fills, strokes, gradients, opacity) + and return the objects to keep, change or add. A run is one undo step. Use them for recolouring, path + clean-up, halftones, generative patterns and the like. +- **Live effects** rewrite one object's geometry each time it is drawn, from its appearance stack, like the + built-in Distort & Transform effects. The art stays editable: move or reshape it and the effect follows. + +This page is the contract for plug-in authors and the reference for the host (`crates/plugins`, the +`plugin.*` commands in `crates/engine/src/cmd/plugin.rs`). + +## Using plug-ins + +| Command | Params | What it does | +|---|---|---| +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, size, source}], "folder": report}` | +| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | +| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true` | Validates and installs a module; returns its `plugin.list` entry. | +| `plugin.remove` | `{"id"}` | Uninstalls. | +| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped. | + +Live effects are applied like any effect: `effect.apply {"effect": "plugin.", "params": {…}}`, edited with +`effect.setParams`, listed by `effect.list` (menu `Effect › Plug-ins`) and baked by Object › Expand Appearance. + +In the app, installed filters appear under **Object › Plug-ins**, next to **Install Plug-in…** and **Reload +Plug-ins**; live effects under **Effect › Plug-ins** and in the Appearance panel's effect menu. A plug-in with +parameters gets a dialog generated from its schema (numbers, whole numbers, checkboxes, dropdowns) with a live +preview; without parameters it runs at once. **File › Open** of a `.wasm` file installs it too (on the web this is +how plug-ins get in). + +**Preference folder.** Edit › Preferences › Performance & Storage › *Additional Plug-ins Folder*: when set, every +`*.wasm` in that folder (not recursive) is installed at startup and whenever the preference changes (desktop builds; +the web build installs from bytes). + +Plug-ins are installed **per process**, not per document. Documents store a live effect as an ordinary effect record +(`{"id": "plugin.", "params": {…}}`): while the plug-in is installed it is drawn, exported and expanded; without it +the object is drawn as if the effect weren't there, and the record is kept for when the plug-in comes back. + +## The sandbox + +The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssembly interpreter +(MIT OR Apache-2.0). It builds for `wasm32-unknown-unknown` too, so plug-ins also run in the web build. Limits +(`vectorcraft_plugins::Limits`, defaults): + +| Limit | Default | On violation | +|---|---|---| +| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install | +| Module size | 32 MiB | rejected | +| Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | +| Output | 64 MiB of JSON | `Limit("output size")` | +| Instructions (fuel) | 50 M per call + 4,000 per input byte for `vc_run` | `Limit("instruction budget")` | +| Wall clock | 60 s per filter run, 5 s per live-effect run (desktop; checked every 20 M instructions) | `Limit("time budget")` | +| Call depth | 1,024 frames | trap | +| Module shape | wasmi's strict limits (≤ 10,000 functions, 1 memory, …) | rejected | + +Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed +modules, bad manifests, out-of-range pointers, negative return codes and invalid output all become errors, and the +document is left untouched: a filter's output is checked completely before one edit applies it. A live effect that +fails draws the object's geometry unchanged; `plugin.info` reports why (`lastError`). + +wasmi is built with its *portable* (loop) dispatcher: the default tail-call dispatcher relies on LLVM +sibling-call optimisation, and when that doesn't happen a long-running module overflows the host stack, which aborts +the process instead of returning an error. + +Drawing evaluates effects often, so live-effect results are cached by plug-in installation, parameters and input +geometry: a plug-in runs again only when one of them changes. + +## ABI v1 + +A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, +saturating conversions; no SIMD, no memory64) with **no imports** and these exports. Integers are `i32` unless noted; +pointers are offsets into the module's memory. + +| Export | Signature | Meaning | +|---|---|---| +| `memory` | memory | The module's linear memory. | +| `vc_abi_version` | `() -> i32` | Must return `1`. | +| `vc_manifest` | `() -> i64` | `(len << 32) \| ptr` of the UTF-8 manifest JSON (≤ 64 KiB). | +| `vc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each run gets a fresh instance. | +| `vc_run` | `(input, input_len, params, params_len) -> i64` | Runs the plug-in on the input JSON with the parameters JSON; returns `(len << 32) \| ptr` of the output JSON, or a negative error code. | + +### Manifest + +```json +{ + "id": "org.example.tidy", + "name": "Tidy Paths", + "version": "1.0.0", + "kind": "filter", + "author": "Example", + "description": "What it does.", + "params": { + "amount": {"type": "number", "min": 0, "max": 100, "default": 50}, + "steps": {"type": "int", "min": 1, "max": 16, "default": 4}, + "closed": {"type": "bool", "default": false}, + "mode": {"type": "choice", "options": ["soft", "hard"], "default": "soft"} + } +} +``` + +- `id`: 1–64 characters from `A-Z a-z 0-9 . _ -` (reverse-DNS recommended). Installing a module with the same id + replaces the old one. A live effect's effect id is `plugin.`. +- `name`: the menu label (1–64 printable characters). `kind`: `"filter"` (object filter) or `"effect"` (live effect). +- `params`: in dialog order. Names are `[A-Za-z0-9_]`, not starting with `_`, and not `id`, `ids`, `params` or + `preview`. The host validates the user's values before calling: missing values take the default, numbers are + clamped to `min..max` (ints rounded), a wrong type or an unknown choice is an error. + +### Input + +`input` is UTF-8 JSON: `{"objects": [...]}`. An object filter gets the selected paths and compound paths in paint +order (bottom first): the selected ones and those inside selected groups and layers; hidden, locked, guide and clipping +paths are left out, and so are other kinds of object (type, images, symbols, live blends…), which a filter never +touches. With nothing selected the list is empty (a generator then adds its own objects). A live effect gets one +object, holding only geometry (`type`, `path`, `fillRule`, `bounds`). + +```json +{"objects": [ + {"id": 12, "type": "path", "name": "Leaf", + "path": {"subpaths": [{"closed": true, "anchors": [ + {"p": [10, 10]}, + {"p": [60, 10], "in": [45, 0], "out": [75, 20], "kind": "Smooth"}, + {"p": [35, 60]}]}]}, + "fillRule": "nonzero", + "fills": [{"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.1}}], + "strokes": [{"paint": {"type": "gradient", "gradient": {"kind": "Linear", "stops": [ + {"offset": 0, "color": {"model": "gray", "k": 0}}, + {"offset": 1, "color": {"model": "cmyk", "c": 0, "m": 0.5, "y": 1, "k": 0}}]}, "angle": 0}, + "width": 2}], + "opacity": 1, + "bounds": [10, 0, 75, 60]} +]} +``` + +- `type`: `"path"` or `"compound"` (a compound path: `path` holds its members' subpaths, painted as one). +- `path`: VectorCraft's own path form. Each subpath is a run of anchors; `p` is the anchor, `in` / `out` its handles + (absent: no handle), `kind` `"Smooth"` when the handles stay collinear. Coordinates are points, y down. +- `fills`, `strokes`: the object's fills and strokes in paint order (bottom first); `null` is no paint. Paints use the + native file format's form: `{"type": "solid", "color", "swatch"?, "tint"?}`, `{"type": "gradient", "gradient": + {"kind": "Linear"|"Radial"|"Freeform", "stops": [{offset, color, opacity, midpoint}]}, "geom"?, "angle", + "freeform"?}`, `{"type": "pattern", "pattern", "xf"?}`. Colours are `{"model": "rgb", r, g, b}`, `{"model": "cmyk", + c, m, y, k}`, `{"model": "gray", k}` (ink: 0 white, 1 black) or `{"model": "lab", l, a, b}`; components 0–1 except + Lab's. +- `bounds`: `[x0, y0, x1, y1]`, the geometric bounds (read only). + +### Parameters + +`params` points at `params_len` bytes of UTF-8 JSON: the validated parameters, plus `_context`: + +```json +{"amount": 50, "mode": "soft", + "_context": {"mode": "filter", "bounds": [10, 0, 75, 60], "artboard": [0, 0, 612, 792]}} +``` + +`mode` is `"filter"` or `"effect"`; `bounds` the selection's geometric bounds (a live effect: the reference box of the +effect, the object's bounds or those of the previous effect's result); `artboard` (filters) the artboard under the +selection's centre, else the first. + +### Output + +The output is UTF-8 JSON in the input's shape: `{"objects": [...]}`. To decline with a message for the user, return +`{"error": "Select two paths."}`. + +For an **object filter**, the output is the new state of the input objects: + +- An object with the `id` of an input object updates it; keys it leaves out keep their value (`{"id": 12}` keeps the + object as it is). `fills` and `strokes` set the object's fills and strokes in order: extra entries add fills (above + the last one) or strokes, missing ones are removed; each stroke keeps its weight unless `width` is given (a new one: 1 pt). +- An object without an `id` is new and needs a `path`. It goes just above the object listed before it, or, listed + before any input object, just below the first one listed after it. New objects take a path's defaults: no fills or + strokes, full opacity. +- Input objects missing from the output are deleted. The order of the input objects themselves doesn't change. +- `type` turns a path into a compound path (one member per subpath) or back; `name: ""` clears the name. + +The host validates everything before the document changes: coordinates must be finite and within ±4,000,000 pt, at +most 100,000 objects and 4,000,000 anchors, 256 fills, strokes or gradient stops; colours, opacities and offsets are +clamped into range, stroke weights to 0–1,000 pt; a pattern must exist in the document. A paint returned unchanged +keeps its swatch link; any other paint is unlinked. + +For a **live effect**, the geometry of every returned object's `path` (their subpaths, in order) replaces the +object's; the rest is ignored (the object keeps its own appearance). An empty list removes the geometry. + +## Writing a plug-in in Rust + +[`crates/plugins/example`](../crates/plugins/example) is a complete object filter, **Desaturate**: it moves every +colour of the selection towards grey, in each colour's own model, using `serde_json` (no imports, no WASI). Build it +with: + +```sh +rustup target add wasm32-unknown-unknown +crates/plugins/example/build.sh # cargo build --release --target wasm32-unknown-unknown +``` + +The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. +The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, +WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never +touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests +build their plug-ins from. + +## Performance + +The interpreter costs roughly 10–100× native speed per instruction. Filters handle a selection in one call, so a +plug-in that parses and writes JSON with `serde_json` takes milliseconds for hundreds of paths. Live effects run once +per change of their input (results are cached), not per frame. + +## Not in v1 + +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host + callbacks (progress, cancel), plug-in file formats. +- Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or + symbols. +- Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/packaging/env.sh b/packaging/env.sh index c70004ff2..8b5391fc4 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -50,6 +50,19 @@ copy_docs() { for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi done + copy_font_licences "$dest" +} + +# Built with craft-fonts (CRAFT_FONTS_DIR, set by release.yml): the embedded fonts' licences go with +# the package, as OFL-.txt. Nothing to do without it. +copy_font_licences() { + local dest="$1" ofl family + [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + for ofl in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do + [ -f "$ofl" ] || continue + family="$(basename "$(dirname "$ofl")")" + cp "$ofl" "$dest/OFL-$family.txt" + done } # Portable SHA-256 of a file (prints just the hash). diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 6c07e5c94..65356490a 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -108,6 +108,8 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" +# The licences of the craft-fonts fonts embedded in the binary (release builds). +copy_font_licences "$APP/Contents/Resources" # Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). # Today the only nested code is the main executable; frameworks/helpers would be signed here too. diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 65d8ace92..de574a43e 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -17,9 +17,10 @@ .EXAMPLE pwsh packaging/windows/package.ps1 -Arch x64 pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild + pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools #> param( - [ValidateSet('x64', 'x86')] [string] $Arch = 'x64', + [ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64', [switch] $SkipBuild ) $ErrorActionPreference = 'Stop' @@ -44,7 +45,7 @@ if (-not $Version) { throw 'could not read [workspace.package] version from Carg # MSI ProductVersion is numeric (major.minor.build); pre-release tags are dropped there. $MsiVersion = ($Version -split '-')[0] -$Target = if ($Arch -eq 'x64') { 'x86_64-pc-windows-msvc' } else { 'i686-pc-windows-msvc' } +$Target = switch ($Arch) { 'x64' { 'x86_64-pc-windows-msvc' } 'x86' { 'i686-pc-windows-msvc' } 'arm64' { 'aarch64-pc-windows-msvc' } } $Dist = if ($env:DIST) { $env:DIST } else { Join-Path $Root 'dist\release' } $TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root 'target' } New-Item -ItemType Directory -Force -Path $Dist | Out-Null @@ -65,6 +66,23 @@ if (-not $SkipBuild) { } $Bin = Join-Path $TargetDir "$Target\release" + +# Check both binaries' PE headers before packaging. Machine (COFF header) must match -Arch, so an +# x64 build can never ship labelled arm64. Subsystem (optional header): 2 = Windows GUI, 3 = console. +# The app must be GUI (no console window opens with it); the CLI must stay console so its output +# reaches the terminal. +function Get-PeHeader([string] $Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + $pe = [BitConverter]::ToInt32($bytes, 0x3C) + return @{ Machine = [BitConverter]::ToUInt16($bytes, $pe + 4); Subsystem = [BitConverter]::ToUInt16($bytes, $pe + 0x5C) } +} +$Machine = switch ($Arch) { 'x64' { 0x8664 } 'x86' { 0x14C } 'arm64' { 0xAA64 } } +foreach ($check in @(@('vectorcraft.exe', 2), @('vectorcraft-cli.exe', 3))) { + $h = Get-PeHeader (Join-Path $Bin $check[0]) + if ($h.Machine -ne $Machine) { throw "$($check[0]) is for machine 0x$('{0:X}' -f $h.Machine), expected 0x$('{0:X}' -f $Machine) ($Arch)" } + if ($h.Subsystem -ne $check[1]) { throw "$($check[0]) has PE subsystem $($h.Subsystem), expected $($check[1])" } + Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)" +} $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null @@ -92,9 +110,22 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } +# Built with craft-fonts (CRAFT_FONTS_DIR, release builds): the embedded fonts' licences. +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue) { + Copy-Item $ofl.FullName (Join-Path $Portable "OFL-$($ofl.Directory.Name).txt") + } +} $Zip = Join-Path $Dist "vectorcraft-$Version-windows-$Arch-portable.zip" Remove-Item -Force $Zip -ErrorAction SilentlyContinue Compress-Archive -Path $Portable -DestinationPath $Zip -Invoke-Native 'vectorcraft-cli --version' { & (Join-Path $Stage 'vectorcraft-cli.exe') --version } +# Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run +# here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead. +$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { + Invoke-Native 'vectorcraft-cli --version' { & (Join-Path $Stage 'vectorcraft-cli.exe') --version } +} else { + Write-Output "skipping vectorcraft-cli --version: an $Arch build doesn't run on this $HostArch machine" +} Get-Item $Msi, $Zip | Format-Table Name, Length diff --git a/packaging/windows/vectorcraft.wxs b/packaging/windows/vectorcraft.wxs index e6e5f77f1..c09ec79f1 100644 --- a/packaging/windows/vectorcraft.wxs +++ b/packaging/windows/vectorcraft.wxs @@ -23,8 +23,8 @@ DowngradeErrorMessage="A newer version of VectorCraft is already installed." /> - - + + @@ -45,7 +45,7 @@ + Icon="VectorcraftIcon.ico" IconIndex="0" /> diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 7f9359056..ad4168f43 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -59,15 +59,16 @@ pub const TABLE: &[(&str, Class)] = &[ ("cad", Class::Layer(3)), ("eps", Class::Layer(3)), ("metafile", Class::Layer(3)), + ("plugins", Class::Layer(2)), ]; /// Explicit orderings *within* a layer (earlier may be used by later). /// The L0 foundation is a small chain: `raster` builds on `color` and /// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`) /// reuses the CPU reference (`compose`) for LUTs and parity tests. EPS previews use the renderer's -/// TIFF writer. +/// TIFF writer. Live effects run effect plug-ins. pub const INTRA_LAYER_ORDER: &[&[&str]] = - &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"], &["render", "eps"]]; + &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"], &["render", "eps"], &["plugins", "effects"]]; fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to)); @@ -177,11 +178,11 @@ pub fn check(crates: &[Crate]) -> Vec { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } + Some(Class::Testkit) if d.kind != DepKind::Dev => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); } + // The testkit as a dev-dependency is how tests use it. + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {