mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-16 12:16:54 -04:00
Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1012361773 | ||
|
|
f2a2671933 | ||
|
|
aafd565b99 | ||
|
|
571881a830 | ||
|
|
347369187f | ||
|
|
d899a63286 | ||
|
|
f95b032c82 | ||
|
|
ce9f3aa7c6 | ||
|
|
ef3fa1689f | ||
|
|
68352dab02 | ||
|
|
51a9b899c1 | ||
|
|
81e7b915af | ||
|
|
fcc9414480 | ||
|
|
377204f90e | ||
|
|
0306d51592 | ||
|
|
ebc6d1af7b | ||
|
|
cdb734aede | ||
|
|
2f743f155f | ||
|
|
4bc0837867 | ||
|
|
2ba0e1f6e5 | ||
|
|
80eb5844c7 | ||
|
|
82be706b94 | ||
|
|
5af667182a | ||
|
|
700b6786c4 | ||
|
|
77159bb08e | ||
|
|
047a3bfc6f | ||
|
|
856e36c694 | ||
|
|
721d1118f3 | ||
|
|
3491b99321 | ||
|
|
546a65f77a | ||
|
|
77708ad8de | ||
|
|
129c9ca61e | ||
|
|
ceb6defcf3 | ||
|
|
ab6a668aa9 | ||
|
|
8dc2fd077f | ||
|
|
66fa0a998a | ||
|
|
b045390311 | ||
|
|
7a39cb7b89 | ||
|
|
15ab25426e | ||
|
|
899a82d31d | ||
|
|
7bca89dd15 | ||
|
|
7f5344a60c | ||
|
|
691a54e7fd | ||
|
|
1733fa5fdb | ||
|
|
7715dcd285 | ||
|
|
caba979b01 | ||
|
|
025315f4bf | ||
|
|
4c6ac00e72 | ||
|
|
44b2b984ed | ||
|
|
2e2bb33909 | ||
|
|
5bb273487e | ||
|
|
695c541f0d | ||
|
|
254251f23a | ||
|
|
99e3aaa798 | ||
|
|
0889f13cd8 | ||
|
|
565b75b91d | ||
|
|
3280586f4c | ||
|
|
8255d606ae | ||
|
|
5a13626faa | ||
|
|
2967c3a9ba | ||
|
|
04fa49413c | ||
|
|
cd99714b42 | ||
|
|
e1225f4c24 | ||
|
|
98a3e15ec3 | ||
|
|
55d7df36e4 | ||
|
|
fe2259536d | ||
|
|
13db157d36 | ||
|
|
bd91cf9123 | ||
|
|
5061404ec6 | ||
|
|
e9abc62f1a | ||
|
|
fc0cb255a4 | ||
|
|
aa08c5bef7 | ||
|
|
e3b079de8c | ||
|
|
85e7acf138 | ||
|
|
e774f15537 | ||
|
|
0f0dbbf9d5 | ||
|
|
7ec936a0c1 | ||
|
|
b7c032df43 | ||
|
|
117aa0b5a3 | ||
|
|
47ef223a02 | ||
|
|
8752561132 | ||
|
|
3d35b8518f | ||
|
|
a5c2d6b682 | ||
|
|
781f797b00 | ||
|
|
c5985b3f47 | ||
|
|
5157dc87b8 | ||
|
|
6210f2f15d | ||
|
|
a0847e8a25 | ||
|
|
7df7cf568a | ||
|
|
b062070642 | ||
|
|
08c70dc35d | ||
|
|
4a59a171e2 | ||
|
|
8974957890 | ||
|
|
c3c4185cef | ||
|
|
3d850a2864 | ||
|
|
aa5b544fca | ||
|
|
92cadd6083 | ||
|
|
4950cc17cf | ||
|
|
c6e69fad33 | ||
|
|
67b2213957 | ||
|
|
5a93e78ad8 | ||
|
|
aafe381eab | ||
|
|
42be928367 | ||
|
|
3bb1431422 | ||
|
|
4b5d776a73 | ||
|
|
fe6d6640fb | ||
|
|
6994f248b2 | ||
|
|
f29e139199 | ||
|
|
ecf245b7c9 | ||
|
|
fe7da442ca | ||
|
|
37c82d082e | ||
|
|
5d21826fa8 | ||
|
|
a05e251622 | ||
|
|
28697019b7 | ||
|
|
08108f693f | ||
|
|
32f73e9cce | ||
|
|
2c02502f43 | ||
|
|
e35f75f733 | ||
|
|
3f0bce6a88 | ||
|
|
a4d93b1462 | ||
|
|
719e648de1 | ||
|
|
945dcbae1d | ||
|
|
86dbc42d42 | ||
|
|
9002f86f73 | ||
|
|
0aba6ef6e4 | ||
|
|
980372f6d0 |
No files matched your search
@@ -1,5 +1,6 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# The `package` job checks packaging/freebsd/package.sh (the release job runs it for real).
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
@@ -11,6 +12,8 @@ on:
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -27,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:
|
||||
@@ -44,5 +54,51 @@ 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
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# 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: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
@@ -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 '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
|
||||
@@ -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: |
|
||||
@@ -201,6 +232,56 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: release
|
||||
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 in the VM).
|
||||
# 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
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -212,6 +293,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
|
||||
@@ -227,6 +315,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:
|
||||
@@ -236,7 +327,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<craft-fonts checkout>` (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-<family>.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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+27
-25
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
@@ -4220,6 +4220,8 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
@@ -4231,7 +4233,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4243,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4259,7 +4261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4275,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4287,7 +4289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4301,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4315,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4347,7 +4349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4364,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4378,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4390,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4409,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4423,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4435,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4462,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4476,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4501,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4525,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4542,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4555,7 +4557,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4570,7 +4572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4583,7 +4585,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4611,7 +4613,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5685,7 +5687,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ 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.95"
|
||||
|
||||
@@ -29,3 +29,8 @@ 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-<family>.txt.
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
|
||||
</p>
|
||||
@@ -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).
|
||||
|
||||
+28
-24
@@ -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 with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, 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 (ruby, mojikumi, vertical font metrics, manual
|
||||
tate-chu-yoko), 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.
|
||||
@@ -94,6 +95,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
|
||||
@@ -111,8 +113,9 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- 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), 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 and Czech interface, vertical type:** VectorCraft ▸ Language switches the menus between English, Japanese and Czech (untranslated labels stay English; Czech covers every menu label, tested) and persists; Japanese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko, upright glyphs centred on the column, point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **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 (every path an edge, open lines included), 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.
|
||||
@@ -124,6 +127,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
@@ -143,8 +147,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| 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, 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) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| 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, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, 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 `<pattern>`/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 |
|
||||
@@ -153,7 +157,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| 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
|
||||
|
||||
@@ -171,7 +175,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
|---|---:|---:|---|---:|
|
||||
| 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 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | 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 |
|
||||
| Strokes, brushes, width profiles | 5 | 88% | brush options depth, brush libraries (generated in code) | 6–10 |
|
||||
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
|
||||
@@ -179,8 +183,8 @@ 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 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), 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 |
|
||||
@@ -189,19 +193,19 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
|
||||
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows (Links, Asset Export, CSS Properties, Attributes are done) | 15–25 |
|
||||
| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu 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,
|
||||
|
||||
@@ -14,11 +14,41 @@
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `println!` / `print!` that end the program quietly when stdout is closed
|
||||
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
|
||||
/// Broken pipe (os error 32)".
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod perf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
||||
/// ripgrep does; any other write error is reported.
|
||||
fn stdout_failed(e: std::io::Error) -> ! {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
@@ -68,11 +98,11 @@ fn main() -> ExitCode {
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{}", usage());
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
@@ -120,7 +150,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
fn commands() -> Result<(), String> {
|
||||
let mut h = Headless::new();
|
||||
let v = h.call("engine.commands", json!({}))?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,7 +176,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +192,7 @@ fn info(args: &[String]) -> Result<(), String> {
|
||||
let artboards: Vec<Value> =
|
||||
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
|
||||
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +282,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
* vectorcraft_geom::Affine::scale(z)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
let opts = vectorcraft_render::RenderOptions::default();
|
||||
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
for threads in [vectorcraft_render::default_threads(), 0] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = threads;
|
||||
@@ -261,7 +291,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
for _ in 0..iters {
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
outln!(
|
||||
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
|
||||
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n)?;
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
outln!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
|
||||
@@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[2]["result"]["isError"], false);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
/// stdout: Broken pipe (os error 32)" and exit status 101.
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 16];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
@@ -30,6 +30,8 @@ clipboard-win = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! `--control <port>` (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;
|
||||
@@ -41,12 +42,27 @@ impl eframe::App for App {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.0.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn discard_marked_text() {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new()
|
||||
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
|
||||
{
|
||||
ic.discardMarkedText();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
|
||||
@@ -18,6 +18,7 @@ enum Handle {
|
||||
}
|
||||
|
||||
pub struct NativeMenu {
|
||||
language: vectorcraft_ui_egui::i18n::Language,
|
||||
_menu: Menu,
|
||||
items: HashMap<String, (String, Value, Handle, String)>,
|
||||
last_refresh: f64,
|
||||
@@ -47,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);
|
||||
}
|
||||
@@ -58,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), plugins: menus::plugin_revision() }
|
||||
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.
|
||||
@@ -79,6 +88,7 @@ impl NativeMenu {
|
||||
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;
|
||||
@@ -111,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) => {
|
||||
@@ -136,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);
|
||||
}
|
||||
@@ -153,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)
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, CharStyle, Dash, Document, LAY
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect};
|
||||
|
||||
use super::ImportOptions;
|
||||
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, TextItem};
|
||||
use super::entity::{Alpha, Col, Converter, Geom, InsertItem, Item, Lw, Props, Span, TextItem};
|
||||
use super::reader::{Drawing, LayoutDef};
|
||||
use crate::{CAP_HEIGHT, MAX_NEST};
|
||||
|
||||
@@ -244,14 +244,18 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
Geom::Text(t) => {
|
||||
let lines = t.text.lines().count().max(1) as f64;
|
||||
let longest = t.text.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64;
|
||||
let w = longest * t.height * 0.9 * t.width;
|
||||
let natural = longest * t.height * 0.9 * t.width;
|
||||
// Fit and aligned text run their span; aligned text's height scales with it.
|
||||
let k = t.span.and_then(|sp| sp.scale(natural)).unwrap_or(1.0);
|
||||
let height = if matches!(t.span, Some(Span::Aligned(_))) { t.height * k } else { t.height };
|
||||
let w = natural * k;
|
||||
let x0 = match t.justify {
|
||||
vectorcraft_doc::Justify::Center => -w / 2.0,
|
||||
vectorcraft_doc::Justify::Right => -w,
|
||||
_ => 0.0,
|
||||
};
|
||||
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + t.height * 0.3;
|
||||
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, t.height)))
|
||||
let below = (lines - 1.0) * t.leading.unwrap_or(0.0) + height * 0.3;
|
||||
Some(t.xf.transform_rect_bbox(Rect::new(x0, -below, x0 + w, height)))
|
||||
}
|
||||
Geom::Insert(ins) => {
|
||||
let b = self.block_bounds(&ins.block, depth);
|
||||
@@ -502,6 +506,9 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
let mut obj = TextObject::point(Point::ZERO, &t.text, style);
|
||||
obj.xf = m * Affine::scale(1.0 / s);
|
||||
obj.para.justify = t.justify;
|
||||
if let Some(span) = t.span {
|
||||
fill_span(&mut obj, span, s);
|
||||
}
|
||||
Some(Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))))
|
||||
}
|
||||
|
||||
@@ -581,6 +588,20 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fit text stretched or squeezed across, aligned text scaled as a whole, to run its span
|
||||
/// (`s`: points per drawing unit), measured in the font it is set in.
|
||||
fn fill_span(obj: &mut TextObject, span: Span, s: f64) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj);
|
||||
let natural = layout.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f64::max);
|
||||
let Some(k) = span.scale(natural / s) else { return };
|
||||
for r in &mut obj.runs {
|
||||
match span {
|
||||
Span::Fit(_) => r.style.h_scale *= k,
|
||||
Span::Aligned(_) => r.style.size *= k,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rgb_color([r, g, b]: [u8; 3]) -> Color {
|
||||
Color::rgb8(r, g, b)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ const LINE_SPACING: f64 = 5.0 / 3.0;
|
||||
const MAX_CELLS: i64 = 10_000;
|
||||
/// Art farther than this from the origin (drawing units) is damaged data, left out.
|
||||
const MAX_COORD: f64 = 1e12;
|
||||
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
|
||||
/// factor 1, the 1 to 10,000% horizontal scale the Character panel allows.
|
||||
const SPAN_SCALE: (f64, f64) = (0.01, 100.0);
|
||||
|
||||
/// The colour of index `i` on paper: index 7 (white on a CAD screen) is black.
|
||||
pub(crate) fn aci_paper(i: u64) -> [u8; 3] {
|
||||
@@ -121,6 +124,27 @@ pub(crate) struct TextItem {
|
||||
pub family: Option<String>,
|
||||
/// Baseline-to-baseline distance (drawing units), for several lines.
|
||||
pub leading: Option<f64>,
|
||||
/// The distance aligned and fit text run along their baseline.
|
||||
pub span: Option<Span>,
|
||||
}
|
||||
|
||||
/// How text fills the distance between its two points (drawing units).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Span {
|
||||
/// Stretched or squeezed across, its height kept.
|
||||
Fit(f64),
|
||||
/// Scaled as a whole, height included.
|
||||
Aligned(f64),
|
||||
}
|
||||
|
||||
impl Span {
|
||||
/// The scale that makes text `natural` long run the span, within [`SPAN_SCALE`] (None when
|
||||
/// either length is degenerate).
|
||||
pub fn scale(self, natural: f64) -> Option<f64> {
|
||||
let (Self::Fit(d) | Self::Aligned(d)) = self;
|
||||
let k = d / natural;
|
||||
(k.is_finite() && k > 0.0).then(|| k.clamp(SPAN_SCALE.0, SPAN_SCALE.1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A block reference: one transform per copy (an array insert makes several), each from the
|
||||
@@ -547,7 +571,14 @@ fn edge(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
|
||||
ctrl.push(c.point(10)?);
|
||||
weights.push(c.num(42).unwrap_or(1.0));
|
||||
}
|
||||
let fit: Vec<Point> = 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<Point> = match fit_count.then(|| c.count(97)).flatten() {
|
||||
Some(n) => (0..n).map(|_| c.point(11)).collect::<Option<_>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
@@ -601,10 +632,13 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
let (h, v) = (g.int_or(72, 0), g.int_or(if attrib { 74 } else { 73 }, 0));
|
||||
let (p1, p2) = (g.point(10), g.point_opt(11));
|
||||
let mut angle = g.num_or(50, 0.0).to_radians();
|
||||
let mut span = None;
|
||||
// Aligned and fit text run from the first point to the second.
|
||||
let anchor = match (h, p2) {
|
||||
(3 | 5, Some(p)) => {
|
||||
angle = (p - p1).atan2();
|
||||
let d = p1.distance(p);
|
||||
span = (d > 1e-12).then_some(if h == 3 { Span::Aligned(d) } else { Span::Fit(d) });
|
||||
p1
|
||||
}
|
||||
(0, _) if v == 0 => p1,
|
||||
@@ -629,6 +663,7 @@ fn text(g: &[Pair<'_>], attrib: bool) -> Option<TextItem> {
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family: None,
|
||||
leading: None,
|
||||
span,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -655,5 +690,15 @@ fn mtext(g: &[Pair<'_>]) -> Option<TextItem> {
|
||||
let direction = g.point_opt(11).map(|p| p.to_vec2()).filter(|d| d.hypot() > 1e-12);
|
||||
let angle = direction.map_or_else(|| g.num_or(50, 0.0), |d| d.atan2());
|
||||
let xf = Affine::translate(g.point(10).to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, first));
|
||||
Some(TextItem { text: s, height, xf, width: 1.0, justify, style: g.name(7).unwrap_or("STANDARD").to_uppercase(), family, leading: Some(leading) })
|
||||
Some(TextItem {
|
||||
text: s,
|
||||
height,
|
||||
xf,
|
||||
width: 1.0,
|
||||
justify,
|
||||
style: g.name(7).unwrap_or("STANDARD").to_uppercase(),
|
||||
family,
|
||||
leading: Some(leading),
|
||||
span: None,
|
||||
})
|
||||
}
|
||||
@@ -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")];
|
||||
@@ -362,6 +397,167 @@ fn text_and_multiline_text() {
|
||||
assert!((leading - 3.5 * 5.0 / 3.0).abs() < 1e-9, "{leading}");
|
||||
}
|
||||
|
||||
/// Where the first line's baseline starts and ends, in document coordinates.
|
||||
fn baseline_ends(t: &TextObject) -> (Point, Point) {
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let l = &layout.lines[0];
|
||||
(t.xf * Point::new(l.x0, l.baseline), t.xf * Point::new(l.x1, l.baseline))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_aligned_text_span_their_two_points() {
|
||||
// Millimetres: red lines at x = 10 and 90, fit and aligned text between them, and the same
|
||||
// text left-justified for its natural size.
|
||||
let e = [
|
||||
(0, "LINE"),
|
||||
(62, "1"),
|
||||
(10, "10"),
|
||||
(20, "0"),
|
||||
(11, "10"),
|
||||
(21, "60"),
|
||||
(0, "LINE"),
|
||||
(62, "1"),
|
||||
(10, "90"),
|
||||
(20, "0"),
|
||||
(11, "90"),
|
||||
(21, "60"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "40"),
|
||||
(40, "5"),
|
||||
(1, "FIT BETWEEN"),
|
||||
(72, "5"),
|
||||
(11, "90"),
|
||||
(21, "40"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "10"),
|
||||
(40, "5"),
|
||||
(41, "0.8"),
|
||||
(1, "ALIGNED"),
|
||||
(72, "3"),
|
||||
(11, "90"),
|
||||
(21, "10"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "25"),
|
||||
(40, "5"),
|
||||
(1, "FIT BETWEEN"),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "50"),
|
||||
(40, "5"),
|
||||
(41, "0.8"),
|
||||
(1, "ALIGNED"),
|
||||
];
|
||||
let o = ImportOptions { center: false, ..ImportOptions::default() };
|
||||
let doc = open(&drawing(&[(9, "$INSUNITS"), (70, "4")], &[], &[], &e), &o).document;
|
||||
let [left, right, fit, aligned, fit_natural, aligned_natural] = &leaves(&doc)[..] else { panic!() };
|
||||
let (x10, x90) = (bounds(left).center().x, bounds(right).center().x);
|
||||
for n in [fit, aligned] {
|
||||
let (a, b) = baseline_ends(text_of(n));
|
||||
assert!((a.x - x10).abs() < 1e-3 && (b.x - x90).abs() < 1e-3, "{a:?} to {b:?}, not {x10} to {x90}");
|
||||
assert!((a.y - b.y).abs() < 1e-9, "level");
|
||||
}
|
||||
// Fit keeps its height and stretches across.
|
||||
let (st, natural) = (text_of(fit).first_style(), text_of(fit_natural).first_style());
|
||||
assert_eq!(st.size, natural.size);
|
||||
assert!(st.h_scale > 100.0, "{}", st.h_scale);
|
||||
// Aligned grows as a whole, its width factor kept.
|
||||
let (a, b) = baseline_ends(text_of(aligned_natural));
|
||||
let k = (x90 - x10) / (b.x - a.x);
|
||||
let (st, natural) = (text_of(aligned).first_style(), text_of(aligned_natural).first_style());
|
||||
assert!((st.size - natural.size * k).abs() < 1e-6 && k > 2.0, "{} vs {} × {k}", st.size, natural.size);
|
||||
assert!((st.h_scale - 80.0).abs() < 1e-9 && (natural.h_scale - 80.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_aligned_text_without_a_second_point_keep_their_size() {
|
||||
for h in ["3", "5"] {
|
||||
let e = [
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "10"),
|
||||
(40, "7"),
|
||||
(1, "Gap"),
|
||||
(72, h),
|
||||
(0, "TEXT"),
|
||||
(10, "10"),
|
||||
(20, "30"),
|
||||
(40, "7"),
|
||||
(1, "Gap"),
|
||||
(72, h),
|
||||
(11, "10"),
|
||||
(21, "30"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let top = height(&doc);
|
||||
for (n, y) in leaves(&doc).iter().zip([10.0, 30.0]) {
|
||||
let t = text_of(n);
|
||||
let st = t.first_style();
|
||||
assert!((st.size - 10.0).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
|
||||
assert!((t.xf * Point::ZERO).distance(Point::new(10.0, top - y)) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_aligned_text_scale_within_limits() {
|
||||
// A span a billion units long and one a millionth of a unit long.
|
||||
let e = [
|
||||
(0, "TEXT"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(40, "7"),
|
||||
(1, "X"),
|
||||
(72, "5"),
|
||||
(11, "1e9"),
|
||||
(21, "0"),
|
||||
(0, "TEXT"),
|
||||
(10, "0"),
|
||||
(20, "10"),
|
||||
(40, "7"),
|
||||
(1, "X"),
|
||||
(72, "3"),
|
||||
(11, "1e-6"),
|
||||
(21, "10"),
|
||||
];
|
||||
let doc = open(&entities(&e), &points()).document;
|
||||
let [fit, aligned] = &leaves(&doc)[..] else { panic!() };
|
||||
let st = text_of(fit).first_style();
|
||||
assert!((st.size - 10.0).abs() < 1e-9 && (st.h_scale - 10_000.0).abs() < 1e-6, "{} at {}%", st.size, st.h_scale);
|
||||
let st = text_of(aligned).first_style();
|
||||
assert!((st.size - 0.1).abs() < 1e-9 && st.h_scale == 100.0, "{} at {}%", st.size, st.h_scale);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_attributes_span_their_two_points() {
|
||||
let inserts = [
|
||||
(0, "INSERT"),
|
||||
(66, "1"),
|
||||
(2, "Door"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "ATTRIB"),
|
||||
(10, "10"),
|
||||
(20, "20"),
|
||||
(40, "7"),
|
||||
(1, "D-01"),
|
||||
(2, "TAG"),
|
||||
(70, "0"),
|
||||
(72, "5"),
|
||||
(74, "0"),
|
||||
(11, "90"),
|
||||
(21, "20"),
|
||||
(0, "SEQEND"),
|
||||
];
|
||||
let doc = open(&with_door(DOOR_LINE, &inserts), &points()).document;
|
||||
let tag = leaves(&doc).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
|
||||
let (a, b) = baseline_ends(text_of(&tag));
|
||||
assert!((a.x - 10.0).abs() < 1e-3 && (b.x - 90.0).abs() < 1e-3, "{a:?} to {b:?}");
|
||||
assert_eq!(text_of(&tag).first_style().size, 10.0);
|
||||
}
|
||||
|
||||
/// A block "Door" (base point 5, 5) holding `block_art`, and `inserts`.
|
||||
fn with_door(block_art: &[(i32, &'static str)], inserts: &[(i32, &'static str)]) -> Vec<u8> {
|
||||
let mut blocks = vec![(0, "BLOCK"), (8, "0"), (2, "Door"), (70, "0"), (10, "5"), (20, "5"), (3, "Door")];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+23
-6
@@ -20,15 +20,18 @@ pub struct Hit {
|
||||
pub leaf: NodeId,
|
||||
pub ancestry: Vec<NodeId>,
|
||||
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<NodeId>,
|
||||
}
|
||||
|
||||
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>) -> 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<NodeId>| 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<Hit> {
|
||||
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<Hit> {
|
||||
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<NodeId
|
||||
if !c.visible || c.locked {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = c.reach_bounds()
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !edits_contents(c)
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
continue;
|
||||
@@ -107,7 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => 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;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
@@ -19,8 +20,10 @@ 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;
|
||||
@@ -65,7 +68,9 @@ 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};
|
||||
@@ -563,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<ExportAsset>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
pub puppet: Option<Arc<PuppetPins>>,
|
||||
}
|
||||
|
||||
fn ppi72() -> f64 {
|
||||
@@ -630,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))));
|
||||
|
||||
+603
-428
File diff suppressed because it is too large.
Load diff
+26
-5
@@ -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),
|
||||
@@ -340,6 +348,9 @@ pub struct Node {
|
||||
/// [`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<Box<crate::PerspectiveAttachment>>,
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
@@ -389,6 +400,7 @@ impl Node {
|
||||
attrs: None,
|
||||
slice: None,
|
||||
bbox_angle: 0.0,
|
||||
perspective: None,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
@@ -522,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(),
|
||||
}
|
||||
@@ -581,6 +593,8 @@ impl Node {
|
||||
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();
|
||||
@@ -632,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 { .. } => {}
|
||||
|
||||
@@ -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<Homography> {
|
||||
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<Homography> {
|
||||
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<Rect>) -> Option<Rect> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
/// The pins on some objects and the shape they warp from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PuppetPins {
|
||||
/// The warped objects.
|
||||
pub ids: Vec<NodeId>,
|
||||
/// 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<Arc<Node>>,
|
||||
/// 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<Arc<Node>>,
|
||||
pub pins: Vec<PuppetPin>,
|
||||
}
|
||||
|
||||
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<Node>> {
|
||||
fn find(nodes: &[Arc<Node>], id: NodeId) -> Option<&Arc<Node>> {
|
||||
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]));
|
||||
}
|
||||
}
|
||||
@@ -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 }],
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
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};
|
||||
@@ -44,6 +44,7 @@ pub fn needs_bake(n: &Node) -> bool {
|
||||
|| 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)))
|
||||
}
|
||||
@@ -92,7 +93,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
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,
|
||||
@@ -281,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<Node> {
|
||||
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<Document> {
|
||||
|
||||
@@ -34,6 +34,7 @@ mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod live;
|
||||
mod marks;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
@@ -52,12 +53,13 @@ use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
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_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
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};
|
||||
|
||||
@@ -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<Node> {
|
||||
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<R>(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<Node> {
|
||||
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))
|
||||
}
|
||||
@@ -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<Node> {
|
||||
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)
|
||||
@@ -146,6 +146,10 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
_ => 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 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
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()
|
||||
|
||||
@@ -16,7 +16,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, Shape as _};
|
||||
use vectorcraft_geom::{FillRule, Point, Rect, Shape as _};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_tools::builder::{
|
||||
self as b, EDGE_NAME, FACE_NAME, LIVE_PAINT_NAME, SOURCES_NAME, edge_near, face_at, is_live_paint, sample_polyline, shapes_for, sorted_roots,
|
||||
@@ -205,52 +205,6 @@ fn stroke_only(paint: Paint, width: f64) -> Appearance {
|
||||
Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(paint, width))], ..Default::default() }
|
||||
}
|
||||
|
||||
/// A point strictly inside a filled path (for re-finding faces after a rebuild).
|
||||
pub(crate) fn interior_point(path: &PathData) -> Option<Point> {
|
||||
let bb = path.bounds()?;
|
||||
let bp = path.to_bezpath();
|
||||
let mut edges: Vec<(Point, Point)> = vec![];
|
||||
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
|
||||
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
|
||||
kurbo::PathEl::MoveTo(p) => {
|
||||
if last != first {
|
||||
edges.push((last, first));
|
||||
}
|
||||
first = p;
|
||||
last = p;
|
||||
}
|
||||
kurbo::PathEl::LineTo(p) => {
|
||||
edges.push((last, p));
|
||||
last = p;
|
||||
}
|
||||
kurbo::PathEl::ClosePath => {
|
||||
if last != first {
|
||||
edges.push((last, first));
|
||||
}
|
||||
last = first;
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if last != first {
|
||||
edges.push((last, first));
|
||||
}
|
||||
let mut best: Option<(f64, Point)> = None;
|
||||
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
|
||||
let y = bb.y0 + bb.height() * f;
|
||||
let mut xs: Vec<f64> =
|
||||
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for w in xs.windows(2) {
|
||||
let m = Point::new((w[0] + w[1]) / 2.0, y);
|
||||
let width = w[1] - w[0];
|
||||
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
|
||||
best = Some((width, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
// ---------- Shape Builder ----------
|
||||
|
||||
fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<Paint>> {
|
||||
@@ -395,7 +349,11 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
src_group.name = Some(SOURCES_NAME.into());
|
||||
src_group.visible = false;
|
||||
children.push(Arc::new(src_group));
|
||||
let regs = po::regions(&shapes);
|
||||
// Every path is an edge, open ones included; faces are the areas they enclose.
|
||||
let (regs, edges) = po::live_paint(&shapes);
|
||||
if regs.is_empty() && edges.is_empty() {
|
||||
return Err(EngineError::Other("Live Paint: these paths are too degenerate to paint".into()));
|
||||
}
|
||||
let nfaces = regs.len();
|
||||
for r in regs {
|
||||
let id = d.alloc_id();
|
||||
@@ -404,10 +362,9 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
|
||||
n.name = Some(FACE_NAME.into());
|
||||
children.push(Arc::new(n));
|
||||
}
|
||||
let edges = po::pathfinder(po::PathfinderOp::Outline, &shapes);
|
||||
let nedges = edges.len();
|
||||
for e in edges {
|
||||
let (_, stroke, w) = &styles[e.key as usize];
|
||||
let Some((_, stroke, w)) = styles.get(e.key as usize) else { continue };
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, e.path, stroke_only(stroke.clone(), *w));
|
||||
n.name = Some(EDGE_NAME.into());
|
||||
@@ -451,7 +408,7 @@ fn lp_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let keep_faces: Vec<(Point, Paint)> = b::faces(&old)
|
||||
.iter()
|
||||
.filter(|f| !f.appearance.fill_paint().is_none())
|
||||
.filter_map(|f| Some((interior_point(f.path_data()?)?, f.appearance.fill_paint())))
|
||||
.filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint())))
|
||||
.collect();
|
||||
let keep_edges: Vec<(Point, Appearance)> = b::edges(&old)
|
||||
.iter()
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
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);
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
_ => 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<NodeId> = 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<Vec<Point>> {
|
||||
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<NodeId> {
|
||||
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<Vec<Sample>> {
|
||||
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<NodeId>,
|
||||
}
|
||||
|
||||
/// 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<Document>,
|
||||
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<usize>,
|
||||
/// Which of `targets.paths` and `targets.blocked` the brush reached.
|
||||
reached: (Vec<bool>, Vec<bool>),
|
||||
}
|
||||
|
||||
impl LiquifyStroke {
|
||||
fn new(base: Arc<Document>, 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<u64>, Option<String>) {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()));
|
||||
}
|
||||
// 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<Vec<NodeId>>) -> Result<Vec<NodeId>> {
|
||||
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<Value> {
|
||||
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<Option<f64>> = 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::<Vec<_>>() });
|
||||
if rest {
|
||||
let pins: Vec<PuppetPin> = 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<arap::Pin> = pins.iter().zip(&moved).map(|(a, b)| arap::Pin { rest: *a, target: *b }).collect();
|
||||
let pins: Vec<arap::Pin> = 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<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
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<Value> {
|
||||
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<PerspectiveGrid> {
|
||||
pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> Result<PerspectiveGrid> {
|
||||
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<Persp
|
||||
}
|
||||
|
||||
fn grid_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Vec<NodeId>> {
|
||||
}
|
||||
|
||||
/// 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<Point>) -> 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<Homography>) -> 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Plane>,
|
||||
copy: bool,
|
||||
step: PerspStep,
|
||||
) -> Result<(Vec<NodeId>, Option<Attached>)> {
|
||||
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::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<NodeId> = 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::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>(), "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<Value> {
|
||||
@@ -580,11 +1053,17 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
/// 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<Value> {
|
||||
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<Document>| {
|
||||
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;
|
||||
}
|
||||
@@ -116,10 +116,21 @@ fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A typing session in progress (the Type tool previews the whole session as one interaction).
|
||||
fn typing_in_progress(s: &Session) -> bool {
|
||||
s.active().and_then(|d| d.interaction.as_ref()).is_some_and(|it| it.label == "Typing" && it.preview.is_some())
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
// 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.
|
||||
// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow
|
||||
// the document.
|
||||
let typing = typing_in_progress(s);
|
||||
s.commit_interaction()?;
|
||||
if typing {
|
||||
s.set_tool_option("endTyping", &Value::Bool(true));
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" 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}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" 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; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
|
||||
let gid = s.edit(&label, |d, sel| {
|
||||
let gid = super::object::group_nodes(d, &loose)?;
|
||||
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
|
||||
}
|
||||
|
||||
/// The selected objects a Pathfinder effect should group before it applies: it combines the
|
||||
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
|
||||
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
|
||||
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let roots = super::appearance::subject_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
|
||||
Ok((roots.len() >= 2 && !grouped).then_some(roots))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let catalog: Vec<Value> = effects::effect_catalog()
|
||||
.into_iter()
|
||||
@@ -507,6 +529,34 @@ mod tests {
|
||||
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_loose_objects_groups_them_first() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert_eq!(r["grouped"], true);
|
||||
let g = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
|
||||
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
// Live: one united shape, so expanding leaves a single path.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 1);
|
||||
// Grouping and applying are one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(g).is_none());
|
||||
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
|
||||
// A selection holding a group applies to each object as before; so does a single object.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert!(r.get("grouped").is_none());
|
||||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
|
||||
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
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, _| {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing}] sorted by name",
|
||||
"{selectionOnly?} → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family, style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)",
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), as the font menus list them (without the system's hidden families, whose names start with a dot; they still resolve by name); with family: {family, styles: [names]} (upright styles by weight, then italics; an error when the family isn't available)",
|
||||
always,
|
||||
font_list
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)",
|
||||
"{} 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 listed, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
@@ -96,12 +96,12 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let families = db.families();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>)> = BTreeMap::new();
|
||||
let mut found: BTreeMap<(String, String), (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||||
for id in ids {
|
||||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone())).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
if !e.1.contains(&id) {
|
||||
e.1.push(id);
|
||||
}
|
||||
@@ -109,10 +109,20 @@ fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style), (runs, objects))| {
|
||||
let have_family = families.iter().any(|f| f.eq_ignore_ascii_case(&family));
|
||||
let missing = !have_family || !db.styles(&family).iter().any(|st| st.eq_ignore_ascii_case(&style));
|
||||
json!({ "family": family, "style": style, "runs": runs, "objects": objects.len(), "missing": missing })
|
||||
.map(|((family, style), (runs, objects, chars))| {
|
||||
let resolved = db.resolve(&family, &style);
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
"status": status.as_str(),
|
||||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||||
"missingGlyphs": lacking.len(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(list))
|
||||
@@ -130,9 +140,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ff, fs) = font_param(p, "from", C)?;
|
||||
let (tf, ts) = font_param(p, "to", C)?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
if !db.families().iter().any(|f| f.eq_ignore_ascii_case(&tf)) {
|
||||
return Err(bad(C, format!("font family `{tf}` is not available")));
|
||||
}
|
||||
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
|
||||
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
|
||||
};
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let matches = |st: &vectorcraft_doc::CharStyle| {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
@@ -196,7 +208,7 @@ fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
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() })),
|
||||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +241,7 @@ fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.family_list().len(), "faces": faces }))
|
||||
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -261,6 +273,39 @@ mod tests {
|
||||
mod open_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn list_says_how_each_font_resolved() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
for (text, font, style) in
|
||||
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black"), ("Gone", "No Such Font Family", "Regular")]
|
||||
{
|
||||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
|
||||
}
|
||||
let list = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let rows: Vec<(String, String, String, String)> = list
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| {
|
||||
(
|
||||
f["style"].as_str().unwrap().into(),
|
||||
f["status"].as_str().unwrap().into(),
|
||||
f["resolved"]["family"].as_str().unwrap().into(),
|
||||
f["resolved"]["style"].as_str().unwrap().into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap();
|
||||
assert_eq!(row("Semibold").1, "exact");
|
||||
assert_eq!(row("Black").1, "substitute", "{rows:?}");
|
||||
assert_eq!(row("Black").2, "Source Sans 3");
|
||||
assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY));
|
||||
s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap();
|
||||
let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap();
|
||||
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_documents_have_exact_text_bounds() {
|
||||
let mut s = Session::new();
|
||||
|
||||
+738
-124
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,7 @@ mod path;
|
||||
mod pathops;
|
||||
mod patterncmds;
|
||||
pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
mod place;
|
||||
pub mod plugin;
|
||||
pub mod prefscmds;
|
||||
@@ -250,6 +251,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(plugin::specs());
|
||||
v.extend(cut::specs());
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, 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::<Vec<_>>() }))
|
||||
}
|
||||
@@ -299,6 +300,9 @@ fn shear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn transform_again(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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 }))
|
||||
@@ -371,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn group(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let gid = s.edit("Group", |d, sel| {
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
let gid = group_nodes(d, &ids)?;
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": gid.0 }))
|
||||
}
|
||||
|
||||
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
|
||||
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
@@ -661,6 +671,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
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;
|
||||
|
||||
@@ -197,20 +197,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<Value>,
|
||||
}
|
||||
|
||||
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<GridDefinition> {
|
||||
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<GridDefinition> {
|
||||
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<PerspectiveGrid> {
|
||||
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<usize> {
|
||||
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<usize>) -> 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<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
Ok(grid_info(doc, &grid_of(doc)))
|
||||
}
|
||||
|
||||
fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let rows: Vec<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Result<Vec<_>>>()?
|
||||
}
|
||||
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<Value> {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
view_option(s, p, |g| &mut g.locked)
|
||||
}
|
||||
|
||||
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.lock_station)
|
||||
}
|
||||
|
||||
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.snap)
|
||||
}
|
||||
|
||||
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Result<Vec<crate::UiRequest>>> {
|
||||
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<Plane> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -329,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<Value, String> {
|
||||
@@ -341,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::<vectorcraft_tools::distort::perspective::widget::WidgetOptions>(merged)
|
||||
.map(|o| json!(o))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
_ => Err(format!("unknown preference `{key}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
@@ -368,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
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 }],
|
||||
@@ -404,6 +405,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
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(),
|
||||
|
||||
@@ -13,6 +13,18 @@ use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -288,6 +315,23 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap();
|
||||
let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let wide = bounds(&s);
|
||||
assert!(wide.width() > wide.height());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.orientation.vertical", &json!({})).unwrap();
|
||||
let tall = bounds(&s);
|
||||
assert!(tall.height() > tall.width(), "{tall:?}");
|
||||
assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bounds(&s), wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_options_columns_change_layout() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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<NodeId>,
|
||||
pub isolation: Option<NodeId>,
|
||||
/// The perspective transform the previews make (`perspective.transform` params): Transform
|
||||
/// Again repeats it once the drag is committed.
|
||||
pub perspective_again: Option<Value>,
|
||||
}
|
||||
|
||||
/// Per-document editing state.
|
||||
@@ -131,6 +135,9 @@ pub struct DocState {
|
||||
pub recovery: Option<cmd::recovery::RecoveryCopy>,
|
||||
/// 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<Value>,
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -476,6 +484,26 @@ pub struct Prefs {
|
||||
/// 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<String, serde_json::Map<String, Value>>,
|
||||
/// 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<vectorcraft_tools::distort::perspective::GridDefinition>,
|
||||
/// 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<vectorcraft_doc::live::BlendDefaults>,
|
||||
/// 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 {
|
||||
@@ -609,6 +637,10 @@ impl Default for Prefs {
|
||||
web_export_settings: None,
|
||||
print_presets: vec![],
|
||||
constrain_proportions: false,
|
||||
tool_settings: Default::default(),
|
||||
perspective_presets: vec![],
|
||||
blend_options: None,
|
||||
perspective_widget: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,6 +708,14 @@ pub struct Session {
|
||||
/// 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<Box<cmd::distortcmds::LiquifyStroke>>,
|
||||
/// 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 {
|
||||
@@ -717,6 +757,9 @@ impl Session {
|
||||
batch_stash: None,
|
||||
recovery: Default::default(),
|
||||
in_tool_actions: false,
|
||||
envelope_defaults: None,
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,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() {
|
||||
@@ -1017,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(())
|
||||
}
|
||||
|
||||
@@ -1034,6 +1082,7 @@ impl Session {
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1062,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(())
|
||||
}
|
||||
@@ -1110,6 +1164,12 @@ 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;
|
||||
@@ -1156,6 +1216,12 @@ 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;
|
||||
@@ -1196,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;
|
||||
@@ -1234,6 +1302,14 @@ 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;
|
||||
@@ -1256,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;
|
||||
@@ -1302,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;
|
||||
@@ -1312,4 +1392,6 @@ mod tests_widthpoints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
@@ -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<Node> {
|
||||
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::<Vec<_>>(), "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;
|
||||
}
|
||||
@@ -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<NodeId>, 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::<Vec<_>>()})).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());
|
||||
}
|
||||
@@ -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<Node> {
|
||||
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<Point> = 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::<Vec<_>>();
|
||||
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<Point> = 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());
|
||||
}
|
||||
@@ -310,6 +310,56 @@ fn live_paint_bucket_tool_fills_face() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
|
||||
let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Crossing lines, as in the bug report: the Live Paint Bucket fills the area they enclose.
|
||||
#[test]
|
||||
fn live_paint_bucket_fills_an_area_enclosed_by_lines() {
|
||||
let mut s = session();
|
||||
let ids = [line(&mut s, (0.0, 0.0), (100.0, 100.0)), line(&mut s, (100.0, 0.0), (0.0, 100.0)), line(&mut s, (-10.0, 80.0), (110.0, 80.0))];
|
||||
s.execute("select.set", &json!({"ids": ids.map(|i| i.0)})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.paint.fill = Paint::solid(Color::rgb(1.0, 0.0, 1.0));
|
||||
// Inside the triangle (20,80) (50,50) (80,80): highlighted, then filled.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 70.0), v).unwrap();
|
||||
assert!(!s.overlays(v).is_empty(), "red face highlight");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap();
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
let gn = node(&s, g);
|
||||
assert!(vectorcraft_tools::builder::is_live_paint(&gn));
|
||||
assert_eq!(vectorcraft_tools::builder::faces(&gn).len(), 1);
|
||||
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(50.0, 70.0)).unwrap();
|
||||
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
|
||||
assert!((vectorcraft_pathops::area(f.path_data().unwrap(), FillRule::NonZero).abs() - 900.0).abs() < 1e-6);
|
||||
// The lines are edges, cut where they cross, and keep their stroke.
|
||||
let edges = vectorcraft_tools::builder::edges(&gn);
|
||||
assert_eq!(edges.len(), 9);
|
||||
assert!(edges.iter().all(|e| !e.appearance.stroke_paint().is_none()));
|
||||
// Outside the triangle there's nothing to fill.
|
||||
assert!(s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 20]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_across_a_live_paint_shape_splits_its_face() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
set_fill(&mut s, r, Color::rgb(1.0, 0.0, 0.0));
|
||||
let l = line(&mut s, (40.0, -20.0), (60.0, 70.0));
|
||||
s.execute("select.set", &json!({"ids": [r.0, l.0]})).unwrap();
|
||||
let made = s.execute("livePaint.make", &json!({})).unwrap();
|
||||
assert_eq!(made["faces"], 2, "{made}");
|
||||
let g = NodeId(made["id"].as_u64().unwrap());
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [80, 25], "color": "#0000ff"})).unwrap();
|
||||
let gn = node(&s, g);
|
||||
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(x, 25.0)).unwrap().appearance.fill_paint();
|
||||
assert_eq!(face(10.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the left half keeps the rectangle's fill");
|
||||
assert_eq!(face(80.0), Paint::solid(Color::from_hex("#0000ff").unwrap()));
|
||||
}
|
||||
|
||||
// ---------- Image Trace ----------
|
||||
|
||||
/// A 100×100 image with a black disc (r = 30 px), placed at (50, 50) scaled ×2.
|
||||
|
||||
@@ -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<Point> = 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;
|
||||
|
||||
@@ -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::<Vec<_>>()})).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<NodeId> = 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<_>>(), 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<u8> {
|
||||
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("<text"), "type in an envelope exports as its distorted outlines");
|
||||
}
|
||||
|
||||
/// Regression: on the canvas, type in an envelope was outlined in its first run's paint only.
|
||||
#[test]
|
||||
fn enveloped_type_keeps_each_runs_colour_on_the_canvas() {
|
||||
let mut s = session();
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "MMMMMMMM", "size": 60, "fill": "#000000"})).unwrap());
|
||||
s.edit("red half", |d, _| {
|
||||
if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind {
|
||||
vectorcraft_text::edit::style_range(&mut tx.runs, 0, 4, |st| {
|
||||
st.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[t]);
|
||||
s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 0})).unwrap();
|
||||
let b = node(&s, t).geometric_bounds().unwrap();
|
||||
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 800.0, 400.0), 1.0, true);
|
||||
// Inked pixels of each half of the line: red at the start, black at the end.
|
||||
let (mut red, mut black) = (0, 0);
|
||||
for y in (b.y0 as u32)..(b.y1 as u32) {
|
||||
for x in (b.x0 as u32)..(b.x1 as u32) {
|
||||
let p = img.pixel(x, y);
|
||||
let left = (x as f64) < b.center().x;
|
||||
if p[0] > 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");
|
||||
}
|
||||
@@ -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::<Vec<_>>()})).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<Node> {
|
||||
effects::expand_live(Some(&s.doc().unwrap().doc), &node(s, e))
|
||||
}
|
||||
|
||||
fn anchors(nodes: &[Node]) -> Vec<Point> {
|
||||
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("<use").count() >= 32 && svg.contains("clip"), "{}", &svg[..svg.len().min(400)]);
|
||||
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_bend_with_the_envelope() {
|
||||
let mut s = session();
|
||||
let inst = s
|
||||
.edit("symbol", |d, sel| {
|
||||
// Symbol art is normalised to ±10 (its instance's transform sizes it).
|
||||
let art = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
|
||||
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((200.0, 150.0)) * Affine::scale_non_uniform(10.0, 5.0);
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf }))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
sel(&mut s, &[inst]);
|
||||
let e = arch(&mut s, 60.0, json!({}));
|
||||
let pts = anchors(&evaluated(&s, e));
|
||||
assert!(pts.iter().any(|p| p.y < 90.0), "the symbol's art rose in the middle");
|
||||
// Editing the symbol redraws the envelope (the renderer's cache follows the symbol's art).
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let region = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] < 60);
|
||||
s.edit("edit symbol", |d, _| {
|
||||
let art = Node::path(NodeId(0), shapes::rectangle(Rect::new(-10.0, -10.0, -8.0, -8.0)), Default::default());
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(r.render_region(&s.doc().unwrap().doc, region, 1.0, true).pixel(200, 150)[1] > 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<vectorcraft_color::GradientGeom> {
|
||||
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));
|
||||
}
|
||||
@@ -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<Point>, 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<Point> = (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<Point> = (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);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,14 @@ fn names(v: &Value) -> Vec<String> {
|
||||
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());
|
||||
assert_eq!(fams, *FontDb::global().menu_family_list());
|
||||
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<String> = installed.families().into_iter().filter(|f| !fams.contains(f)).collect();
|
||||
let missing: Vec<String> = installed.menu_family_list().iter().filter(|f| !fams.contains(f)).cloned().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");
|
||||
@@ -38,7 +38,7 @@ fn refreshing_the_font_list_redraws_type_without_editing_the_document() {
|
||||
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_eq!(r["families"], json!(FontDb::global().menu_family_list().len()));
|
||||
assert!(r["faces"].is_u64());
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.revision > revision, "the canvas redraws");
|
||||
|
||||
@@ -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<Value> = 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<Value> = (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<u64> = 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<Value> = (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<Value> = 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));
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Point> = 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));
|
||||
}
|
||||
@@ -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<Point> {
|
||||
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());
|
||||
}
|
||||
@@ -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<Point> = 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());
|
||||
}
|
||||
@@ -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<String> {
|
||||
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::<Prefs>(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<Point> {
|
||||
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());
|
||||
}
|
||||
@@ -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<Point> {
|
||||
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");
|
||||
}
|
||||
@@ -157,6 +157,98 @@ fn undo_during_a_drag_takes_back_only_the_drag() {
|
||||
assert_eq!(b.x0, 100.0, "back where it was: {b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_composition_shows_in_place_and_commits_as_one_step() {
|
||||
// Romaji → kana → conversion → commit, as the macOS Japanese IME sends 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];
|
||||
let created = undo_labels(&s).len();
|
||||
s.tool_text("曲:", v).unwrap();
|
||||
for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] {
|
||||
s.tool_preedit(t, Some(r), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), format!("曲:{t}"), "marked text lays out in place");
|
||||
assert!(s.tool_composing());
|
||||
}
|
||||
// Conversion: the active clause is the whole word.
|
||||
s.tool_preedit("雅楽", Some(0..2), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
|
||||
assert_eq!(s.tool_options()["composing"], json!(true));
|
||||
// macOS clears the marked text, then commits.
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
s.tool_text("雅楽", v).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽");
|
||||
assert_eq!(s.tool_options()["caret"], json!("曲:雅楽".len()));
|
||||
// Typing goes on after the commit, in the same session.
|
||||
s.tool_text("。", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape, Mods::default());
|
||||
assert_eq!(obj(&s, id).plain_text(), "曲:雅楽。");
|
||||
assert_eq!(undo_labels(&s).len(), created + 1, "one undo step for the session");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = text(&mut s, "雅楽");
|
||||
s.select_tool("type", v).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let (a, b) = vectorcraft_text::caret_position(&lay, "雅楽".len());
|
||||
let p = t.xf * a.midpoint(b);
|
||||
click(&mut s, p.x - 0.1, p.y);
|
||||
assert!(s.tool_wants_text());
|
||||
s.set_tool_option("select", &json!({"start": 6, "end": 6}));
|
||||
let before = undo_labels(&s).len();
|
||||
s.tool_preedit("えんそう", Some(4..4), v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽えんそう");
|
||||
// Escape in the IME: the marked text goes, nothing else changes.
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert!(!s.in_interaction(), "a cancelled composition leaves no typing session");
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽");
|
||||
assert_eq!(undo_labels(&s).len(), before);
|
||||
// A stray clear (no composition) never deletes the selection.
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 3}));
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "雅楽");
|
||||
// A composition over a selection replaces it.
|
||||
s.tool_preedit("が", Some(1..1), v).unwrap();
|
||||
s.tool_preedit("", None, v).unwrap();
|
||||
s.tool_text("我", v).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "我楽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_away_keeps_marked_text_as_typed_and_undo_mid_composition_is_safe() {
|
||||
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_preedit("しょうこ", Some(4..4), v).unwrap();
|
||||
// Keys belong to the IME while it composes.
|
||||
key(&mut s, ToolKey::Backspace, Mods::default());
|
||||
assert_eq!(obj(&s, id).plain_text(), "しょうこ");
|
||||
click(&mut s, 600.0, 500.0);
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id).plain_text(), "しょうこ", "the marked text stays, committed");
|
||||
// The UI holds Undo back while composing; if it comes anyway, the session is committed first.
|
||||
click(&mut s, 50.0, 50.0);
|
||||
let id2 = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_preedit("ひちりき", Some(4..4), v).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!s.tool_composing());
|
||||
assert_eq!(obj(&s, id2).plain_text(), "");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(obj(&s, id2).plain_text(), "ひちりき");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_selection_replace_and_delete() {
|
||||
let mut s = session();
|
||||
@@ -378,3 +470,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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
|
||||
let members: Vec<NodeId> = [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<Vec<(f64, f64, f64)>> {
|
||||
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)]);
|
||||
}
|
||||
+163
-12
@@ -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<vectorcraft_tools::ScreenFrame>,
|
||||
}
|
||||
|
||||
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<dyn Tool> {
|
||||
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<R>(&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<Vec<UiRequest>> {
|
||||
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<Vec<UiRequest>> {
|
||||
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<Vec<UiRequest>> {
|
||||
// 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)
|
||||
@@ -136,19 +214,39 @@ impl Session {
|
||||
self.tool.wants_text()
|
||||
}
|
||||
|
||||
/// IME marked text for the active tool (see `Tool::ime_preedit`; `active_chars` counts
|
||||
/// characters).
|
||||
pub fn tool_preedit(&mut self, text: &str, active_chars: Option<std::ops::Range<usize>>, view: ViewInfo) -> Result<Vec<UiRequest>> {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.ime_preedit(cx, text, active_chars));
|
||||
self.take_tool_panic()?;
|
||||
self.apply_actions(acts)
|
||||
}
|
||||
|
||||
/// Is the active tool showing uncommitted IME text?
|
||||
pub fn tool_composing(&self) -> bool {
|
||||
self.tool.composing()
|
||||
}
|
||||
|
||||
/// The caret line (document space) the IME candidate window follows.
|
||||
pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(Point, Point)> {
|
||||
self.with_tool_cx(view, |t, cx| t.ime_caret(cx))
|
||||
}
|
||||
|
||||
pub fn tool_busy(&self) -> bool {
|
||||
self.tool.busy()
|
||||
}
|
||||
|
||||
/// 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<Overlay> {
|
||||
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,6 +260,47 @@ 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<String, Value>) -> 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<Value, String> {
|
||||
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`]):
|
||||
@@ -190,13 +329,25 @@ impl Session {
|
||||
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)?;
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>().join("\n"));
|
||||
}
|
||||
@@ -1097,7 +1097,7 @@ fn emf_of(records: &[(u32, Vec<u8>)]) -> Vec<u8> {
|
||||
/// A placeable WMF of a 1 × 1 inch box holding `records`.
|
||||
fn wmf_of(records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut b: Vec<u8> = 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))?;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Point> {
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Affine> {
|
||||
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<Rect> {
|
||||
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<Rect> = 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ToolKey> {
|
||||
"[" | "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::<Vec<_>>()).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())?;
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -127,6 +127,8 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"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(),
|
||||
|
||||
@@ -92,7 +92,7 @@ pub(crate) fn rgb24(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
|
||||
// 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<u8>, Vec<u8>) {
|
||||
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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<Vec<_>>())
|
||||
String::from_utf16_lossy(&raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect::<Vec<_>>())
|
||||
} else {
|
||||
slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect()
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# vectorcraft-pathops
|
||||
|
||||
Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`.
|
||||
Path operations for VectorCraft: booleans, the Pathfinder panel, Shape Builder regions, Live Paint planar maps, offset path, outline stroke, simplify and the other Object → Path commands. Everything takes and returns `vectorcraft_geom::PathData`.
|
||||
|
||||
## Booleans (curve-preserving)
|
||||
|
||||
@@ -49,6 +49,15 @@ Each result takes its key as follows:
|
||||
| Crop | Keeps the visible parts of the lower objects inside the front-most object |
|
||||
| Outline | Returns open edge paths, split at every junction; coincident edges are de-duplicated, and each keeps the front-most key |
|
||||
|
||||
## Live Paint
|
||||
|
||||
```rust
|
||||
pub fn live_paint(shapes: &[Shape] /* back → front */) -> (Vec<Region>, Vec<Shape>); // (faces, edges)
|
||||
pub fn interior_point(path: &PathData) -> Option<Point>;
|
||||
```
|
||||
|
||||
Unlike `regions`, which splits the filled areas of closed shapes, `live_paint` treats every path as an edge, open paths included. Three crossing lines enclose a triangle, a line across a rectangle splits it in two, and an area enclosed by paths becomes a face even when nothing fills it. Each face lists the inputs whose fill covers it; the list is empty for an area that is only enclosed by paths. Each edge is a piece of a path, split wherever another path meets it, and keeps the key of the front-most input it lies on.
|
||||
|
||||
## Offset / stroke
|
||||
|
||||
```rust
|
||||
|
||||
@@ -157,7 +157,7 @@ fn el_points(el: &kurbo::PathEl) -> impl Iterator<Item = Point> {
|
||||
/// handle within `eps` of its anchor's height is levelled with it (no y-extremum is left in a
|
||||
/// sub-`eps` sliver of a curve). Anchors move by less than `eps` and handles by less than `2·eps`,
|
||||
/// the order of the sweep's own tolerance.
|
||||
fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
|
||||
pub(crate) fn snap_horizontals(paths: &mut [&mut BezPath], eps: f64) {
|
||||
use kurbo::PathEl::*;
|
||||
let mut ys: Vec<f64> = paths.iter().flat_map(|p| p.elements().iter().filter_map(kurbo::PathEl::end_point)).map(|p| p.y).collect();
|
||||
ys.sort_by(f64::total_cmp);
|
||||
@@ -339,7 +339,7 @@ pub(crate) fn all_contours_to_path(c: &Contours, tidy: &Tidy) -> PathData {
|
||||
/// Contours whose mean width (2·area / perimeter) is below `precision` are numerical debris
|
||||
/// (e.g. the lens left between two coincident curves) and are dropped, like Illustrator's
|
||||
/// Pathfinder precision setting does.
|
||||
fn is_sliver(bp: &BezPath, precision: f64) -> bool {
|
||||
pub(crate) fn is_sliver(bp: &BezPath, precision: f64) -> bool {
|
||||
let a = bp.area().abs();
|
||||
let per = bp.perimeter(1e-6);
|
||||
per <= 0.0 || 2.0 * a / per < precision
|
||||
|
||||
@@ -11,6 +11,7 @@ mod edit;
|
||||
mod fit;
|
||||
mod offset;
|
||||
mod pathfinder;
|
||||
mod planar;
|
||||
|
||||
pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all};
|
||||
pub use edit::{
|
||||
@@ -18,6 +19,7 @@ pub use edit::{
|
||||
};
|
||||
pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region};
|
||||
pub use pathfinder::{PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions};
|
||||
pub use planar::{interior_point, live_paint};
|
||||
|
||||
/// Errors from fallible operations.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
|
||||
@@ -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<Shape> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Region> {
|
||||
let mut out = Vec::new();
|
||||
for mask in arr.distinct_masks() {
|
||||
let c = arr.contours(|m| m == mask.as_slice());
|
||||
let sources: Vec<usize> = 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<usize>> = 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<PathData>> = 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<Vec<usize>> = 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<usize> = 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<Region> {
|
||||
@@ -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<Region> {
|
||||
let mut out = Vec::new();
|
||||
for mask in arr.distinct_masks() {
|
||||
let c = arr.contours(|m| m == mask.as_slice());
|
||||
let sources: Vec<usize> = 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<Value = Shape> {
|
||||
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<Point> = pts.into_iter().map(|(x, y)| Point::new(x, y)).collect();
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
}),
|
||||
];
|
||||
(path, any::<bool>(), any::<u64>())
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
//! Live Paint planar maps: the faces and edges a set of paths divides the plane into.
|
||||
//!
|
||||
//! Unlike Pathfinder [`regions`](crate::regions), which splits the *filled areas* of closed shapes,
|
||||
//! every path here is an edge, open ones included: three crossing lines enclose a triangle, a line
|
||||
//! across a rectangle splits it in two, and an area enclosed by paths is a face whether or not
|
||||
//! anything fills it.
|
||||
//!
|
||||
//! The sweep (`linesweeper`) only takes closed paths, so an open path goes in traced there and
|
||||
//! back. Its two passes would cancel out as winding numbers, so the sweep counts boundary crossings
|
||||
//! instead ([`Crossings`]), which never cancel. Faces are then walked on the planar graph of the
|
||||
//! sweep's output segments; which inputs an edge came from and which fills cover a face are found
|
||||
//! geometrically (the sweep's winding numbers can't be trusted once open paths and coincident
|
||||
//! edges are in).
|
||||
//!
|
||||
//! The sweep runs in a sheared frame ([`SHEAR`]): it merges the two passes of a *horizontal* open
|
||||
//! path as if they cancelled out, and in the sheared frame no edge drawn at a common angle (0°,
|
||||
//! 45°, 90°…) is horizontal. The shear is by a power of two, so it maps typical coordinates
|
||||
//! exactly and keeps exact coincidences (a corner on another shape's edge) exact.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{Affine, BezPath, CubicBez, ParamCurve, PathSeg, Point, Shape as _};
|
||||
use linesweeper::topology::{Topology, WindingNumber};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::boolean::{DEFAULT_PRECISION, Seg, Tidy, eps_for, is_sliver, segs_to_subpath, snap_horizontals, tidy_segments, to_seg};
|
||||
use crate::pathfinder::{Region, Shape};
|
||||
|
||||
/// The sweep's frame: y + x / 32. Only edges sloping -1/32 in the document are horizontal in it.
|
||||
const SHEAR: Affine = Affine::new([1.0, 1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
const UNSHEAR: Affine = Affine::new([1.0, -1.0 / 32.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
|
||||
/// Boundary crossings per input, counted from the far west: slot `2i` counts input `i`'s
|
||||
/// segments running one way, slot `2i + 1` the other. An open path traced there and back adds one
|
||||
/// to each, so its edges never cancel out of the map.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Crossings(Vec<u32>);
|
||||
|
||||
impl Crossings {
|
||||
fn get(&self, slot: usize) -> u32 {
|
||||
self.0.get(slot).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Crossings {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
(0..self.0.len().max(other.0.len())).all(|i| self.get(i) == other.get(i))
|
||||
}
|
||||
}
|
||||
impl Eq for Crossings {}
|
||||
|
||||
impl std::ops::Add for Crossings {
|
||||
type Output = Crossings;
|
||||
fn add(mut self, rhs: Self) -> Crossings {
|
||||
self += rhs;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::AddAssign for Crossings {
|
||||
fn add_assign(&mut self, rhs: Self) {
|
||||
if self.0.len() < rhs.0.len() {
|
||||
self.0.resize(rhs.0.len(), 0);
|
||||
}
|
||||
for (a, b) in self.0.iter_mut().zip(rhs.0) {
|
||||
*a = a.saturating_add(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WindingNumber for Crossings {
|
||||
type Tag = usize;
|
||||
fn single(tag: usize, positive: bool) -> Self {
|
||||
let slot = 2 * tag + usize::from(!positive);
|
||||
let mut v = vec![0; slot + 1];
|
||||
if let Some(c) = v.get_mut(slot) {
|
||||
*c = 1;
|
||||
}
|
||||
Crossings(v)
|
||||
}
|
||||
fn of_tag(&self, tag: usize) -> Self {
|
||||
let mut v = vec![0; 2 * tag + 2];
|
||||
for slot in [2 * tag, 2 * tag + 1] {
|
||||
if let Some(c) = v.get_mut(slot) {
|
||||
*c = self.get(slot);
|
||||
}
|
||||
}
|
||||
Crossings(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in the middle of a run of segments.
|
||||
fn mid(segs: &[Seg]) -> Option<Point> {
|
||||
segs.get(segs.len() / 2).map(|s| s.c.eval(0.5))
|
||||
}
|
||||
|
||||
/// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on.
|
||||
struct Outlines {
|
||||
segs: Vec<(kurbo::Rect, PathSeg, usize)>,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
impl Outlines {
|
||||
fn new(inputs: &[(BezPath, usize)], eps: f64) -> Self {
|
||||
let segs = inputs.iter().flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))).collect();
|
||||
// The sweep moves points by a few `eps` at most.
|
||||
Self { segs, tol: 16.0 * eps }
|
||||
}
|
||||
|
||||
/// The front-most input whose outline passes through `p`.
|
||||
fn source_at(&self, p: Point) -> Option<usize> {
|
||||
use kurbo::ParamCurveNearest;
|
||||
let tol = self.tol;
|
||||
self.segs.iter().filter(|(r, s, _)| r.inflate(tol, tol).contains(p) && s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, _, i)| *i).max()
|
||||
}
|
||||
}
|
||||
|
||||
/// The area an input's fill covers: its closed subpaths (open ones bound no face).
|
||||
struct Fill {
|
||||
path: BezPath,
|
||||
bounds: kurbo::Rect,
|
||||
rule: FillRule,
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
fn new(s: &Shape) -> Self {
|
||||
let mut path = BezPath::new();
|
||||
for sp in s.path.subpaths.iter().filter(|sp| sp.closed && sp.anchors.len() >= 2) {
|
||||
sp.to_bezpath_into(&mut path);
|
||||
}
|
||||
Self { bounds: path.bounding_box(), path, rule: s.rule }
|
||||
}
|
||||
|
||||
fn contains(&self, p: Point) -> bool {
|
||||
!self.path.elements().is_empty() && self.bounds.contains(p) && crate::boolean::inside(self.rule, self.path.winding(p))
|
||||
}
|
||||
}
|
||||
|
||||
/// One output segment of the sweep: an edge of the planar graph between two vertices.
|
||||
struct Edge {
|
||||
segs: Vec<Seg>,
|
||||
ends: [usize; 2],
|
||||
/// The input it came from (the front-most, where inputs share it).
|
||||
source: usize,
|
||||
}
|
||||
|
||||
/// Half-edge `2e` runs along edge `e`, `2e + 1` back along it.
|
||||
fn twin(h: usize) -> usize {
|
||||
h ^ 1
|
||||
}
|
||||
|
||||
/// The segments of half-edge `h`, in its direction.
|
||||
fn half_segs(edges: &[Edge], h: usize) -> Vec<Seg> {
|
||||
let Some(e) = edges.get(h / 2) else { return Vec::new() };
|
||||
if h.is_multiple_of(2) {
|
||||
e.segs.clone()
|
||||
} else {
|
||||
e.segs.iter().rev().map(|s| Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line }).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertex half-edge `h` leaves from.
|
||||
fn origin(edges: &[Edge], h: usize) -> Option<usize> {
|
||||
edges.get(h / 2).map(|e| e.ends[h % 2])
|
||||
}
|
||||
|
||||
/// Where half-edge `h` (leaving `v`) first gets `r` away from it, as an angle around `v`.
|
||||
///
|
||||
/// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however
|
||||
/// they start out (tangent curves, or the sweep's short straight stubs at a vertex).
|
||||
fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 {
|
||||
let segs = half_segs(edges, h);
|
||||
let mut bp = BezPath::new();
|
||||
for (k, s) in segs.iter().enumerate() {
|
||||
if k == 0 {
|
||||
bp.move_to(s.c.p0);
|
||||
}
|
||||
bp.curve_to(s.c.p1, s.c.p2, s.c.p3);
|
||||
}
|
||||
let mut prev = segs.first().map_or(v, |s| s.c.p0);
|
||||
let mut hit: Option<Point> = None;
|
||||
kurbo::flatten(bp.iter(), (r * 1e-4).max(1e-12), |el| {
|
||||
if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) {
|
||||
if p.distance(v) >= r {
|
||||
// The point on prev → p at distance r (prev is closer).
|
||||
let (d, f) = (p - prev, prev - v);
|
||||
let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r);
|
||||
let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
hit = Some(prev + d * t);
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
});
|
||||
// An edge too short to reach r (it can't, r is at most half of every chord) leaves along its
|
||||
// tangent.
|
||||
let far =
|
||||
hit.unwrap_or_else(|| segs.first().map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3)));
|
||||
(far - v).atan2()
|
||||
}
|
||||
|
||||
/// Path of a closed run of half-edges, tidied (split pieces of one input curve joined again).
|
||||
fn cycle_bezpath(edges: &[Edge], cycle: &[usize]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for (k, s) in cycle.iter().flat_map(|&h| half_segs(edges, h)).enumerate() {
|
||||
if k == 0 {
|
||||
bp.move_to(s.c.p0);
|
||||
}
|
||||
if s.line {
|
||||
bp.line_to(s.c.p3);
|
||||
} else {
|
||||
bp.curve_to(s.c.p1, s.c.p2, s.c.p3);
|
||||
}
|
||||
}
|
||||
bp.close_path();
|
||||
bp
|
||||
}
|
||||
|
||||
/// `cycle` without the spurs it runs out and back along (edges with the same face on both sides,
|
||||
/// such as a line's loose end).
|
||||
fn without_spurs(cycle: &[usize]) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(cycle.len());
|
||||
for &h in cycle {
|
||||
if out.last() == Some(&twin(h)) {
|
||||
out.pop();
|
||||
} else {
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
// A spur where the walk started wraps around the ends.
|
||||
while out.len() >= 2 && out.first().map(|&h| twin(h)) == out.last().copied() {
|
||||
out.pop();
|
||||
out.remove(0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Append `open` traced there and back (a closed, zero-area path along it) to `out`.
|
||||
fn there_and_back(open: &BezPath, out: &mut BezPath) {
|
||||
let segs: Vec<PathSeg> = open.segments().collect();
|
||||
let Some(first) = segs.first() else { return };
|
||||
out.move_to(first.start());
|
||||
for s in &segs {
|
||||
out.push(s.as_path_el());
|
||||
}
|
||||
for s in segs.iter().rev() {
|
||||
out.push(s.reverse().as_path_el());
|
||||
}
|
||||
out.close_path();
|
||||
}
|
||||
|
||||
/// `path` back from the sweep's frame, its anchors within `tol` of an input anchor (`sorted` by x)
|
||||
/// put exactly on it, handles moving with them.
|
||||
fn unshear(mut path: PathData, sorted: &[Point], tol: f64) -> PathData {
|
||||
path.transform(UNSHEAR);
|
||||
for a in path.subpaths.iter_mut().flat_map(|sp| sp.anchors.iter_mut()) {
|
||||
let lo = sorted.partition_point(|q| q.x < a.p.x - tol);
|
||||
if let Some(q) = sorted.get(lo..).unwrap_or_default().iter().take_while(|q| q.x <= a.p.x + tol).find(|q| (q.y - a.p.y).abs() <= tol) {
|
||||
let d = *q - a.p;
|
||||
a.p = *q;
|
||||
a.h_in += d;
|
||||
a.h_out += d;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn find(parent: &mut [usize], mut v: usize) -> usize {
|
||||
while let Some(&p) = parent.get(v) {
|
||||
if p == v {
|
||||
break;
|
||||
}
|
||||
let gp = parent.get(p).copied().unwrap_or(p);
|
||||
if let Some(slot) = parent.get_mut(v) {
|
||||
*slot = gp;
|
||||
}
|
||||
v = p;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The Live Paint planar map of `shapes` (back → front).
|
||||
///
|
||||
/// Faces are the bounded areas the paths enclose, holes included, each with the inputs whose fill
|
||||
/// covers it (ascending; empty for an area only enclosed by paths). Edges are the paths split
|
||||
/// wherever they meet another path, keyed by the input they came from (the front-most where
|
||||
/// inputs share one). Empty when the paths can't be swept (non-finite or too degenerate).
|
||||
pub fn live_paint(shapes: &[Shape]) -> (Vec<Region>, Vec<Shape>) {
|
||||
build(shapes).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build(shapes: &[Shape]) -> Option<(Vec<Region>, Vec<Shape>)> {
|
||||
let mut inputs: Vec<(BezPath, usize)> = Vec::new();
|
||||
for (i, s) in shapes.iter().enumerate() {
|
||||
let mut bp = BezPath::new();
|
||||
for sp in s.path.subpaths.iter().filter(|sp| sp.anchors.len() >= 2) {
|
||||
if sp.closed {
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
} else {
|
||||
let mut open = BezPath::new();
|
||||
sp.to_bezpath_into(&mut open);
|
||||
there_and_back(&open, &mut bp);
|
||||
}
|
||||
}
|
||||
if !bp.elements().is_empty() {
|
||||
bp.apply_affine(SHEAR);
|
||||
inputs.push((bp, i));
|
||||
}
|
||||
}
|
||||
if inputs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let eps = eps_for(inputs.iter().map(|p| &p.0)).ok()?;
|
||||
snap_horizontals(&mut inputs.iter_mut().map(|p| &mut p.0).collect::<Vec<_>>(), eps);
|
||||
// The sweep can panic on rare near-degenerate input; that must never take the app down.
|
||||
let sweep =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| Topology::<Crossings>::from_paths(inputs.iter().map(|(p, i)| (p, *i)), eps)));
|
||||
let top = sweep.ok()?.ok()?;
|
||||
let tidy = Tidy::keeping(DEFAULT_PRECISION, inputs.iter().map(|p| &p.0));
|
||||
|
||||
// The planar graph: vertices are the sweep's points, edges its segments. Points closer than
|
||||
// the sweep's tolerance are one vertex (it can put a corner that lies on another path's edge
|
||||
// a rounding error away from where it cuts that edge).
|
||||
let positions = top.compute_positions();
|
||||
let cell = 4.0 * eps;
|
||||
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
let mut points: Vec<Point> = Vec::new();
|
||||
let mut vertex_id = |p: Point| {
|
||||
let (cx, cy) = ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64);
|
||||
let near = (cx - 1..=cx + 1)
|
||||
.flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y)))
|
||||
.filter_map(|k| grid.get(&k))
|
||||
.flatten()
|
||||
.copied()
|
||||
.find(|&v| points.get(v).is_some_and(|q| q.distance(p) <= eps));
|
||||
near.unwrap_or_else(|| {
|
||||
points.push(p);
|
||||
grid.entry((cx, cy)).or_default().push(points.len() - 1);
|
||||
points.len() - 1
|
||||
})
|
||||
};
|
||||
let mut edges: Vec<Edge> = Vec::new();
|
||||
let mut between: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
|
||||
let outlines = Outlines::new(&inputs, eps);
|
||||
for idx in top.segment_indices() {
|
||||
let half = idx.first_half();
|
||||
let segs: Vec<Seg> = positions[idx].path.segments().map(to_seg).collect();
|
||||
let ends = [vertex_id(top.point(half).to_kurbo()), vertex_id(top.point(idx.second_half()).to_kurbo())];
|
||||
if segs.is_empty() || ends[0] == ends[1] {
|
||||
continue;
|
||||
}
|
||||
let Some(source) = mid(&segs).and_then(|m| outlines.source_at(m)) else { continue };
|
||||
// Coincident pieces the sweep left apart are one edge (the front-most input's).
|
||||
let key = (ends[0].min(ends[1]), ends[0].max(ends[1]));
|
||||
let here = mid(&segs);
|
||||
let twin_of = between.get(&key).and_then(|v| {
|
||||
v.iter().copied().find(|&e| {
|
||||
let other = edges.get(e).and_then(|e| mid(&e.segs));
|
||||
matches!((here, other), (Some(a), Some(b)) if a.distance(b) < DEFAULT_PRECISION)
|
||||
})
|
||||
});
|
||||
if let Some(e) = twin_of {
|
||||
if let Some(e) = edges.get_mut(e)
|
||||
&& source > e.source
|
||||
{
|
||||
e.source = source;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
between.entry(key).or_default().push(edges.len());
|
||||
edges.push(Edge { segs, ends, source });
|
||||
}
|
||||
let nv = points.len();
|
||||
|
||||
// Half-edges leaving each vertex, sorted by direction; walking a face, the next half-edge is
|
||||
// the one just clockwise of the way back, so bounded faces come out with positive area and
|
||||
// the outer boundary of each connected piece with negative area.
|
||||
let mut around: Vec<Vec<usize>> = vec![Vec::new(); nv];
|
||||
for h in 0..2 * edges.len() {
|
||||
if let Some(list) = origin(&edges, h).and_then(|v| around.get_mut(v)) {
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
let mut slot_of = vec![0usize; 2 * edges.len()];
|
||||
for (v, list) in around.iter_mut().enumerate() {
|
||||
let Some(&at) = points.get(v) else { continue };
|
||||
// Half the shortest chord of the edges here: every one of them gets that far from it.
|
||||
let r = 0.5 * list.iter().filter_map(|&h| half_segs(&edges, h).last().map(|s| s.c.p3.distance(at))).fold(f64::INFINITY, f64::min);
|
||||
let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect();
|
||||
keyed.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
*list = keyed.into_iter().map(|(_, h)| h).collect();
|
||||
for (k, &h) in list.iter().enumerate() {
|
||||
if let Some(s) = slot_of.get_mut(h) {
|
||||
*s = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
let next = |h: usize| -> Option<usize> {
|
||||
let back = twin(h);
|
||||
let list = around.get(origin(&edges, back)?)?;
|
||||
let k = *slot_of.get(back)?;
|
||||
list.get((k + list.len() - 1) % list.len()).copied()
|
||||
};
|
||||
|
||||
// Connected pieces of the graph.
|
||||
let mut parent: Vec<usize> = (0..nv).collect();
|
||||
for e in &edges {
|
||||
let (a, b) = (find(&mut parent, e.ends[0]), find(&mut parent, e.ends[1]));
|
||||
if let Some(p) = parent.get_mut(a) {
|
||||
*p = b;
|
||||
}
|
||||
}
|
||||
let piece: Vec<usize> = (0..nv).map(|v| find(&mut parent, v)).collect();
|
||||
let piece_of = |cycle: &[usize]| cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| piece.get(v)).copied();
|
||||
|
||||
// Walk every cycle: positive ones are faces, negative ones outer boundaries (holes in the
|
||||
// face around them, if any).
|
||||
let mut seen = vec![false; 2 * edges.len()];
|
||||
let mut faces: Vec<(Vec<usize>, BezPath, f64)> = Vec::new();
|
||||
let mut holes: Vec<(Vec<usize>, BezPath)> = Vec::new();
|
||||
for start in 0..2 * edges.len() {
|
||||
if seen.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut cycle = vec![];
|
||||
let mut h = start;
|
||||
while let Some(s) = seen.get_mut(h).filter(|s| !**s) {
|
||||
*s = true;
|
||||
cycle.push(h);
|
||||
match next(h) {
|
||||
Some(n) => h = n,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let cycle = without_spurs(&cycle);
|
||||
if cycle.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bp = cycle_bezpath(&edges, &cycle);
|
||||
let area = bp.area();
|
||||
if area > 0.0 && !is_sliver(&bp, tidy.precision) {
|
||||
faces.push((cycle, bp, area));
|
||||
} else if area < -(tidy.precision * tidy.precision) {
|
||||
holes.push((cycle, bp));
|
||||
}
|
||||
}
|
||||
|
||||
// Each hole goes in the smallest face of another piece around it.
|
||||
let mut face_holes: Vec<Vec<usize>> = vec![Vec::new(); faces.len()];
|
||||
for (k, (cycle, _)) in holes.iter().enumerate() {
|
||||
let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue };
|
||||
let own = piece_of(cycle);
|
||||
let around_it = faces
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.bounding_box().contains(p) && bp.winding(p) != 0)
|
||||
.min_by(|a, b| a.1.2.total_cmp(&b.1.2))
|
||||
.map(|(i, _)| i);
|
||||
if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) {
|
||||
list.push(k);
|
||||
}
|
||||
}
|
||||
|
||||
let fills: Vec<Fill> = shapes.iter().map(Fill::new).collect();
|
||||
// The inputs' anchors, which results land on again up to the sweep's tolerance.
|
||||
let snap = 2.0 * eps;
|
||||
let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect();
|
||||
anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
|
||||
let subpath = |cycle: &[usize]| {
|
||||
let segs: Vec<Seg> = cycle.iter().flat_map(|&h| half_segs(&edges, h)).collect();
|
||||
segs_to_subpath(&tidy_segments(segs, true, &tidy), true)
|
||||
};
|
||||
let regions = faces
|
||||
.iter()
|
||||
.zip(&face_holes)
|
||||
.filter_map(|((cycle, _, _), hs)| {
|
||||
let mut subpaths = vec![subpath(cycle)?];
|
||||
subpaths.extend(hs.iter().filter_map(|&k| holes.get(k)).filter_map(|(c, _)| subpath(c)));
|
||||
let path = unshear(PathData::new(subpaths), &anchors, snap);
|
||||
let sources =
|
||||
interior_point(&path).map(|p| fills.iter().enumerate().filter(|(_, f)| f.contains(p)).map(|(i, _)| i).collect()).unwrap_or_default();
|
||||
Some(Region { path, sources })
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Edges: chains of segments through vertices where nothing else meets.
|
||||
let degree: Vec<usize> = around.iter().map(Vec::len).collect();
|
||||
let passes = |v: usize| -> Option<(usize, usize)> {
|
||||
let list = around.get(v)?;
|
||||
let (&a, &b) = (list.first()?, list.get(1)?);
|
||||
(degree.get(v) == Some(&2) && edges.get(a / 2)?.source == edges.get(b / 2)?.source).then_some((a, b))
|
||||
};
|
||||
let mut used = vec![false; edges.len()];
|
||||
let mut chains: Vec<(Vec<Seg>, bool, usize)> = Vec::new();
|
||||
// A chain from half-edge `h0` up to the next vertex where it doesn't pass straight through;
|
||||
// closed when it comes back round to where it started.
|
||||
let walk = |h0: usize, used: &mut Vec<bool>| -> Option<(Vec<Seg>, bool, usize)> {
|
||||
let source = edges.get(h0 / 2)?.source;
|
||||
let mut segs = Vec::new();
|
||||
let mut h = h0;
|
||||
loop {
|
||||
let u = used.get_mut(h / 2)?;
|
||||
if *u {
|
||||
return Some((segs, true, source));
|
||||
}
|
||||
*u = true;
|
||||
segs.extend(half_segs(&edges, h));
|
||||
let v = origin(&edges, twin(h))?;
|
||||
let Some((a, b)) = passes(v) else { return Some((segs, origin(&edges, h0) == Some(v), source)) };
|
||||
h = if a == twin(h) { b } else { a };
|
||||
}
|
||||
};
|
||||
for v in 0..nv {
|
||||
if passes(v).is_some() {
|
||||
continue;
|
||||
}
|
||||
for &h in around.get(v).map(Vec::as_slice).unwrap_or_default() {
|
||||
if !used.get(h / 2).copied().unwrap_or(true)
|
||||
&& let Some(c) = walk(h, &mut used)
|
||||
{
|
||||
chains.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
// What's left are closed loops with no junction on them.
|
||||
for e in 0..edges.len() {
|
||||
if !used.get(e).copied().unwrap_or(true)
|
||||
&& let Some(c) = walk(2 * e, &mut used)
|
||||
{
|
||||
chains.push(c);
|
||||
}
|
||||
}
|
||||
let edge_shapes = chains
|
||||
.into_iter()
|
||||
.filter_map(|(segs, closed, source)| {
|
||||
let sp = segs_to_subpath(&tidy_segments(segs, closed, &tidy), closed)?;
|
||||
Some(Shape::new(unshear(PathData::new(vec![sp]), &anchors, snap), FillRule::NonZero, shapes.get(source)?.key))
|
||||
})
|
||||
.collect();
|
||||
Some((regions, edge_shapes))
|
||||
}
|
||||
|
||||
/// A point strictly inside a filled path, well away from its edges where it can be (for re-finding
|
||||
/// a Live Paint face after a rebuild).
|
||||
pub fn interior_point(path: &PathData) -> Option<Point> {
|
||||
let bb = path.bounds()?;
|
||||
let bp = path.to_bezpath();
|
||||
let mut edges: Vec<(Point, Point)> = vec![];
|
||||
let (mut first, mut last) = (Point::ZERO, Point::ZERO);
|
||||
kurbo::flatten(bp.iter(), (bb.width().max(bb.height()) * 1e-3).max(1e-4), |el| match el {
|
||||
kurbo::PathEl::MoveTo(p) => {
|
||||
if last != first {
|
||||
edges.push((last, first));
|
||||
}
|
||||
first = p;
|
||||
last = p;
|
||||
}
|
||||
kurbo::PathEl::LineTo(p) => {
|
||||
edges.push((last, p));
|
||||
last = p;
|
||||
}
|
||||
kurbo::PathEl::ClosePath => {
|
||||
if last != first {
|
||||
edges.push((last, first));
|
||||
}
|
||||
last = first;
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if last != first {
|
||||
edges.push((last, first));
|
||||
}
|
||||
let mut best: Option<(f64, Point)> = None;
|
||||
for f in [0.5, 0.25, 0.75, 0.125, 0.375, 0.625, 0.875, 0.0625, 0.9375] {
|
||||
// A little off the round fractions, which on art drawn to a grid fall on the height of
|
||||
// some corner, where testing the point against other paths is unreliable.
|
||||
let y = bb.y0 + bb.height() * (f + 0.0061803);
|
||||
let mut xs: Vec<f64> =
|
||||
edges.iter().filter(|(a, c)| (a.y <= y) != (c.y <= y)).map(|(a, c)| a.x + (y - a.y) / (c.y - a.y) * (c.x - a.x)).collect();
|
||||
xs.sort_by(f64::total_cmp);
|
||||
for w in xs.windows(2) {
|
||||
let m = Point::new((w[0] + w[1]) / 2.0, y);
|
||||
let width = w[1] - w[0];
|
||||
if width > best.map_or(0.0, |b| b.0) && bp.winding(m) != 0 {
|
||||
best = Some((width, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
use vectorcraft_geom::{Rect, SubPath, shapes};
|
||||
|
||||
fn line(a: (f64, f64), b: (f64, f64), key: u64) -> Shape {
|
||||
Shape::new(PathData::single(SubPath::polyline(&[Point::new(a.0, a.1), Point::new(b.0, b.1)], false)), FillRule::NonZero, key)
|
||||
}
|
||||
|
||||
fn rect(x0: f64, y0: f64, x1: f64, y1: f64, key: u64) -> Shape {
|
||||
Shape::new(shapes::rectangle(Rect::new(x0, y0, x1, y1)), FillRule::NonZero, key)
|
||||
}
|
||||
|
||||
fn area(r: &Region) -> f64 {
|
||||
r.path.to_bezpath().area()
|
||||
}
|
||||
|
||||
fn at(faces: &[Region], x: f64, y: f64) -> Vec<&Region> {
|
||||
faces.iter().filter(|f| f.contains(Point::new(x, y))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_lines_enclose_a_face() {
|
||||
// Three lines crossing pairwise, their ends sticking out: one triangle.
|
||||
let s = [line((0.0, 0.0), (100.0, 100.0), 0), line((100.0, 0.0), (0.0, 100.0), 1), line((-10.0, 80.0), (110.0, 80.0), 2)];
|
||||
let (faces, edges) = live_paint(&s);
|
||||
assert_eq!(faces.len(), 1, "{faces:?}");
|
||||
let f = &faces[0];
|
||||
assert!((area(f) - 900.0).abs() < 1e-6, "triangle (20,80) (50,50) (80,80): {}", area(f));
|
||||
assert!(f.sources.is_empty(), "nothing fills it yet");
|
||||
assert_eq!(f.path.subpaths.len(), 1);
|
||||
assert_eq!(f.path.subpaths[0].anchors.len(), 3, "the loose ends are not part of it");
|
||||
// Each line is cut where the others cross it: 3 + 3 + 3 pieces.
|
||||
assert_eq!(edges.len(), 9);
|
||||
assert_eq!(edges.iter().filter(|e| e.key == 2).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grid_of_lines_makes_its_cells() {
|
||||
let mut s = vec![];
|
||||
for k in 0..4 {
|
||||
let v = f64::from(k) * 10.0;
|
||||
s.push(line((v, -5.0), (v, 35.0), 0));
|
||||
s.push(line((-5.0, v), (35.0, v), 1));
|
||||
}
|
||||
let (faces, _) = live_paint(&s);
|
||||
assert_eq!(faces.len(), 9);
|
||||
assert!(faces.iter().all(|f| (area(f) - 100.0).abs() < 1e-6));
|
||||
assert_eq!(at(&faces, 15.0, 15.0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_across_a_shape_splits_it() {
|
||||
let s = [rect(0.0, 0.0, 100.0, 50.0, 7), line((40.0, -20.0), (60.0, 70.0), 8)];
|
||||
let (faces, edges) = live_paint(&s);
|
||||
assert_eq!(faces.len(), 2);
|
||||
assert!(faces.iter().all(|f| f.sources == [0]), "both halves keep the rectangle's fill");
|
||||
assert!((faces.iter().map(area).sum::<f64>() - 5000.0).abs() < 1e-6);
|
||||
assert_eq!(at(&faces, 10.0, 25.0).len(), 1);
|
||||
assert_eq!(at(&faces, 90.0, 25.0).len(), 1);
|
||||
// The rectangle's outline in two pieces, the line in three.
|
||||
assert_eq!(edges.iter().filter(|e| e.key == 7).count(), 2);
|
||||
assert_eq!(edges.iter().filter(|e| e.key == 8).count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_shapes_keep_their_coverage() {
|
||||
let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)];
|
||||
let (faces, _) = live_paint(&s);
|
||||
assert_eq!(faces.len(), 3);
|
||||
assert_eq!(at(&faces, 25.0, 50.0)[0].sources, [0]);
|
||||
assert_eq!(at(&faces, 75.0, 50.0)[0].sources, [0, 1]);
|
||||
assert_eq!(at(&faces, 125.0, 50.0)[0].sources, [1]);
|
||||
// Same faces as the Pathfinder regions.
|
||||
let regions = crate::regions(&s);
|
||||
assert_eq!(regions.len(), 3);
|
||||
for f in &faces {
|
||||
assert!(regions.iter().any(|r| r.sources == f.sources && (area(r).abs() - area(f)).abs() < 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_loop_of_lines_inside_a_shape_is_a_hole_and_a_face() {
|
||||
let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)];
|
||||
let sq = [(40.0, 40.0), (60.0, 40.0), (60.0, 60.0), (40.0, 60.0), (40.0, 40.0)];
|
||||
for w in sq.windows(2) {
|
||||
s.push(line(w[0], w[1], 1));
|
||||
}
|
||||
// A loose line inside: no face of its own, no hole.
|
||||
s.push(line((10.0, 10.0), (20.0, 20.0), 2));
|
||||
let (faces, _) = live_paint(&s);
|
||||
assert_eq!(faces.len(), 2, "{faces:?}");
|
||||
let outer = at(&faces, 5.0, 5.0);
|
||||
assert_eq!(outer.len(), 1);
|
||||
assert_eq!(outer[0].path.subpaths.len(), 2, "the inner square is a hole in it");
|
||||
assert!((area(outer[0]) - 9600.0).abs() < 1e-6);
|
||||
let inner = at(&faces, 50.0, 50.0);
|
||||
assert_eq!(inner.len(), 1, "the hole doesn't count as the outer face");
|
||||
assert_eq!(inner[0].sources, [0], "the rectangle fills it");
|
||||
assert!((area(inner[0]) - 400.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_area_enclosed_by_shapes_is_an_unfilled_face() {
|
||||
// A frame of four bars around a 50 × 50 gap.
|
||||
let s = [rect(0.0, 0.0, 70.0, 10.0, 0), rect(0.0, 60.0, 70.0, 70.0, 0), rect(0.0, 0.0, 10.0, 70.0, 0), rect(60.0, 0.0, 70.0, 70.0, 0)];
|
||||
let (faces, _) = live_paint(&s);
|
||||
let gap = at(&faces, 35.0, 35.0);
|
||||
assert_eq!(gap.len(), 1);
|
||||
assert!(gap[0].sources.is_empty());
|
||||
assert!((area(gap[0]) - 2500.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curves_and_a_circle_crossed_by_a_line() {
|
||||
let circle = Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), FillRule::NonZero, 0);
|
||||
let (faces, edges) = live_paint(&[circle, line((-10.0, 50.0), (110.0, 50.0), 1)]);
|
||||
assert_eq!(faces.len(), 2);
|
||||
for f in &faces {
|
||||
assert!((area(f) - std::f64::consts::PI * 2500.0 / 2.0).abs() < 2.0, "{}", area(f));
|
||||
assert!(f.path.subpaths[0].anchors.len() <= 4, "no extra anchors from the sweep's splits");
|
||||
}
|
||||
assert_eq!(edges.iter().filter(|e| e.key == 0).count(), 2, "two arcs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_ending_on_an_edge_splits_the_shape() {
|
||||
let s = [line((90.0, 20.0), (70.0, 10.0), 0), rect(80.0, 10.0, 90.0, 50.0, 1)];
|
||||
let (faces, _) = live_paint(&s);
|
||||
assert_eq!(faces.len(), 2);
|
||||
assert!((area(&faces[0]) + area(&faces[1]) - 400.0).abs() < 1e-6);
|
||||
for f in &faces {
|
||||
let p = crate::interior_point(&f.path).unwrap();
|
||||
assert_eq!(at(&faces, p.x, p.y).len(), 1, "{p:?}");
|
||||
assert_eq!(f.sources, [1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_or_garbage_gives_an_empty_map() {
|
||||
assert_eq!(live_paint(&[]), (vec![], vec![]));
|
||||
assert!(live_paint(&[line((0.0, 0.0), (f64::NAN, 1.0), 0)]).0.is_empty());
|
||||
let (faces, edges) = live_paint(&[line((0.0, 0.0), (10.0, 10.0), 0)]);
|
||||
assert!(faces.is_empty());
|
||||
assert_eq!(edges.len(), 1);
|
||||
}
|
||||
|
||||
fn arb_segment() -> impl Strategy<Value = Shape> {
|
||||
let c = || (0..10i32).prop_map(|v| f64::from(v) * 10.0);
|
||||
prop_oneof![
|
||||
(c(), c(), c(), c()).prop_map(|(a, b, x, y)| line((a, b), (x, y), 0)),
|
||||
(c(), c(), 1..5i32, 1..5i32).prop_map(|(x, y, w, h)| rect(x, y, x + f64::from(w) * 10.0, y + f64::from(h) * 10.0, 0)),
|
||||
(c(), c(), 1..5i32).prop_map(|(x, y, r)| {
|
||||
let r = f64::from(r) * 10.0;
|
||||
Shape::new(shapes::ellipse(Rect::new(x, y, x + r, y + r)), FillRule::EvenOdd, 0)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
|
||||
|
||||
/// Faces never overlap, and a face's sources are exactly the inputs whose fill holds a
|
||||
/// point inside it.
|
||||
#[test]
|
||||
fn faces_are_disjoint_and_know_what_covers_them(shapes in prop::collection::vec(arb_segment(), 1..7)) {
|
||||
let (faces, _) = live_paint(&shapes);
|
||||
for f in &faces {
|
||||
prop_assert!(area(f) > 0.0);
|
||||
let Some(p) = crate::interior_point(&f.path) else { continue };
|
||||
prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1, "{:?} in more than one face", p);
|
||||
let covering: Vec<usize> = shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.path.is_closed() && vectorcraft_geom::hit::fill_contains(&s.path.to_bezpath(), s.rule, p))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
prop_assert_eq!(&f.sources, &covering, "at {:?}", p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closed shapes alone: the filled faces cover exactly the Pathfinder regions.
|
||||
#[test]
|
||||
fn filled_faces_are_the_pathfinder_regions(shapes in prop::collection::vec(arb_segment(), 1..6)) {
|
||||
let closed: Vec<Shape> = shapes.into_iter().filter(|s| s.path.is_closed()).collect();
|
||||
let (faces, _) = live_paint(&closed);
|
||||
let filled: f64 = faces.iter().filter(|f| !f.sources.is_empty()).map(area).sum();
|
||||
let regions: f64 = crate::regions(&closed).iter().map(|r| area(r).abs()).sum();
|
||||
// Both refit split curves to [`DEFAULT_PRECISION`], so curved areas may differ by up
|
||||
// to that much along every outline.
|
||||
let outlines: f64 = closed.iter().map(|s| s.path.to_bezpath().perimeter(1e-6)).sum();
|
||||
prop_assert!((filled - regions).abs() <= 1e-9 + DEFAULT_PRECISION * outlines, "{} vs {}", filled, regions);
|
||||
}
|
||||
|
||||
/// Lines anywhere (not on a grid): faces stay disjoint, and nothing panics.
|
||||
#[test]
|
||||
fn free_lines_make_disjoint_faces(pts in prop::collection::vec((-50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64, -50.0..150.0f64), 2..9)) {
|
||||
let lines: Vec<Shape> = pts.into_iter().map(|(a, b, x, y)| line((a, b), (x, y), 0)).collect();
|
||||
let (faces, edges) = live_paint(&lines);
|
||||
prop_assert!(edges.len() >= lines.len().min(1));
|
||||
for f in &faces {
|
||||
prop_assert!(f.sources.is_empty());
|
||||
if let Some(p) = crate::interior_point(&f.path) {
|
||||
prop_assert_eq!(faces.iter().filter(|g| g.contains(p)).count(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_lines_stay_quick() {
|
||||
// 60 lines through a 100 pt square: about 900 crossings.
|
||||
let lines: Vec<Shape> = (0..60)
|
||||
.map(|k| {
|
||||
let t = f64::from(k) * 0.37;
|
||||
line((50.0 + 80.0 * t.cos(), 50.0 + 80.0 * t.sin()), (50.0 - 80.0 * (t + 1.1).cos(), 50.0 - 80.0 * (t + 1.1).sin()), 0)
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
let (faces, _) = live_paint(&lines);
|
||||
assert!(faces.len() > 500, "{}", faces.len());
|
||||
assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,10 @@ impl Families {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
|
||||
_ => name,
|
||||
};
|
||||
// An installed face of that exact PostScript name: its own family and style.
|
||||
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
|
||||
return (family, style, true);
|
||||
}
|
||||
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
|
||||
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
|
||||
let style = match spaced(strip_ps(style)).as_str() {
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user