Compare commits
208
Commits
v0.3.0
...
fix/issue-306
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
406c27ebd8 | ||
|
|
dfe7f1a311 | ||
|
|
079f9a8b9a | ||
|
|
8e40794153 | ||
|
|
ee7a7ebfac | ||
|
|
0d4d125f9b | ||
|
|
deaec7fc71 | ||
|
|
a26aa5b203 | ||
|
|
7be80776f2 | ||
|
|
a9c31fcbe7 | ||
|
|
89ff09e9fd | ||
|
|
71efade26b | ||
|
|
6a62dc58ba | ||
|
|
c462057460 | ||
|
|
539dcb84b7 | ||
|
|
d5e21cc735 | ||
|
|
bc688bd1b5 | ||
|
|
11799fa0cc | ||
|
|
b6649a3fad | ||
|
|
49dc3b90be | ||
|
|
c256818665 | ||
|
|
a20e680f62 | ||
|
|
68a14472c6 | ||
|
|
e2227849a1 | ||
|
|
3c096da84d | ||
|
|
47104f5f4c | ||
|
|
ea5a04504a | ||
|
|
d6eb526186 | ||
|
|
ce3683a508 | ||
|
|
333a487dab | ||
|
|
b8f77aefb6 | ||
|
|
40bdfd3aca | ||
|
|
dd595cf2e8 | ||
|
|
614606cc48 | ||
|
|
13f65ce6d4 | ||
|
|
308c096889 | ||
|
|
ba2ee7a174 | ||
|
|
b743d08f11 | ||
|
|
ab2daad763 | ||
|
|
80323a7a4e | ||
|
|
5f59332ec8 | ||
|
|
926cdc80a5 | ||
|
|
b55e3ad70a | ||
|
|
48c96b504d | ||
|
|
2e134e56be | ||
|
|
a01e045905 | ||
|
|
eac383d5af | ||
|
|
94bc59b068 | ||
|
|
c6ac2bf3c5 | ||
|
|
198725ed38 | ||
|
|
7ce27caf88 | ||
|
|
c91ca3634f | ||
|
|
24f7d5fba5 | ||
|
|
cc4992172a | ||
|
|
9dc05ac980 | ||
|
|
339eb78a9b | ||
|
|
e01077a674 | ||
|
|
6313c8cfae | ||
|
|
f9a89a3211 | ||
|
|
fbcadf317c | ||
|
|
64891c1a2e | ||
|
|
c60cb4a9c2 | ||
|
|
83faf568b0 | ||
|
|
ee1e95d1c7 | ||
|
|
e960baa131 | ||
|
|
0e5e093dd9 | ||
|
|
6572636d62 | ||
|
|
cf7a43d786 | ||
|
|
f4181890bd | ||
|
|
116ff3eee7 | ||
|
|
902c3ae8c1 | ||
|
|
2507ed9dd9 | ||
|
|
e80b7e383b | ||
|
|
597b651bb6 | ||
|
|
945f8db359 | ||
|
|
e50f02e638 | ||
|
|
aeaece9728 | ||
|
|
c6c2c7d0fe | ||
|
|
61cd4ed6e6 | ||
|
|
0e1a5b3e45 | ||
|
|
b05e575eb8 | ||
|
|
1012361773 | ||
|
|
f2a2671933 | ||
|
|
10fafed6c4 | ||
|
|
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
+28
-25
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
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.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4243,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4259,7 +4261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4275,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4287,7 +4289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4301,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4315,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4347,7 +4349,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4364,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4378,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4390,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4409,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4423,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4435,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4462,7 +4464,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4476,7 +4478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4501,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4525,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4542,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4551,11 +4553,12 @@ dependencies = [
|
||||
"subsetter",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4570,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4583,7 +4586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4611,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5685,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
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.4.0"
|
||||
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,14 @@ 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).
|
||||
|
||||
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
|
||||
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
|
||||
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
Register the MCP server with Claude Code:
|
||||
@@ -169,7 +177,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 +186,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.
|
||||
@@ -193,7 +201,8 @@ the UI can be swapped without touching the engine.
|
||||
|
||||
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
|
||||
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
|
||||
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
explains how VectorCraft avoids crashing, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
|
||||
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
@@ -241,7 +250,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).
|
||||
|
||||
+51
-39
@@ -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-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; 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,16 +33,16 @@ 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 |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -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.
|
||||
@@ -87,32 +88,41 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- 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.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- 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.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), 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.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese 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 in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (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,12 +134,14 @@ 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).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export.
|
||||
- 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 with type set along a curve coming back as type on a path, each artboard's art on its own page only, 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).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
@@ -143,17 +155,17 @@ 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 (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, 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 |
|
||||
| M11 | Artboards & views (artboard panel/tool, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs 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,37 +183,37 @@ 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 |
|
||||
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap, SVG Filters | 6–10 |
|
||||
| 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 core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| 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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
@@ -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
|
||||
|
||||
@@ -29,16 +59,17 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. Live effects are kept;
|
||||
--outline-text writes SVG text as paths.
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept; --outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
@@ -52,9 +83,10 @@ USAGE:
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads.
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
|
||||
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
|
||||
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -68,11 +100,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())),
|
||||
@@ -112,6 +144,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
// The binary owns the logger, not the library: installing one here keeps an embedder that
|
||||
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
||||
vectorcraft_mcp::logging::install();
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
@@ -120,7 +155,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 +181,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 +197,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 +287,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 +296,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.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`vectorcraft mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,249 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
@@ -225,11 +241,33 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The power preference wgpu picks the window's graphics adapter with (#306): the
|
||||
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
|
||||
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
|
||||
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
|
||||
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
|
||||
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
|
||||
/// GPU both preferences pick it.
|
||||
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
match (env, pref) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
|
||||
(None, _) => PowerPreference::LowPower,
|
||||
}
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -245,7 +283,9 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
let options = eframe::NativeOptions {
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
@@ -259,6 +299,9 @@ fn main() -> eframe::Result {
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
..Default::default()
|
||||
};
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
@@ -280,6 +323,11 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
@@ -299,3 +347,35 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
|
||||
#[test]
|
||||
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
|
||||
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
|
||||
// A value this version doesn't know (a newer or damaged preference file) is the default.
|
||||
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
|
||||
// WGPU_POWER_PREF wins over the preference, either way.
|
||||
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
|
||||
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
|
||||
}
|
||||
|
||||
/// The preference the engine saves is the one the app reads back before the window opens.
|
||||
#[test]
|
||||
fn the_saved_engine_preference_is_found() {
|
||||
let mut prefs = vectorcraft_engine::Prefs::default();
|
||||
let saved = prefs.to_json();
|
||||
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
|
||||
prefs.gpu_preference = "highPerformance".into();
|
||||
let saved = prefs.to_json();
|
||||
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_ui_egui::VectorcraftApp;
|
||||
use vectorcraft_ui_egui::i18n::{self, Lang, t};
|
||||
use vectorcraft_ui_egui::menus::{self, Item};
|
||||
|
||||
enum Handle {
|
||||
@@ -27,6 +28,8 @@ pub struct NativeMenu {
|
||||
slots: usize,
|
||||
/// Plug-in registry revision the Plug-ins submenus were built for.
|
||||
plugins: u64,
|
||||
/// The language the labels were built in (a change rebuilds the menu).
|
||||
language: Lang,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
@@ -47,7 +50,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(t(title), true);
|
||||
build(app, &sub, &entries, &mut items, &mut counter);
|
||||
let _ = menu.append(&sub);
|
||||
}
|
||||
@@ -58,7 +61,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: i18n::current(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
@@ -74,11 +85,12 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited, workspaces added, saved views, recent files or plug-ins changed: rebuild so
|
||||
// accelerators and lists are current.
|
||||
// Shortcuts edited, workspaces added, saved views, recent files, plug-ins or the UI language
|
||||
// changed: rebuild so accelerators, lists and labels are current.
|
||||
if vectorcraft_ui_egui::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed) != self.generation
|
||||
|| menus::listed_slots(app) != self.slots
|
||||
|| menus::plugin_revision() != self.plugins
|
||||
|| i18n::current() != self.language
|
||||
{
|
||||
*self = NativeMenu::install(app);
|
||||
return;
|
||||
@@ -111,7 +123,7 @@ impl NativeMenu {
|
||||
"view.cornerWidget" | "view.textThreads" | "view.gradientAnnotator" | "effect.last" | "type.hiddenCharacters"
|
||||
)
|
||||
{
|
||||
i.set_text(menus::dynamic_label(app, cmd, ""));
|
||||
i.set_text(menus::display_label(app, cmd, ""));
|
||||
}
|
||||
}
|
||||
Handle::Check(c) => {
|
||||
@@ -136,13 +148,13 @@ fn build(
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Item::Header(h) => {
|
||||
let _ = parent.append(&MenuItem::new(*h, false, None));
|
||||
let _ = parent.append(&MenuItem::new(t(h), false, None));
|
||||
}
|
||||
Item::Todo(label, sc) => {
|
||||
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
|
||||
let _ = parent.append(&MenuItem::new(t(label), false, accel(sc)));
|
||||
}
|
||||
Item::Sub(label, children) => {
|
||||
let sub = Submenu::new(*label, true);
|
||||
let sub = Submenu::new(t(label), true);
|
||||
build(app, &sub, children, items, counter);
|
||||
let _ = parent.append(&sub);
|
||||
}
|
||||
@@ -153,11 +165,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(), menus::display_label(app, cmd, 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(), menus::display_label(app, cmd, 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
+178
-15
@@ -75,12 +75,16 @@ impl LayerColor {
|
||||
#[serde(tag = "shape", rename_all = "lowercase")]
|
||||
pub enum LiveShape {
|
||||
Rectangle {
|
||||
/// Untransformed width/height.
|
||||
/// Untransformed width/height. Transforms fold their scale into these (see
|
||||
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
|
||||
w: f64,
|
||||
h: f64,
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
|
||||
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
|
||||
radii: [f64; 4],
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document.
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
|
||||
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
|
||||
/// their scale into `w`/`h`) put more in it.
|
||||
xf: Affine,
|
||||
},
|
||||
Ellipse {
|
||||
@@ -129,12 +133,44 @@ impl LiveShape {
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
|
||||
/// the caller must regenerate the path with [`LiveShape::to_path`].
|
||||
///
|
||||
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
|
||||
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
|
||||
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
|
||||
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
|
||||
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
|
||||
LiveShape::Rectangle { w, h, radii, xf } => {
|
||||
let m = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
|
||||
if moves_only || !orthogonal || !finite {
|
||||
*xf = m;
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
true
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
*b = a * *b;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +308,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 +384,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 +436,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 +570,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 +629,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();
|
||||
@@ -599,11 +649,10 @@ impl Node {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
if let Some(l) = live {
|
||||
l.transform(a);
|
||||
if sc.keep_corners
|
||||
&& let Some(k) = k
|
||||
&& l.keep_corners(k)
|
||||
{
|
||||
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
|
||||
let reshaped = l.transform(a);
|
||||
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
|
||||
if reshaped || kept {
|
||||
*path = l.to_path();
|
||||
}
|
||||
}
|
||||
@@ -632,15 +681,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 { .. } => {}
|
||||
@@ -1114,6 +1170,113 @@ mod tests {
|
||||
assert_eq!(l.label(), "Rectangle");
|
||||
}
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
|
||||
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
|
||||
(*w, *h, *radii, *xf)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn near_xf(a: Affine, b: Affine) -> bool {
|
||||
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
|
||||
}
|
||||
|
||||
/// Size and radius of a live rectangle, within rounding.
|
||||
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
|
||||
let (lw, lh, radii, _) = live_of(n);
|
||||
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
|
||||
}
|
||||
|
||||
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
|
||||
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
|
||||
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
|
||||
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
|
||||
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
|
||||
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
|
||||
|
||||
#[test]
|
||||
fn uneven_scales_keep_live_corners_circular() {
|
||||
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
|
||||
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
|
||||
n.transform(stretch, KEEP_CORNERS);
|
||||
assert_live(&n, 300.0, 100.0, 20.0);
|
||||
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0);
|
||||
// Scale Corners on: the radius scales by the mean scale, still circular.
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
|
||||
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 100.0, 40.0);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
|
||||
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 25.0);
|
||||
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
|
||||
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, place);
|
||||
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
|
||||
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
assert!(near_xf(live_of(&n).3, place));
|
||||
assert_round_corners(&n, place.inverse(), 10.0);
|
||||
// A reflection stays in `xf`; the corners stay as they were.
|
||||
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
let xf = live_of(&n).3;
|
||||
assert!(near(xf.determinant(), -1.0));
|
||||
assert_round_corners(&n, xf.inverse(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shears_and_moves_keep_the_old_transform() {
|
||||
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
|
||||
let rot = Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, rot);
|
||||
let before = n.clone();
|
||||
let a = Affine::scale_non_uniform(2.0, 1.0);
|
||||
n.transform(a, Scaling::default());
|
||||
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
|
||||
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
|
||||
assert_eq!(*path, old.transformed(a));
|
||||
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
|
||||
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
|
||||
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
|
||||
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
|
||||
// ...and once scaled, has document units and circular corners of its mean radius.
|
||||
n.transform(Affine::scale(2.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
|
||||
assert!(near(live_of(&n).3.determinant(), 1.0));
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_transparency() {
|
||||
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
+93
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
|
||||
@@ -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 }],
|
||||
@@ -472,6 +476,49 @@ impl TextObject {
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
/// Area type's frame (the type area) in document space; None for other type.
|
||||
pub fn area_frame(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
TextKind::Area { frame } => Some(frame.transformed(self.xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size
|
||||
/// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for
|
||||
/// other type, a degenerate `xf` or a frame that would not be finite.
|
||||
pub fn transform_area(&mut self, a: Affine) -> bool {
|
||||
let xf = self.xf;
|
||||
self.reshape_area(|frame, to_text| {
|
||||
frame.transform(to_text * a * xf);
|
||||
true
|
||||
})
|
||||
}
|
||||
/// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with
|
||||
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
|
||||
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
|
||||
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
self.reshape_area(|frame, to_text| {
|
||||
// A distance: only the linear part of the document → text map applies.
|
||||
let local = to_text * d.to_point() - to_text * Point::ZERO;
|
||||
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
|
||||
})
|
||||
}
|
||||
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
|
||||
/// when `f` succeeds and the result is finite.
|
||||
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
|
||||
let det = self.xf.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let to_text = self.xf.inverse();
|
||||
let TextKind::Area { frame } = &mut self.kind else { return false };
|
||||
let mut next = frame.clone();
|
||||
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
*frame = next;
|
||||
true
|
||||
}
|
||||
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
|
||||
/// `1 / scale` keeps their weight through a transform that scales the type).
|
||||
pub fn scale_char_strokes(&mut self, s: f64) {
|
||||
@@ -501,4 +548,49 @@ mod tests {
|
||||
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
|
||||
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
|
||||
}
|
||||
|
||||
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
|
||||
fn area_type() -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) };
|
||||
t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_resizes_its_frame_and_keeps_its_type() {
|
||||
let mut t = area_type();
|
||||
assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0)));
|
||||
// Bottom-right handle to (200, 140), about the top-left corner.
|
||||
let o = Affine::translate((40.0, 40.0));
|
||||
assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse()));
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size");
|
||||
let b = t.bounds().unwrap();
|
||||
assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}");
|
||||
assert_eq!(t.area_frame().unwrap().bounds(), Some(b));
|
||||
// Point type has no area; a degenerate transform or a non-finite frame changes nothing.
|
||||
let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default());
|
||||
assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none());
|
||||
let before = t.clone();
|
||||
assert!(!t.transform_area(Affine::scale(f64::INFINITY)));
|
||||
t.xf = Affine::scale(0.0);
|
||||
assert!(!t.transform_area(Affine::scale(2.0)));
|
||||
t.xf = before.xf;
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_anchors_move_in_document_space() {
|
||||
let mut t = area_type();
|
||||
// The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×.
|
||||
assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0)));
|
||||
let TextKind::Area { frame } = &t.kind else { panic!() };
|
||||
assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0));
|
||||
assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay");
|
||||
// A missing anchor changes nothing.
|
||||
let before = t.clone();
|
||||
assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0)));
|
||||
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
}
|
||||
@@ -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, _| {
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::DocState;
|
||||
|
||||
use super::super::*;
|
||||
use super::encode::single_artboard;
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, merge, writable, write_encoded, write_or_return};
|
||||
use super::{ARTBOARD_PARAMS, Format, default_name, encode, encode_all, encode_with_warnings, merge, writable, write_encoded, write_or_return};
|
||||
|
||||
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
|
||||
@@ -151,8 +151,10 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_presets(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
let (d, bounds) = selection(s, C)?;
|
||||
let bytes = encode(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
// The encoder's warnings (options accepted but not applied, features approximated or left
|
||||
// out), as document.export reports them.
|
||||
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(p))?;
|
||||
write_or_return(path, &bytes, json!({ "format": f.id, "warnings": warnings, "bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()] }))
|
||||
}
|
||||
|
||||
/// File → Save for Office Documents: one artboard as a PNG at `ppi`, on white unless
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Selection…",
|
||||
["File"],
|
||||
None,
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, bytes, bounds} (no path → {dataBase64, bounds})",
|
||||
"{path?, format?: png|jpg|webp|svg|svgz|pdf (default: from the extension, else png), scale?: 1, …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export",
|
||||
has_selection,
|
||||
export::export_selection
|
||||
),
|
||||
@@ -581,6 +581,18 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"eps",
|
||||
];
|
||||
|
||||
/// The extension that picks each writable format when exporting (the format's first; PNG-8 shares
|
||||
/// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order.
|
||||
pub fn export_extensions() -> Vec<&'static str> {
|
||||
let mut v: Vec<&'static str> = Vec::new();
|
||||
for e in FORMATS.iter().filter(|f| f.write).filter_map(|f| f.extensions.first()) {
|
||||
if !v.contains(e) {
|
||||
v.push(e);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
|
||||
@@ -150,6 +150,39 @@ fn open_exts_cover_every_readable_format() {
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
/// The extensions usage texts list for export: one per writable format, each picking that format
|
||||
/// (`.png` picks PNG; PNG-8 is asked for by `format`).
|
||||
#[test]
|
||||
fn export_extensions_name_every_writable_format() {
|
||||
let exts = export_extensions();
|
||||
assert_eq!(
|
||||
exts,
|
||||
[
|
||||
"vectorcraft",
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
"webp",
|
||||
"tif",
|
||||
"bmp",
|
||||
"vctemplate",
|
||||
"txt",
|
||||
"dxf",
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"tga",
|
||||
"psd"
|
||||
]
|
||||
);
|
||||
for e in exts {
|
||||
assert!(format_for_name(&format!("x.{e}")).is_some_and(|f| f.write), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_query_lists_readers_writers_and_options() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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,41 @@ 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();
|
||||
// "Black Wide" is a style no Source Sans 3 has, bundled or installed, so it is always a
|
||||
// substitute (the installed family can hold a real Black).
|
||||
for (text, font, style) in
|
||||
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black Wide"), ("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 Wide").1, "substitute", "{rows:?}");
|
||||
assert_eq!(row("Black Wide").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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform",
|
||||
[],
|
||||
None,
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences)",
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool} apply an affine to the selection (or ids); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
has_doc,
|
||||
transform
|
||||
),
|
||||
@@ -202,15 +202,18 @@ pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling {
|
||||
Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), keep_type_strokes: !strokes, keep_corners: !corners }
|
||||
}
|
||||
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`]).
|
||||
/// Records Transform Again.
|
||||
/// Apply `xf` to `ids` (`copy` param: duplicate first; `strokes`/`corners`: see [`scaling`];
|
||||
/// `typeAreas`: see [`resize_type_area`]). Records Transform Again.
|
||||
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, p: &Value) -> Result<Value> {
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let areas = bool_or(p, "typeAreas", false);
|
||||
let sc = if Scaling::factor(xf).is_some() { scaling(s, p) } else { Scaling::default() };
|
||||
let ids = s.edit(label, |d, sel| {
|
||||
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
|
||||
for id in &targets {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(n) = d.node_mut(*id)
|
||||
&& !(areas && resize_type_area(n, xf))
|
||||
{
|
||||
n.transform(xf, sc);
|
||||
}
|
||||
}
|
||||
@@ -219,10 +222,22 @@ 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<_>>() }))
|
||||
}
|
||||
|
||||
/// Area type resized by its bounding box: `xf` reshapes its frame (the type area) and the text
|
||||
/// reflows at its size. False for anything else (and type in perspective), which transforms as
|
||||
/// usual.
|
||||
fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
|
||||
if n.perspective.is_some() {
|
||||
return false;
|
||||
}
|
||||
let NodeKind::Text(t) = &mut n.kind else { return false };
|
||||
super::typecmd::reshape_area_with(t, |t| t.transform_area(xf))
|
||||
}
|
||||
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
@@ -299,6 +314,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 +389,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 +685,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)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ pub const UNITS: &[(&str, &str)] = &[
|
||||
("feet", "Feet"),
|
||||
];
|
||||
const LINE_STYLE: &[(&str, &str)] = &[("lines", "Lines"), ("dots", "Dots")];
|
||||
/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup.
|
||||
pub const GPU_PREFERENCES: &[(&str, &str)] = &[("powerSaving", "Power Saving (integrated)"), ("highPerformance", "High Performance (discrete)")];
|
||||
const BLACK: &[(&str, &str)] = &[("accurate", "Display All Blacks Accurately"), ("rich", "Display All Blacks as Rich Black")];
|
||||
const BLACK_OUT: &[(&str, &str)] = &[("accurate", "Output All Blacks Accurately"), ("rich", "Output All Blacks as Rich Black")];
|
||||
|
||||
@@ -260,9 +262,12 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
|
||||
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
|
||||
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
|
||||
// `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown.
|
||||
p!("interfaceLanguage", "User Interface", "Language", "Language", text),
|
||||
// Performance
|
||||
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
|
||||
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
|
||||
p!("gpuPreference", "Performance", "GPU Performance", "Graphics Processor", choice(GPU_PREFERENCES)),
|
||||
p!("historyStates", "Performance", "", "History States", int(5, 1000)),
|
||||
p!("realTimeDrawing", "Performance", "", "Real-time Drawing and Editing", bool),
|
||||
p!("renderThreads", "Performance", "", "Render Threads (-1 = Automatic)", int(-1, 64)),
|
||||
@@ -329,10 +334,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 +347,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}`")),
|
||||
}
|
||||
}
|
||||
@@ -380,6 +395,11 @@ pub fn validate(key: &str, v: &Value) -> std::result::Result<Value, String> {
|
||||
Err(format!("`{key}` must be a #rrggbb colour"))
|
||||
}
|
||||
}
|
||||
PrefKind::Text if key == "interfaceLanguage" => {
|
||||
let s = v.as_str().map(str::trim).unwrap_or("");
|
||||
let ok = !s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
|
||||
if ok { Ok(json!(s.to_ascii_lowercase())) } else { Err(format!("`{key}` must be `auto` or a language code such as `en` or `zh-hant`")) }
|
||||
}
|
||||
PrefKind::Text => match v {
|
||||
Value::String(s) => Ok(json!(s)),
|
||||
_ => Err(format!("`{key}` must be a string")),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -188,7 +188,8 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
/// `[[subpath, anchor]…]` anchor references (malformed entries are left out).
|
||||
pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{len_param, num_param, shape_node};
|
||||
@@ -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",
|
||||
@@ -36,10 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
cmd!(
|
||||
"text.reshapeArea",
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
cmd!(
|
||||
"text.fitHeadline",
|
||||
"Fit Headline",
|
||||
@@ -61,6 +82,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);
|
||||
@@ -238,25 +274,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],
|
||||
/// [`TextObject::move_area_anchors`]) and lay its text out again. False, changing nothing, when
|
||||
/// `f` fails or the frame would reach past [`crate::MAX_COORD`] (the text flows over its height).
|
||||
pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObject) -> bool) -> bool {
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| match &t.kind {
|
||||
vectorcraft_doc::TextKind::Area { frame } => {
|
||||
frame.bounds().is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !(f(t) && within(t)) {
|
||||
t.kind = before;
|
||||
return false;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
|
||||
/// Area type's text, if `n` is area type.
|
||||
fn area_text(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height of area type's frame in points, along the type's own axes, and the frame's
|
||||
/// bounds in text space.
|
||||
fn area_size(t: &TextObject) -> Option<(f64, f64, vectorcraft_geom::Rect)> {
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
|
||||
let b = frame.bounds()?;
|
||||
let [a, bb, c, d, _, _] = t.xf.as_coeffs();
|
||||
Some((b.width() * a.hypot(bb), b.height() * c.hypot(d), b))
|
||||
}
|
||||
|
||||
/// Size area type's frame to `w` × `h` points (None: keep that side) from its top-left corner,
|
||||
/// and lay its text out again. False when the frame doesn't change.
|
||||
fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
let Some((cw, ch, b)) = area_size(t) else { return false };
|
||||
let factor = |to: Option<f64>, cur: f64| match to {
|
||||
Some(to) if cur > 1e-9 && (to - cur).abs() > 1e-9 => to / cur,
|
||||
_ => 1.0,
|
||||
};
|
||||
let (sx, sy) = (factor(w, cw), factor(h, ch));
|
||||
if sx == 1.0 && sy == 1.0 {
|
||||
return false;
|
||||
}
|
||||
let o = Affine::translate(b.origin().to_vec2());
|
||||
let a = t.xf * o * Affine::scale_non_uniform(sx, sy) * o.inverse() * t.xf.inverse();
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
text_targets(s, p, C)?
|
||||
.into_iter()
|
||||
.filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })))
|
||||
.collect()
|
||||
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.area.clone(),
|
||||
_ => return Err(bad(C, "select area type (text in a frame)")),
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
let mut v = serde_json::to_value(¤t).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut changed = false;
|
||||
let mut v = options(s)?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some();
|
||||
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||
for (k, val) in src {
|
||||
if o.contains_key(k) && k != "ids" {
|
||||
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
@@ -271,23 +364,66 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||
opts.inset = opts.inset.clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
|
||||
let out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.edit("Area Type Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.area = opts.clone();
|
||||
refresh_bounds(t);
|
||||
// A resized frame has laid its text out again; otherwise the new options do here.
|
||||
if !size_area(t, w, h) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
options(s)
|
||||
}
|
||||
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::parse_refs(p.get("anchors"));
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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();
|
||||
|
||||
@@ -29,7 +29,21 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
}
|
||||
if !n.is_container() {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
// Type shows its characters' paint: the first run's, as paint.proxies and the Stroke
|
||||
// options read it. Fills and strokes of the type object itself, above or below its
|
||||
// characters, come separately so the two stay apart.
|
||||
let default = vectorcraft_doc::CharStyle::default();
|
||||
let style = t.runs.first().map_or(&default, |r| &r.style);
|
||||
v["fill"] = json!(style.fill.label());
|
||||
v["stroke"] = json!(style.stroke.label());
|
||||
v["strokeWidth"] = json!(if style.stroke.is_none() { 0.0 } else { style.stroke_width });
|
||||
if n.appearance.fill().is_some() || n.appearance.stroke().is_some() {
|
||||
v["objectFill"] = json!(n.appearance.fill_paint().label());
|
||||
v["objectStroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["objectStrokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
} else if !n.is_container() {
|
||||
v["fill"] = json!(n.appearance.fill_paint().label());
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
|
||||
@@ -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).
|
||||
@@ -383,9 +391,18 @@ pub struct Prefs {
|
||||
pub large_tabs: bool,
|
||||
pub ui_scaling: f64,
|
||||
pub scale_cursor_with_ui: bool,
|
||||
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
|
||||
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
|
||||
pub interface_language: String,
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
|
||||
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
|
||||
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
|
||||
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
|
||||
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
|
||||
pub gpu_preference: String,
|
||||
pub history_states: u32,
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
@@ -476,6 +493,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 {
|
||||
@@ -564,8 +601,10 @@ impl Default for Prefs {
|
||||
large_tabs: false,
|
||||
ui_scaling: 1.0,
|
||||
scale_cursor_with_ui: false,
|
||||
interface_language: s("auto"),
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("powerSaving"),
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
@@ -609,6 +648,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 +719,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 +768,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 +810,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 +1072,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 +1093,7 @@ impl Session {
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1062,14 +1122,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 +1175,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 +1227,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 +1273,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 +1313,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 +1343,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,8 +1391,12 @@ mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
#[cfg(test)]
|
||||
mod tests_toolsettings;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
@@ -1312,4 +1405,6 @@ mod tests_widthpoints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
@@ -285,6 +285,39 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 50, "y": 60, "text": "HI", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||||
let node = |s: &mut Session| {
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap()
|
||||
};
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#ff0000"), Some("None"), Some(0.0)), "{t}");
|
||||
assert!(t.get("objectFill").is_none(), "no object-level paint: {t}");
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 3, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
// An object-level fill on the type stays distinguishable from its characters' paint.
|
||||
s.execute("appearance.addFill", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00", "item": 0, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!(t["objectFill"], "#ffff00", "{t}");
|
||||
assert_eq!(t["fill"], "#0000ff", "{t}");
|
||||
// A path keeps reporting its own appearance.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r]})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let rect = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == r).cloned().unwrap();
|
||||
assert_eq!(rect["fill"], "#ff0000");
|
||||
assert!(rect.get("objectFill").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id_and_doc() {
|
||||
let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,33 @@ fn export_selection_crops_to_the_selection() {
|
||||
assert_eq!(text.matches("<path").count(), 1, "{text}");
|
||||
}
|
||||
|
||||
/// A selection export reports the encoder's warnings like a whole-document export, and an empty
|
||||
/// list when nothing was approximated.
|
||||
#[test]
|
||||
fn export_selection_reports_the_encoders_warnings() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("transparency.set", &json!({"blend": "Multiply"})).unwrap();
|
||||
let warnings = |v: &serde_json::Value| v["warnings"].as_array().cloned().unwrap_or_else(|| panic!("no warnings key: {v}"));
|
||||
// Control: nothing approximated.
|
||||
let plain = s.execute("document.exportSelection", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(plain["format"], "svg");
|
||||
assert!(warnings(&plain).is_empty(), "{plain}");
|
||||
for (format, options, says) in [
|
||||
("webp", json!({"lossless": false}), "lossless"),
|
||||
("svg", json!({"profile": "tiny12"}), "blend"),
|
||||
("pdf", json!({"createLayers": true, "compatibility": "1.4"}), "layers"),
|
||||
] {
|
||||
let mut p = options.clone();
|
||||
p["format"] = json!(format);
|
||||
let sel = s.execute("document.exportSelection", &p).unwrap();
|
||||
let doc = s.execute("document.export", &p).unwrap();
|
||||
let w = warnings(&sel);
|
||||
assert!(w.iter().any(|w| w.as_str().unwrap_or("").to_lowercase().contains(says)), "{format}: {sel}");
|
||||
assert_eq!(w, warnings(&doc), "{format}: the same warnings as a whole-document export");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_opens_as_untitled() {
|
||||
let mut s = session();
|
||||
@@ -206,3 +233,30 @@ fn raster_export_too_large_is_an_error_not_a_crash() {
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"\x89PNG"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Japanese file and folder names save, open, export and title documents as typed: the native
|
||||
/// format, SVG, PNG and PDF, in a folder with a Japanese name too.
|
||||
#[test]
|
||||
fn japanese_file_names_save_open_and_export() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 80, "text": "日本語のテキスト"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("vc-日本語フォルダ-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("運動会カード(最終版)・2024.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(s.doc().unwrap().title(), "運動会カード(最終版)・2024.vectorcraft", "the tab names the file as typed");
|
||||
s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.path.as_deref(), Some(path.to_string_lossy().as_ref()));
|
||||
let mut found = false;
|
||||
d.doc.walk(|n| found |= matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if t.plain_text() == "日本語のテキスト"));
|
||||
assert!(found, "the Japanese text comes back");
|
||||
for (format, name) in [("svg", "書き出し.svg"), ("png", "書き出し.png"), ("pdf", "書き出し.pdf")] {
|
||||
let out = dir.join(name);
|
||||
s.execute("document.export", &json!({"format": format, "path": out.to_string_lossy()})).unwrap();
|
||||
assert!(std::fs::metadata(&out).is_ok_and(|m| m.len() > 0), "{name}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -122,3 +122,33 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
|
||||
assert_eq!(partial.keyboard_increment, 4.0);
|
||||
assert_eq!(partial.corner_radius, 12.0);
|
||||
}
|
||||
|
||||
/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
|
||||
/// through a save/load round trip, and a bad value is an error that changes nothing.
|
||||
#[test]
|
||||
fn gpu_preference_defaults_to_power_saving_and_validates() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "highPerformance");
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
|
||||
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
|
||||
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
|
||||
assert_eq!(back.gpu_preference, "highPerformance");
|
||||
// Preference files written before the preference existed get the default.
|
||||
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
|
||||
assert_eq!(old.gpu_preference, "powerSaving");
|
||||
let l = s.execute("prefs.list", &json!({})).unwrap();
|
||||
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
|
||||
assert_eq!(row["category"], "Performance");
|
||||
assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
|
||||
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
|
||||
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
|
||||
assert_eq!(s.prefs.gpu_preference, "powerSaving");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -153,6 +153,61 @@ fn scale_corners_keeps_or_scales_live_corner_radii() {
|
||||
assert!((n.geometric_bounds().unwrap().width() - 400.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The extent along x and y of each corner arc of an upright live rounded rectangle's path.
|
||||
fn corner_spans(n: &Node) -> Vec<(f64, f64)> {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a = &path.subpaths[0].anchors;
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
[(1, 2), (3, 4), (5, 6), (7, 0)].iter().map(|(i, j)| ((a[*j].p.x - a[*i].p.x).abs(), (a[*j].p.y - a[*i].p.y).abs())).collect()
|
||||
}
|
||||
|
||||
/// Every corner is a circular arc of radius `r`.
|
||||
fn round_corners(n: &Node, r: f64) -> bool {
|
||||
corner_spans(n).iter().all(|(x, y)| (x - r).abs() < 1e-9 && (y - r).abs() < 1e-9)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_corners_stay_circular_when_a_rectangle_is_scaled_unevenly() {
|
||||
// #291: a rounded square widened into a long rectangle keeps round corners.
|
||||
let mut s = session();
|
||||
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "radius": 10})));
|
||||
run(&mut s, "object.scale", json!({"sx": 300, "sy": 100, "corners": false}));
|
||||
let n = node(&s, id);
|
||||
assert!((n.geometric_bounds().unwrap().width() - 300.0).abs() < 1e-9);
|
||||
assert!(round_corners(&n, 10.0), "{:?}", corner_spans(&n));
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, .. }), .. } = &n.kind else { panic!("not live") };
|
||||
assert!((w - 300.0).abs() < 1e-9 && (h - 100.0).abs() < 1e-9 && (radii[0] - 10.0).abs() < 1e-9, "the panels read document units");
|
||||
// Scale Corners on: the radius scales by the mean scale (√(2 × 0.5) = 1 here, 2 below).
|
||||
run(&mut s, "object.scale", json!({"sx": 200, "sy": 50, "corners": true}));
|
||||
assert!(round_corners(&node(&s, id), 10.0), "{:?}", corner_spans(&node(&s, id)));
|
||||
run(&mut s, "object.scale", json!({"sx": 100, "sy": 400, "corners": true}));
|
||||
assert!(round_corners(&node(&s, id), 20.0), "{:?}", corner_spans(&node(&s, id)));
|
||||
// Undo brings each step back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(round_corners(&node(&s, id), 10.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(round_corners(&node(&s, id), 10.0));
|
||||
assert!((node(&s, id).geometric_bounds().unwrap().width() - 100.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bounding_box_drag_keeps_live_corners_circular() {
|
||||
// The Selection tool's handle drag previews `object.transform` from the shape as it began.
|
||||
let mut s = session();
|
||||
let id = id_of(&run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100, "radius": 25})));
|
||||
s.begin_interaction("Scale").unwrap();
|
||||
for sx in [1.5, 2.5, 4.0] {
|
||||
// Dragging the right-middle handle: scaled about the left edge.
|
||||
s.preview("object.transform", &json!({"matrix": [sx, 0, 0, 1, 50.0 - 50.0 * sx, 0]})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
let n = node(&s, id);
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 50.0).abs() < 1e-9 && (b.width() - 400.0).abs() < 1e-9 && (b.height() - 100.0).abs() < 1e-9, "{b:?}");
|
||||
assert!(round_corners(&n, 25.0), "{:?}", corner_spans(&n));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_journal_records_the_scale_options_used() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -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();
|
||||
@@ -236,6 +328,23 @@ fn select_all_and_cut_like_ui() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "abc defabc def");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_dragged_with_the_type_tools_is_the_dragged_rectangle() {
|
||||
for tool in ["type", "verticalType"] {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (k, x, y) in [(PointerKind::Down, 100.0, 100.0), (PointerKind::Drag, 300.0, 180.0), (PointerKind::Up, 300.0, 180.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
let t = obj(&s, s.doc().unwrap().selection.objects[0]);
|
||||
let TextKind::Area { frame } = &t.kind else { panic!("{tool}: not area type") };
|
||||
let b = frame.bounds().unwrap();
|
||||
let (a, z) = (t.xf * Point::new(b.x0, b.y0), t.xf * Point::new(b.x1, b.y1));
|
||||
assert!((a - Point::new(100.0, 100.0)).hypot() < 1e-9 && (z - Point::new(300.0, 180.0)).hypot() < 1e-9, "{tool}: {a:?} {z:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_from_a_circle_keeps_glyphs_inside() {
|
||||
let mut s = session();
|
||||
@@ -378,3 +487,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,269 @@
|
||||
//! Resizing area type (#252): a bounding-box handle drag and Area Type Options size the type area
|
||||
//! and the text reflows at its size; Direct Selection reshapes the frame by a corner or an edge.
|
||||
//! Point type still scales.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_text::TextLayout;
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it.";
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// 12 pt area type in a `w` × `h` frame at (`x`, `y`).
|
||||
fn area(s: &mut Session, x: f64, y: f64, w: f64, h: f64, text: &str) -> NodeId {
|
||||
id_of(&s.execute("text.create", &json!({"x": x, "y": y, "size": 12, "area": {"width": w, "height": h}, "text": text})).unwrap())
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
k => panic!("not text: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's anchors in text space.
|
||||
fn corners(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.anchors().map(|(_, _, a)| a.p).collect(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(s: &Session, id: NodeId) -> Rect {
|
||||
match text(s, id).kind {
|
||||
TextKind::Area { frame } => frame.bounds().unwrap(),
|
||||
k => panic!("not area type: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(s: &Session, id: NodeId) -> TextLayout {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id))
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn last_step(s: &Session) -> String {
|
||||
s.doc().unwrap().history.undo.last().unwrap().label.clone()
|
||||
}
|
||||
|
||||
/// Press, drag and release `tool` from `from` to `to`.
|
||||
fn drag(s: &mut Session, tool: &str, from: Point, to: Point) {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
fn near(a: Point, b: Point) -> bool {
|
||||
a.distance(b) < 1e-6
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_handle_drag_resizes_the_type_area_and_the_text_reflows() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let before = layout(&s, id);
|
||||
assert!(before.overflow, "the story overflows 120 × 40");
|
||||
sel(&mut s, &[id]);
|
||||
let n = undo_len(&s);
|
||||
// The bounding box's bottom-right handle, from (160, 80) to (200, 140): 160 × 100.
|
||||
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Resize Type Area");
|
||||
let r = frame(&s, id);
|
||||
assert!(near(r.origin(), Point::ZERO) && (r.width() - 160.0).abs() < 1e-6 && (r.height() - 100.0).abs() < 1e-6, "{r:?}");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)), "the type isn't scaled");
|
||||
assert!(t.runs.iter().all(|r| r.style.size == 12.0));
|
||||
let after = layout(&s, id);
|
||||
assert!(after.lines.len() > before.lines.len(), "more lines fit: {} → {}", before.lines.len(), after.lines.len());
|
||||
assert!(after.glyphs.len() > before.glyphs.len());
|
||||
assert!(!after.overflow, "the whole story fits 160 × 100");
|
||||
// The bounding box hugs the new frame.
|
||||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds(), Some(Rect::new(40.0, 40.0, 200.0, 140.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
assert!(layout(&s, id).overflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_type_still_scales_with_its_handles() {
|
||||
let mut s = session();
|
||||
let id = id_of(&s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "text": "Hello"})).unwrap());
|
||||
sel(&mut s, &[id]);
|
||||
let b = s.transform_box(&s.doc().unwrap().selection.objects).unwrap();
|
||||
let (tl, br) = (b.to_doc() * Handle::TopLeft.pos(b.rect), b.to_doc() * Handle::BottomRight.pos(b.rect));
|
||||
drag(&mut s, "selection", br, tl + (br - tl) * 2.0);
|
||||
assert_eq!(last_step(&s), "Scale");
|
||||
let c = text(&s, id).xf.as_coeffs();
|
||||
assert!((c[0] - 2.0).abs() < 1e-6 && (c[3] - 2.0).abs() < 1e-6, "the type doubles: {c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_other_objects_the_type_area_resizes_and_the_others_scale() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 40, "y": 100, "width": 120, "height": 20})).unwrap());
|
||||
sel(&mut s, &[id, r]);
|
||||
// The box spans (40, 40)–(160, 120); its right handle doubles the width.
|
||||
drag(&mut s, "selection", Point::new(160.0, 80.0), Point::new(280.0, 80.0));
|
||||
assert_eq!(last_step(&s), "Scale");
|
||||
assert_eq!(s.doc().unwrap().doc.node(r).unwrap().geometric_bounds(), Some(Rect::new(40.0, 100.0, 280.0, 120.0)));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 240.0, 40.0));
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_type_area_resizes_along_its_own_axes() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
sel(&mut s, &[id]);
|
||||
s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap();
|
||||
let xf = text(&s, id).xf;
|
||||
let b = s.transform_box(&[id]).unwrap();
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let along = (b.to_doc() * Handle::Right.pos(b.rect)) - (b.to_doc() * Handle::Left.pos(b.rect));
|
||||
drag(&mut s, "selection", right, right + along * 0.5);
|
||||
assert_eq!(text(&s, id).xf, xf, "the type keeps its size and angle");
|
||||
let r = frame(&s, id);
|
||||
assert!((r.width() - 180.0).abs() < 1e-6 && (r.height() - 40.0).abs() < 1e-6, "still a rectangle, 180 × 40: {r:?}");
|
||||
assert_eq!(corners(&s, id).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_drags_a_frame_corner_or_edge() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let glyphs = layout(&s, id).glyphs.len();
|
||||
let n = undo_len(&s);
|
||||
// The bottom-right corner, not selected yet: the corner moves, the others stay.
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(200.0, 140.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
let c = corners(&s, id);
|
||||
assert!(near(c[0], Point::ZERO) && near(c[1], Point::new(120.0, 0.0)) && near(c[3], Point::new(0.0, 40.0)), "{c:?}");
|
||||
assert!(near(c[2], Point::new(160.0, 100.0)), "{c:?}");
|
||||
assert!(layout(&s, id).glyphs.len() > glyphs, "more text fits the larger area");
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// The right edge: both its ends move, and the area stays a rectangle.
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 60.0), Point::new(180.0, 60.0));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 140.0, 40.0));
|
||||
assert_eq!(text(&s, id).runs[0].style.size, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_frame_reflows_its_thread() {
|
||||
let mut s = session();
|
||||
let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let b = area(&mut s, 300.0, 40.0, 120.0, 200.0, "");
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let rest = text(&s, b).plain_text().len();
|
||||
assert!(rest > 0, "the overflow flows on");
|
||||
// Bounding-box resize of the first frame to 160 × 100, as the Selection tool sends it.
|
||||
let m = Affine::translate((40.0, 40.0)) * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * Affine::translate((-40.0, -40.0));
|
||||
let c = m.as_coeffs();
|
||||
s.execute("object.transform", &json!({"ids": [a.0], "matrix": c, "typeAreas": true})).unwrap();
|
||||
assert!(text(&s, b).plain_text().len() < rest, "the second frame holds less");
|
||||
assert_eq!(text(&s, a).plain_text().len() + text(&s, b).plain_text().len(), STORY.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_size_the_type_area() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
sel(&mut s, &[id]);
|
||||
let q = s.execute("text.areaOptions", &json!({})).unwrap();
|
||||
assert_eq!((q["width"].as_f64(), q["height"].as_f64()), (Some(120.0), Some(40.0)));
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("text.areaOptions", &json!({"width": 160, "height": 100})).unwrap();
|
||||
assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(160.0), Some(100.0)));
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
|
||||
assert_eq!(text(&s, id).xf, Affine::translate((40.0, 40.0)));
|
||||
assert!(!layout(&s, id).overflow);
|
||||
// The dialog's OK sends the size back unchanged with the other options: only those change.
|
||||
s.execute("text.areaOptions", &json!({"width": 160, "height": 100, "inset": 4})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 160.0, 100.0));
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// Type scaled 200%: sizes are in points on the page, along the type's axes.
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [40, 40]})).unwrap();
|
||||
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["width"].as_f64(), Some(240.0));
|
||||
s.execute("text.areaOptions", &json!({"width": 300})).unwrap();
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 150.0, 40.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_area_takes_untrusted_params() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let point = id_of(&s.execute("text.create", &json!({"x": 40, "y": 300, "text": "Point"})).unwrap());
|
||||
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 50, "height": 50})).unwrap());
|
||||
let n = undo_len(&s);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"anchors": [[0, 2]], "dx": 1, "dy": 1}),
|
||||
json!({"id": point.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": rect.0, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": 99_999, "anchors": [[0, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [["a", 1], [0], "x"], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 9]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 1], [7, 0]], "dx": 1, "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": "far", "dy": 1}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": 0, "dy": 1e300}),
|
||||
json!({"id": id.0, "anchors": [[0, 2]], "dx": -1e7, "dy": 0}),
|
||||
] {
|
||||
assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n, "nothing changed");
|
||||
assert_eq!(frame(&s, id), Rect::new(0.0, 0.0, 120.0, 40.0));
|
||||
// Listed twice, an anchor still moves once.
|
||||
s.execute("text.reshapeArea", &json!({"id": id.0, "anchors": [[0, 2], [0, 2]], "dx": 10, "dy": 5})).unwrap();
|
||||
assert_eq!(corners(&s, id)[2], Point::new(130.0, 45.0));
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
// A frame collapsed to nothing lays out (everything overflows) without trouble.
|
||||
for m in [[0.0, 0.0, 0.0, 1.0, 40.0, 0.0], [1.0, 0.0, 0.0, 0.0, 0.0, 40.0], [0.0; 6]] {
|
||||
s.execute("object.transform", &json!({"ids": [id.0], "matrix": m, "typeAreas": true})).unwrap();
|
||||
assert!(layout(&s, id).overflow);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_area_is_a_registered_command() {
|
||||
let spec = cmd::find_command("text.reshapeArea").unwrap();
|
||||
assert_eq!(spec.label, "Reshape Type Area");
|
||||
assert!(spec.params.contains("anchors") && spec.params.contains("dx"));
|
||||
assert!(cmd::find_command("object.transform").unwrap().params.contains("typeAreas"));
|
||||
assert!(session().commands().iter().any(|c| c.id == "text.reshapeArea" && c.enabled));
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -148,6 +148,27 @@ fn free_transform_distort_mode_via_pointer() {
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_cmd_held_once_a_corner_drag_started_distorts_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("freeTransform", v).unwrap();
|
||||
let before = undo_len(&s);
|
||||
let cmd = Mods { cmd: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 210.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
|
||||
for p in [Point::new(250.0, 260.0), Point::new(100.0, 100.0), Point::new(200.0, 100.0), Point::new(100.0, 200.0)] {
|
||||
assert!(pts.iter().any(|q| q.distance(p) < 1e-6), "{p:?} not in {pts:?}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Distort"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_transform_side_handle_scales() {
|
||||
let mut s = session();
|
||||
|
||||
+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"));
|
||||
}
|
||||
@@ -910,6 +910,9 @@ fn arb_ps_token() -> impl Strategy<Value = String> {
|
||||
"stopped", "begin", "end", "dict", "array", "string", "get", "put", "getinterval", "putinterval", "aload", "astore", "forall",
|
||||
"[", "]", "<<", ">>", "{", "}", "matrix", "currentmatrix", "setmatrix", "transform", "itransform", "invertmatrix", "pathbbox",
|
||||
"clippath", "initclip", "currentpoint", "eexec", "cleartomark", "mark", "counttomark", "showpage", "gstate", "setgstate",
|
||||
"clipsave", "cliprestore", "languagelevel", "cvx", "findresource", "defineresource", "undefineresource", "resourcestatus",
|
||||
"resourceforall", "/Category", "/Generic", "/ProcSet", "(*)", "(G?n*\\\\*?)", "{pop}", "8 string",
|
||||
"(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource",
|
||||
"/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode",
|
||||
"/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "<ff00>", "<~87cURD]i,\"Ebo80~>",
|
||||
"true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 <ff000000ff00>]",
|
||||
@@ -1097,7 +1100,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 +1207,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))?;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_geom::Affine;
|
||||
use super::graphics::{Space, process};
|
||||
use super::interp::{Interp, matrix_of};
|
||||
use super::lex::{find, hex_decode};
|
||||
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
use crate::ps;
|
||||
|
||||
/// Most bytes one filter or image reads.
|
||||
@@ -223,20 +223,15 @@ impl Interp<'_> {
|
||||
ReadHexString | ReadString => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.borrow().len();
|
||||
let n = s.len();
|
||||
let got = if op == ReadString { self.read(&f, n)? } else { self.read_hex(&f, n)? };
|
||||
let full = got.len() == n;
|
||||
self.alloc(got.len())?;
|
||||
if let Some(d) = s.borrow_mut().get_mut(..got.len()) {
|
||||
d.copy_from_slice(&got);
|
||||
}
|
||||
self.push(Obj::string(got))?;
|
||||
self.push(Obj::Bool(full))?;
|
||||
self.fill(s, &got)?;
|
||||
self.push(Obj::Bool(got.len() == n))?;
|
||||
}
|
||||
ReadLine => {
|
||||
let s = self.pop_str()?;
|
||||
let Obj::File(f) = self.pop()? else { return ps_err("typecheck", "") };
|
||||
let n = s.borrow().len();
|
||||
let n = s.len();
|
||||
let mut line = vec![];
|
||||
let mut ended = false;
|
||||
while line.len() < n {
|
||||
@@ -252,8 +247,7 @@ impl Interp<'_> {
|
||||
Some(b) => line.push(*b),
|
||||
}
|
||||
}
|
||||
self.alloc(line.len())?;
|
||||
self.push(Obj::string(line))?;
|
||||
self.fill(s, &line)?;
|
||||
self.push(Obj::Bool(ended))?;
|
||||
}
|
||||
FlushFile | CloseFile => {
|
||||
@@ -307,12 +301,20 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read bytes `got` into string `s` and push the part of `s` they fill.
|
||||
fn fill(&mut self, s: Shared<u8>, got: &[u8]) -> Res {
|
||||
if !s.write(0, got) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, got.len())
|
||||
}
|
||||
|
||||
/// `filter`: a file that decodes its source. Over the program's own data it decodes when
|
||||
/// first read (the data follows the operator that reads it); over other data, at once.
|
||||
fn filter(&mut self) -> Res {
|
||||
let name = self.pop()?.text().ok_or(PsError::Ps("typecheck", "filter".into()))?;
|
||||
let sub = if &*name == "SubFileDecode" {
|
||||
let eod = self.pop_str()?.borrow().clone();
|
||||
let eod = self.pop_str()?.to_vec();
|
||||
let count = self.pop_count()?;
|
||||
(count, eod)
|
||||
} else {
|
||||
@@ -687,8 +689,8 @@ struct Pixels {
|
||||
fn source(o: &Obj) -> Res<Source> {
|
||||
Ok(match o {
|
||||
Obj::File(f) => Source::File(f.clone()),
|
||||
Obj::Str(s) => Source::Str(s.borrow().clone()),
|
||||
p @ Obj::Array { .. } => Source::Proc(Obj::Array { items: p.items().cloned().unwrap_or_default(), exec: true }),
|
||||
Obj::Str(s) => Source::Str(s.to_vec()),
|
||||
Obj::Array { items, .. } => Source::Proc(Obj::Array { items: items.clone(), exec: true }),
|
||||
_ => return ps_err("typecheck", "DataSource"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ pub(crate) struct GState {
|
||||
pub cur: Option<Point>,
|
||||
pub start: Option<Point>,
|
||||
pub clips: Vec<Arc<Clip>>,
|
||||
/// The clips `clipsave` saved, for `cliprestore`.
|
||||
pub saved_clips: Vec<Rc<[Arc<Clip>]>>,
|
||||
pub overprint: bool,
|
||||
pub font: Option<DictRef>,
|
||||
}
|
||||
@@ -128,6 +130,7 @@ impl Default for GState {
|
||||
cur: None,
|
||||
start: None,
|
||||
clips: vec![],
|
||||
saved_clips: vec![],
|
||||
overprint: false,
|
||||
font: None,
|
||||
}
|
||||
@@ -429,10 +432,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)
|
||||
}
|
||||
@@ -491,7 +492,7 @@ impl Interp<'_> {
|
||||
let (family, items) = match o {
|
||||
Obj::Name(n) | Obj::Exec(n) => (n.clone(), vec![]),
|
||||
Obj::Array { items, .. } => {
|
||||
let v = items.borrow().clone();
|
||||
let v = items.to_vec();
|
||||
let n = v.first().and_then(Obj::text).ok_or(PsError::Ps("typecheck", "setcolorspace".into()))?;
|
||||
(n, v)
|
||||
}
|
||||
@@ -704,7 +705,7 @@ impl Interp<'_> {
|
||||
let d = match f {
|
||||
Obj::Dict(d) => d.clone(),
|
||||
Obj::Array { items, .. } => {
|
||||
let fs = items.borrow().clone();
|
||||
let fs = items.to_vec();
|
||||
let mut out = vec![];
|
||||
for g in &fs {
|
||||
out.extend(self.eval(g, t, depth + 1)?.first().copied());
|
||||
@@ -732,7 +733,7 @@ impl Interp<'_> {
|
||||
Ok(c0.iter().zip(&c1).map(|(a, b)| a + x * (b - a)).collect())
|
||||
}
|
||||
Some(3.0) => {
|
||||
let fs = get("Functions").and_then(|o| o.items().map(|i| i.borrow().clone())).unwrap_or_default();
|
||||
let fs = get("Functions").and_then(|o| o.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
let bounds = nums(get("Bounds"));
|
||||
let encode = nums(get("Encode"));
|
||||
let (lo, hi) = (domain.first().copied().unwrap_or(0.0), domain.get(1).copied().unwrap_or(1.0));
|
||||
@@ -1097,6 +1098,19 @@ impl Interp<'_> {
|
||||
self.g.start = self.g.cur;
|
||||
}
|
||||
InitClip => self.g.clips.clear(),
|
||||
ClipSave => {
|
||||
if self.g.saved_clips.len() >= MAX_GSAVE {
|
||||
return Err(PsError::Limit("too many saved clips"));
|
||||
}
|
||||
self.alloc(std::mem::size_of_val(self.g.clips.as_slice()))?;
|
||||
let clips = self.g.clips.as_slice().into();
|
||||
self.g.saved_clips.push(clips);
|
||||
}
|
||||
ClipRestore => {
|
||||
if let Some(clips) = self.g.saved_clips.pop() {
|
||||
self.g.clips = clips.to_vec();
|
||||
}
|
||||
}
|
||||
Clip | EoClip => {
|
||||
let bp = self.g.path.clone();
|
||||
self.clip_with(bp, if op == EoClip { FillRule::EvenOdd } else { FillRule::NonZero });
|
||||
@@ -1120,7 +1134,7 @@ impl Interp<'_> {
|
||||
}
|
||||
RectStroke => {
|
||||
let m = match self.stack.last() {
|
||||
Some(Obj::Array { items, .. }) if items.borrow().len() == 6 => Some(self.pop_matrix()?),
|
||||
Some(Obj::Array { items, .. }) if items.len() == 6 => Some(self.pop_matrix()?),
|
||||
_ => None,
|
||||
};
|
||||
let mut bp = self.rects()?;
|
||||
@@ -1159,7 +1173,7 @@ fn linear_points(f: &Obj) -> Option<Vec<f64>> {
|
||||
match num("FunctionType")? {
|
||||
2.0 if num("N").unwrap_or(1.0) == 1.0 => Some(vec![]),
|
||||
3.0 => {
|
||||
let fs = d.get(&Key::name("Functions"))?.items()?.borrow().clone();
|
||||
let fs = d.get(&Key::name("Functions"))?.items()?.to_vec();
|
||||
if !fs.iter().all(|g| linear_points(g).is_some_and(|p| p.is_empty())) {
|
||||
return None;
|
||||
}
|
||||
|
||||
+139
-96
@@ -11,7 +11,7 @@ use vectorcraft_geom::Affine;
|
||||
|
||||
use super::graphics::{GState, Out};
|
||||
use super::lex::Lexer;
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
|
||||
/// Most operations one file may run.
|
||||
const MAX_OPS: u64 = 50_000_000;
|
||||
@@ -61,6 +61,12 @@ fn encoding() -> Obj {
|
||||
Obj::array(vec![Obj::name(".notdef"); 256])
|
||||
}
|
||||
|
||||
/// The resource categories there are to begin with (instances of `Category`).
|
||||
const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Form Pattern ProcSet ColorSpace Halftone \r
|
||||
ColorRendering IdiomSet InkParams TrapParams OutputDevice ControlLanguage Localization PDL HWOptions \r
|
||||
Filter ColorSpaceFamily Emulator IODevice ColorRenderingType FMapType FontType FormType HalftoneType \r
|
||||
ImageType PatternType FunctionType ShadingType";
|
||||
|
||||
impl<'a> Interp<'a> {
|
||||
pub fn new(src: &'a [u8], g: GState, out: Out) -> Self {
|
||||
let system = new_dict();
|
||||
@@ -72,7 +78,15 @@ impl<'a> Interp<'a> {
|
||||
for name in ["StandardEncoding", "ISOLatin1Encoding"] {
|
||||
d.insert(Key::name(name), encoding());
|
||||
}
|
||||
// An integer, as interpreters have it (`systemdict /languagelevel get 2 ge`): run, the
|
||||
// name pushes it all the same.
|
||||
d.insert(Key::name("languagelevel"), Obj::Int(3));
|
||||
}
|
||||
// Each category's implementation dictionary (`/Generic /Category findresource`).
|
||||
let categories: Dict =
|
||||
CATEGORIES.split_whitespace().map(|c| (Key::name(c), Obj::dict(Dict::from([(Key::name("Category"), Obj::name(c))])))).collect();
|
||||
let resources = new_dict();
|
||||
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
|
||||
let fonts = new_dict();
|
||||
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
|
||||
system.borrow_mut().insert(Key::name("GlobalFontDirectory"), Obj::Dict(fonts.clone()));
|
||||
@@ -87,7 +101,7 @@ impl<'a> Interp<'a> {
|
||||
status: new_dict(),
|
||||
error: new_dict(),
|
||||
fonts,
|
||||
resources: new_dict(),
|
||||
resources,
|
||||
depth: 0,
|
||||
ops: 0,
|
||||
allocated: 0,
|
||||
@@ -175,7 +189,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pop_str(&mut self) -> Res<Rc<RefCell<Vec<u8>>>> {
|
||||
pub fn pop_str(&mut self) -> Res<Shared<u8>> {
|
||||
match self.pop()? {
|
||||
Obj::Str(s) => Ok(s),
|
||||
_ => ps_err("typecheck", "a string"),
|
||||
@@ -183,7 +197,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// An array or procedure.
|
||||
pub fn pop_array(&mut self) -> Res<Rc<RefCell<Vec<Obj>>>> {
|
||||
pub fn pop_array(&mut self) -> Res<Shared<Obj>> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => Ok(items),
|
||||
_ => ps_err("typecheck", "an array"),
|
||||
@@ -197,15 +211,9 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// Store `m` into the matrix operand `items` and push it back.
|
||||
pub fn put_matrix(&mut self, items: Rc<RefCell<Vec<Obj>>>, m: Affine) -> Res {
|
||||
{
|
||||
let mut v = items.borrow_mut();
|
||||
if v.len() != 6 {
|
||||
return ps_err("rangecheck", "a matrix");
|
||||
}
|
||||
for (slot, c) in v.iter_mut().zip(m.as_coeffs()) {
|
||||
*slot = Obj::Real(c);
|
||||
}
|
||||
pub fn put_matrix(&mut self, items: Shared<Obj>, m: Affine) -> Res {
|
||||
if items.len() != 6 || !items.write(0, &m.as_coeffs().map(Obj::Real)) {
|
||||
return ps_err("rangecheck", "a matrix");
|
||||
}
|
||||
self.push(Obj::Array { items, exec: false })
|
||||
}
|
||||
@@ -276,24 +284,24 @@ impl<'a> Interp<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_proc(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
|
||||
fn run_proc(&mut self, items: &Shared<Obj>) -> Res {
|
||||
self.enter()?;
|
||||
let r = self.run_items(items);
|
||||
self.depth -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
fn run_items(&mut self, items: &Rc<RefCell<Vec<Obj>>>) -> Res {
|
||||
fn run_items(&mut self, items: &Shared<Obj>) -> Res {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let Some(o) = items.borrow().get(i).cloned() else { return Ok(()) };
|
||||
let Some(o) = items.get(i) else { return Ok(()) };
|
||||
i += 1;
|
||||
self.exec_token(o)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `proc` as a loop body: `Ok(false)` when it ran `exit`.
|
||||
fn body(&mut self, proc: &Obj) -> Res<bool> {
|
||||
pub fn body(&mut self, proc: &Obj) -> Res<bool> {
|
||||
self.tick()?;
|
||||
match self.call(proc.clone()) {
|
||||
Ok(()) => Ok(true),
|
||||
@@ -303,7 +311,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
|
||||
/// Run a procedure operand, executable or not (`exec`, `if`, loops).
|
||||
fn pop_proc(&mut self) -> Res<Obj> {
|
||||
pub fn pop_proc(&mut self) -> Res<Obj> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => Ok(Obj::Array { items, exec: true }),
|
||||
o @ (Obj::Op(_) | Obj::Exec(_)) => Ok(o),
|
||||
@@ -480,7 +488,7 @@ impl<'a> Interp<'a> {
|
||||
Gt | Ge | Lt | Le => {
|
||||
let (b, a) = (self.pop()?, self.pop()?);
|
||||
let ord = match (&a, &b) {
|
||||
(Obj::Str(x), Obj::Str(y)) => x.borrow().as_slice().partial_cmp(y.borrow().as_slice()),
|
||||
(Obj::Str(x), Obj::Str(y)) => x.borrow().partial_cmp(&*y.borrow()),
|
||||
_ => a.as_num().zip(b.as_num()).and_then(|(x, y)| x.partial_cmp(&y)),
|
||||
}
|
||||
.ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
@@ -646,14 +654,10 @@ impl<'a> Interp<'a> {
|
||||
} else {
|
||||
show_text(&self.pop()?)
|
||||
};
|
||||
let n = text.len();
|
||||
if n > s.borrow().len() {
|
||||
if !s.write(0, text.as_bytes()) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
if let Some(d) = s.borrow_mut().get_mut(..n) {
|
||||
d.copy_from_slice(text.as_bytes());
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, n)?;
|
||||
self.push_interval(Obj::Str(s), 0, text.len())?;
|
||||
}
|
||||
ReadOnly | ExecuteOnly | NoAccess | SetPacking | SetGlobal | SetObjectFormat => {
|
||||
if matches!(op, SetPacking | SetGlobal | SetObjectFormat) {
|
||||
@@ -683,8 +687,8 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Length => {
|
||||
let n = match self.pop()? {
|
||||
Obj::Array { items, .. } => items.borrow().len(),
|
||||
Obj::Str(s) => s.borrow().len(),
|
||||
Obj::Array { items, .. } => items.len(),
|
||||
Obj::Str(s) => s.len(),
|
||||
Obj::Dict(d) => d.borrow().len(),
|
||||
Obj::Name(n) | Obj::Exec(n) => n.len(),
|
||||
_ => return ps_err("typecheck", ""),
|
||||
@@ -699,14 +703,8 @@ impl<'a> Interp<'a> {
|
||||
Get => {
|
||||
let k = self.pop()?;
|
||||
let o = match self.pop()? {
|
||||
Obj::Array { items, .. } => {
|
||||
let i = index(&k)?;
|
||||
items.borrow().get(i).cloned().ok_or(PsError::Ps("rangecheck", String::new()))?
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let i = index(&k)?;
|
||||
Obj::Int(i64::from(*s.borrow().get(i).ok_or(PsError::Ps("rangecheck", String::new()))?))
|
||||
}
|
||||
Obj::Array { items, .. } => items.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?,
|
||||
Obj::Str(s) => Obj::Int(i64::from(s.get(index(&k)?).ok_or(PsError::Ps("rangecheck", String::new()))?)),
|
||||
Obj::Dict(d) => {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
let v = d.borrow().get(&key).cloned();
|
||||
@@ -718,21 +716,21 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Put => {
|
||||
let (v, k, c) = (self.pop()?, self.pop()?, self.pop()?);
|
||||
match c {
|
||||
Obj::Array { items, .. } => {
|
||||
let i = index(&k)?;
|
||||
*items.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = v;
|
||||
}
|
||||
let written = match c {
|
||||
Obj::Array { items, .. } => items.write(index(&k)?, std::slice::from_ref(&v)),
|
||||
Obj::Str(s) => {
|
||||
let i = index(&k)?;
|
||||
let b = v.as_num().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
*s.borrow_mut().get_mut(i).ok_or(PsError::Ps("rangecheck", String::new()))? = b as i64 as u8;
|
||||
s.write(index(&k)?, &[b as i64 as u8])
|
||||
}
|
||||
Obj::Dict(d) => {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", String::new()))?;
|
||||
insert(&d, key, v)?;
|
||||
true
|
||||
}
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
}
|
||||
GetInterval => {
|
||||
@@ -744,41 +742,35 @@ impl<'a> Interp<'a> {
|
||||
PutInterval => {
|
||||
let src = self.pop()?;
|
||||
let at = self.pop_count()?;
|
||||
match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => {
|
||||
let from = from.borrow().clone();
|
||||
let mut v = items.borrow_mut();
|
||||
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
d.clone_from_slice(&from);
|
||||
}
|
||||
(Obj::Str(s), Obj::Str(from)) => {
|
||||
let from = from.borrow().clone();
|
||||
let mut v = s.borrow_mut();
|
||||
let d = v.get_mut(at..at + from.len()).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
d.copy_from_slice(&from);
|
||||
}
|
||||
let written = match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()),
|
||||
(Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()),
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
}
|
||||
Aload => {
|
||||
let items = self.pop_array()?;
|
||||
let all = items.borrow().clone();
|
||||
for o in all {
|
||||
for o in items.to_vec() {
|
||||
self.push(o)?;
|
||||
}
|
||||
self.push(Obj::Array { items, exec: false })?;
|
||||
}
|
||||
Astore => {
|
||||
let items = self.pop_array()?;
|
||||
let n = items.borrow().len();
|
||||
let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("stackunderflow", String::new()))?;
|
||||
*items.borrow_mut() = self.stack.split_off(at);
|
||||
let at = self.stack.len().checked_sub(items.len()).ok_or(PsError::Ps("stackunderflow", String::new()))?;
|
||||
if !items.write(0, self.stack.get(at..).unwrap_or_default()) {
|
||||
return ps_err("rangecheck", "");
|
||||
}
|
||||
self.stack.truncate(at);
|
||||
self.push(Obj::Array { items, exec: false })?;
|
||||
}
|
||||
Search | AnchorSearch => {
|
||||
let seek = self.pop_str()?.borrow().clone();
|
||||
let seek = self.pop_str()?.to_vec();
|
||||
let s = self.pop_str()?;
|
||||
let text = s.borrow().clone();
|
||||
let text = s.to_vec();
|
||||
let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) };
|
||||
match at {
|
||||
Some(i) => {
|
||||
@@ -888,7 +880,6 @@ impl<'a> Interp<'a> {
|
||||
self.push(Obj::Int(v))?;
|
||||
}
|
||||
}
|
||||
LanguageLevel => self.push(Obj::Int(3))?,
|
||||
Version => self.push(Obj::string(b"3010".to_vec()))?,
|
||||
Product => self.push(Obj::string(b"VectorCraft".to_vec()))?,
|
||||
RealTime | UserTime => self.push(Obj::Int(0))?,
|
||||
@@ -922,19 +913,18 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
Obj::Array { items, exec } => {
|
||||
let from = self.pop_array()?.borrow().clone();
|
||||
let n = from.len();
|
||||
{
|
||||
let mut v = items.borrow_mut();
|
||||
v.get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.clone_from_slice(&from);
|
||||
let from = self.pop_array()?.to_vec();
|
||||
if !items.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
self.push_interval(Obj::Array { items, exec }, 0, n)?;
|
||||
self.push_interval(Obj::Array { items, exec }, 0, from.len())?;
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let from = self.pop_str()?.borrow().clone();
|
||||
let n = from.len();
|
||||
s.borrow_mut().get_mut(..n).ok_or(PsError::Ps("rangecheck", String::new()))?.copy_from_slice(&from);
|
||||
self.push_interval(Obj::Str(s), 0, n)?;
|
||||
let from = self.pop_str()?.to_vec();
|
||||
if !s.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
self.push_interval(Obj::Str(s), 0, from.len())?;
|
||||
}
|
||||
Obj::Dict(d) => {
|
||||
let from = self.pop_dict()?.borrow().clone();
|
||||
@@ -946,16 +936,12 @@ impl<'a> Interp<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `n` items of an array or string from `at` (a copy: shared storage isn't modelled).
|
||||
fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
|
||||
let end = at.checked_add(n).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
self.alloc(n * if matches!(o, Obj::Str(_)) { 1 } else { std::mem::size_of::<Obj>() })?;
|
||||
/// `n` items of an array or string from `at`, sharing its storage.
|
||||
pub fn push_interval(&mut self, o: Obj, at: usize, n: usize) -> Res {
|
||||
let range = || PsError::Ps("rangecheck", String::new());
|
||||
let r = match o {
|
||||
Obj::Array { items, exec } => {
|
||||
let v = items.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec();
|
||||
Obj::Array { items: Rc::new(RefCell::new(v)), exec }
|
||||
}
|
||||
Obj::Str(s) => Obj::string(s.borrow().get(at..end).ok_or(PsError::Ps("rangecheck", String::new()))?.to_vec()),
|
||||
Obj::Array { items, exec } => Obj::Array { items: items.sub(at, n).ok_or_else(range)?, exec },
|
||||
Obj::Str(s) => Obj::Str(s.sub(at, n).ok_or_else(range)?),
|
||||
_ => return ps_err("typecheck", "getinterval"),
|
||||
};
|
||||
self.push(r)
|
||||
@@ -966,7 +952,7 @@ impl<'a> Interp<'a> {
|
||||
match self.pop()? {
|
||||
Obj::Array { items, .. } => {
|
||||
let mut i = 0;
|
||||
while let Some(o) = items.borrow().get(i).cloned() {
|
||||
while let Some(o) = items.get(i) {
|
||||
i += 1;
|
||||
self.push(o)?;
|
||||
if !self.body(&p)? {
|
||||
@@ -975,8 +961,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let bytes = s.borrow().clone();
|
||||
for b in bytes {
|
||||
for b in s.to_vec() {
|
||||
self.push(Obj::Int(i64::from(b)))?;
|
||||
if !self.body(&p)? {
|
||||
break;
|
||||
@@ -1000,22 +985,19 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Replace the names in a procedure that stand for operators by the operators, down through
|
||||
/// nested procedures.
|
||||
fn bind(&mut self, items: &Rc<RefCell<Vec<Obj>>>, depth: u32) {
|
||||
fn bind(&mut self, items: &Shared<Obj>, depth: u32) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
let n = items.borrow().len();
|
||||
for i in 0..n {
|
||||
let o = items.borrow().get(i).cloned();
|
||||
match o {
|
||||
for i in 0..items.len() {
|
||||
match items.get(i) {
|
||||
Some(Obj::Exec(name)) => {
|
||||
if let Some(Obj::Op(op)) = self.lookup(&name)
|
||||
&& let Some(slot) = items.borrow_mut().get_mut(i)
|
||||
{
|
||||
*slot = Obj::Op(op);
|
||||
if let Some(o @ Obj::Op(_)) = self.lookup(&name) {
|
||||
// `i` is in range and nothing is reading the procedure: the write can't fail.
|
||||
items.write(i, &[o]);
|
||||
}
|
||||
}
|
||||
Some(Obj::Array { items: inner, exec: true }) if !Rc::ptr_eq(&inner, items) => self.bind(&inner, depth + 1),
|
||||
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1032,6 +1014,67 @@ impl<'a> Interp<'a> {
|
||||
_ => ps_err("typecheck", ""),
|
||||
})
|
||||
}
|
||||
|
||||
/// The instances of `category`: `FontDirectory` for fonts.
|
||||
pub fn instances(&mut self, category: &Obj) -> Res<DictRef> {
|
||||
if category.text().as_deref() == Some("Font") { Ok(self.fonts.clone()) } else { self.category(category) }
|
||||
}
|
||||
|
||||
/// `template proc scratch category resourceforall`: `proc` run on each instance name that
|
||||
/// matches `template`, copied into `scratch` (in name order).
|
||||
pub fn resource_for_all(&mut self) -> Res {
|
||||
let category = self.pop()?;
|
||||
let scratch = self.pop_str()?;
|
||||
let p = self.pop_proc()?;
|
||||
let template = self.pop_str()?.to_vec();
|
||||
let mut names: Vec<Vec<u8>> = self.instances(&category)?.borrow().keys().map(|k| show_text(&k.obj()).into_bytes()).collect();
|
||||
names.sort();
|
||||
for name in names {
|
||||
if !self.matches(&template, &name)? {
|
||||
continue;
|
||||
}
|
||||
if !scratch.write(0, &name) {
|
||||
return ps_err("rangecheck", "resourceforall");
|
||||
}
|
||||
self.push_interval(Obj::Str(scratch.clone()), 0, name.len())?;
|
||||
if !self.body(&p)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does `name` match a `resourceforall` template: `*` any run of bytes, `?` any one, `\` the
|
||||
/// next one as it is? Each step counts as an operation.
|
||||
fn matches(&mut self, template: &[u8], name: &[u8]) -> Res<bool> {
|
||||
let (mut t, mut n) = (0, 0);
|
||||
// Where the last `*` was followed (template, name): a mismatch after it retries from there.
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
loop {
|
||||
self.tick()?;
|
||||
if n == name.len() {
|
||||
return Ok(template.get(t..).unwrap_or_default().iter().all(|b| *b == b'*'));
|
||||
}
|
||||
let (want, width) = match template.get(t) {
|
||||
Some(b'*') => {
|
||||
t += 1;
|
||||
star = Some((t, n));
|
||||
continue;
|
||||
}
|
||||
Some(b'?') => (name.get(n).copied(), 1),
|
||||
Some(b'\\') => (template.get(t + 1).copied(), 2),
|
||||
c => (c.copied(), 1),
|
||||
};
|
||||
if want.is_some() && want == name.get(n).copied() {
|
||||
(t, n) = (t + width, n + 1);
|
||||
continue;
|
||||
}
|
||||
// The last `*` takes one more byte, else there is no match.
|
||||
let Some((st, sn)) = star else { return Ok(false) };
|
||||
star = Some((st, sn + 1));
|
||||
(t, n) = (st, sn + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `v` under `k` in `d`, within [`MAX_ALLOC`] entries.
|
||||
@@ -1057,7 +1100,7 @@ fn equal(a: &Obj, b: &Obj) -> bool {
|
||||
match (a, b) {
|
||||
(Obj::Null, Obj::Null) | (Obj::Mark, Obj::Mark) => true,
|
||||
(Obj::Bool(x), Obj::Bool(y)) => x == y,
|
||||
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => Rc::ptr_eq(x, y),
|
||||
(Obj::Array { items: x, .. }, Obj::Array { items: y, .. }) => x.same(y),
|
||||
(Obj::Dict(x), Obj::Dict(y)) => Rc::ptr_eq(x, y),
|
||||
(Obj::Op(x), Obj::Op(y)) => x == y,
|
||||
_ => match (a.as_num(), b.as_num()) {
|
||||
|
||||
@@ -192,7 +192,10 @@ fn finish(mut out: Out) -> Imported {
|
||||
|
||||
/// A document of `frame`'s size showing TIFF preview `tiff` stretched over it.
|
||||
fn preview(tiff: &[u8], frame: Rect) -> Option<Document> {
|
||||
let img = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).ok()?.to_rgba8();
|
||||
let img = match image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff) {
|
||||
Ok(img) => img.to_rgba8(),
|
||||
Err(_) => crate::tiff::palette_rgba(tiff)?,
|
||||
};
|
||||
let (w, h) = img.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The PostScript objects the interpreter works with, its operators and its errors.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Ref, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -31,10 +31,10 @@ pub(crate) enum Obj {
|
||||
Name(Rc<str>),
|
||||
/// An executable name: looked up and run.
|
||||
Exec(Rc<str>),
|
||||
Str(Rc<RefCell<Vec<u8>>>),
|
||||
Str(Shared<u8>),
|
||||
/// An array; executable ones are procedures.
|
||||
Array {
|
||||
items: Rc<RefCell<Vec<Obj>>>,
|
||||
items: Shared<Obj>,
|
||||
exec: bool,
|
||||
},
|
||||
Dict(DictRef),
|
||||
@@ -53,15 +53,15 @@ impl Obj {
|
||||
}
|
||||
|
||||
pub fn string(bytes: Vec<u8>) -> Self {
|
||||
Self::Str(Rc::new(RefCell::new(bytes)))
|
||||
Self::Str(Shared::new(bytes))
|
||||
}
|
||||
|
||||
pub fn array(items: Vec<Obj>) -> Self {
|
||||
Self::Array { items: Rc::new(RefCell::new(items)), exec: false }
|
||||
Self::Array { items: Shared::new(items), exec: false }
|
||||
}
|
||||
|
||||
pub fn proc(items: Vec<Obj>) -> Self {
|
||||
Self::Array { items: Rc::new(RefCell::new(items)), exec: true }
|
||||
Self::Array { items: Shared::new(items), exec: true }
|
||||
}
|
||||
|
||||
pub fn dict(d: Dict) -> Self {
|
||||
@@ -98,7 +98,7 @@ impl Obj {
|
||||
}
|
||||
|
||||
/// The items of an array or procedure.
|
||||
pub fn items(&self) -> Option<&Rc<RefCell<Vec<Obj>>>> {
|
||||
pub fn items(&self) -> Option<&Shared<Obj>> {
|
||||
match self {
|
||||
Self::Array { items, .. } => Some(items),
|
||||
_ => None,
|
||||
@@ -125,6 +125,71 @@ impl Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of a string or an array: a run of storage that other strings or arrays may share
|
||||
/// (`getinterval` makes a new object over part of the same values, as PostScript does).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Shared<T> {
|
||||
buf: Rc<RefCell<Vec<T>>>,
|
||||
at: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Clone> Shared<T> {
|
||||
pub fn new(v: Vec<T>) -> Self {
|
||||
Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 }
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
/// The values. Only [`Self::write`] borrows the storage mutably, for the copy alone, so this
|
||||
/// never meets it.
|
||||
pub fn borrow(&self) -> Ref<'_, [T]> {
|
||||
let (at, end) = (self.at, self.at.saturating_add(self.len));
|
||||
Ref::map(self.buf.borrow(), |v| v.get(at..end).unwrap_or(&[]))
|
||||
}
|
||||
|
||||
pub fn get(&self, i: usize) -> Option<T> {
|
||||
self.borrow().get(i).cloned()
|
||||
}
|
||||
|
||||
pub fn to_vec(&self) -> Vec<T> {
|
||||
self.borrow().to_vec()
|
||||
}
|
||||
|
||||
/// `n` values from `at`, sharing the storage (`getinterval`); `None` past the end.
|
||||
pub fn sub(&self, at: usize, n: usize) -> Option<Self> {
|
||||
(at.checked_add(n)? <= self.len).then(|| Self { buf: self.buf.clone(), at: self.at + at, len: n })
|
||||
}
|
||||
|
||||
/// Write `vals` from `at`: false when they don't fit (or `vals` is borrowed from the storage).
|
||||
pub fn write(&self, at: usize, vals: &[T]) -> bool {
|
||||
if at.saturating_add(vals.len()) > self.len {
|
||||
return false;
|
||||
}
|
||||
let start = self.at + at;
|
||||
let Ok(mut v) = self.buf.try_borrow_mut() else { return false };
|
||||
match v.get_mut(start..start + vals.len()) {
|
||||
Some(d) => {
|
||||
d.clone_from_slice(vals);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same object (`eq` on arrays): the same values of the same storage.
|
||||
pub fn same(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.buf, &o.buf) && self.at == o.at && self.len == o.len
|
||||
}
|
||||
|
||||
/// Do both see the same storage?
|
||||
pub fn shares(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.buf, &o.buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn name(s: &str) -> Self {
|
||||
Self::Name(Rc::from(s))
|
||||
@@ -209,7 +274,7 @@ ops! {
|
||||
ErrorDict = "errordict", DollarError = "$error", Bind = "bind",
|
||||
// Virtual memory and the environment.
|
||||
Save = "save", Restore = "restore", SetGlobal = "setglobal", CurrentGlobal = "currentglobal", VmStatus = "vmstatus",
|
||||
LanguageLevel = "languagelevel", Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
Version = "version", Product = "product", RealTime = "realtime", UserTime = "usertime",
|
||||
Print = "print", EqPrint = "=", EqEqPrint = "==", Pstack = "pstack", Stack = "stack", Flush = "flush",
|
||||
// Graphics state.
|
||||
Gsave = "gsave", Grestore = "grestore", GrestoreAll = "grestoreall", InitGraphics = "initgraphics",
|
||||
@@ -242,8 +307,8 @@ ops! {
|
||||
RLineTo = "rlineto", CurveTo = "curveto", RCurveTo = "rcurveto", Arc = "arc", Arcn = "arcn", Arct = "arct",
|
||||
Arcto = "arcto", ClosePath = "closepath", FlattenPath = "flattenpath", ReversePath = "reversepath",
|
||||
PathBBox = "pathbbox", ClipPath = "clippath", InitClip = "initclip", Clip = "clip", EoClip = "eoclip",
|
||||
RectClip = "rectclip", Fill = "fill", EoFill = "eofill", Stroke = "stroke", RectFill = "rectfill",
|
||||
RectStroke = "rectstroke", ShFill = "shfill",
|
||||
RectClip = "rectclip", ClipSave = "clipsave", ClipRestore = "cliprestore", Fill = "fill", EoFill = "eofill",
|
||||
Stroke = "stroke", RectFill = "rectfill", RectStroke = "rectstroke", ShFill = "shfill",
|
||||
// Images.
|
||||
Image = "image", ImageMask = "imagemask", ColorImage = "colorimage",
|
||||
// Files.
|
||||
@@ -252,7 +317,8 @@ ops! {
|
||||
// Fonts and type.
|
||||
FindFont = "findfont", ScaleFont = "scalefont", MakeFont = "makefont", SetFont = "setfont", SelectFont = "selectfont",
|
||||
CurrentFont = "currentfont", DefineFont = "definefont", UndefineFont = "undefinefont", FindResource = "findresource",
|
||||
DefineResource = "defineresource", ResourceStatus = "resourcestatus", Show = "show", AShow = "ashow",
|
||||
DefineResource = "defineresource", UndefineResource = "undefineresource", ResourceStatus = "resourcestatus",
|
||||
ResourceForAll = "resourceforall", Show = "show", AShow = "ashow",
|
||||
WidthShow = "widthshow", AWidthShow = "awidthshow", XShow = "xshow", YShow = "yshow", XYShow = "xyshow",
|
||||
KShow = "kshow", GlyphShow = "glyphshow", StringWidth = "stringwidth", CharPath = "charpath",
|
||||
SetCacheDevice = "setcachedevice", SetCharWidth = "setcharwidth",
|
||||
|
||||
@@ -452,3 +452,234 @@ fn device_settings_are_accepted_and_change_nothing() {
|
||||
assert_eq!(objects(&r.document).len(), 1, "{:?}", r.warnings);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Check that `body` draws one square, blue: it sets blue when what it checks holds, else red.
|
||||
fn blue(body: &str) {
|
||||
let r = read(&format!("{body} 10 10 80 80 rectfill"));
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 1, "{body}: {:?}", r.warnings);
|
||||
assert_eq!(fill(&o[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0))), "{body}");
|
||||
assert!(r.warnings.is_empty(), "{body}: {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Check that PostScript `cond` leaves `true`.
|
||||
fn check(cond: &str) {
|
||||
blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_generic_category_defines_new_categories() {
|
||||
// #234: the reference app's prolog.
|
||||
blue("/Generic /Category findresource pop 0 0 1 setrgbcolor");
|
||||
blue(
|
||||
"[/CSA /Gradient /Procedure] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall 0 0 1 setrgbcolor",
|
||||
);
|
||||
let define = "[/CSA /Gradient] { /Generic /Category findresource dup length dict copy /Category defineresource pop } forall \
|
||||
/G1 << /Kind 7 >> /Gradient defineresource pop";
|
||||
check(&format!("{define} /G1 /Gradient findresource /Kind get 7 eq"));
|
||||
check(&format!("{define} /Gradient /Category resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||||
check(&format!("{define} /G1 /Gradient resourcestatus {{ pop pop true }} {{ false }} ifelse"));
|
||||
check(&format!("{define} /G1 /Gradient undefineresource /G1 /Gradient resourcestatus not"));
|
||||
check(&format!("{define} {{ /G2 /Gradient findresource }} stopped"));
|
||||
// Unknown categories aren't instances of Category; a category is a dictionary.
|
||||
check("/Nonsense /Category resourcestatus not");
|
||||
check("{ /Nonsense /Category findresource } stopped");
|
||||
check("{ /X 5 /Category defineresource } stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipsave_and_cliprestore_bring_the_clip_back() {
|
||||
// #234.
|
||||
blue("clipsave 0 0 50 50 rectclip cliprestore 0 0 1 setrgbcolor");
|
||||
// The fill after cliprestore covers the whole square, unclipped.
|
||||
let r = read("clipsave 0 0 50 50 rectclip 1 0 0 setrgbcolor 0 0 10 10 rectfill cliprestore 0 0 1 setrgbcolor 10 10 80 80 rectfill");
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 2, "{:?}", r.warnings);
|
||||
assert!(matches!(o[0].kind, NodeKind::Group { clip: true, .. }), "{:?}", o[0].kind);
|
||||
assert!(matches!(o[1].kind, NodeKind::Path { clipping: false, .. }), "{:?}", o[1].kind);
|
||||
assert!(near(bounds(&o[1]), Rect::new(10.0, 10.0, 90.0, 90.0)), "{:?}", bounds(&o[1]));
|
||||
// Nested, leaving the rest of the graphics state alone.
|
||||
let r = read(
|
||||
"0 0 60 60 rectclip clipsave 0 0 20 20 rectclip clipsave 0 0 5 5 rectclip cliprestore 0 1 0 setrgbcolor cliprestore \
|
||||
10 10 80 80 rectfill",
|
||||
);
|
||||
let NodeKind::Group { children, clip: true } = &objects(&r.document)[0].kind else { panic!("{:?}", r.warnings) };
|
||||
assert!(near(bounds(&children[0]), Rect::new(0.0, 40.0, 60.0, 100.0)), "{:?}", bounds(&children[0]));
|
||||
assert_eq!(fill(&children[1]), Some(&Paint::solid(Color::rgb(0.0, 1.0, 0.0))));
|
||||
// grestore undoes the clipsaves after its gsave; cliprestore with nothing saved does nothing.
|
||||
let o = objects(&read("cliprestore gsave clipsave 0 0 50 50 rectclip grestore cliprestore 10 10 80 80 rectfill").document);
|
||||
assert!(matches!(o[0].kind, NodeKind::Path { .. }), "{:?}", o[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn languagelevel_is_an_integer_and_runs() {
|
||||
// #234.
|
||||
blue(
|
||||
"/Level2? systemdict /languagelevel known dup { pop systemdict /languagelevel get 2 ge } if def \
|
||||
Level2? { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse",
|
||||
);
|
||||
check("languagelevel 3 eq");
|
||||
check("{ languagelevel } bind exec 3 eq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_share_their_arrays_and_strings() {
|
||||
// #234: astore into a subarray stores into the array.
|
||||
blue("/a 4 array def 1 2 a 0 2 getinterval astore pop a 0 get 1 eq { 0 0 1 setrgbcolor } { 1 0 0 setrgbcolor } ifelse");
|
||||
// put, putinterval and copy into an interval; an interval of an interval.
|
||||
check("/a [0 0 0 0 0] def a 1 3 getinterval 1 2 getinterval 0 7 put a 2 get 7 eq");
|
||||
check("/a [0 0 0 0] def a 2 2 getinterval 0 [8 9] putinterval a 3 get 9 eq");
|
||||
check("/a [0 0 0 0] def [5 6] a 1 2 getinterval copy pop a 2 get 6 eq");
|
||||
check("/s (abcd) def s 1 2 getinterval 0 (XY) putinterval s (aXYd) eq");
|
||||
check("/s (abcd) def s 2 1 getinterval 0 90 put s (abZd) eq");
|
||||
check("/s 8 string def 42 s 2 6 getinterval cvs pop s 2 2 getinterval (42) eq");
|
||||
// The same interval is the same object; another one is not.
|
||||
check("/a 4 array def a 0 2 getinterval a 0 2 getinterval eq");
|
||||
check("/a 4 array def a 0 2 getinterval a 0 3 getinterval ne a 0 2 getinterval a 1 2 getinterval ne and");
|
||||
// Reads and writes stay inside the interval.
|
||||
check("/a [1 2 3 4] def { a 1 2 getinterval 2 get } stopped { a 1 2 getinterval 2 0 put } stopped and");
|
||||
check("/a [1 2 3 4] def a 1 2 getinterval length 2 eq a 1 2 getinterval aload pop 3 eq exch 2 eq and and");
|
||||
check("/n 0 def [1 2 3 4] 1 2 getinterval { n add /n exch def } forall n 5 eq");
|
||||
// A procedure from an interval runs only its part.
|
||||
check("{ 1 2 3 } 1 2 getinterval cvx exec add 5 eq");
|
||||
// The prolog's stack save: the operands into part of one array, and back.
|
||||
check(
|
||||
"/stk 10 array def /lev 3 array def 1 2 3 count dup lev exch 0 exch put stk exch 0 exch getinterval astore pop \
|
||||
clear stk 0 lev 0 get getinterval aload pop add add 6 eq",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resourceforall_enumerates_matching_names() {
|
||||
// #234.
|
||||
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
|
||||
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
|
||||
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
|
||||
check(&format!("{define} {} 4 eq", count("*")));
|
||||
check(&format!("{define} {} 2 eq", count("Fo?")));
|
||||
check(&format!("{define} {} 3 eq", count("F*")));
|
||||
check(&format!("{define} {} 1 eq", count("F\\\\*x")));
|
||||
check(&format!("{define} {} 0 eq", count("Q*")));
|
||||
check(&format!("{define} {} 1 eq", count("*a*")));
|
||||
// Each name is copied into the scratch string; exit stops.
|
||||
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
|
||||
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
|
||||
// A scratch string too small for the names, as the prolog probes it.
|
||||
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
|
||||
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
|
||||
}
|
||||
|
||||
/// A `w` × `h` 8-bit palette TIFF with an alpha sample (`ExtraSamples` [1]), uncompressed in
|
||||
/// strips of `per` rows, in either byte order, as the reference app writes its EPS previews:
|
||||
/// pixel (x, y) is entry `(x + y) % 3` of a red, green, blue map, at alpha `12 x`.
|
||||
fn palette_tiff(w: u16, h: u16, per: u16, big: bool) -> Vec<u8> {
|
||||
let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() };
|
||||
// Shorts in an entry, left-justified.
|
||||
let shorts = |v: &[u16]| -> [u8; 4] {
|
||||
let mut f = [0; 4];
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
f[2 * i..2 * i + 2].copy_from_slice(&u16b(*s));
|
||||
}
|
||||
f
|
||||
};
|
||||
let row = u32::from(w) * 2;
|
||||
let mut out = vec![];
|
||||
out.extend(if big { *b"MM\0*" } else { *b"II*\0" });
|
||||
out.extend([0; 4]);
|
||||
let data_at = out.len() as u32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
out.extend([((x + y) % 3) as u8, (x * 12) as u8]);
|
||||
}
|
||||
}
|
||||
let map_at = out.len() as u32;
|
||||
for (c, full) in [0xFFFF, 0x8000, 0xFFFF].into_iter().enumerate() {
|
||||
for i in 0..256 {
|
||||
out.extend(u16b(if i == c { full } else { 0 }));
|
||||
}
|
||||
}
|
||||
let strips: Vec<u16> = (0..h.div_ceil(per)).collect();
|
||||
let offsets_at = out.len() as u32;
|
||||
for k in &strips {
|
||||
out.extend(u32b(data_at + u32::from(k * per) * row));
|
||||
}
|
||||
let counts_at = out.len() as u32;
|
||||
for k in &strips {
|
||||
out.extend(u32b(u32::from(per.min(h - k * per)) * row));
|
||||
}
|
||||
let ifd = out.len() as u32;
|
||||
out[4..8].copy_from_slice(&u32b(ifd));
|
||||
let n = strips.len() as u32;
|
||||
let entries: [(u16, u16, u32, [u8; 4]); 12] = [
|
||||
(256, 3, 1, shorts(&[w])),
|
||||
(257, 3, 1, shorts(&[h])),
|
||||
(258, 3, 2, shorts(&[8, 8])),
|
||||
(259, 3, 1, shorts(&[1])),
|
||||
(262, 3, 1, shorts(&[3])),
|
||||
(273, 4, n, u32b(if n == 1 { data_at } else { offsets_at })),
|
||||
(277, 3, 1, shorts(&[2])),
|
||||
(278, 3, 1, shorts(&[per])),
|
||||
(279, 4, n, u32b(if n == 1 { u32::from(h) * row } else { counts_at })),
|
||||
(284, 3, 1, shorts(&[1])),
|
||||
(320, 3, 768, u32b(map_at)),
|
||||
(338, 3, 1, shorts(&[1])),
|
||||
];
|
||||
out.extend(u16b(entries.len() as u16));
|
||||
for (tag, kind, count, field) in entries {
|
||||
out.extend(u16b(tag));
|
||||
out.extend(u16b(kind));
|
||||
out.extend(u32b(count));
|
||||
out.extend(field);
|
||||
}
|
||||
out.extend([0; 4]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_palette_preview_with_alpha_is_placed() {
|
||||
// #234: the program fails, so the preview is placed.
|
||||
let ps = eps("0 0 1 setrgbcolor 10 10 80 80 rectfill frobnicate");
|
||||
for (big, per) in [(false, 5), (true, 5), (false, 10), (true, 3)] {
|
||||
let tiff = palette_tiff(20, 10, per, big);
|
||||
let r = import(&crate::dos_eps(&ps, &tiff).unwrap()).unwrap();
|
||||
assert!(r.preview, "{big} {per}");
|
||||
assert!(r.warnings[0].contains("preview"), "{:?}", r.warnings);
|
||||
let o = objects(&r.document);
|
||||
let NodeKind::Image(im) = &o[0].kind else { panic!("{:?}", o[0].kind) };
|
||||
assert_eq!((im.width, im.height), (20, 10));
|
||||
assert!(near(bounds(&o[0]), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
let png = image::load_from_memory(&r.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
for (x, y) in [(0, 0), (1, 0), (2, 0), (19, 9), (7, 4), (3, 6)] {
|
||||
let [r, g, b] = [[255, 0, 0], [0, 128, 0], [0, 0, 255]][((x + y) % 3) as usize];
|
||||
assert_eq!(png.get_pixel(x, y).0, [r, g, b, (x * 12) as u8], "({x}, {y}) {big} {per}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_palette_preview_is_refused_without_panicking() {
|
||||
let tiff = palette_tiff(20, 10, 5, false);
|
||||
assert!(crate::tiff::palette_rgba(&tiff).is_some());
|
||||
// Cut anywhere before the last entry's value (the directory is at the end): nothing.
|
||||
for cut in 0..tiff.len() - 6 {
|
||||
assert!(crate::tiff::palette_rgba(&tiff[..cut]).is_none(), "{cut}");
|
||||
}
|
||||
// Any byte changed: a picture or nothing.
|
||||
for big in [false, true] {
|
||||
let tiff = palette_tiff(20, 10, 5, big);
|
||||
for at in 0..tiff.len() {
|
||||
for v in [0, 1, 2, 0x7f, 0x80, 0xff] {
|
||||
let mut t = tiff.clone();
|
||||
t[at] = v;
|
||||
let _ = crate::tiff::palette_rgba(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A damaged preview of an unreadable program: the hard error.
|
||||
let ps = eps("frobnicate");
|
||||
for t in [&tiff[..tiff.len() / 2], b"II*\0garbage", &[0; 64]] {
|
||||
let e = import(&crate::dos_eps(&ps, t).unwrap()).unwrap_err();
|
||||
assert!(e.contains("can't be read"), "{e}");
|
||||
}
|
||||
}
|
||||
@@ -110,38 +110,46 @@ impl Interp<'_> {
|
||||
let category = self.pop()?;
|
||||
let v = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "defineresource".into()))?;
|
||||
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
|
||||
dict.borrow_mut().insert(k, v.clone());
|
||||
// A category is defined by its implementation dictionary.
|
||||
if category.text().as_deref() == Some("Category") && !matches!(v, Obj::Dict(_)) {
|
||||
return ps_err("typecheck", "defineresource");
|
||||
}
|
||||
self.instances(&category)?.borrow_mut().insert(k, v.clone());
|
||||
self.push(v)?;
|
||||
}
|
||||
UndefineResource => {
|
||||
let category = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "undefineresource".into()))?;
|
||||
self.instances(&category)?.borrow_mut().remove(&k);
|
||||
}
|
||||
ResourceStatus => {
|
||||
let category = self.pop()?;
|
||||
let k = self.pop()?.key().ok_or(PsError::Ps("typecheck", "resourcestatus".into()))?;
|
||||
let dict = if category.text().as_deref() == Some("Font") { self.fonts.clone() } else { self.category(&category)? };
|
||||
let known = dict.borrow().contains_key(&k);
|
||||
let known = self.instances(&category)?.borrow().contains_key(&k);
|
||||
if known {
|
||||
self.push(Obj::Int(1))?;
|
||||
self.push(Obj::Int(0))?;
|
||||
}
|
||||
self.push(Obj::Bool(known))?;
|
||||
}
|
||||
ResourceForAll => self.resource_for_all()?,
|
||||
Show => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.show(&s)?;
|
||||
}
|
||||
AShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.nums::<2>()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
WidthShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.pop()?;
|
||||
self.nums::<2>()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
AWidthShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.nums::<2>()?;
|
||||
self.pop()?;
|
||||
self.nums::<2>()?;
|
||||
@@ -149,11 +157,11 @@ impl Interp<'_> {
|
||||
}
|
||||
XShow | YShow | XYShow => {
|
||||
self.pop()?;
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.show(&s)?;
|
||||
}
|
||||
KShow => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.pop()?;
|
||||
self.show(&s)?;
|
||||
}
|
||||
@@ -163,14 +171,14 @@ impl Interp<'_> {
|
||||
self.show(ch.encode_utf8(&mut [0; 4]).as_bytes())?;
|
||||
}
|
||||
StringWidth => {
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
let (_, _, v) = self.set_type(&s, Point::ZERO)?;
|
||||
self.push_num(v.x)?;
|
||||
self.push_num(v.y)?;
|
||||
}
|
||||
CharPath => {
|
||||
self.pop_bool()?;
|
||||
let s = self.pop_str()?.borrow().clone();
|
||||
let s = self.pop_str()?.to_vec();
|
||||
self.char_path(&s)?;
|
||||
}
|
||||
SetCacheDevice => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+110
-1
@@ -1,5 +1,6 @@
|
||||
//! The TIFF preview (little-endian, PackBits strips, written by the raster exports' TIFF writer):
|
||||
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail.
|
||||
//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail,
|
||||
//! and a reader for the palette previews other apps write that the `image` crate can't decode.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -50,3 +51,111 @@ pub(crate) fn png(img: &Raster) -> Result<Vec<u8>, String> {
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most pixels a palette preview may have.
|
||||
const MAX_PIXELS: usize = 1 << 26;
|
||||
/// Most values a TIFF field may have.
|
||||
const MAX_VALUES: usize = 1 << 20;
|
||||
|
||||
/// A TIFF's bytes in its byte order.
|
||||
struct Tiff<'a> {
|
||||
b: &'a [u8],
|
||||
big: bool,
|
||||
}
|
||||
|
||||
impl Tiff<'_> {
|
||||
fn u16(&self, at: usize) -> Option<u16> {
|
||||
let b: [u8; 2] = self.b.get(at..at.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(if self.big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) })
|
||||
}
|
||||
|
||||
fn u32(&self, at: usize) -> Option<u32> {
|
||||
let b: [u8; 4] = self.b.get(at..at.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(if self.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) })
|
||||
}
|
||||
|
||||
/// The values of the directory entry at `at` (bytes, shorts or longs; in the entry when they
|
||||
/// fit in four bytes).
|
||||
fn values(&self, at: usize) -> Option<Vec<u32>> {
|
||||
let (kind, n) = (self.u16(at.checked_add(2)?)?, self.u32(at.checked_add(4)?)? as usize);
|
||||
let size = match kind {
|
||||
1 => 1,
|
||||
3 => 2,
|
||||
4 => 4,
|
||||
_ => return None,
|
||||
};
|
||||
if n > MAX_VALUES {
|
||||
return None;
|
||||
}
|
||||
let field = at.checked_add(8)?;
|
||||
let start = if n * size <= 4 { field } else { self.u32(field)? as usize };
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let at = start.checked_add(i * size)?;
|
||||
match size {
|
||||
1 => self.b.get(at).map(|v| u32::from(*v)),
|
||||
2 => self.u16(at).map(u32::from),
|
||||
_ => self.u32(at),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// An 8-bit palette TIFF, with or without an alpha sample (`ExtraSamples`), uncompressed and
|
||||
/// interleaved, as RGBA: the layout of other apps' EPS previews. `None` for anything else.
|
||||
pub(crate) fn palette_rgba(bytes: &[u8]) -> Option<image::RgbaImage> {
|
||||
let big = match bytes.get(..4)? {
|
||||
b"II*\0" => false,
|
||||
b"MM\0*" => true,
|
||||
_ => return None,
|
||||
};
|
||||
let t = Tiff { b: bytes, big };
|
||||
let ifd = t.u32(4)? as usize;
|
||||
let entries = usize::from(t.u16(ifd)?);
|
||||
let mut fields = std::collections::HashMap::new();
|
||||
for i in 0..entries {
|
||||
let at = ifd.checked_add(2 + i * 12)?;
|
||||
fields.insert(t.u16(at)?, at);
|
||||
}
|
||||
let get = |tag: u16| fields.get(&tag).and_then(|at| t.values(*at));
|
||||
let one = |tag: u16| get(tag).and_then(|v| v.first().copied());
|
||||
let (w, h) = (one(256)? as usize, one(257)? as usize);
|
||||
let samples = one(277).unwrap_or(1) as usize;
|
||||
let alpha = samples == 2 && matches!(get(338)?.as_slice(), [0..=2]);
|
||||
let plain = one(259).unwrap_or(1) == 1 && one(262)? == 3 && one(284).unwrap_or(1) == 1;
|
||||
if !plain || !(samples == 1 || alpha) || get(258)?.iter().any(|b| *b != 8) || w == 0 || h == 0 || w.checked_mul(h)? > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let map = get(320)?;
|
||||
if map.len() != 3 * 256 {
|
||||
return None;
|
||||
}
|
||||
// Strips of `per` rows each, in order.
|
||||
let (offsets, counts) = (get(273)?, get(279)?);
|
||||
let per = (one(278).unwrap_or(u32::MAX) as usize).min(h);
|
||||
let row = w * samples;
|
||||
// The strips are in the file: so is all that's allocated.
|
||||
if per == 0 || offsets.len() != counts.len() || offsets.len() < h.div_ceil(per) || row * h > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let mut data = Vec::with_capacity(row * h);
|
||||
for (k, (at, n)) in offsets.iter().zip(&counts).take(h.div_ceil(per)).enumerate() {
|
||||
let want = per.min(h - k * per) * row;
|
||||
if (*n as usize) < want {
|
||||
return None;
|
||||
}
|
||||
let at = *at as usize;
|
||||
data.extend_from_slice(bytes.get(at..at.checked_add(want)?)?);
|
||||
}
|
||||
// Map entries are 16-bit.
|
||||
let channel = |c: usize, i: usize| map.get(c * 256 + i).map_or(0, |v| ((u64::from(*v).min(65_535) * 255 + 32_767) / 65_535) as u8);
|
||||
let rgba = data
|
||||
.chunks_exact(samples)
|
||||
.flat_map(|px| {
|
||||
let i = usize::from(px.first().copied().unwrap_or(0));
|
||||
[channel(0, i), channel(1, i), channel(2, i), px.get(1).copied().unwrap_or(255)]
|
||||
})
|
||||
.collect();
|
||||
image::RgbaImage::from_raw(w as u32, h as u32, rgba)
|
||||
}
|
||||
@@ -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())?;
|
||||
|
||||
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user