Compare commits
349
Commits
v0.3.0
...
fix/issue-362
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3fec5486d | ||
|
|
dc947d30c1 | ||
|
|
49567e9594 | ||
|
|
831a5e5c96 | ||
|
|
8e80d6bcc9 | ||
|
|
0bf7422d9c | ||
|
|
caeecf4b26 | ||
|
|
58e3916e03 | ||
|
|
3013bbc94f | ||
|
|
12b56818b6 | ||
|
|
aad734e264 | ||
|
|
256e938fff | ||
|
|
acc708ec83 | ||
|
|
746a271aeb | ||
|
|
85dafc78d7 | ||
|
|
2b56a91ce2 | ||
|
|
ce12b4b0f5 | ||
|
|
b5a7994df0 | ||
|
|
5d49a2f4bf | ||
|
|
048db2d230 | ||
|
|
80307f11da | ||
|
|
038f344deb | ||
|
|
b2c6764472 | ||
|
|
accfec7146 | ||
|
|
7485dac6e8 | ||
|
|
d218e60168 | ||
|
|
bc4063f701 | ||
|
|
27e8c695c1 | ||
|
|
1e4db64362 | ||
|
|
3121bba275 | ||
|
|
0687d2d127 | ||
|
|
7c3030ff52 | ||
|
|
edc7c931dc | ||
|
|
249b34fc7c | ||
|
|
87d1acd589 | ||
|
|
cea2ee6a54 | ||
|
|
8f7949c30b | ||
|
|
079c46dafb | ||
|
|
e792d2a964 | ||
|
|
5196337ca8 | ||
|
|
0cf2879d06 | ||
|
|
4f159b98fb | ||
|
|
6b294c28b2 | ||
|
|
ecbb2c8c42 | ||
|
|
e6da1ca3c3 | ||
|
|
5be1790717 | ||
|
|
62d6ea4bed | ||
|
|
df31117196 | ||
|
|
fc59594f6a | ||
|
|
c2f6508447 | ||
|
|
440174d563 | ||
|
|
219c8d4ae3 | ||
|
|
1a5b3f00ba | ||
|
|
b33e964aec | ||
|
|
18df24c04d | ||
|
|
21b3dbf522 | ||
|
|
8b4056aca6 | ||
|
|
062cbdc655 | ||
|
|
79bd76c7e9 | ||
|
|
e91183f0df | ||
|
|
a3109c95f6 | ||
|
|
33a91ad140 | ||
|
|
f020d6cb35 | ||
|
|
e47671ea32 | ||
|
|
9c2f3886c7 | ||
|
|
95532ad2c8 | ||
|
|
bbb7e2839f | ||
|
|
9ee0c4287f | ||
|
|
7ead49429e | ||
|
|
e02d2f64d5 | ||
|
|
b4ad8106d7 | ||
|
|
2bdb739383 | ||
|
|
4a35e9c494 | ||
|
|
de779cc820 | ||
|
|
e99c9d1d6d | ||
|
|
cc60c8711c | ||
|
|
af8b7c7b34 | ||
|
|
a4f88b5b31 | ||
|
|
7487782dea | ||
|
|
2e071f9e0e | ||
|
|
5c1e78b2ab | ||
|
|
b6bdef3de6 | ||
|
|
4a676c4d5e | ||
|
|
f17c34a067 | ||
|
|
f4b5029714 | ||
|
|
d5a060a009 | ||
|
|
f5c3ecb47c | ||
|
|
a1f2f23b72 | ||
|
|
86de124cc8 | ||
|
|
820f405c61 | ||
|
|
aa9e2e89c8 | ||
|
|
5e0c911633 | ||
|
|
17e654a738 | ||
|
|
3b2376af9c | ||
|
|
c074a8031f | ||
|
|
ad8c31b3af | ||
|
|
7830a41b3e | ||
|
|
41c7495844 | ||
|
|
a54be8298f | ||
|
|
59212cb0d4 | ||
|
|
6d1d94bf11 | ||
|
|
3cd2fddeda | ||
|
|
7feeb73a81 | ||
|
|
0830ad68c3 | ||
|
|
c322f24086 | ||
|
|
464a93c7f9 | ||
|
|
736a1bde25 | ||
|
|
133390ef7a | ||
|
|
1b3a03316e | ||
|
|
0b844a13af | ||
|
|
bef928bf17 | ||
|
|
5cacbe36b3 | ||
|
|
1c3b7b16db | ||
|
|
3bc4365ee2 | ||
|
|
1f7873273b | ||
|
|
364c579c04 | ||
|
|
9cd91deafa | ||
|
|
7de04544e4 | ||
|
|
65c5953516 | ||
|
|
0648e99fc4 | ||
|
|
ccdb4d89c1 | ||
|
|
a92905a816 | ||
|
|
6f54843d5a | ||
|
|
f8e3cfbf35 | ||
|
|
7eb3f25f1f | ||
|
|
27a1a48176 | ||
|
|
f9209e8896 | ||
|
|
f16403a851 | ||
|
|
5623f90ef7 | ||
|
|
681d18a08f | ||
|
|
52e1c0eca0 | ||
|
|
fcaeaa74a7 | ||
|
|
b64dcc02df | ||
|
|
406c27ebd8 | ||
|
|
dfe7f1a311 | ||
|
|
7e0ed6b183 | ||
|
|
079f9a8b9a | ||
|
|
6f9b160038 | ||
|
|
dcbe59dae9 | ||
|
|
8e40794153 | ||
|
|
d370f0a542 | ||
|
|
e5e09d5d48 | ||
|
|
d9136c46b9 | ||
|
|
ee7a7ebfac | ||
|
|
0d4d125f9b | ||
|
|
deaec7fc71 | ||
|
|
a40260a441 | ||
|
|
ef167a1345 | ||
|
|
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: PdfCraft 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'
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
|
||||
# weekly, so ordinary PRs never wait on it, and the weekly run shows when a dependency update starts
|
||||
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
|
||||
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
|
||||
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
|
||||
name: Windows 7 x64 compatibility
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 5 * * 1" # weekly
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
|
||||
with:
|
||||
arch: x64
|
||||
- uses: dtolnay/rust-toolchain@7e38f4b43b4db5c8dd498af069a4f6196df1d067 # master
|
||||
with:
|
||||
toolchain: nightly-2026-10-01
|
||||
components: rust-src
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: windows7-x64
|
||||
- name: Build, check and test the portable package
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/windows7.ps1 -Test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: vectorcraft-windows7-x64-portable
|
||||
path: dist/release/vectorcraft-windows7-x64-portable.zip
|
||||
if-no-files-found: error
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
# VectorCraft — instructions for agents
|
||||
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
|
||||
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
@@ -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 |
|
||||
@@ -162,7 +166,9 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-para-justify-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-justify-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-left.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-ltr.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-right.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-para-rtl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-path-eraser.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-pathfinder.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-pen-add.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -310,6 +316,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `docs/images/shot-2-ribbons.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-3-sheet.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-4-bezier.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/feature-sheet.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/shot-5-perspective.png` | VectorCraft contributors | Screenshot of VectorCraft itself, editing `examples/perspective-city.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-neon-drive.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/neon-drive.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-ribbons.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/ribbons.vectorcraft` | MIT OR Apache-2.0 | |
|
||||
| `docs/images/art-pathfinder.png` | VectorCraft contributors | Rendered by VectorCraft from `examples/feature-sheet.vectorcraft` (artboard 1) | MIT OR Apache-2.0 | |
|
||||
@@ -319,3 +326,4 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `examples/neon-drive.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
|
||||
@@ -1,6 +1,6 @@
|
||||
# VectorCraft — instructions for agents
|
||||
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../printcraft` (Acrobat-class); same conventions.
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
|
||||
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
@@ -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
+72
-25
@@ -145,6 +145,21 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -509,6 +524,27 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
"brotli-decompressor",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -916,6 +952,7 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
@@ -1650,6 +1687,7 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
]
|
||||
|
||||
@@ -4211,15 +4249,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
"clipboard-win",
|
||||
"eframe",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
@@ -4231,7 +4273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4243,7 +4285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4259,7 +4301,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4275,7 +4317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4287,7 +4329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4301,7 +4343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4315,7 +4357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4347,7 +4389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4364,7 +4406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4378,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4390,7 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4409,7 +4451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4423,7 +4465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4435,7 +4477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4449,6 +4491,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"skrifa 0.47.0",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
@@ -4462,7 +4505,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4476,7 +4519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4501,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4525,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4542,20 +4585,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"skrifa 0.47.0",
|
||||
"subsetter",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
"unicode-script",
|
||||
"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 +4617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4583,7 +4630,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4611,7 +4658,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5685,7 +5732,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,46 @@ 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.
|
||||
|
||||
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
|
||||
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
|
||||
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
|
||||
other build uses the unmodified crate from crates.io.
|
||||
|
||||
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 (BIZ UDPGothic,
|
||||
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
|
||||
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.
|
||||
|
||||
PDF import decodes CJK text with Adobe's CMap resources (https://github.com/adobe-type-tools/cmap-resources),
|
||||
compiled into the app through the hayro-cmap crate under the BSD 3-Clause License:
|
||||
|
||||
Copyright 1990-2023 Adobe. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
Neither the name of Adobe nor the names of its contributors may be
|
||||
used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -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>
|
||||
@@ -40,7 +40,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/shot-1-neon.png" alt="VectorCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows its live Outer Glow, and the Properties panel shows its character settings" width="100%">
|
||||
<img src="docs/images/shot-1-neon.png" alt="VectorCraft editing the Neon Drive poster: the title is selected, the Appearance panel shows the settings of its live Outer Glow, and the Properties panel shows its character settings" width="100%">
|
||||
<br><sub><b>Neon Drive</b>: a Pathfinder-cut sun, live Outer Glow on the type and grid, and clipping masks · <code>examples/neon-drive.vectorcraft</code></sub>
|
||||
</p>
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons of 55 to 70 steps with smooth colour, clipped to the artboard, with the Layers panel open" width="100%">
|
||||
<p align="center"><sub><b>Live Blends</b>: editable key paths and smooth colour, clipped to the artboard</sub></p>
|
||||
<img src="docs/images/shot-2-ribbons.png" alt="Three live blend ribbons clipped to the artboard, one selected with its key paths showing; the Layers panel lists the clip group, the blends and the selected blend's two key paths" width="100%">
|
||||
<p align="center"><sub><b>Live Blends and Layers</b>: editable key paths, smooth colour, every object a row</sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-4-bezier.png" alt="Direct Selection tool showing anchor points and Bézier handles on a crescent built with Pathfinder, with the contextual task bar below it" width="100%">
|
||||
@@ -72,7 +72,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-5-perspective.png" alt="Two lit building facades drawn on the left and right planes of a two-point perspective grid at sunset, with the Perspective Selection tool and the Plane Switching Widget" width="100%">
|
||||
<p align="center"><sub><b>Perspective Grid</b>: art attached to its planes stays editable in perspective · <code>examples/perspective-city.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-3-sheet.png" alt="Four artboards in the light UI theme: Pathfinder, Gradient Mesh, radial Repeat and Envelope Distort" width="100%">
|
||||
<p align="center"><sub><b>Multiple artboards, light theme</b>: Pathfinder · Gradient Mesh · live radial Repeat · Envelope Distort · <code>examples/feature-sheet.vectorcraft</code></sub></p>
|
||||
</td>
|
||||
@@ -154,6 +158,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:
|
||||
@@ -164,21 +176,25 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp
|
||||
|
||||
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
|
||||
|
||||
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
|
||||
|
||||
## Status
|
||||
|
||||
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-07):** 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
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles, threading and Hebrew/Arabic bidirectional layout, and
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). The interface
|
||||
speaks English, Japanese, Traditional and Simplified Chinese and Spanish (and Czech and Brazilian Portuguese in the menus).
|
||||
The scores are
|
||||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
**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 +209,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
|
||||
|
||||
@@ -207,7 +224,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | **Vector illustration · you are here** | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
@@ -235,13 +252,19 @@ And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video stud
|
||||
<a href="https://getartcraft.com/apps/vectorcraft">VectorCraft</a>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Fvectorcraft&type=date&legend=top-left)
|
||||
|
||||
## License and credits
|
||||
|
||||
VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
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
|
||||
fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md) (Japanese,
|
||||
Simplified Chinese and Arabic faces; 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).
|
||||
|
||||
+66
-47
@@ -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-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS; 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% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), 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, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional 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, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### 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, proportional vertical 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,48 @@ 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`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- 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.
|
||||
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
|
||||
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), 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.
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- 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.
|
||||
- Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). Number fields in panels and dialogs step with Up/Down (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once.
|
||||
- Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields.
|
||||
- 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.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers).
|
||||
- **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; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. 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.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead.
|
||||
- **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.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, onto the Swatches panel a pattern swatch and onto the Brushes panel an Art brush; a symbol dragged onto the canvas places an instance where it is dropped, and a brush dragged onto a path is applied to it.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Layers panel:** every layer, sublayer, group and object is a clickable row at any depth: a click highlights it and makes its layer current (new art goes there), Shift-click a range, Ctrl/Cmd-click toggles. Sublayers are no longer treated as objects: clicks, marquees, Select All and the Select menu take the art inside them, and each sublayer selects in its own colour. Selection squares on rows with selected art (click selects a row's art, Shift adds or removes; dragging the square moves the selection to another row, Alt copies), the eye and lock on every row with drag-down-the-column (one undo step), Ctrl/Cmd-click an eye for per-layer Outline, Alt-click an eye or lock to hide or lock the other layers (again: show or unlock them all), triangles with Alt to open or close everything inside, drag and drop rows above, below or into layers and groups with a drop line (Alt copies, the trash deletes), inline rename, Delete key on highlighted rows. Buttons and the full panel menu: New Layer/Sublayer (Alt: options, Ctrl: on top), Duplicate, Delete, Options for Selection, clipping mask, isolation, Locate Object, Merge Selected, Flatten Artwork, Collect in New Layer, Release to Layers (Sequence and Build), Reverse Order, Template, Hide/Outline/Lock Others and Show/Preview/Unlock All, Paste Remembers Layers, Panel Options (row size, thumbnails per kind, Show Layers Only). Layer Options: name, colour (presets or custom), Template, Lock, Show, Print, Preview (outline drawing and clicking) and Dim Images to %. Every operation is a command (`layer.move`, `layer.highlight`, `layer.merge`…) with validation against cycles and invalid nesting.
|
||||
- **Drawing aids:** Smart Guides and snapping (moving, and resizing by the bounding-box handles: the dragged edges align with the other objects; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H), 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. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it.
|
||||
- **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.
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected.
|
||||
- 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. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text.
|
||||
- **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 and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; 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 (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), 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 +141,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. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed.
|
||||
- **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, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, 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
|
||||
@@ -138,22 +157,22 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, the remaining Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, 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; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) 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, Hebrew/Arabic bidirectional text and Paragraph Direction 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; an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
|
||||
## Parity estimate
|
||||
|
||||
@@ -170,38 +189,38 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | 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 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pencil and the remaining pen modifier nuances, Touch Type | 10–15 |
|
||||
| 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 | 79% | Middle Eastern features beyond bidi layout and Paragraph Direction (Character Direction, digit types, kashidas, the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); 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 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — hanging punctuation, line-start brackets, kinsoku sets and the Mojikumi Settings dialog —, proportional vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 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 |
|
||||
| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard presets/rearrange polish (Document Setup, New Document and the rest of the Layers panel are done) | 4–7 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 |
|
||||
| Guides, grids, smart guides, snapping, rulers | 3 | 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 more real files (two Illustrator 30 files open as vectors 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-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~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,130 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[2]["result"]["isError"], false);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
|
||||
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
|
||||
/// parsing, logger install, stdout pollution, notification order) is untested: this
|
||||
/// drives the real `mcp --headless` process through prompts, completions, resource
|
||||
/// templates, logging and the error taxonomy instead.
|
||||
#[test]
|
||||
fn mcp_protocol_over_stdio() {
|
||||
use std::io::{BufRead, BufReader};
|
||||
let mut child =
|
||||
Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let mut notes = vec![];
|
||||
|
||||
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
|
||||
writeln!(stdin, "{v}").unwrap();
|
||||
}
|
||||
// Next reply with this id; notifications (no id) are stashed for later.
|
||||
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
|
||||
loop {
|
||||
let line = lines.next().unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
return v;
|
||||
}
|
||||
notes.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 1);
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
|
||||
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
|
||||
let r = recv(&mut lines, &mut notes, 2);
|
||||
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
|
||||
let r = recv(&mut lines, &mut notes, 3);
|
||||
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 4);
|
||||
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 7);
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
|
||||
let r = recv(&mut lines, &mut notes, 8);
|
||||
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
|
||||
|
||||
// Unknown resources (-32002) and bad template values (-32602) stay apart.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
|
||||
|
||||
// The binary installed its logger: records arrive as notifications/message, and only
|
||||
// after the client asked for them.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
|
||||
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
|
||||
|
||||
// Malformed input is a parse error, and the server keeps serving afterwards.
|
||||
stdin.write_all(b"{not json\n").unwrap();
|
||||
let parse_err: Value = loop {
|
||||
let line = lines.next().unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
if v.get("error").is_some() {
|
||||
break v;
|
||||
}
|
||||
notes.push(v);
|
||||
};
|
||||
assert_eq!(parse_err["error"]["code"], -32700);
|
||||
assert!(parse_err["id"].is_null(), "{parse_err}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
|
||||
drop(stdin);
|
||||
// Drain whatever the debug window queued; every line off the protocol stream parses.
|
||||
for line in lines.by_ref() {
|
||||
let line = line.unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
notes.push(v);
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
|
||||
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
|
||||
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
|
||||
}
|
||||
|
||||
/// `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);
|
||||
}
|
||||
@@ -108,8 +108,8 @@ impl eframe::App for WebShell {
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
match target {
|
||||
DropTarget::Place { at, embed } => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
|
||||
DropTarget::Place(d) => {
|
||||
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) })
|
||||
}
|
||||
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
|
||||
}
|
||||
|
||||
@@ -9,10 +9,17 @@ default-run = "vectorcraft"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["wgpu", "accessibility"]
|
||||
wgpu = ["eframe/wgpu"]
|
||||
accessibility = ["eframe/accesskit"]
|
||||
# Build with --no-default-features --features windows7 and the win7 Rust target.
|
||||
windows7 = ["eframe/glow"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -30,6 +37,11 @@ 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", "NSApplication", "NSResponder", "NSTextInputContext"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
|
||||
[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,11 +5,21 @@
|
||||
//! `--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")]
|
||||
|
||||
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
|
||||
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
|
||||
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
|
||||
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
|
||||
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
|
||||
compile_error!("the win7 target requires --no-default-features --features windows7");
|
||||
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
mod printing;
|
||||
mod window;
|
||||
|
||||
@@ -29,6 +39,7 @@ impl eframe::App for App {
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0);
|
||||
}
|
||||
open_files(&mut self.0, open_documents::take());
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
@@ -41,10 +52,41 @@ 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();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
|
||||
/// can't be opened is reported in the status bar and on stderr; the others still open.
|
||||
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
for f in files {
|
||||
if let Err(e) = vectorcraft_ui_egui::io::open_path(app, &f) {
|
||||
eprintln!("vectorcraft: {f}: {e}");
|
||||
app.status(format!("Couldn't open {}: {e}", fileio::file_name(&f)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
@@ -225,11 +267,35 @@ 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.
|
||||
#[cfg(feature = "wgpu")]
|
||||
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.
|
||||
#[cfg(feature = "wgpu")]
|
||||
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,6 +311,10 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
@@ -257,8 +327,21 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
options
|
||||
};
|
||||
// Files opened from Finder and the Dock arrive as events, not arguments.
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::install();
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
@@ -280,17 +363,25 @@ 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);
|
||||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
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);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = vectorcraft_ui_egui::io::open_path(&mut app, &f) {
|
||||
eprintln!("vectorcraft: {f}: {e}");
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::set_ui(&cc.egui_ctx);
|
||||
open_files(&mut app, files);
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -299,3 +390,64 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
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 file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
.iter()
|
||||
.flat_map(|key| plist.split(key).skip(1))
|
||||
.filter_map(|rest| rest.split("</array>").next())
|
||||
.flat_map(|array| array.split("<string>").skip(1))
|
||||
.filter_map(|s| s.split("</string>").next())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Finder offers the app for every file File › Open reads (#295, #354), takes over no other
|
||||
/// app's files, and hands them to the app rather than to AppKit's document machinery.
|
||||
#[test]
|
||||
fn the_macos_bundle_opens_every_readable_format() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let declared = plist_extensions(plist);
|
||||
for e in fileio::OPEN_EXTS {
|
||||
assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}");
|
||||
}
|
||||
for e in &declared {
|
||||
assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open");
|
||||
}
|
||||
assert!(!plist.contains("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
|
||||
let types = plist.matches("<key>CFBundleTypeName</key>").count();
|
||||
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
|
||||
assert_eq!(plist.matches("<string>Owner</string>").count(), 2, "only VectorCraft documents and templates are owned");
|
||||
}
|
||||
|
||||
/// 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)
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
|
||||
//! `open -a VectorCraft file`.
|
||||
//!
|
||||
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
|
||||
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
|
||||
//! which fails with "VectorCraft cannot open files in the … format" because VectorCraft isn't an
|
||||
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
|
||||
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
|
||||
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
|
||||
//! wakes the UI, which opens them with [`take`] like files named on the command line.
|
||||
//!
|
||||
//! Adding a method to another crate's Objective-C class has no safe API, hence the scoped
|
||||
//! `unsafe_code` allowance (winit overrides `sendEvent:` on `NSApplication` the same way).
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::ptr::NonNull;
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::runtime::{AnyObject, Imp, Sel};
|
||||
use objc2::sel;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
|
||||
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
|
||||
|
||||
/// Paths that arrived and haven't been opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
/// Handle open-document events from now on. Call before the event loop runs.
|
||||
pub fn install() {
|
||||
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
|
||||
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
|
||||
// runs on the posting thread (AppKit posts it on the main thread). The notification center
|
||||
// keeps the registration (and the block) for the life of the app.
|
||||
unsafe {
|
||||
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
|
||||
Some(NSApplicationWillFinishLaunchingNotification),
|
||||
None,
|
||||
None,
|
||||
&block,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive while the app runs.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
let _ = UI.set(ctx.clone());
|
||||
}
|
||||
|
||||
/// The paths that arrived since the last call.
|
||||
pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// `application:openURLs:` on the application delegate's class.
|
||||
fn add_open_urls_method() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
|
||||
log::warn!("vectorcraft: no application delegate; files opened from Finder won't open");
|
||||
return;
|
||||
};
|
||||
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
|
||||
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
|
||||
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
|
||||
// it with the arguments the type encoding declares, which match `open_urls`: no return value
|
||||
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
|
||||
let added = unsafe {
|
||||
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
|
||||
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
|
||||
};
|
||||
if !added.as_bool() {
|
||||
log::warn!("vectorcraft: the application delegate already handles open-document events");
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit.
|
||||
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
|
||||
let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
|
||||
if let Some(ctx) = UI.get() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2::runtime::NSObject;
|
||||
use objc2_foundation::NSString;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Files queue until the UI takes them; URLs that aren't files are left out.
|
||||
#[test]
|
||||
fn opened_files_wait_for_the_ui() {
|
||||
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.svg")).unwrap();
|
||||
let urls = NSArray::from_retained_slice(&[NSURL::from_file_path("/tmp/a b.pdf").unwrap(), web, NSURL::from_file_path("/tmp/c.ai").unwrap()]);
|
||||
let any = NSObject::new();
|
||||
open_urls(&any, sel!(application:openURLs:), &any, &urls);
|
||||
assert_eq!(take(), ["/tmp/a b.pdf", "/tmp/c.ai"]);
|
||||
assert!(take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,9 @@ mod system {
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
#[cfg(feature = "windows7")]
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let script = "Get-CimInstance Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
@@ -174,7 +177,19 @@ mod system {
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
Command::new("explorer").arg("ms-settings:printers").spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
#[cfg(feature = "windows7")]
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
let mut command = {
|
||||
let mut command = Command::new("explorer");
|
||||
command.arg("ms-settings:printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M11 3V14" /><path d="M14 3V14" /><path d="M16 3H9.5a3 3 0 0 0 0 6H11" /><path d="M4 19H20" /><path d="M17 16L20 19L17 22" /></svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M13 3V14" /><path d="M10 3V14" /><path d="M8 3H14.5a3 3 0 0 1 0 6H13" /><path d="M20 19H4" /><path d="M7 16L4 19L7 22" /></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
@@ -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
+99
-12
@@ -20,19 +20,24 @@ 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>,
|
||||
/// How many entries at the start of `ancestry` are layers (the layer and its sublayers).
|
||||
pub layers: usize,
|
||||
}
|
||||
|
||||
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.
|
||||
self.ancestry.get(1).copied().unwrap_or(self.leaf)
|
||||
// Skip layers and sublayers: they are not objects.
|
||||
self.ancestry.get(self.layers.max(1)).copied().unwrap_or(self.leaf)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +65,7 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
if !layer.visible || layer.locked {
|
||||
continue;
|
||||
}
|
||||
if let NodeKind::Layer { template: true, .. } = layer.kind {
|
||||
if layer.is_template() {
|
||||
continue;
|
||||
}
|
||||
// Pattern editing mode: only the tile art is editable.
|
||||
@@ -68,7 +73,10 @@ 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) {
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
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));
|
||||
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
|
||||
return Some(h);
|
||||
}
|
||||
chain.pop();
|
||||
@@ -76,6 +84,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 {
|
||||
@@ -96,18 +109,29 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
return None;
|
||||
}
|
||||
for c in children.iter().rev() {
|
||||
if !c.visible || c.locked {
|
||||
// Hidden, locked and template sublayers block clicks like top-level layers do.
|
||||
if !c.visible || c.locked || c.is_template() {
|
||||
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;
|
||||
}
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
// A sublayer whose Preview is off is clicked in outline, like an outline view.
|
||||
NodeKind::Layer { preview: false, .. } => hit_children(c, p, HitOptions { outline: true, ..opt }, chain),
|
||||
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, layers: 1 })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
@@ -175,13 +199,28 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
let mut out = Vec::new();
|
||||
let tops: Vec<&std::sync::Arc<Node>> = match scope.and_then(|s| doc.node(s)) {
|
||||
Some(s) => s.children().map(|c| c.iter().collect()).unwrap_or_default(),
|
||||
// The objects of the layers, looking through sublayers (hidden, locked and template ones
|
||||
// are left out).
|
||||
None => doc
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|l| l.visible && !l.locked && !l.is_template() && doc.pattern_edit.as_ref().is_none_or(|e| e.layer == l.id))
|
||||
.flat_map(|l| layer_objects(l))
|
||||
.collect(),
|
||||
};
|
||||
fn layer_objects(l: &Node) -> Vec<&std::sync::Arc<Node>> {
|
||||
let mut out = vec![];
|
||||
for c in l.children().into_iter().flatten() {
|
||||
if c.is_layer() {
|
||||
if c.visible && !c.locked && !c.is_template() {
|
||||
out.extend(layer_objects(c));
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn touches(n: &Node, r: Rect) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
@@ -279,6 +318,54 @@ mod tests {
|
||||
assert!(marquee(&d, Rect::new(100.0, 100.0, 105.0, 105.0), None, false).is_empty());
|
||||
}
|
||||
|
||||
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
|
||||
/// sublayer; hidden, locked and template sublayers block both.
|
||||
#[test]
|
||||
fn sublayers_are_not_objects() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::layer(sub, "Sub", crate::LayerColor::Preset(2))).unwrap();
|
||||
let inner = d.alloc_id();
|
||||
d.insert(Some(sub), 0, Node::layer(inner, "Inner", crate::LayerColor::Preset(3))).unwrap();
|
||||
let a = d.alloc_id();
|
||||
d.insert(Some(inner), 0, Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let g = d.alloc_id();
|
||||
let b = d.alloc_id();
|
||||
let pb = Node::path(b, shapes::rectangle(Rect::new(100.0, 0.0, 150.0, 50.0)), Appearance::default_art());
|
||||
d.insert(Some(sub), 9, Node::group(g, vec![Arc::new(pb)])).unwrap();
|
||||
let h = hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).unwrap();
|
||||
assert_eq!((h.leaf, h.top_object(None), h.layers), (a, a, 3));
|
||||
let h = hit_test(&d, Point::new(110.0, 10.0), HitOptions::default()).unwrap();
|
||||
assert_eq!((h.top_object(None), h.top_object(Some(g))), (g, b));
|
||||
let all = Rect::new(-5.0, -5.0, 200.0, 200.0);
|
||||
assert_eq!(marquee(&d, all, None, false), vec![a, g]);
|
||||
assert_eq!(d.layer_containing(a), Some(inner));
|
||||
assert_eq!(d.layer_color(a), crate::LayerColor::Preset(3).rgb(), "the sublayer's own colour");
|
||||
assert_eq!(d.selectable_art(), vec![a, g]);
|
||||
for f in [
|
||||
|n: &mut Node| n.visible = false,
|
||||
|n: &mut Node| n.locked = true,
|
||||
|n: &mut Node| {
|
||||
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
||||
*template = true;
|
||||
}
|
||||
},
|
||||
] {
|
||||
let mut d2 = d.clone();
|
||||
f(d2.node_mut(inner).unwrap());
|
||||
assert!(hit_test(&d2, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
assert_eq!(marquee(&d2, all, None, false), vec![g]);
|
||||
assert_eq!(d2.selectable_art(), vec![g]);
|
||||
}
|
||||
// A sublayer whose Preview is off is clicked in outline: its fill no longer hits.
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(inner).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
assert!(hit_test(&d, Point::new(25.0, 25.0), HitOptions::default()).is_none());
|
||||
assert!(hit_test(&d, Point::new(0.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_skip() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
+125
-11
@@ -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,15 +68,17 @@ 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};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject,
|
||||
TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
AreaOptions, CharPosition, CharStyle, FirstBaseline, Justify, Mojikumi, ParaDirection, ParaStyle, PathEffect, ScriptMetrics, TabAlign, TabStop,
|
||||
TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
@@ -334,6 +339,32 @@ pub struct SavedView {
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
/// A saved selection (Select → Save Selection…): the objects that were selected, listed by name at
|
||||
/// the bottom of the Select menu.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedSelection {
|
||||
pub name: String,
|
||||
pub objects: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl SavedSelection {
|
||||
/// Most saved selections a document keeps (the Select menu lists every one).
|
||||
pub const MAX: usize = 25;
|
||||
/// Longest name, in characters.
|
||||
pub const MAX_NAME: usize = 255;
|
||||
|
||||
/// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank).
|
||||
pub fn clean_name(name: &str) -> Option<String> {
|
||||
let name: String = name.trim().chars().take(Self::MAX_NAME).collect();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
|
||||
/// The first "Selection N" none of `saved` is named.
|
||||
pub fn default_name(saved: &[SavedSelection]) -> String {
|
||||
(1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Guide {
|
||||
/// true = vertical guide at `pos` (x), false = horizontal at `pos` (y).
|
||||
@@ -485,6 +516,9 @@ pub struct Document {
|
||||
/// View → New View… (up to 25, like Illustrator).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub views: Vec<SavedView>,
|
||||
/// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub saved_selections: Vec<SavedSelection>,
|
||||
#[serde(default)]
|
||||
pub grid: GridPrefs,
|
||||
#[serde(default = "ppi72")]
|
||||
@@ -563,6 +597,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 {
|
||||
@@ -607,6 +645,7 @@ impl Document {
|
||||
symbols: vec![],
|
||||
guides: vec![],
|
||||
views: vec![],
|
||||
saved_selections: vec![],
|
||||
grid: GridPrefs::default(),
|
||||
raster_effects_ppi: 72.0,
|
||||
images: BTreeMap::new(),
|
||||
@@ -630,6 +669,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))));
|
||||
@@ -800,15 +840,17 @@ impl Document {
|
||||
self.layers.push(Arc::new(Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))));
|
||||
id
|
||||
}
|
||||
/// The default name of a new layer or sublayer, "Layer N". Layers and sublayers share the
|
||||
/// numbering: it starts after the number of layers there are and skips any layer's name.
|
||||
pub fn next_layer_name(&self) -> String {
|
||||
let mut i = self.layers.len() + 1;
|
||||
loop {
|
||||
let name = format!("Layer {i}");
|
||||
if !self.layers.iter().any(|l| l.name.as_deref() == Some(&name)) {
|
||||
return name;
|
||||
let (mut count, mut taken) = (0, std::collections::HashSet::new());
|
||||
self.walk(|n| {
|
||||
if n.is_layer() {
|
||||
count += 1;
|
||||
taken.extend(n.name.as_deref());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
});
|
||||
(count + 1..).map(|i| format!("Layer {i}")).find(|name| !taken.contains(name.as_str())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Visit every node depth first in paint order (bottom to top).
|
||||
@@ -827,9 +869,19 @@ impl Document {
|
||||
pub fn is_visible(&self, id: NodeId) -> bool {
|
||||
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
|
||||
}
|
||||
/// Colour of the layer containing `id` (selection highlight colour).
|
||||
/// The innermost layer or sublayer containing `id` (`id` itself when it is a layer).
|
||||
pub fn layer_containing(&self, id: NodeId) -> Option<NodeId> {
|
||||
let a = self.ancestry(id)?;
|
||||
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
|
||||
}
|
||||
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
|
||||
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
|
||||
pub fn selectable_art(&self) -> Vec<NodeId> {
|
||||
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
|
||||
}
|
||||
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
|
||||
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
|
||||
let l = self.layer_of(id).and_then(|l| self.node(l));
|
||||
let l = self.layer_containing(id).and_then(|l| self.node(l));
|
||||
match l.map(|n| &n.kind) {
|
||||
Some(NodeKind::Layer { color, .. }) => color.rgb(),
|
||||
_ => LAYER_COLORS[0].1,
|
||||
@@ -849,6 +901,30 @@ impl Document {
|
||||
pub fn artboard_at(&self, p: Point) -> Option<usize> {
|
||||
self.artboards.iter().rposition(|a| a.rect.contains(p))
|
||||
}
|
||||
/// The art that moves with an artboard at `rect`: unlocked top-level objects (children of
|
||||
/// unlocked layers and sublayers) lying entirely inside it.
|
||||
pub fn art_on_artboard(&self, rect: Rect) -> Vec<NodeId> {
|
||||
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.locked {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
collect(c, rect, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
&& rect.contains(Point::new(b.x0, b.y0))
|
||||
&& rect.contains(Point::new(b.x1, b.y1))
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = vec![];
|
||||
for l in self.layers.iter().filter(|l| !l.locked) {
|
||||
collect(l, rect, &mut art);
|
||||
}
|
||||
art
|
||||
}
|
||||
pub fn next_artboard_id(&self) -> u32 {
|
||||
self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1
|
||||
}
|
||||
@@ -871,6 +947,33 @@ impl Document {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Saved selections as a file gives them, made safe to list and recall: at most
|
||||
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
|
||||
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
|
||||
pub fn tidy_saved_selections(&mut self) {
|
||||
if self.saved_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
self.walk(|n| {
|
||||
ids.insert(n.id);
|
||||
});
|
||||
let mut kept: Vec<SavedSelection> = vec![];
|
||||
for s in std::mem::take(&mut self.saved_selections) {
|
||||
if kept.len() >= SavedSelection::MAX {
|
||||
break;
|
||||
}
|
||||
let Some(name) = SavedSelection::clean_name(&s.name) else { continue };
|
||||
if kept.iter().any(|k| k.name == name) {
|
||||
continue;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect();
|
||||
kept.push(SavedSelection { name, objects });
|
||||
}
|
||||
self.saved_selections = kept;
|
||||
}
|
||||
|
||||
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
|
||||
/// mask already holds (the engine syncs it after every edit).
|
||||
pub fn drop_edit_modes(&mut self) {
|
||||
@@ -969,6 +1072,17 @@ mod tests {
|
||||
assert_eq!(d.artboards[0].rect, Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_on_artboard_takes_unlocked_objects_wholly_inside() {
|
||||
let (mut d, a, b) = doc_with_rects();
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 15.0, 15.0)), vec![a]);
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 25.0, 15.0)), vec![a], "b only half inside");
|
||||
d.node_mut(a).unwrap().locked = true;
|
||||
assert_eq!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)), vec![b]);
|
||||
Arc::make_mut(&mut d.layers[0]).locked = true;
|
||||
assert!(d.art_on_artboard(Rect::new(-1.0, -1.0, 40.0, 15.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_paths() {
|
||||
let (d, a, b) = doc_with_rects();
|
||||
|
||||
+603
-428
File diff suppressed because it is too large.
Load diff
+214
-16
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +259,14 @@ pub enum NodeKind {
|
||||
/// Layer clipping mask: the first (bottom-most) child clips the others, as in a clip group.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
clip: bool,
|
||||
/// Layer Options → Preview: off, the layer's art is drawn (and clicked) in outline on screen
|
||||
/// (Ctrl-click its eye in the Layers panel).
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
preview: bool,
|
||||
/// Layer Options → Dim Images to: on screen, images on the layer show faded to this
|
||||
/// percentage (0–100); `None`: not dimmed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
dim_images: Option<u8>,
|
||||
},
|
||||
/// A group. With `clip`, the first (bottom-most) child is the clipping path.
|
||||
Group {
|
||||
@@ -272,6 +316,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 +392,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 +444,7 @@ impl Node {
|
||||
attrs: None,
|
||||
slice: None,
|
||||
bbox_angle: 0.0,
|
||||
perspective: None,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
@@ -400,7 +456,10 @@ impl Node {
|
||||
Self::new(id, NodeKind::Group { children, clip: false })
|
||||
}
|
||||
pub fn layer(id: NodeId, name: &str, color: LayerColor) -> Self {
|
||||
let mut n = Self::new(id, NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false });
|
||||
let mut n = Self::new(
|
||||
id,
|
||||
NodeKind::Layer { color, template: false, printable: true, children: vec![], clip: false, preview: true, dim_images: None },
|
||||
);
|
||||
n.name = Some(name.to_string());
|
||||
n
|
||||
}
|
||||
@@ -429,6 +488,30 @@ impl Node {
|
||||
pub fn is_layer(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { .. })
|
||||
}
|
||||
/// A template layer (Layer Options → Template).
|
||||
pub fn is_template(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Layer { template: true, .. })
|
||||
}
|
||||
/// The art objects of this container: its children, looking through sublayers, which are not
|
||||
/// objects (bottom first). With `editable`, only the visible, unlocked objects in visible,
|
||||
/// unlocked, non-template sublayers.
|
||||
pub fn layer_art(&self, editable: bool) -> Vec<NodeId> {
|
||||
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if editable && (!c.visible || c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
visit(c, editable, out);
|
||||
} else {
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
visit(self, editable, &mut out);
|
||||
out
|
||||
}
|
||||
pub fn is_container(&self) -> bool {
|
||||
self.children().is_some()
|
||||
}
|
||||
@@ -522,14 +605,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 +664,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 +684,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 +716,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 +1205,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]));
|
||||
}
|
||||
}
|
||||
@@ -98,11 +98,11 @@ impl Selection {
|
||||
}
|
||||
self.slices.retain(|id| doc.is_slice(*id));
|
||||
}
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected,
|
||||
/// anything else is selected itself.
|
||||
/// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected
|
||||
/// (the art of its sublayers too), anything else is selected itself.
|
||||
pub fn set_target(&mut self, doc: &Document, id: NodeId) {
|
||||
match doc.node(id) {
|
||||
Some(n) if n.is_layer() => self.set(n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)),
|
||||
Some(n) if n.is_layer() => self.set(n.layer_art(true)),
|
||||
_ => self.set([id]),
|
||||
}
|
||||
self.target = Some(id);
|
||||
|
||||
+266
-10
@@ -2,12 +2,15 @@
|
||||
|
||||
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};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
/// Align to the start of each paragraph's direction ([`ParaStyle::direction`]): left for
|
||||
/// left-to-right paragraphs, right for right-to-left ones. New type's alignment.
|
||||
Auto,
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
@@ -265,28 +268,148 @@ fn default_align_on() -> char {
|
||||
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
|
||||
|
||||
/// Paragraph attributes (the Paragraph panel).
|
||||
///
|
||||
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
|
||||
/// [`Justify::Auto`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(from = "ParaStyleFile", into = "ParaStyleFile")]
|
||||
pub struct ParaStyle {
|
||||
#[serde(default)]
|
||||
pub justify: Justify,
|
||||
#[serde(default)]
|
||||
pub left_indent: f64,
|
||||
#[serde(default)]
|
||||
pub right_indent: f64,
|
||||
#[serde(default)]
|
||||
pub first_line_indent: f64,
|
||||
#[serde(default)]
|
||||
pub space_before: f64,
|
||||
#[serde(default)]
|
||||
pub space_after: f64,
|
||||
#[serde(default)]
|
||||
pub hyphenate: bool,
|
||||
/// Tab stops (Tabs panel), sorted by position.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tabs: Vec<TabStop>,
|
||||
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub style_name: Option<String>,
|
||||
/// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made
|
||||
/// with the Type tools and `text.create` takes [`Mojikumi::LineEndHalf`]; documents from before
|
||||
/// it and imported text (already set) keep [`Mojikumi::None`].
|
||||
pub mojikumi: Mojikumi,
|
||||
/// Paragraph direction (Paragraph panel): the base direction of each paragraph for
|
||||
/// bidirectional text (UAX #9). None: from each paragraph's first strong character (Hebrew or
|
||||
/// Arabic: right to left).
|
||||
pub direction: Option<ParaDirection>,
|
||||
}
|
||||
|
||||
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
|
||||
/// direction (`Right` for right to left, else `Left`) with `justify_auto` set: builds without Auto
|
||||
/// read the alignment and ignore the flag, and builds with it read Auto back.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct ParaStyleFile {
|
||||
#[serde(default)]
|
||||
justify: Justify,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
justify_auto: bool,
|
||||
#[serde(default)]
|
||||
left_indent: f64,
|
||||
#[serde(default)]
|
||||
right_indent: f64,
|
||||
#[serde(default)]
|
||||
first_line_indent: f64,
|
||||
#[serde(default)]
|
||||
space_before: f64,
|
||||
#[serde(default)]
|
||||
space_after: f64,
|
||||
#[serde(default)]
|
||||
hyphenate: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
tabs: Vec<TabStop>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
style_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
|
||||
mojikumi: Mojikumi,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
direction: Option<ParaDirection>,
|
||||
}
|
||||
|
||||
impl From<ParaStyle> for ParaStyleFile {
|
||||
fn from(p: ParaStyle) -> Self {
|
||||
let justify_auto = p.justify == Justify::Auto;
|
||||
let justify = match p.justify {
|
||||
Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => Justify::Right,
|
||||
Justify::Auto => Justify::Left,
|
||||
j => j,
|
||||
};
|
||||
let ParaStyle {
|
||||
left_indent,
|
||||
right_indent,
|
||||
first_line_indent,
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
tabs,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
justify,
|
||||
justify_auto,
|
||||
left_indent,
|
||||
right_indent,
|
||||
first_line_indent,
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
tabs,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ParaStyleFile> for ParaStyle {
|
||||
fn from(f: ParaStyleFile) -> Self {
|
||||
let justify = if f.justify_auto { Justify::Auto } else { f.justify };
|
||||
let ParaStyleFile {
|
||||
left_indent,
|
||||
right_indent,
|
||||
first_line_indent,
|
||||
space_before,
|
||||
space_after,
|
||||
hyphenate,
|
||||
tabs,
|
||||
style_name,
|
||||
mojikumi,
|
||||
direction,
|
||||
..
|
||||
} = f;
|
||||
Self { justify, left_indent, right_indent, first_line_indent, space_before, space_after, hyphenate, tabs, style_name, mojikumi, direction }
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph's base direction ([`ParaStyle::direction`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ParaDirection {
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
/// How Japanese punctuation is spaced (JLREQ 3.1). Full-width punctuation is half a glyph and half
|
||||
/// a space: an opening bracket's space before it, a closing bracket's, a comma's or a full stop's
|
||||
/// after it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Mojikumi {
|
||||
/// Every character takes its full advance.
|
||||
#[default]
|
||||
None,
|
||||
/// Consecutive punctuation shares one half-em space (JLREQ 3.1.4), and a closing bracket,
|
||||
/// comma or full stop ending a line is set half width.
|
||||
LineEndHalf,
|
||||
}
|
||||
|
||||
impl Mojikumi {
|
||||
pub fn is_none(&self) -> bool {
|
||||
*self == Mojikumi::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Area Type Options "First Baseline" offset.
|
||||
@@ -414,6 +537,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 +563,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 +599,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 +671,90 @@ 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);
|
||||
}
|
||||
|
||||
/// Builds from before [`Justify::Auto`] read new type's alignment as Left or Right (and ignore
|
||||
/// `justify_auto`); this build reads Auto back.
|
||||
#[test]
|
||||
fn auto_alignment_saves_readable_by_older_builds() {
|
||||
#[derive(Deserialize, Debug, PartialEq)]
|
||||
enum OldJustify {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
JustifyLeft,
|
||||
JustifyCenter,
|
||||
JustifyRight,
|
||||
JustifyAll,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct OldPara {
|
||||
justify: OldJustify,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct OldText {
|
||||
para: OldPara,
|
||||
}
|
||||
for (direction, physical) in
|
||||
[(Some(ParaDirection::RightToLeft), OldJustify::Right), (Some(ParaDirection::LeftToRight), OldJustify::Left), (None, OldJustify::Left)]
|
||||
{
|
||||
let mut t = TextObject::point(Point::ZERO, "שלום", CharStyle::default());
|
||||
t.para = ParaStyle { justify: Justify::Auto, direction, ..Default::default() };
|
||||
let json = serde_json::to_string(&t).unwrap();
|
||||
let old: OldText = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(old.para.justify, physical, "{json}");
|
||||
assert_eq!(serde_json::from_str::<TextObject>(&json).unwrap(), t);
|
||||
}
|
||||
// Other alignments save as before, without the flag.
|
||||
let p = ParaStyle { justify: Justify::Right, ..Default::default() };
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(!json.contains("justify_auto"), "{json}");
|
||||
assert_eq!(serde_json::from_str::<ParaStyle>(&json).unwrap(), p);
|
||||
// Files from before the flag keep their alignment.
|
||||
assert_eq!(serde_json::from_str::<ParaStyle>(r#"{"justify":"Center"}"#).unwrap().justify, Justify::Center);
|
||||
}
|
||||
}
|
||||
@@ -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,8 @@ 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);
|
||||
t.para = new_type_para();
|
||||
if let Some(a) = p.get("area") {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
@@ -411,6 +413,13 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
|
||||
}
|
||||
|
||||
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
|
||||
/// each paragraph's direction (left, right for Hebrew and Arabic), Japanese punctuation composed
|
||||
/// with Line-end Punctuation Half Width. Imported text keeps its own.
|
||||
pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
|
||||
vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Auto, mojikumi: vectorcraft_doc::Mojikumi::LineEndHalf, ..Default::default() }
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, SavedSelection, TextKind};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
@@ -93,15 +93,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Save Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
"{name?} remember the current selection for this session (default name \"Selection N\") → {name}",
|
||||
"{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25, names up to 255 characters) → {name}",
|
||||
has_selection,
|
||||
save_selection
|
||||
),
|
||||
cmd!("select.recall", "Recall Selection", ["Select"], None, "{name} → {count}", has_doc, recall_selection),
|
||||
cmd!("select.editSaved", "Edit Selection…", ["Select"], None, "{name, newName?: rename, delete?: bool}", has_doc, edit_saved),
|
||||
cmd!(
|
||||
"select.recall",
|
||||
"Recall Selection",
|
||||
["Select"],
|
||||
None,
|
||||
"{name} select the objects of a saved selection (those since deleted are left out) → {count}",
|
||||
has_saved_selection,
|
||||
recall_selection
|
||||
),
|
||||
cmd!(
|
||||
"select.editSaved",
|
||||
"Edit Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
"{name, newName?: rename, delete?: bool} | {edits: [{name, newName?, delete?}…]} rename or delete saved selections, all in one undo step; names are those before the edit and must stay unique",
|
||||
has_saved_selection,
|
||||
edit_saved
|
||||
),
|
||||
cmd!(query "select.savedList", "Saved Selections", [], None, "{} → [name…] for the active document", has_doc, |s, _| {
|
||||
let t = s.doc()?.title();
|
||||
Ok(json!(s.menu.saved_selections.iter().filter(|x| x.0 == t).map(|x| x.1.clone()).collect::<Vec<_>>()))
|
||||
Ok(json!(s.doc()?.doc.saved_selections.iter().map(|x| x.name.clone()).collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(
|
||||
"view.guides.make",
|
||||
@@ -259,43 +274,79 @@ fn direction_handles(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn save_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "select.save";
|
||||
let st = s.doc()?;
|
||||
let title = st.title();
|
||||
let ids = st.selection.objects.clone();
|
||||
let name = match str_param(p, "name").filter(|n| !n.trim().is_empty()) {
|
||||
Some(n) => n.trim().to_string(),
|
||||
None => {
|
||||
let mut i = 1;
|
||||
while s.menu.saved_selections.iter().any(|x| x.0 == title && x.1 == format!("Selection {i}")) {
|
||||
i += 1;
|
||||
}
|
||||
format!("Selection {i}")
|
||||
let saved = &st.doc.saved_selections;
|
||||
let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved));
|
||||
let existing = saved.iter().position(|x| x.name == name);
|
||||
if existing.is_none() && saved.len() >= SavedSelection::MAX {
|
||||
return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX)));
|
||||
}
|
||||
s.edit("Save Selection", |d, _| {
|
||||
let entry = SavedSelection { name: name.clone(), objects: ids };
|
||||
match existing.and_then(|i| d.saved_selections.get_mut(i)) {
|
||||
Some(x) => *x = entry,
|
||||
None => d.saved_selections.push(entry),
|
||||
}
|
||||
};
|
||||
s.menu.saved_selections.retain(|x| !(x.0 == title && x.1 == name));
|
||||
s.menu.saved_selections.push((title, name.clone(), ids));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn saved_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(cmd, "missing name"))?;
|
||||
let t = s.doc()?.title();
|
||||
s.menu.saved_selections.iter().position(|x| x.0 == t && x.1 == name).ok_or_else(|| bad(cmd, format!("no saved selection `{name}`")))
|
||||
}
|
||||
|
||||
fn recall_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved_index(s, p, "select.recall")?;
|
||||
let ids = s.menu.saved_selections[i].2.clone();
|
||||
const C: &str = "select.recall";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let ids = s.doc()?.doc.saved_selections.iter().find(|x| x.name == name).map(|x| x.objects.clone());
|
||||
let ids = ids.ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
s.select(|d, sel| sel.set(ids.into_iter().filter(|id| d.node(*id).is_some())))?;
|
||||
Ok(json!({ "count": s.doc()?.selection.len() }))
|
||||
}
|
||||
|
||||
/// Recall and Edit Selection… need a document with at least one saved selection.
|
||||
fn has_saved_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.doc.saved_selections.is_empty() { Err("no saved selections".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn edit_saved(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = saved_index(s, p, "select.editSaved")?;
|
||||
if bool_or(p, "delete", false) {
|
||||
s.menu.saved_selections.remove(i);
|
||||
} else if let Some(n) = str_param(p, "newName").filter(|n| !n.trim().is_empty()) {
|
||||
s.menu.saved_selections[i].1 = n.trim().to_string();
|
||||
const C: &str = "select.editSaved";
|
||||
let edits = match p.get("edits").and_then(Value::as_array) {
|
||||
Some(list) => list.as_slice(),
|
||||
None => std::slice::from_ref(p),
|
||||
};
|
||||
let before = s.doc()?.doc.saved_selections.clone();
|
||||
// Edits name the selections as they are now, so they don't depend on each other's order.
|
||||
let mut after: Vec<Option<SavedSelection>> = before.iter().cloned().map(Some).collect();
|
||||
// Files may hold any number of saved selections: keep this linear.
|
||||
let mut index = std::collections::HashMap::with_capacity(before.len());
|
||||
for (i, x) in before.iter().enumerate() {
|
||||
index.entry(x.name.as_str()).or_insert(i);
|
||||
}
|
||||
for e in edits {
|
||||
let name = str_param(e, "name").ok_or_else(|| bad(C, "missing name"))?;
|
||||
let i = *index.get(name).ok_or_else(|| bad(C, format!("no saved selection `{name}`")))?;
|
||||
let Some(slot) = after.get_mut(i) else { continue };
|
||||
if bool_or(e, "delete", false) {
|
||||
*slot = None;
|
||||
} else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name)
|
||||
&& let Some(x) = slot
|
||||
{
|
||||
x.name = n;
|
||||
}
|
||||
}
|
||||
let after: Vec<SavedSelection> = after.into_iter().flatten().collect();
|
||||
let mut names = std::collections::HashSet::with_capacity(after.len());
|
||||
for x in &after {
|
||||
if !names.insert(x.name.as_str()) {
|
||||
return Err(bad(C, format!("two saved selections would be named `{}`", x.name)));
|
||||
}
|
||||
}
|
||||
if after != before {
|
||||
s.edit("Edit Selection", |d, _| {
|
||||
d.saved_selections = after;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -45,6 +45,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
remove_anchor
|
||||
),
|
||||
cmd!(
|
||||
"path.removeAnchors",
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{} remove the direct-selected anchors without opening their paths, refitting the curve round each → {removedObjects}",
|
||||
has_anchors,
|
||||
remove_anchors
|
||||
),
|
||||
cmd!(
|
||||
"path.convertAnchor",
|
||||
"Convert Anchor Point",
|
||||
@@ -592,66 +601,62 @@ fn remove_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let ai = usize_req(p, "anchor", C)?;
|
||||
let removed = s.edit("Delete Anchor Point", |d, sel| {
|
||||
let path = path_mut(d, id)?;
|
||||
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
let n = sp.anchors.len();
|
||||
if ai >= n {
|
||||
let sp = path_mut(d, id)?.subpaths.get(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
|
||||
if ai >= sp.anchors.len() {
|
||||
return Err(EngineError::Other("no such anchor".into()));
|
||||
}
|
||||
let interior = sp.closed || (ai > 0 && ai + 1 < n);
|
||||
if interior && n >= 3 {
|
||||
let (pi, ni) = ((ai + n - 1) % n, (ai + 1) % n);
|
||||
let (prev, a, next) = (sp.anchors[pi], sp.anchors[ai], sp.anchors[ni]);
|
||||
// Keep the outer tangents; stretch them to span both removed segments.
|
||||
let l1 = prev.p.distance(a.p);
|
||||
let l2 = a.p.distance(next.p);
|
||||
let chord = prev.p.distance(next.p).max(1e-9);
|
||||
let first_line = !prev.has_out() && !a.has_in();
|
||||
let second_line = !a.has_out() && !next.has_in();
|
||||
let (mut hout, mut hin) = (prev.h_out, next.h_in);
|
||||
if !(first_line && second_line) {
|
||||
// Curved: stretch the outer handles to span both segments; a straight side aims a
|
||||
// third of the chord at the removed anchor.
|
||||
hout = if prev.has_out() {
|
||||
prev.p + (prev.h_out - prev.p) * ((l1 + l2) / l1.max(1e-9)).min(3.0)
|
||||
} else {
|
||||
prev.p + (a.p - prev.p) * (chord / 3.0 / l1.max(1e-9))
|
||||
};
|
||||
hin = if next.has_in() {
|
||||
next.p + (next.h_in - next.p) * ((l1 + l2) / l2.max(1e-9)).min(3.0)
|
||||
} else {
|
||||
next.p + (a.p - next.p) * (chord / 3.0 / l2.max(1e-9))
|
||||
};
|
||||
}
|
||||
sp.anchors[pi].h_out = hout;
|
||||
sp.anchors[ni].h_in = hin;
|
||||
}
|
||||
sp.anchors.remove(ai);
|
||||
if !sp.closed && !sp.anchors.is_empty() {
|
||||
let (f, l) = (0, sp.anchors.len() - 1);
|
||||
let a0 = sp.anchors[f].p;
|
||||
sp.anchors[f].h_in = a0;
|
||||
let al = sp.anchors[l].p;
|
||||
sp.anchors[l].h_out = al;
|
||||
}
|
||||
if sp.anchors.len() < 3 {
|
||||
sp.closed = sp.closed && sp.anchors.len() == 2 && sp.anchors.iter().any(|a| a.has_in() || a.has_out());
|
||||
}
|
||||
let empty = sp.anchors.len() < 2;
|
||||
if empty {
|
||||
path.subpaths.remove(si);
|
||||
}
|
||||
if path.is_empty() {
|
||||
d.remove(id)?;
|
||||
sel.clear();
|
||||
return Ok(true);
|
||||
}
|
||||
sel.anchors.remove(&id);
|
||||
Ok(false)
|
||||
remove_anchors_of(d, sel, id, [(si, ai)])
|
||||
})?;
|
||||
Ok(json!({ "removedObject": removed }))
|
||||
}
|
||||
|
||||
fn remove_anchors(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let jobs: Vec<(NodeId, Vec<(usize, usize)>)> = st
|
||||
.selection
|
||||
.anchors
|
||||
.iter()
|
||||
.filter(|(id, set)| !set.is_empty() && st.doc.node(**id).is_some_and(|n| matches!(n.kind, NodeKind::Path { .. })))
|
||||
.map(|(id, set)| (*id, set.iter().copied().collect()))
|
||||
.collect();
|
||||
if jobs.is_empty() {
|
||||
return Err(bad("path.removeAnchors", "direct-select anchor points of a path"));
|
||||
}
|
||||
let removed = s.edit("Remove Anchor Points", |d, sel| {
|
||||
let mut removed = 0;
|
||||
for (id, anchors) in jobs {
|
||||
removed += usize::from(remove_anchors_of(d, sel, id, anchors)?);
|
||||
}
|
||||
Ok(removed)
|
||||
})?;
|
||||
Ok(json!({ "removedObjects": removed }))
|
||||
}
|
||||
|
||||
/// Remove `anchors` (subpath, anchor) of path `id` without opening it, refitting the curve round
|
||||
/// each one; a subpath left with one anchor goes, and the path with its last subpath. True when
|
||||
/// the path went.
|
||||
fn remove_anchors_of(d: &mut Document, sel: &mut Selection, id: NodeId, anchors: impl IntoIterator<Item = (usize, usize)>) -> Result<bool> {
|
||||
let mut anchors: Vec<(usize, usize)> = anchors.into_iter().collect();
|
||||
// The last first, so the indexes still to come stay valid.
|
||||
anchors.sort_unstable_by(|a, b| b.cmp(a));
|
||||
anchors.dedup();
|
||||
let path = path_mut(d, id)?;
|
||||
for (si, ai) in anchors {
|
||||
if let Some(sp) = path.subpaths.get_mut(si) {
|
||||
po::remove_anchor(sp, ai);
|
||||
}
|
||||
}
|
||||
path.subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
let gone = path.is_empty();
|
||||
if gone {
|
||||
d.remove(id)?;
|
||||
sel.remove(id);
|
||||
} else {
|
||||
sel.anchors.remove(&id);
|
||||
}
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
fn convert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.convertAnchor";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
@@ -106,7 +106,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
|
||||
if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
|
||||
}
|
||||
|
||||
/// Document Setup's units (also `document.setup {units}`).
|
||||
@@ -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();
|
||||
|
||||
+530
-97
@@ -1,31 +1,125 @@
|
||||
//! Layers panel, artboards.
|
||||
//!
|
||||
//! The Layers panel works on rows: layers, sublayers, groups and objects. Clicking a row makes it
|
||||
//! the highlighted row (`layer.setCurrent`; Shift and Ctrl change several, `layer.highlight`) and
|
||||
//! its layer the current one, where new art goes. Panel commands without `ids` act on the
|
||||
//! highlighted rows, else on the current layer. More panel-menu operations live in
|
||||
//! [`super::layerpanel`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, LayerColor, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
/// Most rows one panel command takes (ids are untrusted input).
|
||||
pub(crate) const MAX_ROWS: usize = 100_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer),
|
||||
cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer),
|
||||
cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer),
|
||||
cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer),
|
||||
cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current),
|
||||
cmd!(
|
||||
"layer.new",
|
||||
"New Layer…",
|
||||
["Window", "Layers"],
|
||||
Some("Cmd+L"),
|
||||
"{name?, top?: bool, color?, template?, locked?, visible?, printable?, preview?, dimImages?} a layer directly above the current layer, at its level (a sublayer beside a current sublayer); `top`: above all layers (Ctrl-click the button). Options as layer.setProps → {id}",
|
||||
has_doc,
|
||||
new_layer
|
||||
),
|
||||
cmd!(
|
||||
"layer.newSublayer",
|
||||
"New Sublayer…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{parent?: layer id (default: the highlighted row's layer, else the current layer), name?, options as layer.new} a sublayer on top of the parent's contents → {id}",
|
||||
has_doc,
|
||||
new_sublayer
|
||||
),
|
||||
cmd!(
|
||||
"layer.delete",
|
||||
"Delete Selection",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?|id?} delete rows: layers, sublayers, groups or objects with what they hold (default: the highlighted rows, else the current layer). The last layer stays → {deleted}",
|
||||
has_doc,
|
||||
delete_rows
|
||||
),
|
||||
cmd!(
|
||||
"layer.duplicate",
|
||||
"Duplicate Selection",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?|id?} copy rows (default: the highlighted rows, else the current layer), each just above its original; a layer's copy is named `<name> copy`. The copies are highlighted → {ids}",
|
||||
has_doc,
|
||||
duplicate_rows
|
||||
),
|
||||
cmd!(
|
||||
"layer.setCurrent",
|
||||
"Set Current Layer",
|
||||
[],
|
||||
None,
|
||||
"{id} click a row: it alone is highlighted, and the layer it is (or the layer it is in) becomes the current layer, where new art goes. Not an undo step → {id, layer}",
|
||||
has_doc,
|
||||
set_current
|
||||
),
|
||||
cmd!(
|
||||
"layer.highlight",
|
||||
"Highlight Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, mode?: \"set\"|\"add\"|\"toggle\"} highlight Layers panel rows (Shift-click: a range, Ctrl-click: toggle one); the last one's layer becomes current. Not an undo step → {rows}",
|
||||
has_doc,
|
||||
highlight
|
||||
),
|
||||
cmd!(
|
||||
"layer.setProps",
|
||||
"Layer Options…",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}",
|
||||
"{ids?|id? (default: the highlighted rows, else the current layer), name?, visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
|
||||
has_doc,
|
||||
set_props
|
||||
),
|
||||
cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect),
|
||||
cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on),
|
||||
cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node),
|
||||
cmd!(
|
||||
"layer.collectInNew",
|
||||
"Collect in New Layer",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} move rows (default: the highlighted rows, else the selected objects) into a new layer where the topmost of them was (a sublayer when they are in a layer) → {id}",
|
||||
has_doc,
|
||||
collect
|
||||
),
|
||||
cmd!(
|
||||
"layer.selectAll",
|
||||
"Select Art",
|
||||
[],
|
||||
None,
|
||||
"{id, add?: bool} the Layers panel's selection column: select the visible, unlocked art of a layer (its sublayers' too), or the object itself; `add` (Shift-click) adds it to the selection, or removes it when it is all selected already → {selected: [..]}",
|
||||
has_doc,
|
||||
select_art
|
||||
),
|
||||
cmd!(
|
||||
"layer.move",
|
||||
"Move Rows",
|
||||
[],
|
||||
None,
|
||||
"{ids, target, place?: \"above\"|\"below\"|\"inside\" (default inside), copy?: bool} drag rows in the Layers panel: above or below the target row (beside it, in its layer or group), or inside a layer or group (on top of its contents). Layers go only in layers or at the top level, objects in layers and groups; an object placed beside a top-level layer goes inside it. A row never goes into itself; a locked layer or group takes nothing. `copy` (Alt-drag) moves copies. One undo step → {ids}",
|
||||
has_doc,
|
||||
move_rows
|
||||
),
|
||||
cmd!(
|
||||
"node.move",
|
||||
"Reorder",
|
||||
[],
|
||||
None,
|
||||
"{id, parent?: id (null = top level), index} put a row at `index` (0 = bottom) of `parent` (layers only at the top level or in layers)",
|
||||
has_doc,
|
||||
move_node
|
||||
),
|
||||
cmd!(
|
||||
"artboard.new",
|
||||
"New Artboard",
|
||||
@@ -52,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make/Release Clipping Mask",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
"{id?: layer or group (default: the one highlighted layer or group row, else the one selected group, else the current layer)} make: its top object (a path, compound path or text, which loses its paint) clips the rest and moves to the bottom (new art added on top is clipped); release: it stops clipping, the clipping path stays unpainted → {clip}",
|
||||
has_doc,
|
||||
clip_toggle
|
||||
),
|
||||
@@ -61,7 +155,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Target",
|
||||
[],
|
||||
None,
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
has_doc,
|
||||
target
|
||||
),
|
||||
@@ -77,6 +171,165 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The rows a panel command acts on: `ids` or `id`, else the highlighted rows, else the current
|
||||
/// layer (capped at [`MAX_ROWS`]).
|
||||
pub(crate) fn rows_param(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
let mut ids = match (ids_param(p, "ids"), id_param(p, "id")) {
|
||||
(Some(ids), _) => ids,
|
||||
(None, Some(id)) => vec![id],
|
||||
(None, None) => {
|
||||
let st = s.doc()?;
|
||||
let rows = st.highlighted_rows();
|
||||
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
|
||||
}
|
||||
};
|
||||
ids.truncate(MAX_ROWS);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// `ids` that exist, without repeats or rows inside another listed row, in paint order (bottom
|
||||
/// first).
|
||||
pub(crate) fn row_roots(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let set: HashSet<NodeId> = ids.iter().copied().filter(|i| d.node(*i).is_some()).collect();
|
||||
let keep: Vec<NodeId> = set
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| d.ancestry(*id).is_some_and(|a| a.split_last().is_none_or(|(_, above)| !above.iter().any(|x| set.contains(x)))))
|
||||
.collect();
|
||||
d.paint_order(keep)
|
||||
}
|
||||
|
||||
/// Whether `parent` (None: the top level) can hold `n`: layers go at the top level or in layers,
|
||||
/// anything else in layers and groups.
|
||||
pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, n: &Node) -> bool {
|
||||
match parent {
|
||||
None => n.is_layer(),
|
||||
Some(p) => match d.node(p).map(|x| &x.kind) {
|
||||
Some(NodeKind::Layer { .. }) => true,
|
||||
Some(NodeKind::Group { .. }) => !n.is_layer(),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` or a container around it is locked.
|
||||
fn locked_within(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked))
|
||||
}
|
||||
|
||||
/// Every layer and sublayer, depth first.
|
||||
fn all_layers(d: &Document) -> Vec<&Node> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n| {
|
||||
if n.is_layer() {
|
||||
out.push(n);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A new layer named `name` (default: the next `Layer N`) in the next layer colour.
|
||||
pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node {
|
||||
let n = all_layers(d).len();
|
||||
let name = name.map(str::to_string).unwrap_or_else(|| d.next_layer_name());
|
||||
let id = d.alloc_id();
|
||||
Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8))
|
||||
}
|
||||
|
||||
/// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case).
|
||||
fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
|
||||
match v {
|
||||
Value::Number(n) => match n.as_u64() {
|
||||
Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)),
|
||||
_ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)),
|
||||
},
|
||||
Value::String(s) => {
|
||||
if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| name.eq_ignore_ascii_case(s.trim())) {
|
||||
return Ok(LayerColor::Preset(i as u8));
|
||||
}
|
||||
let c = vectorcraft_color::Color::from_hex(s).ok_or_else(|| format!("`{s}` is not a colour (#rrggbb or a preset name)"))?;
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
Ok(LayerColor::Custom([r, g, b]))
|
||||
}
|
||||
_ => Err(format!("a colour is an index, #rrggbb or a preset name, not {v}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]).
|
||||
fn apply_options(n: &mut Node, p: &Value) {
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
if v.trim().is_empty() {
|
||||
// A layer keeps a name; an object goes back to its generated `<Kind>` one.
|
||||
if !n.is_layer() {
|
||||
n.name = None;
|
||||
}
|
||||
} else {
|
||||
n.name = Some(v.chars().take(512).collect());
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
n.visible = v;
|
||||
}
|
||||
let locked = p.get("locked").and_then(Value::as_bool);
|
||||
if let Some(v) = locked {
|
||||
n.locked = v;
|
||||
}
|
||||
let mut lock = None;
|
||||
if let NodeKind::Layer { template, printable, color, preview, dim_images, .. } = &mut n.kind {
|
||||
let dim_given = p.get("dimImages").is_some();
|
||||
if let Some(v) = p.get("template").and_then(Value::as_bool)
|
||||
&& v != *template
|
||||
{
|
||||
*template = v;
|
||||
// A template is locked and its images dimmed; it stops being either with it.
|
||||
lock = Some(v);
|
||||
if !dim_given {
|
||||
*dim_images = v.then_some(50);
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
|
||||
*printable = v;
|
||||
}
|
||||
if let Some(v) = p.get("preview").and_then(Value::as_bool) {
|
||||
*preview = v;
|
||||
}
|
||||
match p.get("dimImages") {
|
||||
Some(Value::Bool(false) | Value::Null) => *dim_images = None,
|
||||
Some(Value::Bool(true)) => *dim_images = Some(50),
|
||||
Some(v) => {
|
||||
if let Some(x) = v.as_f64().filter(|x| x.is_finite()) {
|
||||
*dim_images = Some(x.clamp(0.0, 100.0).round() as u8);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(c) = p.get("color").and_then(|v| layer_color_param(v).ok()) {
|
||||
*color = c;
|
||||
}
|
||||
}
|
||||
if let (Some(v), None) = (lock, locked) {
|
||||
n.locked = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bad values in Layer Options params, as an error for `cmd`.
|
||||
fn check_options(cmd: &str, p: &Value) -> Result<()> {
|
||||
if let Some(v) = p.get("color") {
|
||||
layer_color_param(v).map_err(|e| bad(cmd, e))?;
|
||||
}
|
||||
for k in ["visible", "locked", "template", "printable", "preview"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad(cmd, format!("`{k}` is true or false")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("dimImages")
|
||||
&& !(v.is_boolean() || v.is_null() || v.as_f64().is_some_and(f64::is_finite))
|
||||
{
|
||||
return Err(bad(cmd, "`dimImages` is a percentage or false"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Layers panel → Paste Remembers Layers.
|
||||
fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cur = s.doc()?.doc.paste_remembers_layers;
|
||||
@@ -100,8 +353,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
let st = s.doc_mut()?;
|
||||
// Targeting doesn't change which rows are highlighted.
|
||||
st.layer_rows = rows;
|
||||
if layer {
|
||||
st.active_layer = Some(id);
|
||||
}
|
||||
@@ -112,11 +368,16 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// or the clipping mask is released.
|
||||
fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let container = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }));
|
||||
let row = match st.highlighted_rows().as_slice() {
|
||||
[id] if container(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let group = match st.selection.objects.as_slice() {
|
||||
[id] if matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Group { .. })) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let id = id_param(p, "id").or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let id = id_param(p, "id").or(row).or(group).or(st.current_layer()).ok_or_else(|| bad("layer.clippingMask.toggle", "no layer"))?;
|
||||
let clip = s.edit("Make/Release Clipping Mask", |d, _| {
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. }) {
|
||||
@@ -138,99 +399,142 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "clip": clip }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let st = s.doc()?;
|
||||
let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i);
|
||||
let id = s.edit("New Layer", |d, _| {
|
||||
let id = d.add_layer(name.as_deref());
|
||||
if let Some(i) = cur {
|
||||
d.move_node(id, None, i + 1)?;
|
||||
}
|
||||
Ok(id)
|
||||
/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted.
|
||||
fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> {
|
||||
check_options(cmd, p)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let id = s.edit(label, |d, _| {
|
||||
let (parent, index) = place(d)?;
|
||||
let mut n = make_layer(d, name.as_deref());
|
||||
apply_options(&mut n, p);
|
||||
Ok(d.insert(parent, index, n)?)
|
||||
})?;
|
||||
s.doc_mut()?.active_layer = Some(id);
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(id);
|
||||
st.layer_rows = vec![id];
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
// Above the current layer, at its level; on top of everything with `top`.
|
||||
let cur = st.current_layer().filter(|_| !bool_or(p, "top", false));
|
||||
add_layer(s, p, "layer.new", "New Layer", |d| match cur.and_then(|c| d.position(c)) {
|
||||
Some((parent, i, _)) => Ok((parent, i + 1)),
|
||||
None => Ok((None, usize::MAX)),
|
||||
})
|
||||
}
|
||||
|
||||
fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let parent = id_param(p, "parent").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?;
|
||||
let name = str_param(p, "name").map(str::to_string);
|
||||
let id = s.edit("New Sublayer", |d, _| {
|
||||
let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0);
|
||||
let id = d.alloc_id();
|
||||
let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8);
|
||||
let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count()));
|
||||
d.insert(Some(parent), usize::MAX, vectorcraft_doc::Node::layer(id, &nm, color))?;
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
const C: &str = "layer.newSublayer";
|
||||
let st = s.doc()?;
|
||||
let row = st.highlighted_rows().last().and_then(|r| st.doc.layer_containing(*r));
|
||||
let parent = id_param(p, "parent").or(row).or(st.current_layer()).ok_or_else(|| bad(C, "no parent layer"))?;
|
||||
if !st.doc.node(parent).is_some_and(Node::is_layer) {
|
||||
return Err(bad(C, "the parent must be a layer or sublayer"));
|
||||
}
|
||||
add_layer(s, p, C, "New Sublayer", |_| Ok((Some(parent), usize::MAX)))
|
||||
}
|
||||
|
||||
fn delete_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.delete", "no layer"))?;
|
||||
fn delete_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
if st.doc.layers.len() == 1 && st.doc.layers[0].id == id {
|
||||
let ids = row_roots(&st.doc, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.delete", "no such rows"));
|
||||
}
|
||||
if st.doc.layers.iter().all(|l| ids.contains(&l.id)) {
|
||||
return Err(EngineError::Other("a document needs at least one layer".into()));
|
||||
}
|
||||
s.edit("Delete Layer", |d, sel| {
|
||||
d.remove(id)?;
|
||||
s.edit("Delete Selection", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) {
|
||||
st.layer_rows.clear();
|
||||
if st.current_layer().is_none() {
|
||||
st.active_layer = st.doc.default_layer();
|
||||
}
|
||||
ok()
|
||||
Ok(json!({ "deleted": ids.len() }))
|
||||
}
|
||||
|
||||
fn duplicate_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").or(s.doc()?.current_layer()).ok_or_else(|| bad("layer.duplicate", "no layer"))?;
|
||||
let nid = s.edit("Duplicate Layer", |d, _| {
|
||||
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut c = d.reid(&n);
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
Ok(d.insert(par, idx + 1, c)?)
|
||||
fn duplicate_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let ids = row_roots(&s.doc()?.doc, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("layer.duplicate", "no such rows"));
|
||||
}
|
||||
let copies = s.edit("Duplicate Selection", |d, _| {
|
||||
let mut out = vec![];
|
||||
for id in &ids {
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
let mut c = d.reid(&n);
|
||||
if n.is_layer() {
|
||||
c.name = Some(format!("{} copy", n.display_name()));
|
||||
}
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "id": nid.0 }))
|
||||
s.doc_mut()?.layer_rows = copies.clone();
|
||||
Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?;
|
||||
const C: &str = "layer.setCurrent";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let st = s.doc_mut()?;
|
||||
if !st.doc.node(id).is_some_and(|n| n.is_layer()) {
|
||||
return Err(bad("layer.setCurrent", "not a layer"));
|
||||
}
|
||||
st.active_layer = Some(id);
|
||||
let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
ok()
|
||||
Ok(json!({ "id": id.0, "layer": layer.0 }))
|
||||
}
|
||||
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.highlight";
|
||||
let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let mode = str_param(p, "mode").unwrap_or("set");
|
||||
let st = s.doc_mut()?;
|
||||
ids.retain(|id| st.doc.node(*id).is_some());
|
||||
let mut rows = st.highlighted_rows();
|
||||
match mode {
|
||||
"set" => rows.clear(),
|
||||
"add" | "toggle" => {}
|
||||
other => return Err(bad(C, format!("mode is set, add or toggle, not `{other}`"))),
|
||||
}
|
||||
let mut last = None;
|
||||
for id in ids {
|
||||
if mode == "toggle" && rows.contains(&id) {
|
||||
rows.retain(|r| *r != id);
|
||||
} else if !rows.contains(&id) {
|
||||
rows.push(id);
|
||||
last = Some(id);
|
||||
}
|
||||
}
|
||||
if let Some(layer) = last.or(rows.last().copied()).and_then(|id| st.doc.layer_containing(id)) {
|
||||
st.active_layer = Some(layer);
|
||||
}
|
||||
st.layer_rows = rows;
|
||||
st.revision += 1;
|
||||
Ok(json!({ "rows": st.layer_rows.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?;
|
||||
const C: &str = "layer.setProps";
|
||||
check_options(C, p)?;
|
||||
let ids = rows_param(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no rows"));
|
||||
}
|
||||
s.edit("Layer Options", |d, sel| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
if let Some(v) = str_param(p, "name") {
|
||||
n.name = Some(v.to_string());
|
||||
}
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
n.visible = v;
|
||||
}
|
||||
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
|
||||
n.locked = v;
|
||||
}
|
||||
if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind {
|
||||
if let Some(v) = p.get("template").and_then(Value::as_bool) {
|
||||
*template = v;
|
||||
}
|
||||
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
|
||||
*printable = v;
|
||||
}
|
||||
if let Some(v) = p.get("color").and_then(Value::as_u64) {
|
||||
*color = LayerColor::Preset((v % 27) as u8);
|
||||
}
|
||||
for id in &ids {
|
||||
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p);
|
||||
}
|
||||
// Hidden or locked content can't stay selected.
|
||||
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
@@ -242,37 +546,166 @@ fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = super::edit::selected_roots(s)?;
|
||||
let lid = s.edit("Collect in New Layer", |d, _| {
|
||||
let lid = d.add_layer(None);
|
||||
fn collect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.collectInNew";
|
||||
let ids = match ids_param(p, "ids") {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
let rows = s.doc()?.highlighted_rows();
|
||||
if rows.is_empty() { super::edit::selected_roots(s)? } else { rows }
|
||||
}
|
||||
};
|
||||
let ids = row_roots(&s.doc()?.doc, &ids);
|
||||
let Some(top) = ids.last().copied() else { return Err(bad(C, "select rows or objects to collect")) };
|
||||
let lid = s.edit("Collect in New Layer", |d, sel| {
|
||||
// Where the topmost of them is: beside it in its layer, or on top of the layer holding
|
||||
// its group.
|
||||
let (parent, index) = match d.position(top) {
|
||||
Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1),
|
||||
_ => (d.layer_containing(top), usize::MAX),
|
||||
};
|
||||
let layer = make_layer(d, None);
|
||||
let lid = d.insert(parent, index, layer)?;
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(lid), usize::MAX)?;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(lid)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(lid);
|
||||
st.layer_rows = vec![lid];
|
||||
Ok(json!({ "id": lid.0 }))
|
||||
}
|
||||
|
||||
fn select_all_on(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
|
||||
let ids: Vec<NodeId> = s
|
||||
.doc()?
|
||||
.doc
|
||||
.node(id)
|
||||
.and_then(|n| n.children())
|
||||
.map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect())
|
||||
.unwrap_or_default();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
/// The selection column: select a row's art.
|
||||
fn select_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.selectAll";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let add = bool_or(p, "add", false);
|
||||
let st = s.doc()?;
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let ids: Vec<NodeId> = if !st.doc.is_editable(id) {
|
||||
vec![]
|
||||
} else if n.is_layer() {
|
||||
n.layer_art(true)
|
||||
} else {
|
||||
vec![id]
|
||||
};
|
||||
let all_selected = !ids.is_empty() && ids.iter().all(|i| st.selection.contains(*i));
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|_, sel| {
|
||||
if !add {
|
||||
sel.set(ids.iter().copied());
|
||||
} else if all_selected {
|
||||
for i in &ids {
|
||||
sel.remove(*i);
|
||||
}
|
||||
} else {
|
||||
for i in &ids {
|
||||
sel.add(*i);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
// The selection column leaves the highlighted rows as they are.
|
||||
st.layer_rows = rows;
|
||||
Ok(json!({ "selected": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Place {
|
||||
Above,
|
||||
Below,
|
||||
Inside,
|
||||
}
|
||||
|
||||
/// Drag rows in the Layers panel.
|
||||
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.move";
|
||||
let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
|
||||
let place = match str_param(p, "place").unwrap_or("inside") {
|
||||
"above" => Place::Above,
|
||||
"below" => Place::Below,
|
||||
"inside" | "into" => Place::Inside,
|
||||
other => return Err(bad(C, format!("place is above, below or inside, not `{other}`"))),
|
||||
};
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
let ids = row_roots(d, &ids);
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no such rows"));
|
||||
}
|
||||
// Never into itself or a row inside it.
|
||||
let around = d.ancestry(target).unwrap_or_default();
|
||||
if ids.iter().any(|i| around.contains(i)) {
|
||||
return Err(bad(C, "a row can't go into itself"));
|
||||
}
|
||||
let nodes: Vec<&Node> = ids.iter().filter_map(|i| d.node(*i)).collect();
|
||||
let (parent, anchor) = match place {
|
||||
Place::Inside => (Some(target), None),
|
||||
_ => match d.parent_of(target) {
|
||||
// Objects can't be at the top level: beside a top-level layer means in it.
|
||||
None if nodes.iter().any(|n| !n.is_layer()) => (Some(target), None),
|
||||
parent => (parent, Some(target)),
|
||||
},
|
||||
};
|
||||
if let Some(n) = nodes.iter().find(|n| !accepts(d, parent, n)) {
|
||||
let into = parent.and_then(|p| d.node(p)).map_or("the top level", |p| if p.is_layer() { "a layer" } else { p.kind_label() });
|
||||
return Err(bad(C, format!("{} can't go into {into}", n.display_name())));
|
||||
}
|
||||
if parent.is_some_and(|p| locked_within(d, p)) {
|
||||
return Err(EngineError::Other("the layer or group is locked".into()));
|
||||
}
|
||||
let label = if copy { "Duplicate Rows" } else { "Move Rows" };
|
||||
let moved = s.edit(label, |d, sel| {
|
||||
let mut nodes = vec![];
|
||||
for id in &ids {
|
||||
nodes.push(if copy {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
d.reid(&n)
|
||||
} else {
|
||||
Arc::unwrap_or_clone(d.remove(*id)?)
|
||||
});
|
||||
}
|
||||
let count = d.children(parent).map_or(0, Vec::len);
|
||||
let mut index = match anchor {
|
||||
None => count,
|
||||
Some(a) => {
|
||||
let (_, i, _) = d.position(a).ok_or(EngineError::NoNode(a))?;
|
||||
if place == Place::Above { i + 1 } else { i }
|
||||
}
|
||||
};
|
||||
// The clipping path stays at the bottom of a clip group or a clipping layer.
|
||||
if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) {
|
||||
index = index.max(1);
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.push(d.insert(parent, index, n)?);
|
||||
index += 1;
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(out)
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.layer_rows = moved.clone();
|
||||
Ok(json!({ "ids": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?;
|
||||
const C: &str = "node.move";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let parent = id_param(p, "parent");
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
|
||||
if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) {
|
||||
return Err(bad("node.move", "only layers can be at the top level"));
|
||||
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize;
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
if !accepts(d, parent, n) {
|
||||
return Err(bad(C, "layers go only at the top level or in layers, objects in layers and groups"));
|
||||
}
|
||||
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
|
||||
ok()
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! The Layers panel menu's operations on rows: Merge Selected, Flatten Artwork, Release to Layers,
|
||||
//! Reverse Order, Template, Hide/Outline/Lock Others and their Show/Preview/Unlock All
|
||||
//! counterparts, Locate Object. Rows default to the highlighted ones, else the current layer (see
|
||||
//! [`super::layer`]).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
use super::layer::{make_layer, row_roots, rows_param};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"layer.merge",
|
||||
"Merge Selected",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) merge two or more rows into the layer listed (highlighted) last: the other layers' contents and the other rows move into it, keeping their stacking order, and the emptied layers go. One undo step → {id}",
|
||||
has_doc,
|
||||
merge
|
||||
),
|
||||
cmd!(
|
||||
"layer.flatten",
|
||||
"Flatten Artwork",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the highlighted row's top-level layer, else the current one's) move the art of every other visible top-level layer into this one, keeping the stacking order; hidden layers and their art are deleted, template layers stay. One undo step → {id, discarded}",
|
||||
has_doc,
|
||||
flatten
|
||||
),
|
||||
cmd!(
|
||||
"layer.releaseToLayers",
|
||||
"Release to Layers (Sequence)",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?: layer or group (default: the highlighted row, else the current layer), build?: bool} each object in it goes into a new sublayer of its own, in stacking order (a group's objects go into sublayers of its layer, where the group was). `build`: Release to Layers (Build), each new sublayer holds copies of the objects up to its own, so the layers add up. One undo step → {ids}",
|
||||
has_doc,
|
||||
release
|
||||
),
|
||||
cmd!(
|
||||
"layer.releaseToLayersBuild",
|
||||
"Release to Layers (Build)",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} layer.releaseToLayers with build: true → {ids}",
|
||||
has_doc,
|
||||
|s, p| {
|
||||
let mut p = p.clone();
|
||||
if let Some(m) = p.as_object_mut() {
|
||||
m.insert("build".into(), json!(true));
|
||||
} else {
|
||||
p = json!({"build": true});
|
||||
}
|
||||
release(s, &p)
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"layer.reverse",
|
||||
"Reverse Order",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} (default: the highlighted rows) reverse the stacking order of two or more rows that share a layer or group; each keeps the places the others held. One undo step",
|
||||
has_doc,
|
||||
reverse
|
||||
),
|
||||
cmd!(
|
||||
"layer.template",
|
||||
"Template",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?, on?: bool (default: toggle the first)} make layers templates (locked, dimmed, left out of print and export) or ordinary layers again (layer.setProps template) → {on}",
|
||||
has_doc,
|
||||
template
|
||||
),
|
||||
cmd!(
|
||||
"layer.hideOthers",
|
||||
"Hide Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} hide every top-level layer but those holding the highlighted rows (else the current layer). One undo step → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Visible, false)
|
||||
),
|
||||
cmd!("layer.showAll", "Show All Layers", ["Window", "Layers"], None, "{} show every layer and sublayer → {count}", has_doc, |s, _| all(
|
||||
s,
|
||||
Flag::Visible,
|
||||
true
|
||||
)),
|
||||
cmd!(
|
||||
"layer.outlineOthers",
|
||||
"Outline Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} turn Preview off (outline view) for every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Preview, false)
|
||||
),
|
||||
cmd!(
|
||||
"layer.previewAll",
|
||||
"Preview All Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{} turn Preview on for every layer and sublayer → {count}",
|
||||
has_doc,
|
||||
|s, _| all(s, Flag::Preview, true)
|
||||
),
|
||||
cmd!(
|
||||
"layer.lockOthers",
|
||||
"Lock Others",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{ids?} lock every top-level layer but those holding the highlighted rows (else the current layer) → {count}",
|
||||
has_doc,
|
||||
|s, p| others(s, p, Flag::Locked, true)
|
||||
),
|
||||
cmd!(
|
||||
"layer.unlockAll",
|
||||
"Unlock All Layers",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{} unlock every layer and sublayer → {count}",
|
||||
has_doc,
|
||||
|s, _| all(s, Flag::Locked, false)
|
||||
),
|
||||
cmd!(
|
||||
"layer.locate",
|
||||
"Locate Object",
|
||||
["Window", "Layers"],
|
||||
None,
|
||||
"{id?} (default: the last selected object) highlight the object's row; the Layers panel opens the layers around it and scrolls to it → {id, ancestry: [layer…, id]}",
|
||||
has_doc,
|
||||
locate
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A layer flag the panel menu sets on many layers.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Flag {
|
||||
Visible,
|
||||
Locked,
|
||||
Preview,
|
||||
}
|
||||
|
||||
impl Flag {
|
||||
fn get(self, n: &Node) -> bool {
|
||||
match (self, &n.kind) {
|
||||
(Flag::Visible, _) => n.visible,
|
||||
(Flag::Locked, _) => n.locked,
|
||||
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview,
|
||||
(Flag::Preview, _) => true,
|
||||
}
|
||||
}
|
||||
fn set(self, n: &mut Node, v: bool) {
|
||||
match (self, &mut n.kind) {
|
||||
(Flag::Visible, _) => n.visible = v,
|
||||
(Flag::Locked, _) => n.locked = v,
|
||||
(Flag::Preview, NodeKind::Layer { preview, .. }) => *preview = v,
|
||||
(Flag::Preview, _) => {}
|
||||
}
|
||||
}
|
||||
fn label(self, on: bool) -> &'static str {
|
||||
match (self, on) {
|
||||
(Flag::Visible, true) => "Show All Layers",
|
||||
(Flag::Visible, false) => "Hide Others",
|
||||
(Flag::Locked, true) => "Lock Others",
|
||||
(Flag::Locked, false) => "Unlock All Layers",
|
||||
(Flag::Preview, true) => "Preview All Layers",
|
||||
(Flag::Preview, false) => "Outline Others",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `flag` to `v` on `ids` (layers that differ) in one undo step labelled `label` → how many
|
||||
/// changed.
|
||||
fn set_flag(s: &mut Session, ids: Vec<NodeId>, flag: Flag, v: bool, label: &str) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| flag.get(n) != v)).collect();
|
||||
if !ids.is_empty() {
|
||||
s.edit(label, |d, sel| {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
flag.set(n, v);
|
||||
}
|
||||
}
|
||||
// Hidden or locked art can't stay selected.
|
||||
let out: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
|
||||
for o in out {
|
||||
sel.remove(o);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
/// The top-level layers holding the rows a command acts on.
|
||||
fn kept_layers(s: &Session, p: &Value) -> Result<HashSet<NodeId>> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(ids.iter().filter_map(|id| d.layer_of(*id)).collect())
|
||||
}
|
||||
|
||||
fn others(s: &mut Session, p: &Value, flag: Flag, v: bool) -> Result<Value> {
|
||||
let keep = kept_layers(s, p)?;
|
||||
let ids: Vec<NodeId> = s.doc()?.doc.layers.iter().map(|l| l.id).filter(|l| !keep.contains(l)).collect();
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
}
|
||||
|
||||
fn all(s: &mut Session, flag: Flag, v: bool) -> Result<Value> {
|
||||
let mut ids = vec![];
|
||||
s.doc()?.doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
set_flag(s, ids, flag, v, flag.label(v))
|
||||
}
|
||||
|
||||
fn template(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(Node::is_layer)).collect();
|
||||
let Some(first) = ids.first().and_then(|id| d.node(*id)) else { return Err(bad("layer.template", "select a layer")) };
|
||||
let on = bool_or(p, "on", !first.is_template());
|
||||
s.execute("layer.setProps", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "template": on}))?;
|
||||
Ok(json!({ "on": on }))
|
||||
}
|
||||
|
||||
/// Move the contents of `others` (layers give their children, anything else itself; none inside
|
||||
/// another) into layer `into`, keeping the document's stacking order, then delete the emptied
|
||||
/// layers.
|
||||
fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> {
|
||||
let own: Vec<NodeId> =
|
||||
d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default();
|
||||
let mut moved = vec![];
|
||||
for id in others {
|
||||
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
if n.is_layer() {
|
||||
moved.extend(n.children().into_iter().flatten().map(|c| c.id));
|
||||
} else {
|
||||
moved.push(*id);
|
||||
}
|
||||
}
|
||||
// Where everything is now: the merged layer stacks its contents in this order.
|
||||
let keys: HashMap<NodeId, Vec<usize>> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect();
|
||||
let mut taken = vec![];
|
||||
for id in &moved {
|
||||
taken.push(d.remove(*id)?);
|
||||
}
|
||||
for id in others {
|
||||
if d.node(*id).is_some() {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
}
|
||||
let ch = d.children_mut(Some(into))?;
|
||||
let mut all = std::mem::take(ch);
|
||||
all.extend(taken);
|
||||
all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id)));
|
||||
*ch = all;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.merge";
|
||||
let ids = rows_param(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let mut seen = HashSet::new();
|
||||
let ids: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some() && seen.insert(*id)).collect();
|
||||
if ids.len() < 2 {
|
||||
return Err(bad(C, "highlight two or more rows to merge"));
|
||||
}
|
||||
let into =
|
||||
ids.iter().rev().copied().find(|id| d.node(*id).is_some_and(Node::is_layer)).ok_or_else(|| bad(C, "highlight a layer to merge into"))?;
|
||||
let around = d.ancestry(into).unwrap_or_default();
|
||||
// (Rows inside the target layer merge too: only rows inside other listed rows go with those.)
|
||||
let rest: Vec<NodeId> = ids.iter().copied().filter(|id| *id != into).collect();
|
||||
let others = row_roots(d, &rest);
|
||||
if others.iter().any(|o| around.contains(o)) {
|
||||
return Err(bad(C, "a layer can't be merged into its own sublayer"));
|
||||
}
|
||||
s.edit("Merge Selected", |d, sel| {
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(into);
|
||||
st.layer_rows = vec![into];
|
||||
Ok(json!({ "id": into.0 }))
|
||||
}
|
||||
|
||||
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.flatten";
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let from = id_param(p, "id").or_else(|| st.highlighted_rows().last().copied()).or(st.current_layer());
|
||||
let into = from.and_then(|id| d.layer_of(id)).ok_or_else(|| bad(C, "no layer"))?;
|
||||
if !d.node(into).is_some_and(|n| n.visible) {
|
||||
return Err(EngineError::Other("the layer to flatten into is hidden".into()));
|
||||
}
|
||||
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
let hidden: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect();
|
||||
s.edit("Flatten Artwork", |d, sel| {
|
||||
for h in &hidden {
|
||||
d.remove(*h)?;
|
||||
}
|
||||
merge_into(d, into, &others)?;
|
||||
sel.prune(d);
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
st.active_layer = Some(into);
|
||||
st.layer_rows = vec![into];
|
||||
Ok(json!({ "id": into.0, "discarded": hidden.len() }))
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.releaseToLayers";
|
||||
let build = bool_or(p, "build", false);
|
||||
let st = s.doc()?;
|
||||
let id = match id_param(p, "id") {
|
||||
Some(id) => id,
|
||||
None => match st.highlighted_rows().as_slice() {
|
||||
[id] => *id,
|
||||
_ => st.current_layer().ok_or_else(|| bad(C, "no layer"))?,
|
||||
},
|
||||
};
|
||||
let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let group = match n.kind {
|
||||
NodeKind::Layer { .. } => false,
|
||||
NodeKind::Group { .. } => true,
|
||||
_ => return Err(bad(C, "release a layer or a group")),
|
||||
};
|
||||
// Its objects, bottom first (sublayers stay as they are).
|
||||
let objects: Vec<NodeId> = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect();
|
||||
if objects.is_empty() {
|
||||
return Err(EngineError::Other("there are no objects to release".into()));
|
||||
}
|
||||
let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| {
|
||||
// A group's objects go into sublayers of the layer holding it, where it was.
|
||||
let (parent, mut index) = if group {
|
||||
let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?;
|
||||
let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) }
|
||||
} else {
|
||||
(id, 0)
|
||||
};
|
||||
let mut originals = vec![];
|
||||
for o in &objects {
|
||||
originals.push(Arc::unwrap_or_clone(d.remove(*o)?));
|
||||
}
|
||||
if group {
|
||||
d.remove(id)?;
|
||||
}
|
||||
let mut new = vec![];
|
||||
let last = originals.len().saturating_sub(1);
|
||||
for k in 0..originals.len() {
|
||||
let mut layer = make_layer(d, None);
|
||||
let lid = layer.id;
|
||||
let held: Vec<Node> = if build {
|
||||
// Copies of the objects below and itself; the top layer keeps the originals.
|
||||
(0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect()
|
||||
} else {
|
||||
originals.get(k).cloned().into_iter().collect()
|
||||
};
|
||||
if let Some(ch) = layer.children_mut() {
|
||||
ch.extend(held.into_iter().map(Arc::new));
|
||||
}
|
||||
let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) };
|
||||
d.insert(Some(parent), at, layer)?;
|
||||
index = at.saturating_add(1);
|
||||
new.push(lid);
|
||||
}
|
||||
sel.prune(d);
|
||||
Ok(new)
|
||||
})?;
|
||||
s.doc_mut()?.layer_rows = new.clone();
|
||||
Ok(json!({ "ids": new.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.reverse";
|
||||
let ids = rows_param(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = row_roots(d, &ids);
|
||||
// Rows grouped by the layer or group holding them (None: top-level layers).
|
||||
let mut by_parent: Vec<(Option<NodeId>, Vec<usize>)> = vec![];
|
||||
for id in &ids {
|
||||
let Some((par, i, _)) = d.position(*id) else { continue };
|
||||
match by_parent.iter_mut().find(|(p, _)| *p == par) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => by_parent.push((par, vec![i])),
|
||||
}
|
||||
}
|
||||
by_parent.retain(|(_, v)| v.len() >= 2);
|
||||
if by_parent.is_empty() {
|
||||
return Err(bad(C, "highlight two or more rows in one layer or group"));
|
||||
}
|
||||
s.edit("Reverse Order", |d, _| {
|
||||
for (par, mut slots) in by_parent {
|
||||
slots.sort_unstable();
|
||||
let ch = d.children_mut(par)?;
|
||||
let nodes: Vec<Arc<Node>> = slots.iter().filter_map(|i| ch.get(*i).cloned()).collect();
|
||||
for (slot, n) in slots.iter().zip(nodes.into_iter().rev()) {
|
||||
if let Some(c) = ch.get_mut(*slot) {
|
||||
*c = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn locate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?;
|
||||
let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?;
|
||||
st.layer_rows = vec![id];
|
||||
st.revision += 1;
|
||||
Ok(json!({ "id": id.0, "ancestry": ancestry.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
+738
-124
File diff suppressed because it is too large.
Load diff
@@ -18,8 +18,6 @@ use super::*;
|
||||
/// Session-level (not saved) state owned by the menu commands.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MenuState {
|
||||
/// Saved selections: (document title, name, object ids).
|
||||
pub saved_selections: Vec<(String, String, Vec<NodeId>)>,
|
||||
/// View → Guides → Lock Guides.
|
||||
pub guides_locked: bool,
|
||||
/// Transparency panel menu: "New Opacity Masks Are Clipping" turned off.
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
pub mod links;
|
||||
mod live;
|
||||
pub(crate) mod maskedit;
|
||||
@@ -46,6 +47,7 @@ mod path;
|
||||
mod pathops;
|
||||
mod patterncmds;
|
||||
pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
mod place;
|
||||
pub mod plugin;
|
||||
pub mod prefscmds;
|
||||
@@ -142,6 +144,10 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
|
||||
}
|
||||
pub fn has_anchors(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.anchors.values().all(|a| a.is_empty()) { Err("no anchor points selected".into()) } else { Ok(()) }
|
||||
}
|
||||
pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
|
||||
@@ -190,6 +196,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(gradient::specs());
|
||||
v.extend(opacitymask::specs());
|
||||
v.extend(layer::specs());
|
||||
v.extend(layerpanel::specs());
|
||||
v.extend(draw2::specs());
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
@@ -250,6 +257,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;
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character / Paragraph",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool}",
|
||||
"{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt, hyphenate?: bool, mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character)}",
|
||||
has_doc,
|
||||
set_format
|
||||
),
|
||||
@@ -54,17 +54,27 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let index = s.edit("Duplicate Artboard", |d, _| {
|
||||
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
let right = d.artboards.iter().map(|a| a.rect.x1).fold(f64::MIN, f64::max);
|
||||
let mut a = src.clone();
|
||||
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1;
|
||||
a.name = format!("{} copy", src.name);
|
||||
let dx = right + 20.0 - src.rect.x0;
|
||||
a.rect = vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1);
|
||||
d.artboards.push(a);
|
||||
Ok(d.artboards.len() - 1)
|
||||
Ok(push_artboard_copy(d, &src, vectorcraft_geom::Rect::new(src.rect.x0 + dx, src.rect.y0, src.rect.x1 + dx, src.rect.y1)))
|
||||
})?;
|
||||
Ok(json!({"index": index}))
|
||||
}
|
||||
|
||||
/// Add a copy of artboard `src` at `rect`, named `<name> copy` (`<name> copy 2`… when taken),
|
||||
/// with an id of its own → its index.
|
||||
pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: vectorcraft_geom::Rect) -> usize {
|
||||
let mut a = src.clone();
|
||||
a.id = d.artboards.iter().map(|a| a.id).max().unwrap_or(0).saturating_add(1);
|
||||
let taken = |name: &str| d.artboards.iter().any(|a| a.name == name);
|
||||
a.name = std::iter::once(format!("{} copy", src.name))
|
||||
.chain((2u64..).map(|i| format!("{} copy {i}", src.name)))
|
||||
.find(|name| !taken(name))
|
||||
.unwrap_or_default();
|
||||
a.rect = rect;
|
||||
d.artboards.push(a);
|
||||
d.artboards.len() - 1
|
||||
}
|
||||
|
||||
// ---------- text ----------
|
||||
|
||||
fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -101,11 +111,30 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"spaceBefore",
|
||||
"spaceAfter",
|
||||
"hyphenate",
|
||||
"mojikumi",
|
||||
"direction",
|
||||
];
|
||||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||||
return Err(bad(C, "nothing to change"));
|
||||
}
|
||||
let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?;
|
||||
let mojikumi = match p.get("mojikumi") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("none") => vectorcraft_doc::Mojikumi::None,
|
||||
Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf,
|
||||
_ => return Err(bad(C, "`mojikumi` must be \"none\" or \"lineEndHalf\"")),
|
||||
}),
|
||||
};
|
||||
let direction = match p.get("direction") {
|
||||
None => None,
|
||||
Some(v) => Some(match v.as_str() {
|
||||
Some("auto") => None,
|
||||
Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight),
|
||||
Some("rightToLeft") => Some(vectorcraft_doc::ParaDirection::RightToLeft),
|
||||
_ => return Err(bad(C, "`direction` must be \"auto\", \"leftToRight\" or \"rightToLeft\"")),
|
||||
}),
|
||||
};
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -161,6 +190,12 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = flag("hyphenate") {
|
||||
para.hyphenate = v;
|
||||
}
|
||||
if let Some(v) = mojikumi {
|
||||
para.mojikumi = v;
|
||||
}
|
||||
if let Some(v) = direction {
|
||||
para.direction = v;
|
||||
}
|
||||
super::typecmd::refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -87,11 +87,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"path.deleteAnchors",
|
||||
"Remove Anchor Points",
|
||||
["Object", "Path"],
|
||||
"Delete Anchor Points",
|
||||
[],
|
||||
None,
|
||||
"{} delete direct-selected anchors",
|
||||
has_selection,
|
||||
"{} delete the direct-selected anchors and their segments, opening closed paths there (the Delete key)",
|
||||
has_anchors,
|
||||
delete_anchors
|
||||
),
|
||||
cmd!(
|
||||
@@ -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.visible {
|
||||
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).visible.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 };
|
||||
|
||||
@@ -97,17 +97,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
|
||||
/// The objects Select All takes: those of the isolated container, else of every layer (looking
|
||||
/// through sublayers, which are not objects).
|
||||
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
|
||||
match iso.and_then(|i| d.node(i)) {
|
||||
Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(),
|
||||
None => d
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|l| l.visible && !l.locked)
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.visible && !n.locked)
|
||||
.map(|n| n.id)
|
||||
.collect(),
|
||||
Some(c) => c.layer_art(true),
|
||||
None => d.selectable_art(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +183,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()
|
||||
@@ -249,7 +245,11 @@ fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Valu
|
||||
/// inside accepted ones); Select > Reselect repeats `cmd` with `p`.
|
||||
fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &Node) -> bool) -> Result<Value> {
|
||||
fn visit(d: &Document, n: &Node, f: &impl Fn(&Document, &Node) -> bool, ids: &mut Vec<NodeId>) {
|
||||
if n.visible && !n.locked && !n.is_layer() && f(d, n) {
|
||||
// Hidden or locked objects and sublayers (and what they hold) are out of reach.
|
||||
if !n.visible || n.locked || n.is_template() {
|
||||
return;
|
||||
}
|
||||
if !n.is_layer() && f(d, n) {
|
||||
return ids.push(n.id);
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
@@ -306,14 +306,9 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
|
||||
let ids: Vec<NodeId> = layers
|
||||
.iter()
|
||||
.filter_map(|l| st.doc.node(*l))
|
||||
.flat_map(|l| l.children().into_iter().flatten())
|
||||
.filter(|n| n.visible && !n.locked)
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
// The layers or sublayers the selected objects are on (art in their sublayers too).
|
||||
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_containing(*id)).collect();
|
||||
let ids: Vec<NodeId> = layers.iter().filter_map(|l| st.doc.node(*l)).flat_map(|l| l.layer_art(true)).collect();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -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,10 +368,11 @@ 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 }],
|
||||
para: Default::default(),
|
||||
para: super::create::new_type_para(),
|
||||
area: Default::default(),
|
||||
path_effect: Default::default(),
|
||||
wrap: Vec::new(),
|
||||
@@ -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(),
|
||||
|
||||
@@ -115,12 +115,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
// ---------- attribute plumbing ----------
|
||||
|
||||
/// `v` as a JSON map with one key per attribute: Auto alignment, which files save as its
|
||||
/// direction's alignment plus `justify_auto` (see `vectorcraft_doc::ParaStyle`), is `"justify":
|
||||
/// "Auto"`, so a style's `justify` replaces it.
|
||||
fn to_map<T: Serialize>(v: &T) -> Option<Map<String, Value>> {
|
||||
let Ok(Value::Object(mut m)) = serde_json::to_value(v) else { return None };
|
||||
if m.remove("justify_auto") == Some(Value::Bool(true)) {
|
||||
m.insert("justify".into(), Value::from("Auto"));
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A style's attributes as a JSON map, without the style name.
|
||||
fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
let mut m = match serde_json::to_value(v) {
|
||||
Ok(Value::Object(m)) => m,
|
||||
_ => Map::new(),
|
||||
};
|
||||
let mut m = to_map(v).unwrap_or_default();
|
||||
m.remove("style_name");
|
||||
m
|
||||
}
|
||||
@@ -128,7 +136,7 @@ fn to_attrs<T: Serialize>(v: &T) -> Map<String, Value> {
|
||||
/// `base` with `attrs` written over it (unknown or ill-typed attributes are an error).
|
||||
fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Value>, cmd: &str) -> Result<T> {
|
||||
let mut m = to_attrs(base);
|
||||
if let Ok(Value::Object(full)) = serde_json::to_value(base) {
|
||||
if let Some(full) = to_map(base) {
|
||||
m.insert("style_name".into(), full.get("style_name").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
for (k, v) in attrs {
|
||||
@@ -144,7 +152,7 @@ fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
|
||||
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
|
||||
/// round-trip through JSON.
|
||||
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
|
||||
let Ok(Value::Object(mut m)) = serde_json::to_value(cur) else { return cur.clone() };
|
||||
let Some(mut m) = to_map(cur) else { return cur.clone() };
|
||||
for (k, v) in new {
|
||||
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
|
||||
m.insert(k.clone(), v.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",
|
||||
@@ -54,13 +75,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
|
||||
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -154,6 +190,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn justify_param(v: &str) -> Option<Justify> {
|
||||
Some(match v.to_ascii_lowercase().as_str() {
|
||||
"auto" => Justify::Auto,
|
||||
"left" => Justify::Left,
|
||||
"center" => Justify::Center,
|
||||
"right" => Justify::Right,
|
||||
@@ -180,7 +217,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let tracking = num_param(p, "tracking");
|
||||
let justify = match str_param(p, "justify") {
|
||||
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be left|center|right|justifyAll"))?),
|
||||
Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?),
|
||||
None => None,
|
||||
};
|
||||
let fill = match p.get("fill") {
|
||||
@@ -238,25 +275,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 +365,91 @@ 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 new_type_is_composed_with_line_end_half_width_punctuation() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "雅楽。"})).unwrap()["id"].as_u64().unwrap();
|
||||
let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.para.clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf);
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.execute("text.setFormat", &json!({"mojikumi": "everything"})).is_err());
|
||||
s.execute("text.setFormat", &json!({"mojikumi": "none"})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(para(&s).mojikumi, Mojikumi::LineEndHalf);
|
||||
// Saved only when set; documents from before it read as None.
|
||||
let json = serde_json::to_value(para(&s)).unwrap();
|
||||
assert_eq!(json["mojikumi"], "lineEndHalf");
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert_eq!(old.mojikumi, Mojikumi::None);
|
||||
assert!(serde_json::to_value(&old).unwrap().get("mojikumi").is_none());
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -275,6 +275,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (sl, sr) = match t.para.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5),
|
||||
Justify::Right | Justify::JustifyRight => (slack, 0.0),
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0),
|
||||
_ => (0.0, slack),
|
||||
};
|
||||
let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0));
|
||||
@@ -330,6 +331,7 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ox = match t.para.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1,
|
||||
_ => x0,
|
||||
};
|
||||
t.kind = TextKind::Point;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Artboard",
|
||||
[],
|
||||
None,
|
||||
"{index, dx, dy, moveArt?: bool} move an artboard (and the unlocked art fully inside it)",
|
||||
"{index, dx, dy, moveArt?: bool, copy?: bool} move an artboard (and the unlocked art fully inside it); `copy` (Alt-drag) leaves them and moves copies → {index, moved: the art moved or the copies}",
|
||||
has_doc,
|
||||
artboard_move
|
||||
),
|
||||
@@ -374,40 +374,32 @@ fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let move_art = bool_or(p, "moveArt", false);
|
||||
let st = s.doc()?;
|
||||
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// Top-level objects (children of layers) lying entirely inside the artboard.
|
||||
let mut art = vec![];
|
||||
if move_art {
|
||||
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if c.locked {
|
||||
continue;
|
||||
}
|
||||
if c.is_layer() {
|
||||
collect(c, rect, out);
|
||||
} else if let Some(b) = c.geometric_bounds()
|
||||
&& rect.contains(Point::new(b.x0, b.y0))
|
||||
&& rect.contains(Point::new(b.x1, b.y1))
|
||||
{
|
||||
out.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &st.doc.layers {
|
||||
if !l.locked {
|
||||
collect(l, rect, &mut art);
|
||||
}
|
||||
}
|
||||
}
|
||||
let art = if move_art { st.doc.art_on_artboard(rect) } else { vec![] };
|
||||
let scale_strokes = s.prefs.scale_strokes;
|
||||
s.edit("Move Artboard", |d, _| {
|
||||
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
a.rect = a.rect + dv;
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
let copy = bool_or(p, "copy", false);
|
||||
let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| {
|
||||
let src = d.artboards.get(i).cloned().ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
// A copy keeps the artboard (and its art) where they are and moves duplicates instead.
|
||||
if !copy {
|
||||
if let Some(a) = d.artboards.get_mut(i) {
|
||||
a.rect = src.rect + dv;
|
||||
}
|
||||
for id in &art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(Affine::translate(dv), scale_strokes);
|
||||
}
|
||||
}
|
||||
return Ok((i, art.clone()));
|
||||
}
|
||||
Ok(())
|
||||
let index = super::panelcmds::push_artboard_copy(d, &src, src.rect + dv);
|
||||
let mut copies = Vec::with_capacity(art.len());
|
||||
for id in &art {
|
||||
let (Some((parent, at, _)), Some(n)) = (d.position(*id), d.node(*id).cloned()) else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(Affine::translate(dv), scale_strokes);
|
||||
copies.push(d.insert(parent, at + 1, c)?);
|
||||
}
|
||||
Ok((index, copies))
|
||||
})?;
|
||||
Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
Ok(json!({ "index": index, "moved": moved.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -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());
|
||||
@@ -38,6 +52,15 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
v["strokeOptions"] = stroke_options(&st);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Layer { color, template, printable, preview, dim_images, clip, .. } => {
|
||||
let [r, g, b] = color.rgb();
|
||||
v["color"] = json!(format!("#{r:02x}{g:02x}{b:02x}"));
|
||||
v["template"] = json!(template);
|
||||
v["printable"] = json!(printable);
|
||||
v["preview"] = json!(preview);
|
||||
v["dimImages"] = json!(dim_images);
|
||||
v["clip"] = json!(clip);
|
||||
}
|
||||
NodeKind::Path { path, .. } => {
|
||||
v["anchors"] = json!(path.anchor_count());
|
||||
v["closed"] = json!(path.is_closed());
|
||||
@@ -65,6 +88,8 @@ pub fn document(s: &Session) -> Value {
|
||||
// Top of the stack first, like the Layers panel.
|
||||
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
|
||||
"currentLayer": st.active_layer.map(|l| l.0),
|
||||
// The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`).
|
||||
"layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"isolation": st.isolation.map(|l| l.0),
|
||||
"selection": st.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>(),
|
||||
"selectionBounds": rect_json(d.bounds_of(&st.selection.objects, false)),
|
||||
|
||||
+121
-2
@@ -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.
|
||||
@@ -98,6 +102,11 @@ pub struct DocState {
|
||||
saved_doc: Arc<Document>,
|
||||
/// The layer new art goes into (the "current layer" in the Layers panel).
|
||||
pub active_layer: Option<NodeId>,
|
||||
/// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order
|
||||
/// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on.
|
||||
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
|
||||
/// `layer.highlight`).
|
||||
pub layer_rows: Vec<NodeId>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
pub interaction: Option<Interaction>,
|
||||
@@ -131,6 +140,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);
|
||||
@@ -149,6 +161,7 @@ impl DocState {
|
||||
path,
|
||||
revision: 1,
|
||||
active_layer,
|
||||
layer_rows: vec![],
|
||||
isolation: None,
|
||||
interaction: None,
|
||||
last_transform: None,
|
||||
@@ -163,6 +176,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).
|
||||
@@ -228,7 +242,13 @@ impl DocState {
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer())
|
||||
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
|
||||
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
|
||||
}
|
||||
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
|
||||
/// remembered row must still be in the document).
|
||||
pub fn highlighted_rows(&self) -> Vec<NodeId> {
|
||||
self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect()
|
||||
}
|
||||
/// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while
|
||||
/// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it.
|
||||
@@ -383,9 +403,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 +505,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 +613,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 +660,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 +731,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 +780,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 +822,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() {
|
||||
@@ -1014,9 +1081,23 @@ impl Session {
|
||||
pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> {
|
||||
self.active_appearance_item = None;
|
||||
let st = self.doc_mut()?;
|
||||
let before = st.selection.objects.clone();
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
// Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of
|
||||
// the reference app: new art then goes beside it.
|
||||
if st.selection.objects != before
|
||||
&& let Some(layer) = st.selection.objects.last().and_then(|id| st.doc.layer_containing(*id)).filter(|l| st.doc.is_editable(*l))
|
||||
&& st.active_layer != Some(layer)
|
||||
{
|
||||
st.active_layer = Some(layer);
|
||||
st.layer_rows.clear();
|
||||
}
|
||||
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 +1115,7 @@ impl Session {
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1062,14 +1144,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 +1197,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 +1249,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;
|
||||
@@ -1190,12 +1289,16 @@ mod tests_labspots;
|
||||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_linked_stops;
|
||||
#[cfg(test)]
|
||||
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 +1337,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 +1367,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 +1415,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 +1429,6 @@ mod tests_widthpoints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
@@ -252,6 +252,33 @@ fn pen_tool_draws_closed_path() {
|
||||
assert!(p.is_closed());
|
||||
}
|
||||
|
||||
/// Auto Add/Delete: a Pen click on a selected path's segment adds an anchor, unless General →
|
||||
/// Disable Auto Add/Delete is on.
|
||||
#[test]
|
||||
fn pen_click_on_a_selected_path_adds_an_anchor_unless_disabled() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let r = s.execute("path.create", &json!({"anchors": [{"x": 10, "y": 100}, {"x": 110, "y": 100}, {"x": 210, "y": 100}]})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.select_tool("pen", v).unwrap();
|
||||
let click = |s: &mut Session| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 101.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 101.0), v).unwrap();
|
||||
};
|
||||
click(&mut s);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 1);
|
||||
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 4);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "disableAutoAddDelete", "value": true})).unwrap();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
click(&mut s);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.layers[0].children().unwrap().len(), 2, "a new path starts");
|
||||
assert_eq!(d.node(id).unwrap().path_data().unwrap().anchor_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_moves_one_anchor() {
|
||||
let mut s = session();
|
||||
@@ -267,6 +294,43 @@ fn direct_selection_moves_one_anchor() {
|
||||
assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
/// Dragging a curved segment with Direct Selection bends it (its anchors stay), and dragging a
|
||||
/// straight one moves its two anchors, not the whole path; each is one undo step.
|
||||
#[test]
|
||||
fn direct_selection_drags_segments() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64)| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), 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();
|
||||
};
|
||||
let arch = &json!({"anchors": [{"x": 100, "y": 200, "out": [100, 100]}, {"x": 300, "y": 200, "in": [300, 100]}]});
|
||||
let arch = NodeId(s.execute("path.create", arch).unwrap()["id"].as_u64().unwrap());
|
||||
let square = &json!({"anchors": [{"x": 150, "y": 400}, {"x": 150, "y": 300}, {"x": 250, "y": 300}, {"x": 250, "y": 400}], "closed": true});
|
||||
let square = NodeId(s.execute("path.create", square).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
let steps = |s: &Session| s.doc().unwrap().history.undo.len();
|
||||
let before = steps(&s);
|
||||
// The arch's middle, 40 up: the curve bends, its two anchors stay.
|
||||
drag(&mut s, (200.0, 125.0), (200.0, 85.0));
|
||||
let sp = s.doc().unwrap().doc.node(arch).unwrap().path_data().unwrap().subpaths[0].clone();
|
||||
let (a, b) = (sp.anchors[0], sp.anchors[1]);
|
||||
assert_eq!((a.p, b.p), (vectorcraft_geom::Point::new(100.0, 200.0), vectorcraft_geom::Point::new(300.0, 200.0)), "the anchors stay");
|
||||
assert!(a.h_out.y < 100.0 && b.h_in.y < 100.0, "the curve bent up: {a:?} {b:?}");
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
// The square's top edge, 40 up: its two anchors move, the bottom two stay.
|
||||
drag(&mut s, (200.0, 300.0), (200.0, 260.0));
|
||||
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((sq.y0, sq.y1, sq.x0, sq.x1), (260.0, 400.0, 150.0, 250.0), "the edge moved, the rest stayed");
|
||||
assert_eq!(steps(&s), before + 2);
|
||||
// Its fill still moves the whole square.
|
||||
drag(&mut s, (200.0, 350.0), (200.0, 330.0));
|
||||
let sq = s.doc().unwrap().doc.node(square).unwrap().geometric_bounds().unwrap();
|
||||
assert_eq!((sq.y0, sq.y1), (240.0, 380.0), "the whole path moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_create_has_bounds() {
|
||||
let mut s = session();
|
||||
@@ -285,6 +349,54 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
|
||||
/// the same fields the full-tree summary carries, without serializing the whole tree.
|
||||
#[test]
|
||||
fn document_node_summary_matches_inspect() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
|
||||
let full = s.execute("document.node", &json!({"id": id})).unwrap();
|
||||
assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}");
|
||||
let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap();
|
||||
assert_eq!(summary, node, "{summary} vs {node}");
|
||||
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -510,3 +622,20 @@ fn a_panicking_command_rolls_back_instead_of_crashing() {
|
||||
assert!(s.doc().unwrap().doc.node(b).is_some());
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("shape.rectangle"));
|
||||
}
|
||||
|
||||
/// New layers and sublayers share one "Layer N" numbering, whatever art the document holds, and
|
||||
/// never repeat a name.
|
||||
#[test]
|
||||
fn new_layers_and_sublayers_are_numbered_together() {
|
||||
let name = |s: &Session, id: &Value| s.doc().unwrap().doc.node(NodeId(id["id"].as_u64().unwrap())).unwrap().display_name().to_string();
|
||||
let mut s = session();
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &sub), "Layer 2", "not its parent's name");
|
||||
for x in [0.0, 60.0, 120.0, 180.0, 240.0] {
|
||||
rect(&mut s, x, 0.0, 50.0, 50.0);
|
||||
}
|
||||
let layer = s.execute("layer.new", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &layer), "Layer 3", "after the sublayer, not a repeat of it");
|
||||
let sub = s.execute("layer.newSublayer", &json!({})).unwrap();
|
||||
assert_eq!(name(&s, &sub), "Layer 4", "the art doesn't count");
|
||||
}
|
||||
@@ -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);
|
||||
@@ -241,6 +247,42 @@ fn grid_show_and_plane_are_view_state() {
|
||||
assert!(s.overlays(ViewInfo::default()).len() > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_hides_while_a_perspective_tool_is_chosen() {
|
||||
// #321: choosing the Perspective Grid tool showed the grid for good.
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
for tool in ["perspectiveGrid", "perspectiveSelection"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
assert!(s.overlays(v).len() > 20, "{tool}");
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("perspective.grid.show", &json!({})).unwrap(), json!({"visible": false}));
|
||||
assert!(s.overlays(v).is_empty(), "{tool}: the hidden grid still draws");
|
||||
assert_eq!(undo_len(&s), n);
|
||||
// Choosing the tool again shows it again.
|
||||
s.select_tool(tool, v).unwrap();
|
||||
assert!(s.overlays(v).len() > 20, "{tool}");
|
||||
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_grid_stays_hidden_back_from_a_temporary_tool() {
|
||||
// Cmd-dragging with a perspective tool borrows the Selection tool; going back to the
|
||||
// perspective tool is not choosing it, so the grid hidden meanwhile stays hidden.
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("perspectiveGrid", v).unwrap();
|
||||
s.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||||
s.switch_tool("selection", v).unwrap();
|
||||
s.switch_tool("perspectiveGrid", v).unwrap();
|
||||
assert!(s.overlays(v).is_empty());
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.select_tool("perspectiveGrid", v).unwrap();
|
||||
assert!(s.overlays(v).len() > 20, "choosing it shows the grid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_preset_and_set_are_undoable() {
|
||||
let mut s = session();
|
||||
@@ -290,6 +332,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 +357,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;
|
||||
|
||||
@@ -375,6 +375,7 @@ fn draw2_commands_reject_bad_params() {
|
||||
("path.freehand", json!({"points": [[1, 2]]})),
|
||||
("path.curvature", json!({"points": []})),
|
||||
("path.removeAnchor", json!({"id": id.0, "anchor": 99})),
|
||||
("path.removeAnchors", json!({})),
|
||||
("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})),
|
||||
("path.reshapeSegment", json!({"id": id.0, "segment": 42})),
|
||||
("path.split", json!({"id": id.0})),
|
||||
@@ -389,3 +390,147 @@ fn draw2_commands_reject_bad_params() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from (100, 100) to (300, 100) whose first anchor has an out handle at (150, 100).
|
||||
fn curve(s: &mut Session) -> NodeId {
|
||||
let anchors = json!([{"x": 100, "y": 100, "out": [150, 100]}, {"x": 300, "y": 100, "in": [250, 100]}]);
|
||||
NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Drag the out handle of path `id`'s first anchor with `tool` to `to`, holding `mods`. Direct
|
||||
/// Selection clicks the anchor first, so its handles show.
|
||||
fn drag_handle(s: &mut Session, tool: &str, id: NodeId, to: (f64, f64), mods: Mods, v: ViewInfo) {
|
||||
let from = path(s, id).subpaths[0].anchors[0].h_out;
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.select_tool(tool, v).unwrap();
|
||||
if tool == "directSelection" {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 100.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 100.0, 100.0), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y).with_mods(mods), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(mods), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(mods), v).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
}
|
||||
|
||||
/// The out handle of path `id`'s first anchor.
|
||||
fn out_handle(s: &Session, id: NodeId) -> Point {
|
||||
path(s, id).subpaths[0].anchors[0].h_out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_a_dragged_handle_at_45_degree_steps() {
|
||||
// #322.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
for tool in ["directSelection", "anchorPoint"] {
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
drag_handle(&mut s, tool, id, (190.0, 130.0), shift, view());
|
||||
let h = out_handle(&s, id);
|
||||
assert!(near(h, Point::new(100.0 + 90.0f64.hypot(30.0), 100.0)), "{tool}: {h:?}");
|
||||
drag_handle(&mut s, tool, id, (160.0, 155.0), shift, view());
|
||||
let h = out_handle(&s, id);
|
||||
assert!((h.x - h.y).abs() < 1e-6 && h.x > 100.0, "{tool}: 45°: {h:?}");
|
||||
// Without Shift the handle goes where it is dragged.
|
||||
drag_handle(&mut s, tool, id, (190.0, 130.0), Mods::default(), view());
|
||||
assert_eq!(out_handle(&s, id), Point::new(190.0, 130.0), "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_handle_snaps_to_smart_guides() {
|
||||
// #322: in line with the other anchor, with smart guides on.
|
||||
for tool in ["directSelection", "anchorPoint"] {
|
||||
let mut s = session();
|
||||
let id = curve(&mut s);
|
||||
drag_handle(&mut s, tool, id, (298.0, 160.0), Mods::default(), ViewInfo::default());
|
||||
assert_eq!(out_handle(&s, id), Point::new(300.0, 160.0), "{tool}");
|
||||
drag_handle(&mut s, tool, id, (298.0, 170.0), Mods::default(), view());
|
||||
assert_eq!(out_handle(&s, id), Point::new(298.0, 170.0), "{tool}: smart guides off");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_keeps_a_smooth_anchor_smooth_and_alt_breaks_it() {
|
||||
// #322: the opposite handle of a smooth anchor turns with the constrained one; Alt still
|
||||
// moves the dragged handle alone.
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let shift_alt = Mods { shift: true, alt: true, ..Mods::default() };
|
||||
for (mods, opposite_follows) in [(shift, true), (shift_alt, false)] {
|
||||
let mut s = session();
|
||||
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
|
||||
s.select_tool("directSelection", view()).unwrap();
|
||||
for (kind, x, y, m) in [
|
||||
(PointerKind::Down, 200.0, 100.0, Mods::default()),
|
||||
(PointerKind::Up, 200.0, 100.0, Mods::default()),
|
||||
(PointerKind::Down, 250.0, 100.0, mods),
|
||||
(PointerKind::Drag, 275.0, 165.0, mods),
|
||||
(PointerKind::Up, 275.0, 165.0, mods),
|
||||
] {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(m), view()).unwrap();
|
||||
}
|
||||
let a = path(&s, id).subpaths[0].anchors[1];
|
||||
let (o, i) = (a.h_out - a.p, a.h_in - a.p);
|
||||
assert!((o.x - o.y).abs() < 1e-6 && o.x > 0.0, "{mods:?}: 45°: {o:?}");
|
||||
if opposite_follows {
|
||||
assert!(near(a.h_in, a.p - o.normalize() * 50.0), "{mods:?}: {i:?}");
|
||||
} else {
|
||||
assert_eq!(a.h_in, Point::new(150.0, 100.0), "{mods:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_anchor_refits_a_split_curve_and_undo_restores_it() {
|
||||
let mut s = session();
|
||||
let made = s
|
||||
.execute(
|
||||
"path.create",
|
||||
&json!({"anchors": [
|
||||
{"x": 0, "y": 0, "out": [30, 80]},
|
||||
{"x": 100, "y": 0, "in": [70, 80]}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = NodeId(made["id"].as_u64().unwrap());
|
||||
let before = path(&s, id);
|
||||
let inserted = s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.4})).unwrap();
|
||||
let ai = inserted["anchor"].as_u64().unwrap();
|
||||
s.execute("path.removeAnchor", &json!({"id": id.0, "anchor": ai})).unwrap();
|
||||
let after = path(&s, id);
|
||||
assert_eq!(after.subpaths[0].anchors.len(), 2);
|
||||
let (got, orig) = (&after.subpaths[0].anchors, &before.subpaths[0].anchors);
|
||||
assert!(got[0].h_out.distance(orig[0].h_out) < 0.5, "{:?}", got[0].h_out);
|
||||
assert!(got[1].h_in.distance(orig[1].h_in) < 0.5, "{:?}", got[1].h_in);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_anchor_points_keeps_the_paths_closed() {
|
||||
// Object › Path › Remove Anchor Points, unlike the Delete key: a rectangle loses a corner and
|
||||
// stays closed, a straight-sided triangle; a curve keeps its shape round a removed point.
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 80.0);
|
||||
let made = s.execute("path.create", &json!({"anchors": [{"x": 300, "y": 0, "out": [330, 80]}, {"x": 400, "y": 0, "in": [370, 80]}]})).unwrap();
|
||||
let c = NodeId(made["id"].as_u64().unwrap());
|
||||
let before = path(&s, c).subpaths[0].segment(0);
|
||||
s.execute("path.insertAnchor", &json!({"id": c.0, "segment": 0, "t": 0.5})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": r.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 1]], "mode": "add"})).unwrap();
|
||||
assert_eq!(s.execute("path.removeAnchors", &json!({})).unwrap()["removedObjects"], 0);
|
||||
let sp = &path(&s, r).subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
|
||||
assert!(!sp.anchors.iter().any(|a| a.has_in() || a.has_out()), "straight sides stay straight: {sp:?}");
|
||||
let after = path(&s, c).subpaths[0].segment(0);
|
||||
assert_eq!(path(&s, c).subpaths[0].anchors.len(), 2);
|
||||
assert!(after.p1.distance(before.p1) < 0.5 && after.p2.distance(before.p2) < 0.5, "{after:?}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove Anchor Points");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, r).subpaths[0].anchors.len(), 4);
|
||||
assert_eq!(path(&s, c).subpaths[0].anchors.len(), 3);
|
||||
let spec = find_command("path.removeAnchors").unwrap();
|
||||
assert_eq!(spec.menu, &["Object", "Path"][..]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -100,9 +100,10 @@ fn type_picks_up_font_and_size() {
|
||||
assert!(width(&s, dst) > before, "laid out again at the new size");
|
||||
// Without character or paragraph attributes only the paints go.
|
||||
let dst2 = id_of(s.execute("text.create", &json!({"x": 10, "y": 500, "text": "Other"})).unwrap());
|
||||
let preserved_alignment = style(&s, dst2).1;
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"character": false, "paragraph": false}})).unwrap();
|
||||
let (st, justify) = style(&s, dst2);
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, Justify::Left, "#00ff00".into()));
|
||||
assert_eq!((st.size, justify, st.fill.color().unwrap().to_hex()), (12.0, preserved_alignment, "#00ff00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -115,8 +115,8 @@ fn the_top_object_must_be_able_to_clip() {
|
||||
assert_eq!(layer(&s), before);
|
||||
// An explicit id picks the layer even with a group selected.
|
||||
let l = before.id;
|
||||
s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap();
|
||||
let sub = layer(&s).children().unwrap().last().unwrap().id;
|
||||
let sub = id_of(&s.execute("layer.newSublayer", &json!({"parent": l.0})).unwrap());
|
||||
assert_eq!(s.doc().unwrap().doc.node(l).unwrap().children().unwrap().last().unwrap().id, sub, "on top of the layer's contents");
|
||||
let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0}));
|
||||
assert!(err.is_err(), "an empty sublayer has nothing to clip by");
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! The Layers panel's commands: rows at every depth (layers, sublayers, groups, objects) are
|
||||
//! clickable, highlighted and targeted; their art is selected through the selection column; rows
|
||||
//! move, copy and reparent with validation; and the panel menu's operations (merge, flatten,
|
||||
//! release to layers, reverse, template, others/all) are single undo steps.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LayerColor, Node, NodeId, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
id(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 20, "height": 20})).unwrap())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
|
||||
s.doc().unwrap().doc.parent_of(id)
|
||||
}
|
||||
|
||||
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
node(s, id).children().unwrap().iter().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
fn undos(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Layer 1 holding a sublayer `sub` with a rectangle `a` and a group `g` of rectangle `b`, plus a
|
||||
/// rectangle `c` on Layer 1 itself → (layer, sub, a, g, b, c). Nothing selected.
|
||||
fn tree(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let c = rect(s, 300.0);
|
||||
let sub = id(&run(s, "layer.newSublayer", json!({"name": "Sub"})));
|
||||
let a = rect(s, 0.0);
|
||||
let b = rect(s, 100.0);
|
||||
run(s, "select.set", json!({"ids": [b.0]}));
|
||||
let g = id(&run(s, "object.group", json!({})));
|
||||
run(s, "select.none", json!({}));
|
||||
(layer, sub, a, g, b, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, _, c) = tree(&mut s);
|
||||
assert_eq!(parent(&s, a), Some(sub), "new art went into the current sublayer");
|
||||
let n = run(&mut s, "select.all", json!({}))["count"].as_u64().unwrap();
|
||||
assert_eq!(n, 3);
|
||||
let sel = s.doc().unwrap().selection.objects.clone();
|
||||
assert!(sel.contains(&a) && sel.contains(&g) && sel.contains(&c) && !sel.contains(&sub), "{sel:?}");
|
||||
// The Selection tool's click takes the object in the sublayer.
|
||||
let h = vectorcraft_doc::hit::hit_test(&s.doc().unwrap().doc, vectorcraft_geom::Point::new(10.0, 10.0), Default::default()).unwrap();
|
||||
assert_eq!(h.top_object(None), a);
|
||||
// Deleting it works (layers were skipped by Clear before).
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
run(&mut s, "edit.clear", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, c) = tree(&mut s);
|
||||
let before = undos(&s);
|
||||
for (row, current) in [(layer, layer), (sub, sub), (g, sub), (b, sub), (a, sub), (c, layer)] {
|
||||
let r = run(&mut s, "layer.setCurrent", json!({"id": row.0}));
|
||||
assert_eq!(NodeId(r["layer"].as_u64().unwrap()), current);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![row], Some(current)));
|
||||
}
|
||||
assert_eq!(undos(&s), before, "clicking rows is not an undo step");
|
||||
// New art goes into the current sublayer.
|
||||
run(&mut s, "layer.setCurrent", json!({"id": a.0}));
|
||||
let n = rect(&mut s, 50.0);
|
||||
assert_eq!(parent(&s, n), Some(sub));
|
||||
assert!(s.execute("layer.setCurrent", &json!({"id": 9999})).is_err());
|
||||
// Shift/Ctrl: several rows.
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0], "mode": "set"}));
|
||||
run(&mut s, "layer.highlight", json!({"ids": [c.0], "mode": "add"}));
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0], "mode": "toggle"}));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.highlighted_rows(), st.current_layer()), (vec![g, c], Some(layer)));
|
||||
assert!(s.execute("layer.highlight", &json!({"ids": [a.0], "mode": "nope"})).is_err());
|
||||
assert_eq!(document_rows(&s), vec![g.0, c.0], "document.inspect lists the rows");
|
||||
}
|
||||
|
||||
fn document_rows(s: &Session) -> Vec<u64> {
|
||||
crate::inspect::document(s)["layerRows"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_art_makes_its_layer_current() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, _, _, c) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [c.0]}));
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(layer));
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(sub));
|
||||
let n = rect(&mut s, 200.0);
|
||||
assert_eq!(parent(&s, n), Some(sub));
|
||||
// A locked sublayer takes no new art: it goes to the top layer.
|
||||
run(&mut s, "select.none", json!({}));
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let n = rect(&mut s, 200.0);
|
||||
assert_eq!(parent(&s, n), Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_column_selects_a_rows_art_and_shift_adds_or_removes_it() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let sel = |s: &Session| s.doc().unwrap().selection.objects.clone();
|
||||
run(&mut s, "layer.selectAll", json!({"id": layer.0}));
|
||||
assert_eq!(sel(&s), vec![c, a, g], "the layer's art, its sublayer's too");
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0}));
|
||||
assert_eq!(sel(&s), vec![a, g]);
|
||||
run(&mut s, "layer.selectAll", json!({"id": c.0, "add": true}));
|
||||
assert_eq!(sel(&s), vec![a, g, c]);
|
||||
run(&mut s, "layer.selectAll", json!({"id": sub.0, "add": true}));
|
||||
assert_eq!(sel(&s), vec![c], "all selected already: removed");
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![sub], "the highlighted row stays");
|
||||
// Locked rows select nothing.
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": true}));
|
||||
run(&mut s, "layer.selectAll", json!({"id": a.0}));
|
||||
assert!(sel(&s).is_empty());
|
||||
// Targeting a layer selects its sublayers' art too.
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "locked": false}));
|
||||
assert_eq!(ids_sel(&run(&mut s, "layer.target", json!({"id": layer.0}))), vec![c, a, g]);
|
||||
}
|
||||
|
||||
fn ids_sel(v: &Value) -> Vec<NodeId> {
|
||||
v["selected"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_and_lock_per_row_are_undoable() {
|
||||
let mut s = session();
|
||||
let (_, sub, a, g, b, _) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
|
||||
let before = undos(&s);
|
||||
run(&mut s, "layer.setProps", json!({"ids": [b.0, sub.0], "visible": false}));
|
||||
assert!(!node(&s, b).visible && !node(&s, sub).visible);
|
||||
assert!(s.doc().unwrap().selection.is_empty(), "hidden art is deselected");
|
||||
assert_eq!(undos(&s), before + 1, "one step for several rows");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(node(&s, b).visible && node(&s, sub).visible);
|
||||
run(&mut s, "layer.setProps", json!({"id": a.0, "locked": true, "name": "Box"}));
|
||||
assert!(node(&s, a).locked);
|
||||
assert_eq!(node(&s, a).display_name(), "Box");
|
||||
run(&mut s, "layer.setProps", json!({"id": a.0, "name": ""}));
|
||||
assert_eq!(node(&s, a).display_name(), "<Rectangle>", "an empty name goes back to the generated one");
|
||||
run(&mut s, "layer.setProps", json!({"id": sub.0, "name": ""}));
|
||||
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_validate_and_template_locks_and_dims() {
|
||||
let mut s = session();
|
||||
let (layer, ..) = tree(&mut s);
|
||||
for bad in [json!({"color": 27}), json!({"color": "#zz"}), json!({"color": [1]}), json!({"locked": "yes"}), json!({"dimImages": "half"})] {
|
||||
let mut p = bad.clone();
|
||||
p["id"] = json!(layer.0);
|
||||
assert!(s.execute("layer.setProps", &p).is_err(), "{bad}");
|
||||
}
|
||||
let color = |s: &Session| match node(s, layer).kind {
|
||||
NodeKind::Layer { color, .. } => color,
|
||||
_ => panic!("not a layer"),
|
||||
};
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "red"}));
|
||||
assert_eq!(color(&s), LayerColor::Preset(1));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": "#123456"}));
|
||||
assert_eq!(color(&s), LayerColor::Custom([0x12, 0x34, 0x56]));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "color": 4}));
|
||||
assert_eq!(color(&s), LayerColor::Preset(4));
|
||||
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
|
||||
let n = node(&s, layer);
|
||||
assert!(n.is_template() && n.locked);
|
||||
assert!(matches!(n.kind, NodeKind::Layer { dim_images: Some(50), .. }));
|
||||
run(&mut s, "layer.template", json!({"ids": [layer.0]}));
|
||||
let n = node(&s, layer);
|
||||
assert!(!n.is_template() && !n.locked);
|
||||
assert!(matches!(n.kind, NodeKind::Layer { dim_images: None, .. }));
|
||||
run(&mut s, "layer.setProps", json!({"id": layer.0, "preview": false, "dimImages": 30, "printable": false}));
|
||||
assert!(matches!(node(&s, layer).kind, NodeKind::Layer { preview: false, dim_images: Some(30), printable: false, .. }));
|
||||
let summary = &crate::inspect::document(&s)["layers"][0];
|
||||
assert_eq!((summary["preview"].clone(), summary["dimImages"].clone()), (json!(false), json!(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layers_go_above_the_current_one_at_its_level() {
|
||||
let mut s = session();
|
||||
let (layer, sub, _, g, ..) = tree(&mut s);
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
let n = id(&run(&mut s, "layer.new", json!({})));
|
||||
assert_eq!(parent(&s, n), Some(layer), "a sublayer beside the current sublayer");
|
||||
assert_eq!(children(&s, layer).iter().position(|c| *c == n), children(&s, layer).iter().position(|c| *c == sub).map(|i| i + 1));
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![n]);
|
||||
let top = id(&run(&mut s, "layer.new", json!({"top": true, "name": "Top", "color": "green", "locked": true})));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.layers.last().unwrap().id, top);
|
||||
assert!(st.doc.layers.last().unwrap().locked);
|
||||
// Sublayers only go into layers.
|
||||
assert!(s.execute("layer.newSublayer", &json!({"parent": g.0})).is_err());
|
||||
let inner = id(&run(&mut s, "layer.newSublayer", json!({"parent": sub.0})));
|
||||
assert_eq!(parent(&s, inner), Some(sub));
|
||||
// Names count sublayers: no two layers share one.
|
||||
let mut names = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if n.is_layer() {
|
||||
names.push(n.display_name());
|
||||
}
|
||||
});
|
||||
let unique: std::collections::HashSet<&String> = names.iter().collect();
|
||||
assert_eq!(unique.len(), names.len(), "{names:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_move_above_below_and_into_layers_and_groups() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, c) = tree(&mut s);
|
||||
let before = undos(&s);
|
||||
// Into the group: on top of its contents.
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0, "place": "inside"}));
|
||||
assert_eq!(children(&s, g), vec![b, a]);
|
||||
// Below b, in the group.
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": b.0, "place": "below"}));
|
||||
assert_eq!(children(&s, g), vec![a, b]);
|
||||
// Above the sublayer, out into Layer 1.
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": sub.0, "place": "above"}));
|
||||
assert_eq!(parent(&s, a), Some(layer));
|
||||
assert_eq!(undos(&s), before + 3);
|
||||
// Out of the layer into a new top-level layer: an object beside a top-level layer goes in it.
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
|
||||
run(&mut s, "layer.move", json!({"ids": [c.0], "target": l2.0, "place": "above"}));
|
||||
assert_eq!(parent(&s, c), Some(l2));
|
||||
// A sublayer becomes a top-level layer, and back.
|
||||
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": l2.0, "place": "above"}));
|
||||
assert_eq!(parent(&s, sub), None);
|
||||
assert_eq!(s.doc().unwrap().doc.layers.last().unwrap().id, sub);
|
||||
run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0}));
|
||||
assert_eq!(parent(&s, sub), Some(layer));
|
||||
// Several rows keep their stacking order.
|
||||
run(&mut s, "layer.move", json!({"ids": [g.0, a.0], "target": l2.0}));
|
||||
assert_eq!(children(&s, l2), vec![c, a, g]);
|
||||
// Undo walks it back.
|
||||
for _ in 0..4 {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
assert_eq!(parent(&s, sub), Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_are_validated() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, b, _) = tree(&mut s);
|
||||
let doc_before = s.doc().unwrap().doc.clone();
|
||||
let bad = [
|
||||
json!({"ids": [sub.0], "target": g.0, "place": "inside"}),
|
||||
json!({"ids": [layer.0], "target": a.0, "place": "inside"}),
|
||||
json!({"ids": [layer.0], "target": sub.0, "place": "above"}),
|
||||
json!({"ids": [sub.0], "target": sub.0}),
|
||||
json!({"ids": [g.0], "target": b.0, "place": "inside"}),
|
||||
json!({"ids": [a.0], "target": b.0, "place": "inside"}),
|
||||
json!({"ids": [a.0], "target": 9999}),
|
||||
json!({"ids": [9999], "target": a.0}),
|
||||
json!({"ids": [a.0], "target": g.0, "place": "sideways"}),
|
||||
json!({"ids": [], "target": g.0}),
|
||||
json!({"target": g.0}),
|
||||
];
|
||||
for p in bad {
|
||||
assert!(s.execute("layer.move", &p).is_err(), "{p}");
|
||||
}
|
||||
// A locked group takes nothing.
|
||||
run(&mut s, "layer.setProps", json!({"id": g.0, "locked": true}));
|
||||
assert!(s.execute("layer.move", &json!({"ids": [a.0], "target": g.0})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &doc_before), "nothing changed");
|
||||
// node.move: layers only at the top level or in layers.
|
||||
assert!(s.execute("node.move", &json!({"id": sub.0, "parent": g.0, "index": 0})).is_err());
|
||||
assert!(s.execute("node.move", &json!({"id": a.0, "index": 0})).is_err());
|
||||
assert!(s.execute("node.move", &json!({"id": layer.0, "parent": sub.0, "index": 0})).is_err());
|
||||
run(&mut s, "node.move", json!({"id": sub.0, "index": 5}));
|
||||
assert_eq!(parent(&s, sub), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_drag_copies_rows_and_the_selected_art_moves_between_layers() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
let count = s.doc().unwrap().doc.node_count();
|
||||
let copies = ids(&run(&mut s, "layer.move", json!({"ids": [sub.0], "target": layer.0, "place": "above", "copy": true})));
|
||||
assert_eq!(copies.len(), 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count + 4, "the sublayer and its art (a, the group, b), copied");
|
||||
assert_eq!(parent(&s, copies[0]), None, "a copy of the sublayer at the top level");
|
||||
// Dragging the selected-art square: the selection moves into another layer.
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, g.0]}));
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0, g.0], "target": layer.0}));
|
||||
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a, g], "it stays selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_and_duplicate_rows() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
|
||||
let copies = ids(&run(&mut s, "layer.duplicate", json!({})));
|
||||
assert_eq!(copies.len(), 2);
|
||||
assert_eq!(children(&s, sub).len(), 4);
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), copies);
|
||||
run(&mut s, "layer.duplicate", json!({"id": sub.0}));
|
||||
assert!(children(&s, layer).iter().any(|l| node(&s, *l).display_name() == "Sub copy"));
|
||||
run(&mut s, "layer.highlight", json!({"ids": copies.iter().map(|c| c.0).collect::<Vec<_>>()}));
|
||||
assert_eq!(run(&mut s, "layer.delete", json!({}))["deleted"], 2);
|
||||
assert_eq!(children(&s, sub), vec![a, g]);
|
||||
run(&mut s, "layer.delete", json!({"ids": [c.0, a.0]}));
|
||||
assert!(s.doc().unwrap().doc.node(c).is_none() && s.doc().unwrap().doc.node(a).is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(c).is_some());
|
||||
assert!(s.execute("layer.delete", &json!({"ids": [layer.0]})).is_err(), "the last layer stays");
|
||||
run(&mut s, "layer.setCurrent", json!({"id": sub.0}));
|
||||
run(&mut s, "layer.delete", json!({}));
|
||||
assert!(s.doc().unwrap().doc.node(sub).is_none());
|
||||
assert_eq!(s.doc().unwrap().current_layer(), Some(layer), "the current layer moves on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_merge_flatten_and_reverse() {
|
||||
let mut s = session();
|
||||
let (layer, sub, a, g, _, c) = tree(&mut s);
|
||||
// Collect two rows of the sublayer in a new sublayer of it.
|
||||
run(&mut s, "layer.highlight", json!({"ids": [a.0, g.0]}));
|
||||
let col = id(&run(&mut s, "layer.collectInNew", json!({})));
|
||||
assert_eq!((parent(&s, col), children(&s, col)), (Some(sub), vec![a, g]));
|
||||
// Reverse their order.
|
||||
run(&mut s, "layer.reverse", json!({"ids": [a.0, g.0]}));
|
||||
assert_eq!(children(&s, col), vec![g, a]);
|
||||
assert!(s.execute("layer.reverse", &json!({"ids": [a.0]})).is_err());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// Merge the sublayer into Layer 1 (listed last).
|
||||
run(&mut s, "layer.merge", json!({"ids": [col.0, layer.0]}));
|
||||
assert!(s.doc().unwrap().doc.node(col).is_none());
|
||||
assert_eq!(children(&s, layer), vec![c, sub, a, g]);
|
||||
assert!(s.execute("layer.merge", &json!({"ids": [layer.0, sub.0]})).is_err(), "not into its own sublayer");
|
||||
assert!(s.execute("layer.merge", &json!({"ids": [layer.0]})).is_err());
|
||||
// Flatten: other visible layers merge in, hidden ones go, templates stay.
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({"top": true})));
|
||||
let d = rect(&mut s, 0.0);
|
||||
let l3 = id(&run(&mut s, "layer.new", json!({"top": true, "visible": false})));
|
||||
let l4 = id(&run(&mut s, "layer.new", json!({"top": true, "template": true})));
|
||||
let before = undos(&s);
|
||||
let r = run(&mut s, "layer.flatten", json!({"id": layer.0}));
|
||||
assert_eq!(r["discarded"], 1);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.layers.iter().map(|l| l.id).collect::<Vec<_>>(), vec![layer, l4]);
|
||||
assert!(st.doc.node(l2).is_none() && st.doc.node(l3).is_none());
|
||||
assert_eq!(parent(&s, d), Some(layer));
|
||||
assert_eq!(undos(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_to_layers_sequence_and_build() {
|
||||
let mut s = session();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let (a, b, c) = (rect(&mut s, 0.0), rect(&mut s, 50.0), rect(&mut s, 100.0));
|
||||
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0})));
|
||||
assert_eq!(new.len(), 3);
|
||||
assert_eq!(children(&s, layer), new);
|
||||
assert_eq!(new.iter().map(|l| children(&s, *l)).collect::<Vec<_>>(), vec![vec![a], vec![b], vec![c]]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let new = ids(&run(&mut s, "layer.releaseToLayers", json!({"id": layer.0, "build": true})));
|
||||
let counts: Vec<usize> = new.iter().map(|l| children(&s, *l).len()).collect();
|
||||
assert_eq!(counts, vec![1, 2, 3], "the layers add up");
|
||||
assert_eq!(children(&s, new[2]), vec![a, b, c], "the top one keeps the originals");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// A group's objects go into sublayers where the group was.
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
let g = id(&run(&mut s, "object.group", json!({})));
|
||||
let new = ids(&run(&mut s, "layer.releaseToLayersBuild", json!({"id": g.0})));
|
||||
assert_eq!(new.len(), 2);
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
assert_eq!(children(&s, layer), vec![new[0], new[1], c]);
|
||||
assert!(s.execute("layer.releaseToLayers", &json!({"id": c.0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_lock_and_outline_others_and_their_all_counterparts() {
|
||||
let mut s = session();
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id;
|
||||
let l2 = id(&run(&mut s, "layer.new", json!({})));
|
||||
let l3 = id(&run(&mut s, "layer.new", json!({})));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": l2.0}));
|
||||
let flags = |s: &Session, f: fn(&Node) -> bool| [l1, l2, l3].map(|l| f(&node(s, l)));
|
||||
assert_eq!(run(&mut s, "layer.hideOthers", json!({}))["count"], 2);
|
||||
assert_eq!(flags(&s, |n| n.visible), [false, true, false]);
|
||||
run(&mut s, "layer.showAll", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.visible), [true; 3]);
|
||||
run(&mut s, "layer.lockOthers", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [true, false, true]);
|
||||
run(&mut s, "layer.unlockAll", json!({}));
|
||||
assert_eq!(flags(&s, |n| n.locked), [false; 3]);
|
||||
run(&mut s, "layer.outlineOthers", json!({}));
|
||||
let preview = |n: &Node| matches!(n.kind, NodeKind::Layer { preview: true, .. });
|
||||
assert_eq!(flags(&s, preview), [false, true, false]);
|
||||
run(&mut s, "layer.previewAll", json!({}));
|
||||
assert_eq!(flags(&s, preview), [true; 3]);
|
||||
assert_eq!(run(&mut s, "layer.previewAll", json!({}))["count"], 0, "nothing to do: no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_object_highlights_its_row() {
|
||||
let mut s = session();
|
||||
let (layer, sub, _, g, b, _) = tree(&mut s);
|
||||
run(&mut s, "select.set", json!({"ids": [g.0]}));
|
||||
let r = run(&mut s, "layer.locate", json!({}));
|
||||
assert_eq!(r["ancestry"], json!([layer.0, sub.0, g.0]));
|
||||
assert_eq!(s.doc().unwrap().highlighted_rows(), vec![g]);
|
||||
assert_eq!(run(&mut s, "layer.locate", json!({"id": b.0}))["id"], b.0);
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("layer.locate", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clipping_mask_button_acts_on_the_highlighted_group() {
|
||||
let mut s = session();
|
||||
let (_, _, a, g, _, _) = tree(&mut s);
|
||||
run(&mut s, "layer.move", json!({"ids": [a.0], "target": g.0}));
|
||||
run(&mut s, "layer.setCurrent", json!({"id": g.0}));
|
||||
assert_eq!(run(&mut s, "layer.clippingMask.toggle", json!({}))["clip"], true);
|
||||
assert!(node(&s, g).clips());
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -526,6 +526,92 @@ fn save_and_recall_selection() {
|
||||
assert!(s.execute("select.recall", &json!({"name": "Pair"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_needs_a_saved_selection_and_a_free_name() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
// Edit Selection… is off until something is saved.
|
||||
assert!(s.execute("select.editSaved", &json!({"name": "Selection 1", "newName": "A"})).is_err());
|
||||
s.execute("select.save", &json!({"name": "One"})).unwrap();
|
||||
s.execute("select.save", &json!({"name": "Two"})).unwrap();
|
||||
// A name already saved can't be taken; both selections keep theirs.
|
||||
assert!(s.execute("select.editSaved", &json!({"name": "One", "newName": " Two "})).is_err());
|
||||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["One", "Two"]));
|
||||
// Renaming to its own name, or a blank name, changes nothing; a free name renames.
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": "One"})).unwrap();
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": " "})).unwrap();
|
||||
s.execute("select.editSaved", &json!({"name": "One", "newName": " Uno "})).unwrap();
|
||||
assert_eq!(s.execute("select.savedList", &json!({})).unwrap(), json!(["Uno", "Two"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_selections_live_in_the_document() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.save", &json!({"name": "Both"})).unwrap();
|
||||
// They travel with the file.
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
||||
assert_eq!(loaded.saved_selections.iter().map(|x| x.name.as_str()).collect::<Vec<_>>(), ["A", "Both"]);
|
||||
// Saving is an undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(list(&mut s), json!(["A"]));
|
||||
// An existing name is replaced by the current selection.
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("select.save", &json!({"name": "A"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("select.recall", &json!({"name": "A"})).unwrap()["count"], 1);
|
||||
assert_eq!(selected(&s), vec![b]);
|
||||
// Objects deleted since are left out of a recall.
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("select.save", &json!({"name": "Pair"})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("select.recall", &json!({"name": "Pair"})).unwrap()["count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_selection_applies_several_edits_as_one_step() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
for n in ["A", "B", "C"] {
|
||||
s.execute("select.save", &json!({ "name": n })).unwrap();
|
||||
}
|
||||
let list = |s: &mut Session| s.execute("select.savedList", &json!({})).unwrap();
|
||||
// Edits name the selections as they are, so two can swap names; a delete goes with them.
|
||||
s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}, {"name": "B", "newName": "A"}, {"name": "C", "delete": true}]}))
|
||||
.unwrap();
|
||||
assert_eq!(list(&mut s), json!(["B", "A"]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
|
||||
// Two ending with one name, or an unknown name, change nothing.
|
||||
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "A", "newName": "B"}]})).is_err());
|
||||
assert!(s.execute("select.editSaved", &json!({"edits": [{"name": "Z", "delete": true}]})).is_err());
|
||||
assert_eq!(list(&mut s), json!(["A", "B", "C"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_keeps_at_most_25_saved_selections() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
for i in 0..25 {
|
||||
s.execute("select.save", &json!({ "name": format!("S{i}") })).unwrap();
|
||||
}
|
||||
assert!(s.execute("select.save", &json!({"name": "One too many"})).is_err());
|
||||
// A name already saved is replaced even when the list is full.
|
||||
s.execute("select.save", &json!({"name": "S3"})).unwrap();
|
||||
// Unnamed saves take the first free "Selection N".
|
||||
s.execute("select.editSaved", &json!({"name": "S0", "delete": true})).unwrap();
|
||||
assert_eq!(s.execute("select.save", &json!({})).unwrap()["name"], "Selection 1");
|
||||
}
|
||||
|
||||
// ---------- Type ----------
|
||||
|
||||
#[test]
|
||||
@@ -707,3 +793,16 @@ fn every_new_command_has_params_doc_and_menu() {
|
||||
assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_selection_names_are_trimmed_and_capped() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[a]);
|
||||
let long = "n".repeat(1000);
|
||||
let name = s.execute("select.save", &json!({ "name": format!(" {long} ") })).unwrap()["name"].clone();
|
||||
assert_eq!(name.as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||||
s.execute("select.editSaved", &json!({"name": name, "newName": format!("{long}é")})).unwrap();
|
||||
let list = s.execute("select.savedList", &json!({})).unwrap();
|
||||
assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME));
|
||||
}
|
||||
@@ -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);
|
||||
// A hidden grid takes no clicks; choosing a perspective tool shows it.
|
||||
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();
|
||||
|
||||
Loaded 100 of 402 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user