Compare commits
286
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f16403a851 | ||
|
|
b64dcc02df | ||
|
|
406c27ebd8 | ||
|
|
dfe7f1a311 | ||
|
|
079f9a8b9a | ||
|
|
8e40794153 | ||
|
|
ee7a7ebfac | ||
|
|
0d4d125f9b | ||
|
|
deaec7fc71 | ||
|
|
a26aa5b203 | ||
|
|
7be80776f2 | ||
|
|
a9c31fcbe7 | ||
|
|
89ff09e9fd | ||
|
|
71efade26b | ||
|
|
6a62dc58ba | ||
|
|
c462057460 | ||
|
|
539dcb84b7 | ||
|
|
d5e21cc735 | ||
|
|
bc688bd1b5 | ||
|
|
11799fa0cc | ||
|
|
b6649a3fad | ||
|
|
49dc3b90be | ||
|
|
c256818665 | ||
|
|
a20e680f62 | ||
|
|
68a14472c6 | ||
|
|
e2227849a1 | ||
|
|
3c096da84d | ||
|
|
47104f5f4c | ||
|
|
ea5a04504a | ||
|
|
d6eb526186 | ||
|
|
ce3683a508 | ||
|
|
333a487dab | ||
|
|
b8f77aefb6 | ||
|
|
40bdfd3aca | ||
|
|
dd595cf2e8 | ||
|
|
614606cc48 | ||
|
|
13f65ce6d4 | ||
|
|
308c096889 | ||
|
|
ba2ee7a174 | ||
|
|
b743d08f11 | ||
|
|
ab2daad763 | ||
|
|
80323a7a4e | ||
|
|
5f59332ec8 | ||
|
|
926cdc80a5 | ||
|
|
b55e3ad70a | ||
|
|
48c96b504d | ||
|
|
2e134e56be | ||
|
|
a01e045905 | ||
|
|
eac383d5af | ||
|
|
94bc59b068 | ||
|
|
c6ac2bf3c5 | ||
|
|
198725ed38 | ||
|
|
7ce27caf88 | ||
|
|
c91ca3634f | ||
|
|
24f7d5fba5 | ||
|
|
cc4992172a | ||
|
|
9dc05ac980 | ||
|
|
339eb78a9b | ||
|
|
e01077a674 | ||
|
|
6313c8cfae | ||
|
|
f9a89a3211 | ||
|
|
fbcadf317c | ||
|
|
64891c1a2e | ||
|
|
c60cb4a9c2 | ||
|
|
83faf568b0 | ||
|
|
ee1e95d1c7 | ||
|
|
e960baa131 | ||
|
|
0e5e093dd9 | ||
|
|
6572636d62 | ||
|
|
cf7a43d786 | ||
|
|
f4181890bd | ||
|
|
116ff3eee7 | ||
|
|
902c3ae8c1 | ||
|
|
2507ed9dd9 | ||
|
|
e80b7e383b | ||
|
|
597b651bb6 | ||
|
|
945f8db359 | ||
|
|
e50f02e638 | ||
|
|
aeaece9728 | ||
|
|
c6c2c7d0fe | ||
|
|
61cd4ed6e6 | ||
|
|
0e1a5b3e45 | ||
|
|
b05e575eb8 | ||
|
|
1012361773 | ||
|
|
f2a2671933 | ||
|
|
10fafed6c4 | ||
|
|
aafd565b99 | ||
|
|
571881a830 | ||
|
|
347369187f | ||
|
|
d899a63286 | ||
|
|
f95b032c82 | ||
|
|
ce9f3aa7c6 | ||
|
|
ef3fa1689f | ||
|
|
68352dab02 | ||
|
|
51a9b899c1 | ||
|
|
81e7b915af | ||
|
|
fcc9414480 | ||
|
|
377204f90e | ||
|
|
0306d51592 | ||
|
|
ebc6d1af7b | ||
|
|
cdb734aede | ||
|
|
2f743f155f | ||
|
|
4bc0837867 | ||
|
|
2ba0e1f6e5 | ||
|
|
80eb5844c7 | ||
|
|
82be706b94 | ||
|
|
5af667182a | ||
|
|
700b6786c4 | ||
|
|
77159bb08e | ||
|
|
047a3bfc6f | ||
|
|
856e36c694 | ||
|
|
721d1118f3 | ||
|
|
3491b99321 | ||
|
|
546a65f77a | ||
|
|
77708ad8de | ||
|
|
129c9ca61e | ||
|
|
ceb6defcf3 | ||
|
|
ab6a668aa9 | ||
|
|
8dc2fd077f | ||
|
|
66fa0a998a | ||
|
|
b045390311 | ||
|
|
7a39cb7b89 | ||
|
|
15ab25426e | ||
|
|
899a82d31d | ||
|
|
7bca89dd15 | ||
|
|
7f5344a60c | ||
|
|
691a54e7fd | ||
|
|
1733fa5fdb | ||
|
|
7715dcd285 | ||
|
|
caba979b01 | ||
|
|
025315f4bf | ||
|
|
4c6ac00e72 | ||
|
|
44b2b984ed | ||
|
|
2e2bb33909 | ||
|
|
5bb273487e | ||
|
|
695c541f0d | ||
|
|
254251f23a | ||
|
|
99e3aaa798 | ||
|
|
0889f13cd8 | ||
|
|
565b75b91d | ||
|
|
3280586f4c | ||
|
|
8255d606ae | ||
|
|
5a13626faa | ||
|
|
2967c3a9ba | ||
|
|
04fa49413c | ||
|
|
cd99714b42 | ||
|
|
e1225f4c24 | ||
|
|
98a3e15ec3 | ||
|
|
55d7df36e4 | ||
|
|
fe2259536d | ||
|
|
13db157d36 | ||
|
|
bd91cf9123 | ||
|
|
5061404ec6 | ||
|
|
e9abc62f1a | ||
|
|
fc0cb255a4 | ||
|
|
aa08c5bef7 | ||
|
|
e3b079de8c | ||
|
|
85e7acf138 | ||
|
|
e774f15537 | ||
|
|
0f0dbbf9d5 | ||
|
|
7ec936a0c1 | ||
|
|
b7c032df43 | ||
|
|
117aa0b5a3 | ||
|
|
47ef223a02 | ||
|
|
8752561132 | ||
|
|
3d35b8518f | ||
|
|
a5c2d6b682 | ||
|
|
781f797b00 | ||
|
|
c5985b3f47 | ||
|
|
5157dc87b8 | ||
|
|
6210f2f15d | ||
|
|
a0847e8a25 | ||
|
|
7df7cf568a | ||
|
|
b062070642 | ||
|
|
08c70dc35d | ||
|
|
4a59a171e2 | ||
|
|
8974957890 | ||
|
|
c3c4185cef | ||
|
|
3d850a2864 | ||
|
|
aa5b544fca | ||
|
|
92cadd6083 | ||
|
|
4950cc17cf | ||
|
|
c6e69fad33 | ||
|
|
67b2213957 | ||
|
|
5a93e78ad8 | ||
|
|
aafe381eab | ||
|
|
42be928367 | ||
|
|
3bb1431422 | ||
|
|
4b5d776a73 | ||
|
|
fe6d6640fb | ||
|
|
6994f248b2 | ||
|
|
f29e139199 | ||
|
|
ecf245b7c9 | ||
|
|
fe7da442ca | ||
|
|
37c82d082e | ||
|
|
5d21826fa8 | ||
|
|
a05e251622 | ||
|
|
28697019b7 | ||
|
|
08108f693f | ||
|
|
32f73e9cce | ||
|
|
2c02502f43 | ||
|
|
e35f75f733 | ||
|
|
3f0bce6a88 | ||
|
|
a4d93b1462 | ||
|
|
719e648de1 | ||
|
|
945dcbae1d | ||
|
|
86dbc42d42 | ||
|
|
9002f86f73 | ||
|
|
fd99375b1b | ||
|
|
f24198ec7e | ||
|
|
8c8ac37300 | ||
|
|
5e901c9c17 | ||
|
|
b71e843c96 | ||
|
|
2d740acbd1 | ||
|
|
242e8a5fd5 | ||
|
|
d0aeeb0076 | ||
|
|
39ac89fc26 | ||
|
|
11740c9731 | ||
|
|
c4b87e36b2 | ||
|
|
f12428264c | ||
|
|
49e8413ebe | ||
|
|
285d94a3e6 | ||
|
|
9920156bb6 | ||
|
|
a844503e4c | ||
|
|
1c46c4c654 | ||
|
|
d396bc23af | ||
|
|
9106e344f8 | ||
|
|
52d6b9a2ba | ||
|
|
2076f82b47 | ||
|
|
a52c030963 | ||
|
|
25a194cc00 | ||
|
|
831d916d3d | ||
|
|
3d890203dd | ||
|
|
679191b4ca | ||
|
|
811ad1efd8 | ||
|
|
cccdf96cc1 | ||
|
|
7228275f1c | ||
|
|
649be860e2 | ||
|
|
4f01bc13b2 | ||
|
|
9c707b37fc | ||
|
|
6992c843b4 | ||
|
|
6780ffae60 | ||
|
|
1499834de7 | ||
|
|
8adc629055 | ||
|
|
871474f1a9 | ||
|
|
b4c39b8cb9 | ||
|
|
8939caa284 | ||
|
|
dd69402f8b | ||
|
|
96604acffb | ||
|
|
d3d6cce917 | ||
|
|
72be324732 | ||
|
|
638edec175 | ||
|
|
03ea4079f9 | ||
|
|
e94dfed775 | ||
|
|
2f75b6cd29 | ||
|
|
69dc539db5 | ||
|
|
30550f038a | ||
|
|
535220cf34 | ||
|
|
65dd757d59 | ||
|
|
088ffc844b | ||
|
|
885b7acac1 | ||
|
|
63bf2c4598 | ||
|
|
d0d934bfda | ||
|
|
258337cf7c | ||
|
|
d4c4361b4a | ||
|
|
0aba6ef6e4 | ||
|
|
36a6a8ae49 | ||
|
|
2b15d3234f | ||
|
|
980372f6d0 | ||
|
|
a867c1e020 | ||
|
|
7c0a253b50 | ||
|
|
7a861ae064 | ||
|
|
466f863d00 | ||
|
|
a1deca3d13 | ||
|
|
cd8973a520 | ||
|
|
79581c7ff5 | ||
|
|
8961824876 | ||
|
|
87df4a0648 | ||
|
|
a09cbe58c8 | ||
|
|
0e4d3f6871 | ||
|
|
d7e9f2a47a | ||
|
|
1f922fcbbc | ||
|
|
1b9b37454b | ||
|
|
9779d21962 | ||
|
|
a825aed763 | ||
|
|
4e72f8c0ec |
No files matched your search
@@ -0,0 +1,104 @@
|
||||
# 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:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
# The checkout above is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
|
||||
package:
|
||||
name: FreeBSD 14 (package)
|
||||
# The release build takes a while: only for PRs that touch FreeBSD packaging (or dependencies)
|
||||
# and on demand, not on every push to main.
|
||||
if: github.event_name != 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
# Every part the package must have, the craft-fonts licences included.
|
||||
tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz >/var/tmp/contents.txt
|
||||
for want in '/bin/vectorcraft$' '/bin/vectorcraft-cli$' '/share/applications/.*\.desktop$' '/share/icons/hicolor/' '/share/doc/vectorcraft/NOTICE$' '/share/doc/vectorcraft/OFL-.*\.txt$'; do
|
||||
grep -Eq "$want" /var/tmp/contents.txt || { echo "missing from the package: $want"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: freebsd-package
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
@@ -47,6 +47,10 @@ jobs:
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/vectorcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/vectorcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.vectorcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.vectorcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.vectorcraft.metainfo.xml"
|
||||
|
||||
@@ -70,6 +70,13 @@ jobs:
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -90,6 +97,8 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -112,11 +121,21 @@ jobs:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -141,6 +160,8 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -167,6 +188,13 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -184,6 +212,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
@@ -201,6 +232,56 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml).
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
environment: release
|
||||
env:
|
||||
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR in the VM).
|
||||
# Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
if: inputs.version != ''
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# v1.5.9, pinned by commit.
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
mem: 12288
|
||||
# Passed through by name; never templated into the script.
|
||||
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
|
||||
prepare: |
|
||||
pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM.
|
||||
# The craft-fonts checkout is synced into the VM with the workspace.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
export CARGO_TARGET_DIR=/var/tmp/vectorcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -212,6 +293,13 @@ jobs:
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Japanese UI/document fonts, embedded by crates/text/build.rs (CRAFT_FONTS_DIR on the build
|
||||
# step). Bump the commit deliberately; see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -227,6 +315,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$VECTORCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -236,7 +327,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, web]
|
||||
needs: [version, macos, windows, linux, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
|
||||
# out for now: PrintCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vectorcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'VectorCraft'
|
||||
foreach ($exe in 'vectorcraft.exe', 'vectorcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'vectorcraft-cli.exe'
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "vectorcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok vectorcraft-cli --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'vectorcraft.exe')) { throw 'uninstall left vectorcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
@@ -5,3 +5,5 @@ plan/
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
# A craft-fonts checkout for CRAFT_FONTS_DIR (CI and release workflows check it out here)
|
||||
/craft-fonts
|
||||
@@ -22,6 +22,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Creative Commons with redistribution allowed.
|
||||
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
|
||||
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
|
||||
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
|
||||
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
|
||||
@@ -37,6 +38,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
|
||||
@@ -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 |
|
||||
@@ -143,6 +147,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-join-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-join.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-knife.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-line.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-list-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-live-bucket.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -150,6 +155,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-mask-none.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-measure.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mesh.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-mirror-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-fill.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-item.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -182,6 +188,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-pucker.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-puppet.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rearrange.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-cut.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-rect-grid.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-reference-point.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-remove-brush.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
@@ -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.
|
||||
@@ -37,6 +38,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
|
||||
Generated
+181
-28
@@ -1231,6 +1231,12 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -1439,7 +1445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1455,7 +1461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -1603,6 +1609,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash 0.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -1611,7 +1626,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1620,7 +1635,7 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2059,6 +2074,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -3667,6 +3688,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
|
||||
|
||||
[[package]]
|
||||
name = "spirv"
|
||||
version = "0.4.0+sdk-1.4.341.0"
|
||||
@@ -3697,6 +3724,16 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
@@ -4174,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"clipboard-win",
|
||||
@@ -4183,17 +4220,20 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-ui-egui",
|
||||
"webbrowser",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -4205,7 +4245,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4221,7 +4261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"vectorcraft-color",
|
||||
@@ -4237,7 +4277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -4249,7 +4289,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4263,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde_json",
|
||||
@@ -4271,12 +4311,13 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4296,17 +4337,19 @@ dependencies = [
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
"vectorcraft-trace",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4323,7 +4366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4337,7 +4380,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -4349,7 +4392,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -4368,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4382,7 +4425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -4394,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"flate2",
|
||||
@@ -4419,9 +4462,23 @@ dependencies = [
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"wasmi",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -4446,7 +4503,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -4470,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4487,7 +4544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4496,11 +4553,12 @@ dependencies = [
|
||||
"subsetter",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4515,7 +4573,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4528,7 +4586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -4545,16 +4603,18 @@ dependencies = [
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pdf",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4719,6 +4779,99 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.261.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27"
|
||||
dependencies = [
|
||||
"spin",
|
||||
"wasmi_collections",
|
||||
"wasmi_core",
|
||||
"wasmi_ir",
|
||||
"wasmparser 0.228.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_collections"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557"
|
||||
dependencies = [
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_core"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmi_ir"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6"
|
||||
dependencies = [
|
||||
"wasmi_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.228.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "261.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
"memchr",
|
||||
"unicode-width",
|
||||
"wasm-encoder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.261.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -5535,7 +5688,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+9
-2
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -32,6 +32,7 @@ vectorcraft-testkit = { path = "crates/testkit" }
|
||||
vectorcraft-cad = { path = "crates/cad" }
|
||||
vectorcraft-eps = { path = "crates/eps" }
|
||||
vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -43,6 +44,12 @@ log = "0.4"
|
||||
proptest = "1"
|
||||
# tests: a TCP port bound without listening (connections to it are refused)
|
||||
socket2 = "0.6"
|
||||
# Sandboxed WebAssembly plug-ins: a pure-Rust interpreter (MIT OR Apache-2.0) that builds for wasm32
|
||||
# too. Without `wat` (text parsing is test-only) and memory64; `validate` keeps full module
|
||||
# validation on; the portable dispatcher can't overflow the host stack on long-running modules.
|
||||
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
|
||||
# Tests: WebAssembly text -> binary for the plug-in fixtures (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
|
||||
# rendering / formats
|
||||
vello_cpu = "0.2"
|
||||
|
||||
@@ -26,3 +26,11 @@ BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
|
||||
|
||||
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
|
||||
based in part on the work of the Independent JPEG Group.
|
||||
|
||||
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
|
||||
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ
|
||||
UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL
|
||||
Open Font License 1.1. Their license texts ship with the release packages as OFL-<family>.txt.
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Adobe Illustrator workflow. It runs natively on
|
||||
macOS, Windows and Linux, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
macOS, Windows, Linux and FreeBSD, and in the browser via WebAssembly. Built by the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Status: in active development" src="https://img.shields.io/badge/status-in%20active%20development-e8573f">
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-b83a24?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows, Linux and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-555555">
|
||||
<img alt="Runs on macOS, Windows, Linux, FreeBSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-555555">
|
||||
<img alt="MCP server for agents" src="https://img.shields.io/badge/agents-MCP%20server-555555">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-555555">
|
||||
</p>
|
||||
@@ -154,6 +154,14 @@ cd apps/vectorcraft-web && trunk build --release # web build → dist
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm, vendor names
|
||||
```
|
||||
|
||||
Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), an optional
|
||||
build input (releases always use it): `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p vectorcraft` (an absolute path).
|
||||
Without it, Japanese text uses the installed system fonts. See [`docs/development.md`](docs/development.md#fonts-craft-fonts-optional-build-input).
|
||||
|
||||
On laptops with two graphics processors, VectorCraft uses the power-saving (integrated) one by default.
|
||||
To use the discrete one, choose **Preferences › Performance › Graphics Processor › High Performance**
|
||||
and restart, or start the app with `WGPU_POWER_PREF=high` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
|
||||
|
||||
### Use it from Claude Code and other agents
|
||||
|
||||
Register the MCP server with Claude Code:
|
||||
@@ -169,16 +177,16 @@ The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`]
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) covers what ships today, the
|
||||
milestones, and honest time-to-parity estimates.
|
||||
|
||||
**Where we are (2026-10-05):** roughly 68–74% of Illustrator's features exist and work, and about 40–55% of
|
||||
**Where we are (2026-10-06):** roughly 69–75% of Illustrator's features exist and work, and about 40–55% of
|
||||
"a power user can't tell the difference". Everyday vector illustration is close to usable: drawing and path tools,
|
||||
Pathfinder and Shape Builder, paint, gradients, appearance and transparency, type with styles and threading, and
|
||||
files (SVG, PDF and PDF-compatible `.ai`, EPS, DXF, raster formats, Print, Package). The scores are
|
||||
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, EPS, DXF, EMF/WMF, raster formats and PSD, Print, Package). 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.
|
||||
@@ -187,13 +195,14 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
|
||||
advanced type, then hardening and packaging for 1.0. The prioritized list is in
|
||||
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
|
||||
the UI can be swapped without touching the engine.
|
||||
|
||||
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
|
||||
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
|
||||
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
explains how VectorCraft avoids crashing, and [`docs/releasing.md`](docs/releasing.md) how releases are built,
|
||||
signed and published. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
@@ -241,7 +250,9 @@ VectorCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-AP
|
||||
Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ASSETS.md](ASSETS.md).
|
||||
with its author, source and license in [ASSETS.md](ASSETS.md). Release builds also embed the
|
||||
Japanese fonts of [craft-fonts](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md)
|
||||
(SIL Open Font License 1.1).
|
||||
|
||||
The app icon (an engraved dragon on VectorCraft red, `#e8573f`) is the owner's original artwork; its
|
||||
palette and files are in [`assets/app-icon/`](assets/app-icon/README.md).
|
||||
|
||||
+64
-51
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
|
||||
|
||||
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
|
||||
|
||||
_Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
_Last updated: 2026-10-07 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, and the 2026-10-07 issue fixes; see [Honest assessment](#honest-assessment-2026-10-05))._
|
||||
|
||||
## Where we are
|
||||
|
||||
@@ -12,10 +12,10 @@ _Last updated: 2026-10-05 (after M4.14–M4.98 and M14.4–M14.6; see [Honest as
|
||||
|---|---|
|
||||
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
|
||||
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~74%** on the rubric · **68–74%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
|
||||
| Feature surface vs full Illustrator (weighted, see below) | **~75%** on the rubric · **69–75%** honest range (self-graded, see [Honest assessment](#honest-assessment-2026-10-05)) |
|
||||
| Parity including interaction fidelity and hardening ("a power user can't tell the difference, but faster") | **~40–55%** |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~230–350 h** one agent · **~60–100 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~335–520 h** one agent · **~85–150 h** with 4–6 agents |
|
||||
| Time to **feature parity** (every menu item, tool, panel, effect and dialog functional) | **~210–330 h** one agent · **~55–95 h** with 4–6 agents |
|
||||
| Time to **full parity** (feature parity + interaction-fidelity pass + hardening) | **~320–500 h** one agent · **~80–145 h** with 4–6 agents |
|
||||
|
||||
## Honest assessment (2026-10-05)
|
||||
|
||||
@@ -33,16 +33,16 @@ since 2026-10-01.
|
||||
|
||||
| Dimension | Estimate | Evidence and what's missing |
|
||||
|---|---|---|
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 18 menu items still stubbed; every tool implemented except Touch Type, vertical type ×3 and Print Tiling; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~68–74% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~35%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, vertical/CJK type, Variables (data merge), scripting |
|
||||
| **Breadth:** menus, tools and panels exist | ~90% | 16 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules |
|
||||
| **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) |
|
||||
| **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku and tate-chu-yoko; ruby, mojikumi and vertical metrics open), Variables (data merge), scripting |
|
||||
| **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first |
|
||||
| **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured |
|
||||
| **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai`, EPS, DXF, EMF/WMF, raster formats, PSD export, Place and Links, Package, Print, clipboard flavours. Native `.ai` private data is out of scope by design; DWG has no open spec |
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; Windows/Linux packaging and accessibility are pending |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); Windows/Linux packaging and accessibility are pending |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -58,13 +58,14 @@ Ordered by how much each gap blocks someone from switching. Sizes are one-agent
|
||||
3. **3D and Materials:** Extrude & Bevel, Revolve, Inflate and Rotate with lighting and materials, using a software
|
||||
renderer in its own crate (layering allows it below L6), with output in SVG/PDF as rasters or projected vectors.
|
||||
The largest single gap. 50–80 h.
|
||||
4. **Advanced type:** vertical type and its tools, CJK composition, Optical Margin Alignment, the
|
||||
composer/hyphenation options, tab leaders, a spell-check dictionary (open licence), Touch Type and Snap to Glyph.
|
||||
41–55 h across type core and advanced.
|
||||
4. **Advanced type:** CJK composition for vertical type (ruby, mojikumi, vertical font metrics, manual
|
||||
tate-chu-yoko), Optical Margin Alignment, the composer/hyphenation options, tab leaders, a
|
||||
spell-check dictionary (open licence), Touch Type and Snap to Glyph. Vertical point, area and path type have
|
||||
initial support. 35–48 h across type core and advanced.
|
||||
5. **Brushes, symbols and libraries:** brush options depth; original brush, symbol and graphic-style libraries
|
||||
generated in code (never Adobe's); dynamic symbols; Start Global Edit. 14–22 h.
|
||||
6. **Automation:** Variables (data merge), a scripting surface over the command registry, batch. 8–12 h.
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, the Print Tiling tool, video
|
||||
7. **Views and windows:** multiple windows and arrange, Consolidate All Windows, video
|
||||
rulers. 15–25 h.
|
||||
8. **Hardening at scale:** a corpus of real-world SVG/PDF/EPS files, idle-machine perf budgets, Windows, Linux and
|
||||
browser QA, accessibility, packaging. 50–80 h.
|
||||
@@ -87,32 +88,42 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Automation:**
|
||||
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
|
||||
- JSON-lines control channel with real egui pointer and keyboard injection.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless.
|
||||
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
|
||||
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
|
||||
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
|
||||
- Actions panel that records and plays back commands.
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- 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) ships complete, and Czech and Japanese cover every menu label, with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content.
|
||||
- On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife.
|
||||
- **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.
|
||||
- **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 (free and perspective distort and shear, switched by the keys held during a handle drag).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
- **Panel drag and drop:** art dragged off the canvas onto the Symbols panel becomes a symbol, and a symbol dragged onto the canvas places an instance where it is dropped.
|
||||
- **Tool double-clicks:** double-clicking a tool's button opens its options: Hand fits the artboard in the window, Zoom shows 100 %, Rotate, Scale, Reflect and Shear open their Object › Transform dialogs, Gradient, Eyedropper, Blend and the Liquify tools their panels or options.
|
||||
- **Drawing aids:** Smart Guides and snapping, guides dragged out of the rulers, and Draw Normal / Behind / Inside modes.
|
||||
- **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard and fits it, and Fit Artboard in Window and Actual Size show the navigator's artboard.
|
||||
- **Geometry and effects:**
|
||||
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
|
||||
- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done.
|
||||
- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese covers the whole interface, Czech and Japanese every menu label (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines.
|
||||
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type, Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
|
||||
- **Paint and appearance (M3):**
|
||||
- **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries and five gradient libraries in a library panel, saved and loaded as `.vcswatches`, `.gpl` or CSS. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate.
|
||||
- **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel follows each colour's own model and Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers, panels have their shortcuts, and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening.
|
||||
@@ -124,12 +135,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing.
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts).
|
||||
- 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.
|
||||
- Place and the Links panel, Package, File Info, Print with print presets and PostScript output, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage) and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
@@ -137,23 +150,23 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly 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), 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, polish | 6–10 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; confirm the Scale Strokes & Effects default | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, 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 and content-sized dialogs and menus done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs and menus that scroll when longer than the window done; accessibility, Windows/Linux packaging pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 50–80 |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~335–520** |
|
||||
| | **Total to full parity** (one agent; re-derived from the [Parity estimate](#parity-estimate) table) | | **~320–500** |
|
||||
|
||||
## Parity estimate
|
||||
|
||||
@@ -169,39 +182,39 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
|
||||
|
||||
| Area | Weight | Done | Missing (main items) | One agent (h) |
|
||||
|---|---:|---:|---|---:|
|
||||
| Selection, transform & align tools | 6 | 85% | rotated persistent bounding box, Start Global Edit, transform nuances | 4–6 |
|
||||
| Selection, transform & align tools | 6 | 85% | Free Transform/Scale/Reflect along a rotated box, Start Global Edit, transform nuances (the rotated persistent bounding box is done) | 4–6 |
|
||||
| Drawing tools | 7 | 85% | Shaper Groups (merge/punch overlapping shapes), pen/pencil modifier nuances, Touch Type | 10–15 |
|
||||
| Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 85% | Live Paint gap options, Shape Builder edge cases | 3–6 |
|
||||
| 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 | 97% | flattener presets in PDF/EPS/print, raster effect reach in preview bounds | 2–4 |
|
||||
| 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 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 |
|
||||
| Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters, Transform Again after a type-area resize (it scales the type), partial selection of type-area anchors by marquee | 15–20 |
|
||||
| Type advanced | 4 | 48% | CJK composition for vertical type (ruby, mojikumi, vertical metrics, manual tate-chu-yoko), variable font axis sliders (named instances are styles), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support | 20–28 |
|
||||
| Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done) | 5–8 |
|
||||
| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 |
|
||||
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
|
||||
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
|
||||
| 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 and presets are done | 6–10 |
|
||||
| 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, ruler-drag modifiers (Shift snaps to ticks, Alt swaps orientation) and dragging existing guides | 4–8 |
|
||||
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, Illustrator EPS checked against real files (only reduced repros so far), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers and presets are done | 6–10 |
|
||||
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
|
||||
| Print, colour management, separations, flattener | 3 | 70% | Print Tiling tool and Show Print Tiling, flattener presets in print/PDF, print preview fidelity (Print dialog, presets, PostScript, marks and separations are done) | 5–8 |
|
||||
| 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 five (Touch Type, vertical type ×3, Print Tiling); 51 panel modules; Illustrator-style live
|
||||
_Inventories (2026-10-05):_ 16 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool
|
||||
implemented except Touch Type; 51 panel modules; Illustrator-style live
|
||||
effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust.
|
||||
|
||||
_Where we already beat Illustrator:_ exact curve booleans, off-thread multithreaded rendering, undo that never runs out,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text]
|
||||
//! vectorcraft-cli info FILE
|
||||
@@ -14,11 +14,41 @@
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
/// `println!` / `print!` that end the program quietly when stdout is closed
|
||||
/// (`vectorcraft-cli commands | head`) instead of panicking with "failed printing to stdout:
|
||||
/// Broken pipe (os error 32)".
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! out {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = write!(std::io::stdout(), $($arg)*) {
|
||||
$crate::stdout_failed(e);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
mod perf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly, as
|
||||
/// ripgrep does; any other write error is reported.
|
||||
fn stdout_failed(e: std::io::Error) -> ! {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("vectorcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
@@ -29,16 +59,17 @@ USAGE:
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
order, export each FILE in the format its extension picks (see Writable formats). Prints one
|
||||
JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text]
|
||||
Open IN (any readable format) and export OUT by extension (.svg, .pdf, .png, .jpg, .webp,
|
||||
.vectorcraft). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, the other formats the first. Live effects are kept;
|
||||
--outline-text writes SVG text as paths.
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
Live effects are kept; --outline-text writes SVG text as paths.
|
||||
|
||||
vectorcraft-cli info FILE
|
||||
Print a JSON summary: title, colour mode, units, artboards, object counts by kind, fonts.
|
||||
@@ -52,9 +83,10 @@ USAGE:
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads.
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
fn usage() -> String {
|
||||
format!("{USAGE}\nReadable formats: .{}\n", vectorcraft_engine::cmd::fileio::OPEN_EXTS.join(", ."))
|
||||
use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};
|
||||
format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -68,11 +100,11 @@ fn main() -> ExitCode {
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{}", usage());
|
||||
out!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
outln!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
@@ -112,6 +144,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
// The binary owns the logger, not the library: installing one here keeps an embedder that
|
||||
// uses `vectorcraft_mcp` free to bring its own. Silent until a client sends logging/setLevel.
|
||||
vectorcraft_mcp::logging::install();
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
@@ -120,7 +155,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
fn commands() -> Result<(), String> {
|
||||
let mut h = Headless::new();
|
||||
let v = h.call("engine.commands", json!({}))?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,7 +181,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
json!({"command": "document.export", "params": {"path": output, "scale": scale, "artboard": artboard, "range": range, "outlineText": outline_text}}),
|
||||
)
|
||||
.map_err(|e| format!("export {output}: {e}"))?;
|
||||
println!("{r}");
|
||||
outln!("{r}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -162,7 +197,7 @@ fn info(args: &[String]) -> Result<(), String> {
|
||||
let artboards: Vec<Value> =
|
||||
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
|
||||
let v = json!({"file": file, "info": base, "artboards": artboards, "kinds": kinds, "fonts": fonts});
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +287,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
* vectorcraft_geom::Affine::scale(z)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
let opts = vectorcraft_render::RenderOptions::default();
|
||||
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
outln!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
for threads in [vectorcraft_render::default_threads(), 0] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = threads;
|
||||
@@ -261,7 +296,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
for _ in 0..iters {
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
outln!(
|
||||
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
|
||||
@@ -81,14 +81,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
outln!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n)?;
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -150,16 +150,16 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
outln!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
outln!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
outln!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
|
||||
@@ -82,3 +82,17 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[2]["result"]["isError"], false);
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
/// stdout: Broken pipe (os error 32)" and exit status 101.
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
use std::io::Read;
|
||||
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 16];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && !err.contains("panicked"), "{:?}: {err}", out.status);
|
||||
}
|
||||
@@ -22,12 +22,16 @@ rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
# The window's monitors, size and position (the same winit eframe runs on).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
clipboard-win = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows.
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`vectorcraft mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
/// Connections served at once; further ones get an error line and are closed.
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -19,44 +33,249 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
// Best effort: the connection is dropped either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let open = Arc::clone(&open);
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &tx, &ctx);
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})))
|
||||
});
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let Some(mut r) = handle(method, params) else { return };
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").and_then(|()| out.flush()).is_err() {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
},
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
// Best effort: the connection closes either way, and a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject})).and_then(|()| out.flush());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
let replies = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect();
|
||||
(replies, calls)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
assert_eq!(r[1]["id"], 2);
|
||||
assert_eq!(r[2]["id"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_closes_before_the_body_runs() {
|
||||
// A browser's cross-origin `fetch` POST: the request line is rejected and the
|
||||
// connection closed, so the JSON body is never executed.
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nOrigin: https://attacker.example\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":7,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.new\",\"params\":{}}}\n";
|
||||
let (r, calls) = run(http);
|
||||
assert!(calls.is_empty(), "nothing may run: {calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_request_json_closes_the_connection() {
|
||||
for junk in [
|
||||
&b"[1,2]\n{\"method\":\"a\"}\n"[..],
|
||||
b"{\"id\":1}\n{\"method\":\"a\"}\n",
|
||||
b"{\"method\":5}\n{\"method\":\"a\"}\n",
|
||||
b"\xff\xfe\n{\"method\":\"a\"}\n",
|
||||
] {
|
||||
let (r, calls) = run(junk);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert_eq!(r[0]["ok"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_line_is_refused() {
|
||||
// One byte over the limit, then a valid request: neither runs.
|
||||
let mut input = vec![b'x'; MAX_LINE + 1];
|
||||
input.extend_from_slice(b"\n{\"method\":\"a\"}\n");
|
||||
let (r, calls) = run(&input);
|
||||
assert!(calls.is_empty(), "{calls:?}");
|
||||
assert_eq!(r.len(), 1);
|
||||
assert!(r[0]["error"].as_str().unwrap().contains("longer than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_at_the_limit_is_accepted() {
|
||||
let mut buf = Vec::new();
|
||||
let line = vec![b'a'; 10];
|
||||
let mut input = line.clone();
|
||||
input.push(b'\n');
|
||||
assert_eq!(read_bounded_line(&mut &input[..], &mut buf, 10), Ok(true));
|
||||
assert_eq!(buf, line);
|
||||
assert!(read_bounded_line(&mut &[b'a'; 11][..], &mut buf, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_connection_is_closed_after_junk() {
|
||||
// End to end over a real loopback socket (the transport the app uses).
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "{text}");
|
||||
assert!(lines[0].contains("\"id\":7"));
|
||||
assert!(lines[1].contains("closing the connection"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
+110
-11
@@ -5,12 +5,14 @@
|
||||
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `vectorcraft_ui_egui::control` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod clipboard;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod printing;
|
||||
mod window;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
@@ -30,6 +32,7 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
if self.0.ui.status == "quit" {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
@@ -39,12 +42,27 @@ impl eframe::App for App {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.0.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as
|
||||
/// typed). winit's IME toggle only clears its own copy, so the IME would type it again.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn discard_marked_text() {
|
||||
if let Some(mtm) = objc2::MainThreadMarker::new()
|
||||
&& let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm)
|
||||
{
|
||||
ic.discardMarkedText();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
@@ -71,21 +89,32 @@ fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp) {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
/// Runs without preferences (`VECTORCRAFT_NO_PREFS`, agents' test runs) neither read nor write them.
|
||||
fn prefs_enabled() -> bool {
|
||||
std::env::var_os("VECTORCRAFT_NO_PREFS").is_none()
|
||||
}
|
||||
|
||||
/// The saved UI preferences, read before the window opens (they hold its size and position).
|
||||
fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
if !prefs_enabled() {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
|
||||
if let Some(bytes) = bytes
|
||||
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
|
||||
{
|
||||
if let Some(ui) = saved {
|
||||
app.ui = ui.sanitized();
|
||||
}
|
||||
vectorcraft_ui_egui::prefs_dialog::restore(app);
|
||||
}
|
||||
|
||||
fn save_prefs(app: &VectorcraftApp) {
|
||||
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
||||
if !prefs_enabled() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
@@ -212,11 +241,33 @@ fn app_icon() -> egui::IconData {
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The power preference wgpu picks the window's graphics adapter with (#306): the
|
||||
/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
|
||||
/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
|
||||
/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
|
||||
/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
|
||||
/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
|
||||
/// GPU both preferences pick it.
|
||||
fn power_preference(pref: Option<&str>, env: Option<eframe::wgpu::PowerPreference>) -> eframe::wgpu::PowerPreference {
|
||||
use eframe::wgpu::PowerPreference;
|
||||
match (env, pref) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some("highPerformance")) => PowerPreference::HighPerformance,
|
||||
(None, _) => PowerPreference::LowPower,
|
||||
}
|
||||
}
|
||||
|
||||
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
|
||||
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
||||
format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)
|
||||
}
|
||||
|
||||
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
||||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -230,11 +281,15 @@ fn main() -> eframe::Result {
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
let saved = read_prefs();
|
||||
let saved_window = saved.as_ref().and_then(|ui| ui.window);
|
||||
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
|
||||
let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
@@ -244,12 +299,19 @@ fn main() -> eframe::Result {
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
..Default::default()
|
||||
};
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.power_preference = power;
|
||||
}
|
||||
eframe::run_native(
|
||||
"VectorCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = VectorcraftApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
load_prefs(&mut app, saved);
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
if let Some(w) = cc.winit_window() {
|
||||
app.ui.window = Some(window::restore(w, saved_window));
|
||||
}
|
||||
// User Defined swatch and graphic style libraries live next to the preferences.
|
||||
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
||||
app.session.swatch_libraries.set_user_dir(swatches);
|
||||
@@ -261,6 +323,11 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
if let Some(port) = control_port {
|
||||
@@ -280,3 +347,35 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::wgpu::PowerPreference;
|
||||
|
||||
#[test]
|
||||
fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
|
||||
assert_eq!(power_preference(None, None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
|
||||
// A value this version doesn't know (a newer or damaged preference file) is the default.
|
||||
assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
|
||||
// WGPU_POWER_PREF wins over the preference, either way.
|
||||
assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
|
||||
assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
|
||||
assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
|
||||
}
|
||||
|
||||
/// The preference the engine saves is the one the app reads back before the window opens.
|
||||
#[test]
|
||||
fn the_saved_engine_preference_is_found() {
|
||||
let mut prefs = vectorcraft_engine::Prefs::default();
|
||||
let saved = prefs.to_json();
|
||||
assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
|
||||
prefs.gpu_preference = "highPerformance".into();
|
||||
let saved = prefs.to_json();
|
||||
let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
|
||||
assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::Value;
|
||||
use vectorcraft_ui_egui::VectorcraftApp;
|
||||
use vectorcraft_ui_egui::i18n::{self, Lang, t};
|
||||
use vectorcraft_ui_egui::menus::{self, Item};
|
||||
|
||||
enum Handle {
|
||||
@@ -25,6 +26,10 @@ pub struct NativeMenu {
|
||||
generation: u64,
|
||||
/// Saved views and recent files listed (empty slots are left out, so a change rebuilds).
|
||||
slots: usize,
|
||||
/// Plug-in registry revision the Plug-ins submenus were built for.
|
||||
plugins: u64,
|
||||
/// 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
|
||||
@@ -45,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);
|
||||
}
|
||||
@@ -56,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) }
|
||||
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.
|
||||
@@ -72,10 +85,12 @@ impl NativeMenu {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
// Shortcuts edited, workspaces added, saved views or recent files 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;
|
||||
@@ -108,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) => {
|
||||
@@ -133,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);
|
||||
}
|
||||
@@ -150,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,256 @@
|
||||
//! The main window's size and position. The first launch fits the window to its monitor, opening
|
||||
//! it maximized where the default size leaves no room for a taskbar or Dock. Later launches restore
|
||||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
/// The first window's size, in points.
|
||||
pub const DEFAULT_SIZE: [f32; 2] = [1440.0, 900.0];
|
||||
/// The smallest window, in points.
|
||||
pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||||
/// Room the first window leaves around itself for a taskbar, Dock or menu bar (the system doesn't
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
pos: [i64; 2],
|
||||
size: [i64; 2],
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl Monitor {
|
||||
pub fn new(pos: PhysicalPosition<i32>, size: PhysicalSize<u32>, scale: f64) -> Self {
|
||||
let scale = scale as f32;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
Self { pos: [pos.x.into(), pos.y.into()], size: [size.width.into(), size.height.into()], scale }
|
||||
}
|
||||
|
||||
/// Its size in points.
|
||||
fn points(&self) -> [f32; 2] {
|
||||
self.size.map(|s| s as f32 / self.scale)
|
||||
}
|
||||
|
||||
/// Its size in points less room for a taskbar, Dock or menu bar.
|
||||
fn usable(&self) -> [f32; 2] {
|
||||
let points = self.points();
|
||||
std::array::from_fn(|i| points[i] - SCREEN_ROOM[i])
|
||||
}
|
||||
|
||||
/// A window of `size` points on this monitor, in physical pixels.
|
||||
fn pixels(&self, size: [f32; 2]) -> [i64; 2] {
|
||||
size.map(|s| (s * self.scale) as i64)
|
||||
}
|
||||
|
||||
/// How much of a window at `pos` of `size` points this monitor shows, in square pixels.
|
||||
fn overlap(&self, pos: [i32; 2], size: [f32; 2]) -> i64 {
|
||||
let px = self.pixels(size);
|
||||
let span = |i: usize| {
|
||||
let start = i64::from(pos[i]).max(self.pos[i]);
|
||||
let end = i64::from(pos[i]).saturating_add(px[i]).min(self.pos[i] + self.size[i]);
|
||||
(end - start).max(0)
|
||||
};
|
||||
span(0).saturating_mul(span(1))
|
||||
}
|
||||
|
||||
/// The top-left corner nearest to `pos` (centred when none) that keeps a window of `size`
|
||||
/// points on this monitor.
|
||||
fn keep_on(&self, pos: Option<[i32; 2]>, size: [f32; 2]) -> [i32; 2] {
|
||||
let px = self.pixels(size);
|
||||
std::array::from_fn(|i| {
|
||||
let lo = self.pos[i];
|
||||
let hi = lo + self.size[i].saturating_sub(px[i]).max(0);
|
||||
let want = pos.map_or(lo + (hi - lo) / 2, |p| i64::from(p[i]));
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
/// the default size on `home` (the monitor the system opens it on). Without monitors (the system
|
||||
/// doesn't list them, so nothing to check a saved geometry against) the default size.
|
||||
pub fn place(saved: Option<WindowGeometry>, monitors: &[Monitor], home: Option<usize>) -> WindowGeometry {
|
||||
let default = WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false };
|
||||
let Some(home) = home.and_then(|i| monitors.get(i)).or(monitors.first()) else {
|
||||
return default;
|
||||
};
|
||||
let Some(saved) = saved else {
|
||||
let usable = home.usable();
|
||||
if (0..2).all(|i| DEFAULT_SIZE[i] <= usable[i]) {
|
||||
return default;
|
||||
}
|
||||
// A small screen: maximized, with a size that fits once un-maximized.
|
||||
let size = fit(DEFAULT_SIZE, usable);
|
||||
return WindowGeometry { pos: Some(home.keep_on(None, size)), size, maximized: true };
|
||||
};
|
||||
// The monitor showing most of the window; none when it was on a monitor that is gone.
|
||||
let shown = saved.pos.and_then(|p| {
|
||||
let (area, m) = monitors.iter().map(|m| (m.overlap(p, saved.size), m)).max_by_key(|(area, _)| *area)?;
|
||||
(area > 0).then_some(m)
|
||||
});
|
||||
let m = shown.unwrap_or(home);
|
||||
// Bigger than its monitor (a smaller screen than last time): maximized, like the first window.
|
||||
let room = m.points();
|
||||
let too_big = (0..2).any(|i| saved.size[i] > room[i]);
|
||||
let size = fit(saved.size, if too_big { m.usable() } else { room });
|
||||
WindowGeometry {
|
||||
// Moved fully onto its monitor, or centred on the home monitor.
|
||||
pos: saved.pos.map(|p| m.keep_on(shown.map(|_| p), size)),
|
||||
size,
|
||||
maximized: saved.maximized || too_big,
|
||||
}
|
||||
}
|
||||
|
||||
/// `size` no bigger than `room`, and no smaller than the smallest window unless `room` is.
|
||||
fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry.
|
||||
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
|
||||
let handles: Vec<_> = window.available_monitors().collect();
|
||||
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
|
||||
let home = window.current_monitor().or_else(|| window.primary_monitor()).and_then(|c| handles.iter().position(|m| *m == c));
|
||||
let g = place(saved, &monitors, home);
|
||||
if let Some([x, y]) = g.pos {
|
||||
window.set_outer_position(PhysicalPosition::new(x, y));
|
||||
}
|
||||
// Returns the new size when it applies at once; either way the window gets it.
|
||||
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
|
||||
if g.maximized {
|
||||
window.set_maximized(true);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
|
||||
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
|
||||
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
|
||||
let zoom = ctx.zoom_factor();
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
if v.minimized == Some(true) || v.fullscreen == Some(true) {
|
||||
return;
|
||||
}
|
||||
if v.maximized == Some(true) {
|
||||
if let Some(g) = geometry {
|
||||
g.maximized = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(inner) = v.inner_rect else { return };
|
||||
let pos = v.outer_rect.map(|r| [r.min.x, r.min.y].map(|c| (c * i.pixels_per_point).round() as i32));
|
||||
*geometry = Some(WindowGeometry { pos, size: [inner.width() * zoom, inner.height() * zoom], maximized: false });
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn monitor(x: i32, y: i32, w: u32, h: u32, scale: f64) -> Monitor {
|
||||
Monitor::new(PhysicalPosition::new(x, y), PhysicalSize::new(w, h), scale)
|
||||
}
|
||||
|
||||
fn saved(pos: Option<[i32; 2]>, size: [f32; 2], maximized: bool) -> Option<WindowGeometry> {
|
||||
Some(WindowGeometry { pos, size, maximized })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_window_fits_its_screen() {
|
||||
// 1920 × 1080: the default size, where the system puts it.
|
||||
let big = [monitor(0, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(None, &big, Some(0)), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
// 1600 × 900, 1366 × 768 and 1920 × 1080 at 150% (1280 × 720 points): maximized, and a
|
||||
// size that fits the screen with room for the taskbar once un-maximized.
|
||||
for (m, size) in [
|
||||
(monitor(0, 0, 1600, 900, 1.0), [1440.0, 772.0]),
|
||||
(monitor(0, 0, 1366, 768, 1.0), [1302.0, 640.0]),
|
||||
(monitor(0, 0, 1920, 1080, 1.5), [1216.0, 592.0]),
|
||||
] {
|
||||
let g = place(None, &[m], Some(0));
|
||||
assert!(g.maximized, "{m:?}");
|
||||
assert_eq!(g.size, size, "{m:?}");
|
||||
let [x, y] = g.pos.unwrap();
|
||||
let px = m.pixels(g.size);
|
||||
assert!(x >= 0 && y >= 0 && i64::from(x) + px[0] <= m.size[0] && i64::from(y) + px[1] <= m.size[1], "{m:?}: {g:?}");
|
||||
}
|
||||
// No monitor information: the default size, whatever was saved.
|
||||
assert_eq!(place(None, &[], None), WindowGeometry { pos: None, size: DEFAULT_SIZE, maximized: false });
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
for g in [saved(Some([100, 80]), [1200.0, 800.0], false), saved(Some([2700, 50]), [1000.0, 700.0], true), saved(None, [1000.0, 700.0], false)]
|
||||
{
|
||||
assert_eq!(Some(place(g, &two, Some(0))), g);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_window_stays_on_a_connected_monitor() {
|
||||
let laptop = [monitor(0, 0, 1600, 900, 1.0)];
|
||||
// Last time on a second monitor that is gone: centred on the remaining one.
|
||||
let g = place(saved(Some([2700, 50]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([300, 100]), size: [1000.0, 700.0], maximized: false });
|
||||
// Half off the monitor's right and bottom edges: moved fully onto it.
|
||||
let g = place(saved(Some([1200, 600]), [1000.0, 700.0], false), &laptop, Some(0));
|
||||
assert_eq!(g.pos, Some([600, 200]));
|
||||
// Bigger than the screen now (it was on a larger one): it fits, and opens maximized.
|
||||
let g = place(saved(Some([0, 0]), [2400.0, 1300.0], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([0, 0]), size: [1536.0, 772.0], maximized: true });
|
||||
// A monitor left of the primary one (negative coordinates) still counts.
|
||||
let left = [monitor(0, 0, 1920, 1080, 1.0), monitor(-1920, 0, 1920, 1080, 1.0)];
|
||||
assert_eq!(place(saved(Some([-1800, 40]), [1000.0, 700.0], false), &left, Some(0)).pos, Some([-1800, 40]));
|
||||
// Nonsense sizes from a damaged preferences file are kept within the screen.
|
||||
let g = place(saved(Some([i32::MAX, i32::MIN]), [-5.0, f32::MAX], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||||
let g = place(saved(Some([5, 5]), [f32::NEG_INFINITY, f32::INFINITY], false), &laptop, Some(0));
|
||||
assert_eq!(g, WindowGeometry { pos: Some([400, 64]), size: [800.0, 772.0], maximized: true });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_geometry_is_tracked_for_the_next_launch() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut geometry = None;
|
||||
let mut frame = |info: egui::ViewportInfo| {
|
||||
let mut input = egui::RawInput::default();
|
||||
input.viewports.insert(egui::ViewportId::ROOT, info);
|
||||
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
|
||||
geometry
|
||||
};
|
||||
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
|
||||
let normal = egui::ViewportInfo {
|
||||
native_pixels_per_point: Some(1.5),
|
||||
inner_rect: Some(rect),
|
||||
outer_rect: Some(rect),
|
||||
maximized: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Position in physical pixels, size in points.
|
||||
let g = WindowGeometry { pos: Some([30, 45]), size: [1000.0, 600.0], maximized: false };
|
||||
assert_eq!(frame(normal.clone()), Some(g));
|
||||
// Maximized or minimized: the size to restore stays.
|
||||
let big = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 700.0));
|
||||
let maximized = egui::ViewportInfo { inner_rect: Some(big), outer_rect: Some(big), maximized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(maximized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
let minimized = egui::ViewportInfo { inner_rect: None, outer_rect: None, minimized: Some(true), ..normal.clone() };
|
||||
assert_eq!(frame(minimized), Some(WindowGeometry { maximized: true, ..g }));
|
||||
assert_eq!(frame(normal), Some(g));
|
||||
// Saved with the preferences; older preference files have no window.
|
||||
let mut ui = vectorcraft_ui_egui::UiState { window: Some(g), ..Default::default() };
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_slice(&serde_json::to_vec(&ui).unwrap()).unwrap();
|
||||
assert_eq!(back.window, Some(g));
|
||||
ui.window = None;
|
||||
let json = serde_json::to_value(&ui).unwrap();
|
||||
assert!(json.get("window").is_none());
|
||||
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back.window, None);
|
||||
}
|
||||
}
|
||||
@@ -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="round" stroke-linejoin="round"><path d="M3 21L21 3" stroke-dasharray="2 2"/><path d="M4 4H16.5L4 16.5Z" fill="currentColor" fill-opacity="0.35"/><path d="M20 7.5V20H7.5Z" fill="currentColor" fill-opacity="0.35"/></svg>
|
||||
|
After Width: | Height: | Size: 371 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="round" stroke-linejoin="round"><path d="M12 2V22" stroke-dasharray="2 2"/><path d="M12 5C7.5 5 4 8 4 12C4 16 7.5 19 12 19Z" fill="currentColor" fill-opacity="0.35"/><path d="M12 5C16.5 5 20 8 20 12C20 16 16.5 19 12 19" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 400 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="round" stroke-linejoin="round"><rect x="3" y="4" width="14" height="16" stroke-dasharray="2 2"/><path d="M17 6.34A6 6 0 1 0 17 17.66Z" fill="currentColor" fill-opacity="0.35"/><path d="M17 6.34A6 6 0 0 1 17 17.66" stroke-opacity="0.5"/></svg>
|
||||
|
After Width: | Height: | Size: 395 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
+23
-6
@@ -20,15 +20,18 @@ pub struct Hit {
|
||||
pub leaf: NodeId,
|
||||
pub ancestry: Vec<NodeId>,
|
||||
pub kind: HitKind,
|
||||
/// The innermost envelope in the ancestry whose contents are being edited (Edit Contents):
|
||||
/// its content, not the envelope, is the object clicked.
|
||||
pub contents_of: Option<NodeId>,
|
||||
}
|
||||
|
||||
impl Hit {
|
||||
/// The object the Selection tool selects: the child of the layer (the outermost group).
|
||||
/// Inside isolation mode (`scope`), the child of the isolated container instead.
|
||||
pub fn top_object(&self, scope: Option<NodeId>) -> NodeId {
|
||||
if let Some(s) = scope
|
||||
&& let Some(i) = self.ancestry.iter().position(|x| *x == s)
|
||||
{
|
||||
// The innermost of the isolated container and an envelope whose contents are edited.
|
||||
let at = |s: Option<NodeId>| s.and_then(|s| self.ancestry.iter().position(|x| *x == s));
|
||||
if let Some(i) = at(scope).max(at(self.contents_of)) {
|
||||
return self.ancestry.get(i + 1).copied().unwrap_or(self.leaf);
|
||||
}
|
||||
// Skip layers and sublayers.
|
||||
@@ -68,7 +71,8 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
continue;
|
||||
}
|
||||
chain.push(layer.id);
|
||||
if let Some(h) = hit_children(layer, p, opt, &mut chain) {
|
||||
if let Some(mut h) = hit_children(layer, p, opt, &mut chain) {
|
||||
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
|
||||
return Some(h);
|
||||
}
|
||||
chain.pop();
|
||||
@@ -76,6 +80,11 @@ pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Is `n` an envelope whose contents are being edited (they hit, not the envelope)?
|
||||
fn edits_contents(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Envelope { editing: true, .. })
|
||||
}
|
||||
|
||||
/// Is `p` inside the region `clip` clips to ([`Node::clip_shapes`])? Text counts by its frame:
|
||||
/// glyph outlines need the font engine, above this crate.
|
||||
fn clip_contains(clip: &Node, p: Point) -> bool {
|
||||
@@ -99,7 +108,9 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
if !c.visible || c.locked {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = c.reach_bounds()
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
if !edits_contents(c)
|
||||
&& let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
continue;
|
||||
@@ -107,7 +118,13 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
||||
chain.push(c.id);
|
||||
let hit = match &c.kind {
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => hit_children(c, p, opt, chain),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind }),
|
||||
// Edit Contents: the envelope's content hits, undistorted.
|
||||
NodeKind::Envelope { editing: true, .. } => hit_children(c, p, opt, chain),
|
||||
// A blend's key objects first (Direct and Group Selection pick them); its steps hit
|
||||
// as the blend.
|
||||
NodeKind::Blend { .. } => hit_children(c, p, opt, chain)
|
||||
.or_else(|| hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None })),
|
||||
_ => hit_leaf(c, p, opt).map(|kind| Hit { leaf: c.id, ancestry: chain.clone(), kind, contents_of: None }),
|
||||
};
|
||||
if hit.is_some() {
|
||||
return hit;
|
||||
|
||||
+60
-20
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod appearance;
|
||||
pub mod assets;
|
||||
pub mod blend;
|
||||
pub mod clipnest;
|
||||
pub mod graph;
|
||||
pub mod hit;
|
||||
@@ -16,13 +17,17 @@ pub mod live;
|
||||
pub mod marks;
|
||||
pub mod metadata;
|
||||
pub mod node;
|
||||
pub mod orient;
|
||||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub mod profiles;
|
||||
pub mod puppet;
|
||||
pub mod range;
|
||||
pub mod rastersettings;
|
||||
mod reach;
|
||||
pub mod recolor;
|
||||
pub mod selection;
|
||||
pub mod setup;
|
||||
pub mod slices;
|
||||
@@ -61,8 +66,11 @@ pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, Selection};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
@@ -175,8 +183,11 @@ impl Unit {
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
|
||||
///
|
||||
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
|
||||
/// math typed before a field's unit suffix works.
|
||||
pub fn parse(self, s: &str) -> Option<f64> {
|
||||
parse_measure(s, self)
|
||||
parse_measure(s, self).filter(|v| v.is_finite())
|
||||
}
|
||||
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
|
||||
/// `feetInches`).
|
||||
@@ -219,14 +230,20 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
if l.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let a = parse_measure(l, default)?;
|
||||
return match op {
|
||||
'+' => Some(a + parse_measure(r, default)?),
|
||||
'-' => Some(a - parse_measure(r, default)?),
|
||||
'*' => Some(a * r.trim().parse::<f64>().ok()?),
|
||||
'+' => Some(parse_measure(l, default)? + parse_measure(r, default)?),
|
||||
'-' => Some(parse_measure(l, default)? - parse_measure(r, default)?),
|
||||
_ => {
|
||||
let d = r.trim().parse::<f64>().ok()?;
|
||||
if d == 0.0 { None } else { Some(a / d) }
|
||||
// A factor or divisor is a plain number; a unit after it is the expression's.
|
||||
let (k, unit) = number_unit(r)?;
|
||||
let a = parse_measure(l, unit.unwrap_or(default))?;
|
||||
if op == '*' {
|
||||
Some(a * k)
|
||||
} else if k == 0.0 {
|
||||
None
|
||||
} else {
|
||||
Some(a / k)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -240,22 +257,40 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
|
||||
{
|
||||
return Some(p.trim().parse::<f64>().ok()? * 12.0 + pt.trim().parse::<f64>().unwrap_or(0.0));
|
||||
}
|
||||
let (v, unit) = number_unit(s)?;
|
||||
Some(unit.unwrap_or(default).to_pt(v))
|
||||
}
|
||||
|
||||
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
|
||||
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
|
||||
let s = s.trim();
|
||||
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
|
||||
let v: f64 = s[..num_end].trim().parse().ok()?;
|
||||
let unit = match s[num_end..].trim() {
|
||||
"" => default,
|
||||
"pt" => Unit::Points,
|
||||
"px" => Unit::Pixels,
|
||||
"in" | "\"" => Unit::Inches,
|
||||
"mm" => Unit::Millimeters,
|
||||
"cm" => Unit::Centimeters,
|
||||
"m" => Unit::Meters,
|
||||
"ft" | "'" => Unit::Feet,
|
||||
"yd" => Unit::Yards,
|
||||
"pc" => Unit::Picas,
|
||||
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
|
||||
let unit = match s.get(num_end..)?.trim() {
|
||||
"" => None,
|
||||
"pt" => Some(Unit::Points),
|
||||
"px" => Some(Unit::Pixels),
|
||||
"in" | "\"" => Some(Unit::Inches),
|
||||
"mm" => Some(Unit::Millimeters),
|
||||
"cm" => Some(Unit::Centimeters),
|
||||
"m" => Some(Unit::Meters),
|
||||
"ft" | "'" => Some(Unit::Feet),
|
||||
"yd" => Some(Unit::Yards),
|
||||
"p" | "pc" => Some(Unit::Picas),
|
||||
_ => return None,
|
||||
};
|
||||
Some(unit.to_pt(v))
|
||||
Some((v, unit))
|
||||
}
|
||||
|
||||
/// A unitless field value (percent, degrees, counts) with the same `+ - * /` arithmetic as
|
||||
/// [`Unit::parse`] (`45*2`, `100/3`); text other than digits, `.` and operators reads as nothing.
|
||||
pub fn parse_number(s: &str) -> Option<f64> {
|
||||
if !s.chars().all(|c| c.is_ascii_digit() || " .+-*/".contains(c)) {
|
||||
return None;
|
||||
}
|
||||
// A leading `+` is a sign (`+5`), as a plain number read it.
|
||||
let s = s.trim();
|
||||
Unit::Points.parse(s.strip_prefix('+').unwrap_or(s))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -533,6 +568,10 @@ pub struct Document {
|
||||
/// export settings are Export for Screens' ([`Document::export_settings`]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<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 {
|
||||
@@ -600,6 +639,7 @@ impl Document {
|
||||
slices_clip_to_artboard: true,
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
puppet: None,
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0))));
|
||||
|
||||
+603
-428
File diff suppressed because it is too large.
Load diff
+187
-15
@@ -75,12 +75,16 @@ impl LayerColor {
|
||||
#[serde(tag = "shape", rename_all = "lowercase")]
|
||||
pub enum LiveShape {
|
||||
Rectangle {
|
||||
/// Untransformed width/height.
|
||||
/// Untransformed width/height. Transforms fold their scale into these (see
|
||||
/// [`LiveShape::transform`]), so they are document lengths unless `xf` shears.
|
||||
w: f64,
|
||||
h: f64,
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left.
|
||||
/// Corner radii: top-left, top-right, bottom-right, bottom-left. In the same units as
|
||||
/// `w`/`h`: the corners are circular arcs in the document whenever `xf` doesn't scale.
|
||||
radii: [f64; 4],
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document.
|
||||
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
|
||||
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
|
||||
/// their scale into `w`/`h`) put more in it.
|
||||
xf: Affine,
|
||||
},
|
||||
Ellipse {
|
||||
@@ -129,12 +133,44 @@ impl LiveShape {
|
||||
LiveShape::Line { a, b } => shapes::line(*a, *b),
|
||||
}
|
||||
}
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
/// Apply `a` to the shape. True when the shape is no longer `a` applied to the old path, so
|
||||
/// the caller must regenerate the path with [`LiveShape::to_path`].
|
||||
///
|
||||
/// A rectangle keeps round corners whatever its proportions: when `a` scales it along its own
|
||||
/// sides (any rotation or reflection, no shear), the scale goes into `w`/`h` and `xf` keeps
|
||||
/// only the rotation, reflection and position. The radii scale by the mean scale (the square
|
||||
/// root of the determinant), which [`LiveShape::keep_corners`] undoes for Scale Corners off.
|
||||
/// A shear can't keep circular corners: `xf` takes it, as do moves (so files saved with a
|
||||
/// scale in `xf` keep their exact geometry until they're next transformed).
|
||||
pub fn transform(&mut self, a: Affine) -> bool {
|
||||
match self {
|
||||
LiveShape::Rectangle { xf, .. } | LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => *xf = a * *xf,
|
||||
LiveShape::Rectangle { w, h, radii, xf } => {
|
||||
let m = a * *xf;
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
let moves_only = (a0 - 1.0).abs() < 1e-12 && a1.abs() < 1e-12 && a2.abs() < 1e-12 && (a3 - 1.0).abs() < 1e-12;
|
||||
let [m0, m1, m2, m3, m4, m5] = m.as_coeffs();
|
||||
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
|
||||
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
|
||||
let finite = m.as_coeffs().iter().all(|c| c.is_finite()) && w.is_finite() && h.is_finite();
|
||||
if moves_only || !orthogonal || !finite {
|
||||
*xf = m;
|
||||
return false;
|
||||
}
|
||||
let k = (sx * sy).sqrt();
|
||||
radii.iter_mut().for_each(|r| *r *= k);
|
||||
*w *= sx;
|
||||
*h *= sy;
|
||||
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
|
||||
true
|
||||
}
|
||||
LiveShape::Ellipse { xf, .. } | LiveShape::Polygon { xf, .. } => {
|
||||
*xf = a * *xf;
|
||||
false
|
||||
}
|
||||
LiveShape::Line { a: p, b } => {
|
||||
*p = a * *p;
|
||||
*b = a * *b;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +308,14 @@ pub enum NodeKind {
|
||||
/// Edit Contents mode (the content, not the envelope, is edited).
|
||||
#[serde(default)]
|
||||
editing: bool,
|
||||
/// Envelope Options besides Fidelity.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
options: crate::live::EnvelopeOptions,
|
||||
/// The envelope's own axes (→ the document): the transforms it took since it was made that
|
||||
/// the page axes can't stand for (rotation, shear, reflection). The content maps from its
|
||||
/// bounds in this frame, and a warp bends along it.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
frame: Affine,
|
||||
},
|
||||
/// Gradient mesh.
|
||||
Mesh(GradientMesh),
|
||||
@@ -335,6 +379,14 @@ pub struct Node {
|
||||
/// these options.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slice: Option<Box<crate::SliceOptions>>,
|
||||
/// The angle of the object's own axes, counter-clockwise degrees (0: square to the page): its
|
||||
/// bounding box and handles stand at this angle. Transforms turn it with the object (see
|
||||
/// [`crate::orient`]); Reset Bounding Box sets it back to 0.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bbox_angle: f64,
|
||||
/// Object › Perspective: the perspective grid plane the object is attached to.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub perspective: Option<Box<crate::PerspectiveAttachment>>,
|
||||
}
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
@@ -383,6 +435,8 @@ impl Node {
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
slice: None,
|
||||
bbox_angle: 0.0,
|
||||
perspective: None,
|
||||
}
|
||||
}
|
||||
pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self {
|
||||
@@ -516,14 +570,14 @@ impl Node {
|
||||
.iter()
|
||||
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
|
||||
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric_bounds())),
|
||||
NodeKind::Text(t) => t.bounds(),
|
||||
NodeKind::Text(t) => self.projected(t.bounds()),
|
||||
NodeKind::Image(im) => Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64))),
|
||||
NodeKind::SymbolInstance { xf, .. } => Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0))),
|
||||
NodeKind::SymbolInstance { xf, .. } => self.projected(Some(xf.transform_rect_bbox(Rect::new(-10.0, -10.0, 10.0, 10.0)))),
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = crate::live::nodes_bounds(children);
|
||||
vectorcraft_geom::union_opt(b, spec.spine.as_ref().and_then(|s| s.bounds()))
|
||||
}
|
||||
NodeKind::Envelope { content, kind, .. } => crate::live::envelope_bounds(content, kind),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, *frame),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
NodeKind::Repeat(r) => r.bounds(),
|
||||
}
|
||||
@@ -572,6 +626,11 @@ impl Node {
|
||||
self.transform_scaled(a, &scaling.into());
|
||||
}
|
||||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
// Type and symbols in perspective keep looking the same, moved by `a`.
|
||||
self.transform_projection(a);
|
||||
// Refitting an unplaced gradient only reproduces moves and uniform scales.
|
||||
if !keeps_gradient_fit(a) {
|
||||
self.pin_gradients();
|
||||
@@ -590,11 +649,10 @@ impl Node {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
path.transform(a);
|
||||
if let Some(l) = live {
|
||||
l.transform(a);
|
||||
if sc.keep_corners
|
||||
&& let Some(k) = k
|
||||
&& l.keep_corners(k)
|
||||
{
|
||||
// A rectangle scaled unevenly keeps circular corners: its path is regenerated.
|
||||
let reshaped = l.transform(a);
|
||||
let kept = sc.keep_corners && k.is_some_and(|k| l.keep_corners(k));
|
||||
if reshaped || kept {
|
||||
*path = l.to_path();
|
||||
}
|
||||
}
|
||||
@@ -623,15 +681,22 @@ impl Node {
|
||||
s.transform(a);
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { content, kind, .. } => {
|
||||
NodeKind::Envelope { content, kind, frame, .. } => {
|
||||
for c in content.iter_mut() {
|
||||
Arc::make_mut(c).transform_scaled(a, sc);
|
||||
}
|
||||
*frame = crate::live::envelope_frame(a * *frame);
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
EnvelopeKind::Mesh { points, handles, .. } => {
|
||||
for p in points.iter_mut() {
|
||||
*p = a * *p;
|
||||
}
|
||||
// Handles are offsets: they take the linear part.
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
let lin = Affine::new([m0, m1, m2, m3, 0.0, 0.0]);
|
||||
for h in handles.iter_mut().flatten() {
|
||||
*h = (lin * h.to_point()).to_vec2();
|
||||
}
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||||
EnvelopeKind::Warp { .. } => {}
|
||||
@@ -1105,6 +1170,113 @@ mod tests {
|
||||
assert_eq!(l.label(), "Rectangle");
|
||||
}
|
||||
|
||||
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
|
||||
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
|
||||
let live = LiveShape::Rectangle { w, h, radii: [r; 4], xf };
|
||||
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn live_of(n: &Node) -> (f64, f64, [f64; 4], Affine) {
|
||||
let NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. } = &n.kind else { panic!("not a live rectangle") };
|
||||
(*w, *h, *radii, *xf)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn near_xf(a: Affine, b: Affine) -> bool {
|
||||
a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| near(*x, y))
|
||||
}
|
||||
|
||||
/// Size and radius of a live rectangle, within rounding.
|
||||
fn assert_live(n: &Node, w: f64, h: f64, r: f64) {
|
||||
let (lw, lh, radii, _) = live_of(n);
|
||||
assert!(near(lw, w) && near(lh, h) && radii.iter().all(|x| near(*x, r)), "{lw} × {lh} r {radii:?}, want {w} × {h} r {r}");
|
||||
}
|
||||
|
||||
/// Each corner arc of a rounded rectangle's path, mapped by `back` (to undo a rotation),
|
||||
/// spans `r` along x and y: the corner is a circular arc of radius `r`.
|
||||
fn assert_round_corners(n: &Node, back: Affine, r: f64) {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { panic!("not a path") };
|
||||
let a: Vec<Point> = path.subpaths[0].anchors.iter().map(|a| back * a.p).collect();
|
||||
assert_eq!(a.len(), 8, "a rounded rectangle");
|
||||
// Anchors 1→2, 3→4, 5→6 and 7→0 are the arcs.
|
||||
for (i, j) in [(1, 2), (3, 4), (5, 6), (7, 0)] {
|
||||
let (x, y) = ((a[j].x - a[i].x).abs(), (a[j].y - a[i].y).abs());
|
||||
assert!(near(x, r) && near(y, r), "corner spans {x} × {y}, want {r} × {r}");
|
||||
}
|
||||
}
|
||||
|
||||
const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true };
|
||||
|
||||
#[test]
|
||||
fn uneven_scales_keep_live_corners_circular() {
|
||||
// A 100 pt rounded square at (10, 10) stretched to 300 × 100 (#291).
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::translate((10.0, 10.0)));
|
||||
let stretch = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(3.0, 1.0) * Affine::translate((-10.0, -10.0));
|
||||
n.transform(stretch, KEEP_CORNERS);
|
||||
assert_live(&n, 300.0, 100.0, 20.0);
|
||||
assert!(near_xf(live_of(&n).3, Affine::translate((10.0, 10.0))), "the scale went into the size");
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
assert!(near(b.x0, 10.0) && near(b.y0, 10.0) && near(b.x1, 310.0) && near(b.y1, 110.0), "{b:?}");
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0);
|
||||
// Scale Corners on: the radius scales by the mean scale, still circular.
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
|
||||
n.transform(Affine::scale_non_uniform(4.0, 1.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 100.0, 40.0);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
// Squashed below the radius, the short sides are fully round; stretched back, it returns.
|
||||
n.transform(Affine::scale_non_uniform(1.0, 0.5), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 25.0);
|
||||
n.transform(Affine::scale_non_uniform(1.0, 2.0), KEEP_CORNERS);
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
|
||||
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, place);
|
||||
// Stretched along its own width, as a bounding-box drag of a rotated shape does.
|
||||
n.transform(place * Affine::scale_non_uniform(2.0, 1.0) * place.inverse(), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
assert!(near_xf(live_of(&n).3, place));
|
||||
assert_round_corners(&n, place.inverse(), 10.0);
|
||||
// A reflection stays in `xf`; the corners stay as they were.
|
||||
n.transform(Affine::scale_non_uniform(-1.0, 1.0), KEEP_CORNERS);
|
||||
assert_live(&n, 200.0, 50.0, 10.0);
|
||||
let xf = live_of(&n).3;
|
||||
assert!(near(xf.determinant(), -1.0));
|
||||
assert_round_corners(&n, xf.inverse(), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shears_and_moves_keep_the_old_transform() {
|
||||
// Scaling a rotated rectangle along the page axes shears it: `xf` takes it all, as before.
|
||||
let rot = Affine::rotate(30f64.to_radians());
|
||||
let mut n = live_rect(100.0, 50.0, 10.0, rot);
|
||||
let before = n.clone();
|
||||
let a = Affine::scale_non_uniform(2.0, 1.0);
|
||||
n.transform(a, Scaling::default());
|
||||
assert_eq!(live_of(&n), (100.0, 50.0, [10.0; 4], a * rot));
|
||||
let (NodeKind::Path { path, .. }, NodeKind::Path { path: old, .. }) = (&n.kind, &before.kind) else { panic!() };
|
||||
assert_eq!(*path, old.transformed(a));
|
||||
// A file saved with an uneven scale in `xf` keeps its exact geometry when moved...
|
||||
let legacy = Affine::translate((5.0, 5.0)) * Affine::scale_non_uniform(2.0, 1.0);
|
||||
let mut n = live_rect(100.0, 100.0, 20.0, legacy);
|
||||
n.transform(Affine::translate((10.0, 0.0)), KEEP_CORNERS);
|
||||
assert_eq!(live_of(&n), (100.0, 100.0, [20.0; 4], Affine::translate((10.0, 0.0)) * legacy));
|
||||
// ...and once scaled, has document units and circular corners of its mean radius.
|
||||
n.transform(Affine::scale(2.0), Scaling::default());
|
||||
assert_live(&n, 400.0, 200.0, 20.0 * 2f64.sqrt() * 2.0);
|
||||
assert!(near(live_of(&n).3.determinant(), 1.0));
|
||||
assert_round_corners(&n, Affine::IDENTITY, 20.0 * 2f64.sqrt() * 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_transparency() {
|
||||
let p = || Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Rotated bounding boxes. Every object keeps the angle of its own axes ([`Node::bbox_angle`])
|
||||
//! through the transforms applied to it, so after a rotation its bounding box and handles stay
|
||||
//! square to it: scaling by a side handle stretches it along its own width or height. Path
|
||||
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
|
||||
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// Angles closer than this (degrees) are the same.
|
||||
const EPS_DEG: f64 = 1e-6;
|
||||
|
||||
/// A bounding box square to a frame turned by `angle`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OrientedBox {
|
||||
/// Counter-clockwise degrees in (-180, 180]; 0: square to the page.
|
||||
pub angle: f64,
|
||||
/// The box in the turned frame: [`OrientedBox::to_doc`] places it on the page.
|
||||
pub rect: Rect,
|
||||
}
|
||||
|
||||
impl OrientedBox {
|
||||
/// A box square to the page.
|
||||
pub fn aligned(rect: Rect) -> Self {
|
||||
Self { angle: 0.0, rect }
|
||||
}
|
||||
/// The turned frame → the page (a rotation about the page origin).
|
||||
pub fn to_doc(&self) -> Affine {
|
||||
if self.angle == 0.0 { Affine::IDENTITY } else { Affine::rotate(-self.angle.to_radians()) }
|
||||
}
|
||||
/// A page point in the turned frame.
|
||||
pub fn to_local(&self, p: Point) -> Point {
|
||||
self.to_doc().inverse() * p
|
||||
}
|
||||
/// `a`, a transform of the turned frame, as a page transform.
|
||||
pub fn conjugate(&self, a: Affine) -> Affine {
|
||||
if self.angle == 0.0 { a } else { self.to_doc() * a * self.to_doc().inverse() }
|
||||
}
|
||||
pub fn center(&self) -> Point {
|
||||
self.to_doc() * self.rect.center()
|
||||
}
|
||||
/// One of the 9 reference points (row-major from the box's own top-left), on the page.
|
||||
pub fn reference_point(&self, index: usize) -> Point {
|
||||
self.to_doc() * vectorcraft_geom::reference_point(self.rect, index)
|
||||
}
|
||||
/// The corners on the page: top-left, top-right, bottom-right, bottom-left of the box itself.
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
let r = self.rect;
|
||||
[(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| self.to_doc() * Point::new(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle (counter-clockwise degrees) of an object's axes after `a`: where `a` takes its x axis.
|
||||
/// A mirroring `a` allows two boxes 180° apart: the one nearer the old angle (then the smaller
|
||||
/// angle) is kept, so flipping an upright object keeps it upright.
|
||||
pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
|
||||
let [m0, m1, m2, m3, _, _] = a.as_coeffs();
|
||||
if (m0, m1, m2, m3) == (1.0, 0.0, 0.0, 1.0) {
|
||||
return angle;
|
||||
}
|
||||
let (s, c) = (-angle.to_radians()).sin_cos();
|
||||
let v = Vec2::new(m0 * c + m2 * s, m1 * c + m3 * s);
|
||||
// Degenerate (or non-finite) transforms leave the angle alone.
|
||||
let len = v.hypot();
|
||||
if !len.is_finite() || len <= 1e-12 {
|
||||
return angle;
|
||||
}
|
||||
let mut out = normalize_deg(-v.y.atan2(v.x).to_degrees());
|
||||
let dist = |x: f64| normalize_deg(x - angle).abs();
|
||||
if a.determinant() < 0.0 {
|
||||
let alt = normalize_deg(out + 180.0);
|
||||
let (d0, d1) = (dist(out), dist(alt));
|
||||
if d1 < d0 - EPS_DEG || ((d1 - d0).abs() <= EPS_DEG && alt.abs() < out.abs()) {
|
||||
out = alt;
|
||||
}
|
||||
}
|
||||
// Moves and scales along the object's own axes keep its angle exactly.
|
||||
if dist(out) <= EPS_DEG {
|
||||
angle
|
||||
} else if out.abs() <= EPS_DEG {
|
||||
0.0
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
|
||||
/// without changing it.
|
||||
pub fn bounds_in(&self, a: Affine, visual: bool) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: false } => {
|
||||
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(a, visual)));
|
||||
// The group's own strokes paint around its members (as in `visual_bounds`).
|
||||
let o = if visual { self.appearance.outset() } else { 0.0 };
|
||||
b.map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
let clip = children.first()?;
|
||||
let stroked = matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_));
|
||||
clip.bounds_in(a, visual && stroked)
|
||||
}
|
||||
_ => {
|
||||
let mut n = self.clone();
|
||||
n.transform(a, false);
|
||||
if visual { n.visual_bounds() } else { n.geometric_bounds() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The angle the bounding box of `ids` stands at: the `bbox_angle` they share, else 0.
|
||||
pub fn bbox_angle(&self, ids: &[NodeId]) -> f64 {
|
||||
let mut angles = ids.iter().filter_map(|id| self.node(*id)).map(|n| n.bbox_angle);
|
||||
let Some(first) = angles.next() else { return 0.0 };
|
||||
if angles.all(|a| normalize_deg(a - first).abs() <= EPS_DEG) { first } else { 0.0 }
|
||||
}
|
||||
/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]):
|
||||
/// geometric bounds, or `visual` with their strokes.
|
||||
pub fn oriented_bounds(&self, ids: &[NodeId], visual: bool) -> Option<OrientedBox> {
|
||||
let angle = self.bbox_angle(ids);
|
||||
if angle == 0.0 {
|
||||
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
|
||||
}
|
||||
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(to_local, visual)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Appearance;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-9
|
||||
}
|
||||
|
||||
fn rot(deg: f64) -> Affine {
|
||||
Affine::rotate(-deg.to_radians())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotations_add_and_moves_keep_the_angle() {
|
||||
assert!(close(transformed_angle(0.0, rot(45.0)), 45.0));
|
||||
assert!(close(transformed_angle(170.0, rot(20.0)), -170.0));
|
||||
assert_eq!(transformed_angle(30.0, Affine::translate((5.0, 7.0))), 30.0);
|
||||
assert_eq!(transformed_angle(45.0, rot(-45.0)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_scales_keep_the_angle_exactly() {
|
||||
let b = OrientedBox { angle: 45.0, rect: Rect::ZERO };
|
||||
assert_eq!(transformed_angle(45.0, b.conjugate(Affine::scale_non_uniform(3.0, 0.5))), 45.0);
|
||||
// A flip along the object's own axis keeps the box too.
|
||||
let b = OrientedBox { angle: 135.0, rect: Rect::ZERO };
|
||||
assert_eq!(transformed_angle(135.0, b.conjugate(Affine::scale_non_uniform(-1.0, 1.0))), 135.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_flips_mirror_the_angle() {
|
||||
let flip_h = Affine::scale_non_uniform(-1.0, 1.0);
|
||||
let flip_v = Affine::scale_non_uniform(1.0, -1.0);
|
||||
assert_eq!(transformed_angle(0.0, flip_h), 0.0);
|
||||
assert_eq!(transformed_angle(0.0, flip_v), 0.0);
|
||||
assert!(close(transformed_angle(30.0, flip_h), -30.0));
|
||||
assert!(close(transformed_angle(30.0, flip_v), -30.0));
|
||||
// Degenerate transforms leave it alone.
|
||||
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_bounds_hug_a_rotated_rectangle() {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art())).unwrap();
|
||||
let c = Point::new(50.0, 25.0);
|
||||
d.node_mut(id).unwrap().transform(Affine::translate(c.to_vec2()) * rot(30.0) * Affine::translate(-c.to_vec2()), false);
|
||||
let b = d.oriented_bounds(&[id], false).unwrap();
|
||||
assert!(close(b.angle, 30.0));
|
||||
assert!(close(b.rect.width(), 100.0) && close(b.rect.height(), 50.0));
|
||||
assert!(b.center().distance(c) < 1e-9);
|
||||
// The page box is bigger.
|
||||
assert!(d.bounds_of(&[id], false).unwrap().width() > 100.0);
|
||||
// Grouped: the group has no angle of its own, the bounds are square to the page.
|
||||
let g = d.alloc_id();
|
||||
d.insert(Some(l), 1, Node::group(g, vec![])).unwrap();
|
||||
d.move_node(id, Some(g), 0).unwrap();
|
||||
assert_eq!(d.oriented_bounds(&[g], false).unwrap().angle, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,7 @@ pub fn multiply_overprints(a: &mut Arc<Node>, discard_white: bool) {
|
||||
}
|
||||
|
||||
/// A solid paint of paper white (no ink in any colour model).
|
||||
fn is_white(p: &Paint) -> bool {
|
||||
pub fn is_white(p: &Paint) -> bool {
|
||||
let Paint::Solid { color, .. } = p else { return false };
|
||||
match *color {
|
||||
Color::Rgb { r, g, b } => r.min(g).min(b) >= 1.0,
|
||||
@@ -149,6 +149,33 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any fill or stroke of `n`'s subtree overprints, characters' too.
|
||||
fn overprints_anywhere(n: &Node) -> bool {
|
||||
n.appearance.items.iter().any(|i| i.overprint())
|
||||
|| matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().any(|r| r.style.overprint_fill || r.style.overprint_stroke))
|
||||
|| n.children().is_some_and(|c| c.iter().any(|c| overprints_anywhere(c)))
|
||||
}
|
||||
|
||||
/// Stop every fill and stroke of `a`'s subtree overprinting (characters' too), copying only the
|
||||
/// nodes on the way to them.
|
||||
pub fn clear_overprints(a: &mut Arc<Node>) {
|
||||
if !overprints_anywhere(a) {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(a);
|
||||
for it in &mut n.appearance.items {
|
||||
*it.overprint_mut() = false;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
for r in &mut t.runs {
|
||||
(r.style.overprint_fill, r.style.overprint_stroke) = (false, false);
|
||||
}
|
||||
}
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
clear_overprints(c);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The colour visitor that recolouring shares: Edit Colors, Recolor Artwork and the live colour
|
||||
//! adjustment effects walk an object's colours through [`recolor_node`] and decide what each
|
||||
//! colour, mesh point and embedded image becomes through a [`ColorVisitor`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
|
||||
use crate::{AppearanceItem, Node, NodeKind};
|
||||
|
||||
/// What a recolouring pass does to the colours it meets.
|
||||
pub trait ColorVisitor {
|
||||
/// A fill's or stroke's paint (an object's, or a text run's): true when it changed.
|
||||
fn paint(&mut self, p: &mut Paint) -> bool;
|
||||
/// One gradient-mesh point's colour.
|
||||
fn color(&mut self, c: Color) -> Color;
|
||||
/// The key of embedded image `key` recoloured, `None` when it stays as it is.
|
||||
fn image(&mut self, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
|
||||
/// embedded images.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reach {
|
||||
pub fill: bool,
|
||||
pub stroke: bool,
|
||||
pub images: bool,
|
||||
}
|
||||
|
||||
impl Reach {
|
||||
/// Every colour.
|
||||
pub const ALL: Reach = Reach { fill: true, stroke: true, images: true };
|
||||
}
|
||||
|
||||
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
|
||||
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
|
||||
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
|
||||
/// number of paints, meshes and images that changed.
|
||||
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
|
||||
let mut changed = 0;
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
|
||||
AppearanceItem::Stroke(l) if reach.stroke => changed += v.paint(&mut l.paint) as usize,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
if reach.fill {
|
||||
changed += v.paint(&mut r.style.fill) as usize;
|
||||
}
|
||||
if reach.stroke {
|
||||
changed += v.paint(&mut r.style.stroke) as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) if reach.fill => {
|
||||
let mut ch = false;
|
||||
for p in &mut m.points {
|
||||
let c = v.color(p.color);
|
||||
ch |= c != p.color;
|
||||
p.color = c;
|
||||
}
|
||||
changed += ch as usize;
|
||||
}
|
||||
NodeKind::Image(im) if reach.images && im.link.is_none() => {
|
||||
if let Some(k) = v.image(&im.key) {
|
||||
im.key = k;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
let mut m = (**c).clone();
|
||||
let k = recolor_node(&mut m, reach, v);
|
||||
if k > 0 {
|
||||
*c = Arc::new(m);
|
||||
changed += k;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `f` to the solid colour or gradient stops of a paint, with their swatch link and tint
|
||||
/// ([`Paint::map_links`]); a gradient that changes is no longer its gradient swatch's. Returns
|
||||
/// whether anything changed.
|
||||
pub fn map_links(p: &mut Paint, f: &dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool) -> bool {
|
||||
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
|
||||
if changed && let Paint::Gradient(g) = p {
|
||||
g.swatch = None;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
|
||||
/// anything changed.
|
||||
pub fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
|
||||
map_links(p, &|c, link, _| {
|
||||
let n = f(*c);
|
||||
if n == *c {
|
||||
return false;
|
||||
}
|
||||
*c = n;
|
||||
*link = None;
|
||||
true
|
||||
})
|
||||
}
|
||||
+93
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeLayer};
|
||||
|
||||
@@ -414,6 +414,9 @@ pub enum TextKind {
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TextObject {
|
||||
/// Top-to-bottom, right-to-left writing. Defaults to horizontal for old documents.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub vertical: bool,
|
||||
pub kind: TextKind,
|
||||
/// Maps text space (origin = first baseline start for point type) to the document.
|
||||
pub xf: Affine,
|
||||
@@ -437,6 +440,7 @@ pub struct TextObject {
|
||||
impl TextObject {
|
||||
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
|
||||
Self {
|
||||
vertical: false,
|
||||
kind: TextKind::Point,
|
||||
xf: Affine::translate(origin.to_vec2()),
|
||||
runs: vec![TextRun { text: text.into(), style }],
|
||||
@@ -472,6 +476,49 @@ impl TextObject {
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
self.xf = a * self.xf;
|
||||
}
|
||||
/// Area type's frame (the type area) in document space; None for other type.
|
||||
pub fn area_frame(&self) -> Option<PathData> {
|
||||
match &self.kind {
|
||||
TextKind::Area { frame } => Some(frame.transformed(self.xf)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Reshape area type's frame by `a`, a document-space transform, keeping the type at its size
|
||||
/// (a bounding-box resize: the text reflows, it isn't scaled). False, changing nothing, for
|
||||
/// other type, a degenerate `xf` or a frame that would not be finite.
|
||||
pub fn transform_area(&mut self, a: Affine) -> bool {
|
||||
let xf = self.xf;
|
||||
self.reshape_area(|frame, to_text| {
|
||||
frame.transform(to_text * a * xf);
|
||||
true
|
||||
})
|
||||
}
|
||||
/// Move anchors `refs` (subpath, anchor) of area type's frame by `d` in document space, with
|
||||
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
|
||||
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
|
||||
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
self.reshape_area(|frame, to_text| {
|
||||
// A distance: only the linear part of the document → text map applies.
|
||||
let local = to_text * d.to_point() - to_text * Point::ZERO;
|
||||
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
|
||||
})
|
||||
}
|
||||
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
|
||||
/// when `f` succeeds and the result is finite.
|
||||
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
|
||||
let det = self.xf.determinant();
|
||||
if !det.is_finite() || det.abs() < 1e-12 {
|
||||
return false;
|
||||
}
|
||||
let to_text = self.xf.inverse();
|
||||
let TextKind::Area { frame } = &mut self.kind else { return false };
|
||||
let mut next = frame.clone();
|
||||
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
*frame = next;
|
||||
true
|
||||
}
|
||||
/// Scale the character strokes' weights and dashes by `s` (they are drawn in text space, so
|
||||
/// `1 / scale` keeps their weight through a transform that scales the type).
|
||||
pub fn scale_char_strokes(&mut self, s: f64) {
|
||||
@@ -501,4 +548,49 @@ mod tests {
|
||||
assert!(b.x0 >= 10.0 - 1e-9 && b.y1 > 20.0);
|
||||
assert_eq!(CharStyle::default().effective_leading(), 14.399999999999999);
|
||||
}
|
||||
|
||||
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
|
||||
fn area_type() -> TextObject {
|
||||
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
|
||||
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 20.0)) };
|
||||
t.xf = Affine::translate((40.0, 40.0)) * Affine::scale(2.0);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_resizes_its_frame_and_keeps_its_type() {
|
||||
let mut t = area_type();
|
||||
assert_eq!(t.bounds(), Some(Rect::new(40.0, 40.0, 160.0, 80.0)));
|
||||
// Bottom-right handle to (200, 140), about the top-left corner.
|
||||
let o = Affine::translate((40.0, 40.0));
|
||||
assert!(t.transform_area(o * Affine::scale_non_uniform(160.0 / 120.0, 100.0 / 40.0) * o.inverse()));
|
||||
assert_eq!(t.xf, Affine::translate((40.0, 40.0)) * Affine::scale(2.0), "the type keeps its size");
|
||||
let b = t.bounds().unwrap();
|
||||
assert!((b.x1 - 200.0).abs() < 1e-9 && (b.y1 - 140.0).abs() < 1e-9 && b.x0 == 40.0, "{b:?}");
|
||||
assert_eq!(t.area_frame().unwrap().bounds(), Some(b));
|
||||
// Point type has no area; a degenerate transform or a non-finite frame changes nothing.
|
||||
let mut p = TextObject::point(Point::ZERO, "x", CharStyle::default());
|
||||
assert!(!p.transform_area(Affine::scale(2.0)) && p.area_frame().is_none());
|
||||
let before = t.clone();
|
||||
assert!(!t.transform_area(Affine::scale(f64::INFINITY)));
|
||||
t.xf = Affine::scale(0.0);
|
||||
assert!(!t.transform_area(Affine::scale(2.0)));
|
||||
t.xf = before.xf;
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_anchors_move_in_document_space() {
|
||||
let mut t = area_type();
|
||||
// The bottom-right corner (anchor 2) 20 pt right: 10 pt in text space at 2×.
|
||||
assert!(t.move_area_anchors(&[(0, 2)], Vec2::new(20.0, 0.0)));
|
||||
let TextKind::Area { frame } = &t.kind else { panic!() };
|
||||
assert_eq!(frame.subpaths[0].anchors[2].p, Point::new(70.0, 20.0));
|
||||
assert_eq!(frame.subpaths[0].anchors[1].p, Point::new(60.0, 0.0), "the others stay");
|
||||
// A missing anchor changes nothing.
|
||||
let before = t.clone();
|
||||
assert!(!t.move_area_anchors(&[(0, 1), (0, 9)], Vec2::new(5.0, 5.0)));
|
||||
assert!(!t.move_area_anchors(&[(3, 0)], Vec2::new(5.0, 5.0)));
|
||||
assert_eq!(t, before);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
//! Colour adjustment effects (Effect → Color Adjustments): Brightness/Contrast, Curves, Levels,
|
||||
//! Hue/Saturation, Shift to Color and Temperature/Tint.
|
||||
//!
|
||||
//! They recolour what an object paints with, non-destructively: its fills and strokes (solid
|
||||
//! colours and gradient stops), type, gradient meshes, embedded images and, on a group or layer,
|
||||
//! every member — through the colour visitor the Edit Colors commands use
|
||||
//! ([`vectorcraft_doc::recolor`]). Each colour keeps its colour model. An effect on one fill or
|
||||
//! stroke recolours that paint only. [`adjust`] evaluates them, innermost first and each list in
|
||||
//! stack order; the renderer draws the result (caching recoloured copies of embedded images) and
|
||||
//! [`crate::bake_document`] writes it for export, recoloured images as new embedded images.
|
||||
//! Pattern tiles, and the shadows and glows of raster effects, keep their colours.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint, keep_model};
|
||||
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node};
|
||||
use vectorcraft_doc::{Document, Effect, Node, NodeKind};
|
||||
|
||||
use crate::merged_params;
|
||||
use crate::raster::color_param;
|
||||
use crate::util::{flag, num, text};
|
||||
|
||||
/// The colour adjustment effects, in menu order.
|
||||
pub const ADJUSTMENTS: [&str; 6] =
|
||||
["adjust.brightnessContrast", "adjust.curves", "adjust.hueSaturation", "adjust.levels", "adjust.shiftToColor", "adjust.temperatureTint"];
|
||||
|
||||
/// Is `id` a colour adjustment effect?
|
||||
pub fn is_adjustment(id: &str) -> bool {
|
||||
ADJUSTMENTS.contains(&id)
|
||||
}
|
||||
|
||||
fn visible_adjustment(e: &Effect) -> bool {
|
||||
e.visible && is_adjustment(&e.id)
|
||||
}
|
||||
|
||||
/// Does `n` carry a visible colour adjustment, on the object or on one of its fills or strokes?
|
||||
pub fn has_adjustment(n: &Node) -> bool {
|
||||
n.appearance.effects.iter().any(visible_adjustment) || n.appearance.items.iter().any(|i| i.effects().iter().any(visible_adjustment))
|
||||
}
|
||||
|
||||
/// The channels a tone curve (Curves, Levels) applies to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Channel {
|
||||
All,
|
||||
One(usize),
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
fn of(p: &Value) -> Self {
|
||||
match text(p, "channel", "rgb").to_ascii_lowercase().as_str() {
|
||||
"red" | "r" => Channel::One(0),
|
||||
"green" | "g" => Channel::One(1),
|
||||
"blue" | "b" => Channel::One(2),
|
||||
_ => Channel::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries of a sampled tone curve.
|
||||
const LUT: usize = 256;
|
||||
|
||||
/// One adjustment, its parameters normalised.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Adjust {
|
||||
/// Brightness and contrast, -1..1 each.
|
||||
BrightnessContrast { brightness: f32, contrast: f32 },
|
||||
/// A tone curve (Curves, Levels) sampled at [`LUT`] inputs, on `channel`.
|
||||
Tone { channel: Channel, lut: Arc<[f32]> },
|
||||
/// Hue shift in degrees, saturation and lightness -1..1; `colorize` sets the hue instead.
|
||||
HueSaturation { hue: f32, saturation: f32, lightness: f32, colorize: bool },
|
||||
/// Move colours `amount` (0..1) of the way to `target` (sRGB), keeping their lightness when
|
||||
/// `preserve`.
|
||||
ShiftToColor { target: [f32; 3], amount: f32, preserve: bool },
|
||||
/// Warm/cool and magenta/green shifts, -1..1.
|
||||
TemperatureTint { temperature: f32, tint: f32 },
|
||||
}
|
||||
|
||||
/// The colour adjustments of an effect list, composed in order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ColorMap(Vec<Adjust>);
|
||||
|
||||
impl ColorMap {
|
||||
/// `self`, then `next`.
|
||||
pub fn then(&self, next: &ColorMap) -> ColorMap {
|
||||
ColorMap(self.0.iter().chain(&next.0).cloned().collect())
|
||||
}
|
||||
|
||||
/// Adjust display (sRGB) components 0..1.
|
||||
pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
|
||||
self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }))
|
||||
}
|
||||
|
||||
/// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is).
|
||||
pub fn apply(&self, c: Color) -> Color {
|
||||
let rgb = c.to_rgb();
|
||||
let out = self.apply_rgb(rgb);
|
||||
if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) {
|
||||
return c;
|
||||
}
|
||||
keep_model(c, Color::rgb(out[0], out[1], out[2]))
|
||||
}
|
||||
|
||||
/// Adjust an 8-bit sRGB pixel.
|
||||
pub fn apply_rgb8(&self, [r, g, b]: [u8; 3]) -> [u8; 3] {
|
||||
let out = self.apply_rgb([r, g, b].map(|v| v as f32 / 255.0));
|
||||
out.map(|v| (v * 255.0).round() as u8)
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible colour adjustments of `effects` as one map, `None` when there are none.
|
||||
pub fn color_map(effects: &[Effect]) -> Option<ColorMap> {
|
||||
let v: Vec<Adjust> =
|
||||
effects.iter().filter(|e| visible_adjustment(e)).filter_map(|e| Adjust::parse(&e.id, &merged_params(&e.id, &e.params))).collect();
|
||||
(!v.is_empty()).then_some(ColorMap(v))
|
||||
}
|
||||
|
||||
/// A parameter in -100..100 as -1..1.
|
||||
fn amount(p: &Value, key: &str) -> f32 {
|
||||
(num(p, key, 0.0).clamp(-100.0, 100.0) / 100.0) as f32
|
||||
}
|
||||
|
||||
/// A level 0..255 as 0..1.
|
||||
fn level(p: &Value, key: &str, default: f64) -> f32 {
|
||||
(num(p, key, default).clamp(0.0, 255.0) / 255.0) as f32
|
||||
}
|
||||
|
||||
impl Adjust {
|
||||
fn parse(id: &str, p: &Value) -> Option<Self> {
|
||||
Some(match id {
|
||||
"adjust.brightnessContrast" => Adjust::BrightnessContrast { brightness: amount(p, "brightness"), contrast: amount(p, "contrast") },
|
||||
"adjust.levels" => {
|
||||
let (ib, iw) = (level(p, "inputBlack", 0.0), level(p, "inputWhite", 255.0));
|
||||
let (ob, ow) = (level(p, "outputBlack", 0.0), level(p, "outputWhite", 255.0));
|
||||
let gamma = num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32;
|
||||
let f = |x: f32| {
|
||||
let t = if iw > ib {
|
||||
((x - ib) / (iw - ib)).clamp(0.0, 1.0)
|
||||
} else if x >= ib {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
ob + t.powf(1.0 / gamma) * (ow - ob)
|
||||
};
|
||||
Adjust::Tone { channel: Channel::of(p), lut: sample(f) }
|
||||
}
|
||||
"adjust.curves" => {
|
||||
let pts = curve_points(p.get("points"));
|
||||
Adjust::Tone { channel: Channel::of(p), lut: sample(|x| curve_at(&pts, x)) }
|
||||
}
|
||||
"adjust.hueSaturation" => Adjust::HueSaturation {
|
||||
hue: num(p, "hue", 0.0).clamp(-360.0, 360.0) as f32,
|
||||
saturation: amount(p, "saturation"),
|
||||
lightness: amount(p, "lightness"),
|
||||
colorize: flag(p, "colorize", false),
|
||||
},
|
||||
"adjust.shiftToColor" => Adjust::ShiftToColor {
|
||||
target: color_param(p, Color::rgb(1.0, 0.5, 0.0)).to_rgb(),
|
||||
amount: (num(p, "amount", 50.0).clamp(0.0, 100.0) / 100.0) as f32,
|
||||
preserve: flag(p, "preserveLightness", true),
|
||||
},
|
||||
"adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") },
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn apply(&self, [r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
match self {
|
||||
Adjust::BrightnessContrast { brightness, contrast } => {
|
||||
// Brightness bends the tones (black and white stay), contrast spreads them from the middle.
|
||||
let gamma = 2f32.powf(-brightness);
|
||||
let k = if *contrast >= 0.0 { 1.0 / (1.0 - contrast * 0.99) } else { 1.0 + contrast };
|
||||
[r, g, b].map(|v| (v.clamp(0.0, 1.0).powf(gamma) - 0.5) * k + 0.5)
|
||||
}
|
||||
Adjust::Tone { channel, lut } => {
|
||||
let f = |v: f32| lookup(lut, v);
|
||||
match channel {
|
||||
Channel::All => [f(r), f(g), f(b)],
|
||||
Channel::One(i) => {
|
||||
let mut c = [r, g, b];
|
||||
if let Some(v) = c.get_mut(*i) {
|
||||
*v = f(*v);
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
Adjust::HueSaturation { hue, saturation, lightness, colorize } => {
|
||||
let [h, s, l] = to_hsl([r, g, b]);
|
||||
let (h, s) = if *colorize {
|
||||
(hue.rem_euclid(360.0), (saturation + 1.0) / 2.0)
|
||||
} else {
|
||||
let s = if *saturation >= 0.0 { s / (1.0 - saturation * 0.99) } else { s * (1.0 + saturation) };
|
||||
((h + hue).rem_euclid(360.0), s)
|
||||
};
|
||||
let l = if *lightness >= 0.0 { l + (1.0 - l) * lightness } else { l * (1.0 + lightness) };
|
||||
from_hsl([h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0)])
|
||||
}
|
||||
Adjust::ShiftToColor { target, amount, preserve } => {
|
||||
let to = if *preserve {
|
||||
let [th, ts, _] = to_hsl(*target);
|
||||
from_hsl([th, ts, to_hsl([r, g, b])[2]])
|
||||
} else {
|
||||
*target
|
||||
};
|
||||
let mix = |a: f32, b: f32| a + (b - a) * amount;
|
||||
[mix(r, to[0]), mix(g, to[1]), mix(b, to[2])]
|
||||
}
|
||||
Adjust::TemperatureTint { temperature, tint } => {
|
||||
let (t, m) = (temperature * 0.3, tint * 0.3);
|
||||
[r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `f` sampled at [`LUT`] inputs 0..1.
|
||||
fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> {
|
||||
(0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// The tone curve `lut` at `v` (linear between samples).
|
||||
fn lookup(lut: &[f32], v: f32) -> f32 {
|
||||
let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32;
|
||||
let i = (x.floor() as usize).min(LUT - 2);
|
||||
let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v));
|
||||
a + (b - a) * (x - i as f32)
|
||||
}
|
||||
|
||||
/// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter:
|
||||
/// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line.
|
||||
pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
|
||||
let mut pts: Vec<(f64, f64)> = match v {
|
||||
Some(Value::String(s)) => s
|
||||
.split(|c: char| c.is_whitespace() || c == ';')
|
||||
.filter_map(|pair| {
|
||||
let (x, y) = pair.split_once(',')?;
|
||||
Some((x.trim().parse().ok()?, y.trim().parse().ok()?))
|
||||
})
|
||||
.collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|xy| Some((xy.get(0)?.as_f64()?, xy.get(1)?.as_f64()?))).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
pts.retain(|(x, y)| x.is_finite() && y.is_finite());
|
||||
// At most a point per level is meaningful.
|
||||
pts.truncate(256);
|
||||
let mut pts: Vec<(f32, f32)> =
|
||||
pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect();
|
||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
|
||||
if pts.len() < 2 {
|
||||
return vec![(0.0, 0.0), (1.0, 1.0)];
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// Curves' curve through `pts` ([`curve_points`]) at `x` (0..1): a monotone cubic, flat beyond
|
||||
/// the end points.
|
||||
pub fn curve_at(pts: &[(f32, f32)], x: f32) -> f32 {
|
||||
let (Some(&first), Some(&last)) = (pts.first(), pts.last()) else { return x };
|
||||
if x <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
if x >= last.0 {
|
||||
return last.1;
|
||||
}
|
||||
let n = pts.len();
|
||||
let slope = |i: usize| -> f32 {
|
||||
match (pts.get(i), pts.get(i + 1)) {
|
||||
(Some(a), Some(b)) if b.0 > a.0 => (b.1 - a.1) / (b.0 - a.0),
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
// Tangents: the harmonic mean of the neighbouring slopes, zero at extrema, so the curve never
|
||||
// overshoots its points.
|
||||
let tangent = |i: usize| -> f32 {
|
||||
if i == 0 {
|
||||
return slope(0);
|
||||
}
|
||||
if i + 1 >= n {
|
||||
return slope(n - 2);
|
||||
}
|
||||
let (a, b) = (slope(i - 1), slope(i));
|
||||
if a * b <= 0.0 { 0.0 } else { 2.0 * a * b / (a + b) }
|
||||
};
|
||||
let k = pts.windows(2).position(|w| x >= w[0].0 && x <= w[1].0).unwrap_or(0);
|
||||
let (Some(&(x0, y0)), Some(&(x1, y1))) = (pts.get(k), pts.get(k + 1)) else { return x };
|
||||
let h = x1 - x0;
|
||||
if h <= 0.0 {
|
||||
return y0;
|
||||
}
|
||||
let t = (x - x0) / h;
|
||||
let (m0, m1) = (tangent(k) * h, tangent(k + 1) * h);
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
(2.0 * t3 - 3.0 * t2 + 1.0) * y0 + (t3 - 2.0 * t2 + t) * m0 + (-2.0 * t3 + 3.0 * t2) * y1 + (t3 - t2) * m1
|
||||
}
|
||||
|
||||
/// HSL with hue in degrees, saturation and lightness 0..1.
|
||||
fn to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let l = (max + min) / 2.0;
|
||||
let d = max - min;
|
||||
if d <= 1e-6 {
|
||||
return [0.0, 0.0, l];
|
||||
}
|
||||
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
||||
let h = if max == r {
|
||||
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
[h * 60.0, s, l]
|
||||
}
|
||||
|
||||
fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] {
|
||||
if s <= 0.0 {
|
||||
return [l; 3];
|
||||
}
|
||||
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
||||
let p = 2.0 * l - q;
|
||||
let hue = |t: f32| {
|
||||
let t = t.rem_euclid(1.0);
|
||||
if t < 1.0 / 6.0 {
|
||||
p + (q - p) * 6.0 * t
|
||||
} else if t < 0.5 {
|
||||
q
|
||||
} else if t < 2.0 / 3.0 {
|
||||
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
} else {
|
||||
p
|
||||
}
|
||||
};
|
||||
let h = h / 360.0;
|
||||
[hue(h + 1.0 / 3.0), hue(h), hue(h - 1.0 / 3.0)]
|
||||
}
|
||||
|
||||
/// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is).
|
||||
pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option<String> + 'a;
|
||||
|
||||
/// The live visitor: every colour through `map`, embedded images through the hook.
|
||||
struct Live<'a, 'b> {
|
||||
map: &'a ColorMap,
|
||||
images: &'a mut ImageHook<'b>,
|
||||
}
|
||||
|
||||
impl ColorVisitor for Live<'_, '_> {
|
||||
fn paint(&mut self, p: &mut Paint) -> bool {
|
||||
map_paint(p, &|c| self.map.apply(c))
|
||||
}
|
||||
fn color(&mut self, c: Color) -> Color {
|
||||
self.map.apply(c)
|
||||
}
|
||||
fn image(&mut self, key: &str) -> Option<String> {
|
||||
(self.images)(key, self.map)
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` with the colour adjustments in it applied, `None` when it has none: its members' first,
|
||||
/// then those of its fills and strokes on their paints, then its own followed by `outer` (the
|
||||
/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects
|
||||
/// are gone from the result; embedded images take the keys `images` gives.
|
||||
pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option<Node> {
|
||||
let mut m: Option<Node> = None;
|
||||
if let Some(ch) = n.children() {
|
||||
for (i, c) in ch.iter().enumerate() {
|
||||
if let Some(new) = adjust(c, None, images)
|
||||
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
|
||||
{
|
||||
*slot = Arc::new(new);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, item) in n.appearance.items.iter().enumerate() {
|
||||
if let Some(map) = color_map(item.effects())
|
||||
&& let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i)
|
||||
{
|
||||
map_paint(it.paint_mut(), &|c| map.apply(c));
|
||||
it.effects_mut().retain(|e| !is_adjustment(&e.id));
|
||||
}
|
||||
}
|
||||
let map = match (color_map(&n.appearance.effects), outer) {
|
||||
(Some(own), Some(outer)) => Some(own.then(outer)),
|
||||
(own, None) => own,
|
||||
(None, Some(outer)) => Some(outer.clone()),
|
||||
};
|
||||
if let Some(map) = map {
|
||||
let mm = m.get_or_insert_with(|| n.clone());
|
||||
mm.appearance.effects.retain(|e| !is_adjustment(&e.id));
|
||||
recolor_node(mm, Reach::ALL, &mut Live { map: &map, images });
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
|
||||
/// `d` (named by their content), and a symbol instance stands for its symbol's art.
|
||||
pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
let art;
|
||||
let src = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
|
||||
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone());
|
||||
art = crate::reshape::as_art(n, symbol.as_deref())?;
|
||||
&art
|
||||
}
|
||||
_ => n,
|
||||
};
|
||||
let mut hook = |key: &str, map: &ColorMap| -> Option<String> {
|
||||
let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?;
|
||||
let k = blob.content_key();
|
||||
d.images.entry(k.clone()).or_insert(blob);
|
||||
Some(k)
|
||||
};
|
||||
adjust(src, None, &mut hook)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Appearance, NodeId};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn map(id: &str, params: Value) -> ColorMap {
|
||||
color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap()
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_settings_change_nothing() {
|
||||
for id in ADJUSTMENTS {
|
||||
let m = color_map(&[Effect { id: id.into(), params: json!({"amount": 0}), visible: true }]).unwrap();
|
||||
for c in [[0.2, 0.4, 0.6], [1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]] {
|
||||
assert!(close(m.apply_rgb(c), c), "{id} {c:?} → {:?}", m.apply_rgb(c));
|
||||
}
|
||||
}
|
||||
let cmyk = Color::cmyk(0.1, 0.2, 0.3, 0.4);
|
||||
assert_eq!(map("adjust.levels", json!({})).apply(cmyk), cmyk, "an untouched colour keeps its exact values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustments_move_colours_the_expected_way() {
|
||||
let grey = [0.5, 0.5, 0.5];
|
||||
assert!(map("adjust.brightnessContrast", json!({"brightness": 50})).apply_rgb(grey)[0] > 0.6);
|
||||
assert!(map("adjust.brightnessContrast", json!({"contrast": 50})).apply_rgb([0.7; 3])[0] > 0.8);
|
||||
assert!(close(map("adjust.levels", json!({"inputBlack": 64, "inputWhite": 192})).apply_rgb([0.25, 0.5, 0.75]), [0.0, 0.5, 1.0]));
|
||||
assert!(close(map("adjust.levels", json!({"outputWhite": 128, "channel": "red"})).apply_rgb([1.0; 3]), [0.502, 1.0, 1.0]));
|
||||
// An S curve darkens the shadows and lightens the highlights, through its points exactly.
|
||||
let s = map("adjust.curves", json!({"points": "0,0 64,32 192,224 255,255"}));
|
||||
assert!(close(s.apply_rgb([64.0 / 255.0; 3]), [32.0 / 255.0; 3]));
|
||||
assert!(s.apply_rgb([0.85; 3])[0] > 0.9);
|
||||
let arr = map("adjust.curves", json!({"points": [[0, 255], [255, 0]]}));
|
||||
assert!(close(arr.apply_rgb([0.0, 1.0, 0.25]), [1.0, 0.0, 0.75]), "an inverting curve");
|
||||
assert!(close(map("adjust.hueSaturation", json!({"hue": 120})).apply_rgb([1.0, 0.0, 0.0]), [0.0, 1.0, 0.0]));
|
||||
assert!(close(map("adjust.hueSaturation", json!({"saturation": -100})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3]));
|
||||
assert!(close(map("adjust.hueSaturation", json!({"saturation": 100})).apply_rgb(grey), grey), "greys stay grey");
|
||||
assert!(close(map("adjust.hueSaturation", json!({"colorize": true, "hue": 240, "saturation": 100})).apply_rgb(grey), [0.0, 0.0, 1.0]));
|
||||
let shift = map("adjust.shiftToColor", json!({"color": "#0000ff", "amount": 100, "preserveLightness": false}));
|
||||
assert!(close(shift.apply_rgb([1.0, 1.0, 0.0]), [0.0, 0.0, 1.0]));
|
||||
let warm = map("adjust.temperatureTint", json!({"temperature": 100})).apply_rgb(grey);
|
||||
assert!(warm[0] > grey[0] && warm[2] < grey[2]);
|
||||
// Effects compose in stack order.
|
||||
let both = map("adjust.levels", json!({"outputWhite": 128})).then(&map("adjust.brightnessContrast", json!({"brightness": 100})));
|
||||
assert!(close(both.apply_rgb([1.0; 3]), [(128.0f32 / 255.0).sqrt(); 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjust_recolours_paints_members_and_strips_the_effects() {
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let leaf = |id| Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red.clone()));
|
||||
let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]);
|
||||
g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
|
||||
assert!(has_adjustment(&g));
|
||||
let out = adjust(&g, None, &mut |_, _| None).unwrap();
|
||||
assert!(out.appearance.effects.is_empty());
|
||||
for c in out.children().unwrap() {
|
||||
assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
|
||||
}
|
||||
// A hidden effect does nothing; a stroke's own effect recolours just that stroke.
|
||||
g.appearance.effects[0].visible = false;
|
||||
assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none());
|
||||
let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true });
|
||||
let out = adjust(&p, None, &mut |_, _| None).unwrap();
|
||||
assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff");
|
||||
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
|
||||
assert!(out.appearance.items[1].effects().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,21 @@
|
||||
//! outlines, [`reshape`]) — is evaluated exactly like the renderer does, and so are the own fills,
|
||||
//! strokes and geometry effects of groups and layers ([`evaluate_container`]). Raster effects
|
||||
//! (shadows, glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
|
||||
//! Colour adjustments recolour the objects ([`adjust_in_document`]; recoloured embedded images
|
||||
//! become images of their own).
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use crate::group::{has_own_paint, paints};
|
||||
use crate::{
|
||||
GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline,
|
||||
reshape,
|
||||
GeomContext, adjust_in_document, apply_geometry_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
has_pathfinder, is_geometry, needs_outline, reshape,
|
||||
};
|
||||
|
||||
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
|
||||
@@ -37,10 +39,12 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
||||
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
has_crop_marks(n)
|
||||
has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
|| has_own_paint(n)
|
||||
|| has_geometry(&n.appearance.effects)
|
||||
|| n.projection().is_some()
|
||||
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
|
||||
}
|
||||
@@ -78,12 +82,18 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if !needs_bake(n) {
|
||||
return None;
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
if has_adjustment(n)
|
||||
&& let Some(m) = adjust_in_document(d, n)
|
||||
{
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
// Crop marks: the object and its marks.
|
||||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines.
|
||||
if needs_outline(n) && has_geometry(&n.appearance.effects) {
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
let symbol = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
|
||||
_ => None,
|
||||
@@ -272,6 +282,48 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope Options → Distort Appearance: path or compound path `n` as plain filled art that an
|
||||
/// envelope bends as a whole: its geometry effects applied, every visible fill a copy of the path
|
||||
/// painted by that fill and every visible stroke without a brush its filled outline in the
|
||||
/// stroke's paint, opacity and blend mode (a brushed stroke stays a stroke), in paint order, as
|
||||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<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> {
|
||||
|
||||
+101
-8
@@ -18,15 +18,23 @@
|
||||
//! art painting its members (its raster effects then apply to the composite).
|
||||
//! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and
|
||||
//! PDF writers.
|
||||
//! - **Plug-in effects** (`plugin.<plug-in id>`, Effect › Plug-ins) are geometry effects run by an
|
||||
//! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like
|
||||
//! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is.
|
||||
//! - **Colour adjustments** (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color,
|
||||
//! Temperature/Tint) recolour what an object paints with: [`adjust`] evaluates them for the
|
||||
//! renderer and the exporters.
|
||||
//!
|
||||
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
|
||||
//! noise (`seed` parameter, default 0).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod adjust;
|
||||
mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod live;
|
||||
mod marks;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
@@ -44,12 +52,14 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Rect};
|
||||
|
||||
pub use bake::{StrokeArt, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use adjust::{ADJUSTMENTS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment};
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
|
||||
pathfinder_children,
|
||||
};
|
||||
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
|
||||
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
@@ -125,6 +135,8 @@ const STYLIZE: &[&str] = &["Effect", "Stylize"];
|
||||
const WARP: &[&str] = &["Effect", "Warp"];
|
||||
const BLUR: &[&str] = &["Effect", "Blur"];
|
||||
const PATHFINDER: &[&str] = &["Effect", "Pathfinder"];
|
||||
const PLUGINS: &[&str] = &["Effect", "Plug-ins"];
|
||||
const ADJUST: &[&str] = &["Effect", "Color Adjustments"];
|
||||
|
||||
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
|
||||
pub const WARP_STYLES: [(&str, &str); 15] = [
|
||||
@@ -254,6 +266,50 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
|
||||
];
|
||||
v.extend([
|
||||
g(
|
||||
"adjust.brightnessContrast",
|
||||
"Brightness/Contrast…",
|
||||
ADJUST,
|
||||
"{brightness: -100..100 (0; bends the tones, black and white stay), contrast: -100..100 (0)} recolours the object's fills, strokes, type, meshes and embedded images (each colour keeps its model)",
|
||||
json!({"brightness": 0.0, "contrast": 0.0}),
|
||||
),
|
||||
g(
|
||||
"adjust.curves",
|
||||
"Curves…",
|
||||
ADJUST,
|
||||
"{points: \"x,y x,y …\" or [[x, y], …] (input, output 0..255; a smooth monotone curve through them, flat past the ends; \"0,0 128,128 255,255\"), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
|
||||
json!({"points": "0,0 128,128 255,255", "channel": "rgb"}),
|
||||
),
|
||||
g(
|
||||
"adjust.hueSaturation",
|
||||
"Hue/Saturation…",
|
||||
ADJUST,
|
||||
"{hue: deg -180..180 (0), saturation: -100..100 (0), lightness: -100..100 (0), colorize: bool (false; true: every colour takes hue `hue` (0..360) at saturation (100 + saturation) / 2 %)} recolours as Brightness/Contrast does",
|
||||
json!({"hue": 0.0, "saturation": 0.0, "lightness": 0.0, "colorize": false}),
|
||||
),
|
||||
g(
|
||||
"adjust.levels",
|
||||
"Levels…",
|
||||
ADJUST,
|
||||
"{inputBlack: 0..255 (0), inputWhite: 0..255 (255), gamma: 0.1..10 (1; above 1 lightens the midtones), outputBlack: 0..255 (0), outputWhite: 0..255 (255), channel: \"rgb\"|\"red\"|\"green\"|\"blue\" (\"rgb\")} recolours as Brightness/Contrast does",
|
||||
json!({"inputBlack": 0.0, "inputWhite": 255.0, "gamma": 1.0, "outputBlack": 0.0, "outputWhite": 255.0, "channel": "rgb"}),
|
||||
),
|
||||
g(
|
||||
"adjust.shiftToColor",
|
||||
"Shift to Color…",
|
||||
ADJUST,
|
||||
"{color: \"#rrggbb\" (\"#ff8000\"), amount: 0..100 % (50), preserveLightness: bool (true: colours take the target's hue and saturation and keep their lightness; false: they mix with it)} recolours as Brightness/Contrast does",
|
||||
json!({"color": "#ff8000", "amount": 50.0, "preserveLightness": true}),
|
||||
),
|
||||
g(
|
||||
"adjust.temperatureTint",
|
||||
"Temperature/Tint…",
|
||||
ADJUST,
|
||||
"{temperature: -100 (cooler) .. 100 (warmer) (0), tint: -100 (greener) .. 100 (more magenta) (0)} recolours as Brightness/Contrast does",
|
||||
json!({"temperature": 0.0, "tint": 0.0}),
|
||||
),
|
||||
]);
|
||||
for (suffix, label) in WARP_STYLES {
|
||||
let id: &'static str = match suffix {
|
||||
"arc" => "warp.arc",
|
||||
@@ -293,21 +349,47 @@ fn catalog_index() -> &'static std::collections::HashMap<&'static str, EffectInf
|
||||
INDEX.get_or_init(|| effect_catalog().into_iter().map(|e| (e.id, e)).collect())
|
||||
}
|
||||
|
||||
/// Catalogue entry for `id`.
|
||||
/// Catalogue entry for `id` (built-in, or an installed plug-in effect).
|
||||
pub fn effect_info(id: &str) -> Option<EffectInfo> {
|
||||
catalog_index().get(id).cloned()
|
||||
catalog_index().get(id).cloned().or_else(|| plugin_info(id))
|
||||
}
|
||||
|
||||
/// The catalogue entry of the installed effect plug-in with effect id `id` (`plugin.<id>`).
|
||||
fn plugin_info(id: &str) -> Option<EffectInfo> {
|
||||
use vectorcraft_plugins::registry::intern;
|
||||
let p = vectorcraft_plugins::effect::installed(id)?;
|
||||
let m = p.manifest();
|
||||
let dots = if m.params.is_empty() || m.name.ends_with('…') { "" } else { "…" };
|
||||
Some(EffectInfo {
|
||||
id: intern(id),
|
||||
label: intern(&format!("{}{dots}", m.name)),
|
||||
menu: PLUGINS,
|
||||
params: intern(&m.params_doc()),
|
||||
defaults: Value::Object(m.defaults()),
|
||||
raster: false,
|
||||
lengths: Lengths::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The installed effect plug-ins as catalogue entries (Effect › Plug-ins), by plug-in id.
|
||||
pub fn plugin_effects() -> Vec<EffectInfo> {
|
||||
vectorcraft_plugins::registry::list().iter().filter_map(|p| plugin_info(&vectorcraft_plugins::effect::effect_id(p.id()))).collect()
|
||||
}
|
||||
|
||||
/// Dialog defaults for `id` (`None` for unknown effects).
|
||||
pub fn default_params(id: &str) -> Option<Value> {
|
||||
catalog_index().get(id).map(|e| e.defaults.clone())
|
||||
match catalog_index().get(id) {
|
||||
Some(e) => Some(e.defaults.clone()),
|
||||
None => plugin_info(id).map(|e| e.defaults),
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept).
|
||||
pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
_ => Map::new(),
|
||||
Some(_) => Map::new(),
|
||||
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
|
||||
};
|
||||
if let Value::Object(p) = params {
|
||||
for (k, v) in p {
|
||||
@@ -319,7 +401,9 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
|
||||
/// A new effect record with default parameters (overridden by `params`).
|
||||
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
|
||||
catalog_index().get(id)?;
|
||||
if !catalog_index().contains_key(id) {
|
||||
vectorcraft_plugins::effect::installed(id)?;
|
||||
}
|
||||
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
|
||||
}
|
||||
|
||||
@@ -346,9 +430,15 @@ pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].)
|
||||
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
|
||||
/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
|
||||
/// leaves the geometry as it is).
|
||||
pub fn is_geometry(id: &str) -> bool {
|
||||
!is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id)
|
||||
!is_raster(id)
|
||||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
/// Any visible geometry effect in the list?
|
||||
@@ -420,6 +510,9 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx:
|
||||
|
||||
pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
|
||||
use util::*;
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
"distort.freeDistort" => distort::free_distort(path, b, p),
|
||||
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -64,7 +64,8 @@ pub fn blend_mode(s: &str) -> BlendMode {
|
||||
BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal)
|
||||
}
|
||||
|
||||
fn color(p: &Value, default: Color) -> Color {
|
||||
/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object).
|
||||
pub(crate) fn color_param(p: &Value, default: Color) -> Color {
|
||||
match p.get("color") {
|
||||
Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default),
|
||||
Some(Value::Array(a)) if a.len() >= 3 => {
|
||||
@@ -95,19 +96,19 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
dx: num(&p, "x", 7.0).clamp(-1e4, 1e4),
|
||||
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
|
||||
blur,
|
||||
color: color(&p, Color::BLACK),
|
||||
color: color_param(&p, Color::BLACK),
|
||||
},
|
||||
"stylize.outerGlow" => RasterFx::OuterGlow {
|
||||
mode: blend_mode(text(&p, "mode", "screen")),
|
||||
opacity: opacity(&p),
|
||||
blur,
|
||||
color: color(&p, Color::rgb(1.0, 1.0, 0.0)),
|
||||
color: color_param(&p, Color::rgb(1.0, 1.0, 0.0)),
|
||||
},
|
||||
"stylize.innerGlow" => RasterFx::InnerGlow {
|
||||
mode: blend_mode(text(&p, "mode", "screen")),
|
||||
opacity: opacity(&p),
|
||||
blur,
|
||||
color: color(&p, Color::WHITE),
|
||||
color: color_param(&p, Color::WHITE),
|
||||
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
|
||||
},
|
||||
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
|
||||
|
||||
@@ -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)
|
||||
@@ -131,7 +131,7 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
}
|
||||
|
||||
/// [`reshape`] without its checks (no geometry effects: just the art).
|
||||
fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
|
||||
let fx = geometry_effects(n);
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
@@ -146,6 +146,10 @@ fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option<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 @@ 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()
|
||||
|
||||
@@ -24,6 +24,7 @@ vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
vectorcraft-eps = { workspace = true }
|
||||
vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
@@ -35,3 +36,4 @@ log = { workspace = true }
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
@@ -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()
|
||||
|
||||
@@ -13,7 +13,9 @@ use vectorcraft_color::harmony::{Guide, GuideOptions, Harmony, Variation};
|
||||
use vectorcraft_color::{Color, Paint, keep_model};
|
||||
use vectorcraft_doc::live::lerp_color;
|
||||
use vectorcraft_doc::pattern::PatternDef;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
pub(crate) use vectorcraft_doc::recolor::map_paint;
|
||||
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
|
||||
use vectorcraft_doc::{Document, ImageBlob, Node, NodeId, NodeKind};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
@@ -144,30 +146,6 @@ fn gray_level(c: Color) -> f32 {
|
||||
/// gradient stop): `(colour, link, tint)`, true when it changed them.
|
||||
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
|
||||
/// Apply `f` to the solid colour or gradient stops of a paint ([`Paint::map_links`]); a gradient
|
||||
/// that changes is no longer its gradient swatch's. Returns whether anything changed.
|
||||
fn map_links(p: &mut Paint, f: &LinkMap) -> bool {
|
||||
let changed = p.map_links(&mut |c, l, t| f(c, l, t));
|
||||
if changed && let Paint::Gradient(g) = p {
|
||||
g.swatch = None;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `f` to a paint's colours; a colour that changes loses its swatch link. Returns whether
|
||||
/// anything changed.
|
||||
pub(crate) fn map_paint(p: &mut Paint, f: &dyn Fn(Color) -> Color) -> bool {
|
||||
map_links(p, &|c, link, _| {
|
||||
let n = f(*c);
|
||||
if n == *c {
|
||||
return false;
|
||||
}
|
||||
*c = n;
|
||||
*link = None;
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// The identity colour map (a pass that only maps linked colours).
|
||||
fn same(c: Color) -> Color {
|
||||
c
|
||||
@@ -258,68 +236,24 @@ impl<'a> Recolor<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `n` recoloured, or `None` when nothing changed.
|
||||
/// `n` recoloured (through the shared colour visitor), or `None` when nothing changed.
|
||||
fn subtree(&mut self, d: &Document, n: &Node) -> Option<Node> {
|
||||
let before = self.changed;
|
||||
let mut m = n.clone();
|
||||
self.node(d, &mut m);
|
||||
(self.changed > before).then_some(m)
|
||||
}
|
||||
|
||||
fn node(&mut self, d: &Document, n: &mut Node) {
|
||||
let Scope { fill, stroke, images, .. } = self.scope;
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) if fill => self.paint(d, &mut l.paint),
|
||||
AppearanceItem::Stroke(l) if stroke => self.paint(d, &mut l.paint),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
if fill {
|
||||
self.paint(d, &mut r.style.fill);
|
||||
}
|
||||
if stroke {
|
||||
self.paint(d, &mut r.style.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Mesh(m) if fill => {
|
||||
let mut ch = false;
|
||||
for p in &mut m.points {
|
||||
let c = (self.f)(p.color);
|
||||
ch |= c != p.color;
|
||||
p.color = c;
|
||||
}
|
||||
self.changed += ch as usize;
|
||||
}
|
||||
NodeKind::Image(im) if images && im.link.is_none() => {
|
||||
if let Some(k) = self.image(d, &im.key) {
|
||||
im.key = k;
|
||||
self.changed += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for c in n.children_mut().into_iter().flatten() {
|
||||
if let Some(new) = self.subtree(d, c) {
|
||||
*c = Arc::new(new);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut m = n.clone();
|
||||
let changed = recolor_node(&mut m, Reach { fill, stroke, images }, &mut Visit { pass: self, d });
|
||||
self.changed += changed;
|
||||
(changed > 0).then_some(m)
|
||||
}
|
||||
|
||||
fn paint(&mut self, d: &Document, p: &mut Paint) {
|
||||
let changed = match p {
|
||||
/// Recolour one paint: true when it changed.
|
||||
fn paint(&mut self, d: &Document, p: &mut Paint) -> bool {
|
||||
match p {
|
||||
Paint::Pattern { pattern, .. } if self.scope.patterns => self.pattern(d, pattern).map(|new| *pattern = new).is_some(),
|
||||
_ => match self.links {
|
||||
Some(f) => map_links(p, f),
|
||||
None => map_paint(p, self.f),
|
||||
},
|
||||
};
|
||||
self.changed += changed as usize;
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of image `key` recoloured (one copy per pass), `None` when no pixel changes.
|
||||
@@ -370,6 +304,24 @@ impl<'a> Recolor<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a pass over document `d` does to each colour the shared visitor meets.
|
||||
struct Visit<'p, 'a> {
|
||||
pass: &'p mut Recolor<'a>,
|
||||
d: &'p Document,
|
||||
}
|
||||
|
||||
impl ColorVisitor for Visit<'_, '_> {
|
||||
fn paint(&mut self, p: &mut Paint) -> bool {
|
||||
self.pass.paint(self.d, p)
|
||||
}
|
||||
fn color(&mut self, c: Color) -> Color {
|
||||
(self.pass.f)(c)
|
||||
}
|
||||
fn image(&mut self, key: &str) -> Option<String> {
|
||||
self.pass.image(self.d, key)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -401,6 +401,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let y = f64_req(p, "y", "text.create")?;
|
||||
let text = str_param(p, "text").unwrap_or("");
|
||||
let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p));
|
||||
t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some(a) = p.get("area") {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Cutting tools' commands: Mirror & Cut, Line Cut and Rectangle Cut.
|
||||
//!
|
||||
//! They work on the selected paths and compound paths (inside groups too; a compound path is cut
|
||||
//! as one shape): filled shapes through `vectorcraft-pathops` booleans against a half-plane or a
|
||||
//! rectangle, open paths by splitting them where they cross the line or the rectangle's edge (the
|
||||
//! Knife and Path Eraser helpers). Each command is one undo step; the tools in
|
||||
//! `vectorcraft_tools::cut` preview it live while dragging.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Anchor, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::draw2::{components, erase_outline, ids_json, replace_with_pieces};
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::node_path;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"path.mirrorCut",
|
||||
"Mirror & Cut",
|
||||
[],
|
||||
None,
|
||||
"{axis?: \"vertical\"|\"horizontal\"|\"free\" (vertical), from?: [x,y], to?: [x,y] (free: the axis through both; vertical/horizontal: the axis through `from`, default the selection's centre), keep?: \"left\"|\"right\"|\"top\"|\"bottom\" (the side kept; default left, or top for a horizontal-ish axis)} cut the selected paths and compound paths at the axis, reflect the kept half across it and join both halves into one closed shape (open paths are joined where they meet the axis); art wholly on the other side is deleted, as one undo step → {ids, removed}",
|
||||
has_selection,
|
||||
mirror_cut
|
||||
),
|
||||
cmd!(
|
||||
"path.lineCut",
|
||||
"Line Cut",
|
||||
[],
|
||||
None,
|
||||
"{from: [x,y], to: [x,y]} split the selected paths and compound paths along the line through both points, extended across the art: filled shapes become their pieces on each side (holes kept), open paths split where they cross; as one undo step → {ids, removed: 0}",
|
||||
has_selection,
|
||||
line_cut
|
||||
),
|
||||
cmd!(
|
||||
"path.rectCut",
|
||||
"Rectangle Cut",
|
||||
[],
|
||||
None,
|
||||
"{rect: [x, y, width, height]} or {from: [x,y], to: [x,y]} (opposite corners) crop the selected paths and compound paths to the rectangle as real geometry (not a clipping mask): filled shapes are intersected with it, open paths keep their parts inside, art wholly outside is deleted; as one undo step → {ids, removed}",
|
||||
has_selection,
|
||||
rect_cut
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Largest coordinate the cut commands accept (keeps the half-plane polygons finite).
|
||||
const MAX_COORD: f64 = 1e7;
|
||||
/// How close an open path's end must be to the axis to be joined to its reflection.
|
||||
const AXIS_TOL: f64 = 1e-3;
|
||||
|
||||
/// A straight line through `p` along the unit vector `d`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Axis {
|
||||
p: Point,
|
||||
d: Vec2,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// The line through `a` and `b` (`None` when they coincide).
|
||||
fn through(a: Point, b: Point) -> Option<Self> {
|
||||
let v = b - a;
|
||||
let len = v.hypot();
|
||||
(len > 1e-9 && len.is_finite()).then(|| Axis { p: a, d: v / len })
|
||||
}
|
||||
|
||||
/// Signed distance of `q` from the line: positive on the side `d` points to when turned a
|
||||
/// quarter turn clockwise on screen (y pointing down).
|
||||
fn side(&self, q: Point) -> f64 {
|
||||
self.d.cross(q - self.p)
|
||||
}
|
||||
|
||||
/// `q` moved onto the line.
|
||||
fn project(&self, q: Point) -> Point {
|
||||
self.p + self.d * self.d.dot(q - self.p)
|
||||
}
|
||||
|
||||
/// The reflection across the line.
|
||||
fn reflection(&self) -> Affine {
|
||||
let (x, y) = (self.d.x, self.d.y);
|
||||
let (a, b, d) = (2.0 * x * x - 1.0, 2.0 * x * y, 2.0 * y * y - 1.0);
|
||||
let p = self.p;
|
||||
Affine::new([a, b, b, d, p.x - (a * p.x + b * p.y), p.y - (b * p.x + d * p.y)])
|
||||
}
|
||||
|
||||
/// A polygon covering everything in `around` on one side of the line (`sign` > 0: where
|
||||
/// [`Axis::side`] is positive).
|
||||
fn half_plane(&self, sign: f64, around: Rect) -> PathData {
|
||||
let r = around.width() + around.height() + self.p.distance(around.center()) + 10.0;
|
||||
let n = Vec2::new(-self.d.y, self.d.x) * sign.signum() * 2.0 * r;
|
||||
let (a, b) = (self.p - self.d * r, self.p + self.d * r);
|
||||
polygon(&[a, b, b + n, a + n])
|
||||
}
|
||||
}
|
||||
|
||||
fn polygon(pts: &[Point]) -> PathData {
|
||||
PathData::single(SubPath::new(pts.iter().map(|p| Anchor::corner(*p)).collect(), true))
|
||||
}
|
||||
|
||||
/// A point param that must be finite and within [`MAX_COORD`].
|
||||
fn point_req(p: &Value, key: &str, cmd: &str) -> Result<Point> {
|
||||
let q = point_param(p, key).ok_or_else(|| bad(cmd, format!("missing point `{key}` ([x, y])")))?;
|
||||
if q.x.abs() > MAX_COORD || q.y.abs() > MAX_COORD {
|
||||
return Err(bad(cmd, format!("`{key}` is out of range")));
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
/// The editable paths and compound paths in the selection (inside groups and layers too; a
|
||||
/// compound path is one target; guides and clipping paths are left alone).
|
||||
fn cut_targets(s: &Session) -> Result<Vec<NodeId>> {
|
||||
fn visit(n: &Node, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } => out.push(n.id),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let roots = selected_roots(s)?;
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
if let Some(n) = doc.node(id) {
|
||||
visit(n, &mut out);
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out.retain(|id| doc.is_editable(*id));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a cut does to one object: its new pieces (none deletes it).
|
||||
type Plan = Vec<(NodeId, Vec<PathData>)>;
|
||||
|
||||
/// Run `cut` on every target's geometry (`None`: unchanged) and apply the result as one undo step.
|
||||
fn run_cut(s: &mut Session, cmd: &str, label: &str, cut: &dyn Fn(&PathData, FillRule) -> Option<Vec<PathData>>) -> Result<Value> {
|
||||
let targets = cut_targets(s)?;
|
||||
if targets.is_empty() {
|
||||
return Err(bad(cmd, "select paths or compound paths"));
|
||||
}
|
||||
let doc = &s.doc()?.doc;
|
||||
let mut plan: Plan = vec![];
|
||||
for id in targets {
|
||||
let Some((pd, rule)) = doc.node(id).and_then(node_path) else { continue };
|
||||
if pd.subpaths.iter().flat_map(|sp| &sp.anchors).any(|a| !(a.p.x.is_finite() && a.p.y.is_finite())) {
|
||||
return Err(bad(cmd, "paths contain invalid coordinates"));
|
||||
}
|
||||
if let Some(pieces) = cut(&pd, rule) {
|
||||
plan.push((id, pieces));
|
||||
}
|
||||
}
|
||||
apply(s, label, plan)
|
||||
}
|
||||
|
||||
fn apply(s: &mut Session, label: &str, plan: Plan) -> Result<Value> {
|
||||
if plan.is_empty() {
|
||||
return Ok(json!({ "ids": [], "removed": 0 }));
|
||||
}
|
||||
let (ids, removed) = s.edit(label, |d: &mut Document, sel| {
|
||||
let (mut all, mut removed) = (vec![], 0);
|
||||
for (id, pieces) in plan {
|
||||
removed += pieces.is_empty() as usize;
|
||||
all.extend(replace_with_pieces(d, id, pieces)?);
|
||||
}
|
||||
sel.set(all.iter().copied());
|
||||
Ok((all, removed))
|
||||
})?;
|
||||
let mut out = ids_json(&ids);
|
||||
out["removed"] = json!(removed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bounds of `pd` grown a little (the reference box of the half-planes).
|
||||
fn reach(pd: &PathData) -> Rect {
|
||||
pd.control_bounds().unwrap_or_default().inflate(1.0, 1.0)
|
||||
}
|
||||
|
||||
/// `pd ∩ region`, dropping slivers.
|
||||
fn intersect(pd: &PathData, rule: FillRule, region: &PathData) -> PathData {
|
||||
let r = po::boolean(pd, rule, region, FillRule::NonZero, BoolOp::Intersect);
|
||||
if po::area(&r, FillRule::NonZero) <= 1e-6 { PathData::default() } else { r }
|
||||
}
|
||||
|
||||
// ---------- Line Cut ----------
|
||||
|
||||
fn line_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.lineCut";
|
||||
let axis = Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?;
|
||||
run_cut(s, C, "Line Cut", &|pd, rule| split_by_line(pd, rule, &axis))
|
||||
}
|
||||
|
||||
/// `pd` split by the line: the pieces on both sides, `None` when the line misses it.
|
||||
fn split_by_line(pd: &PathData, rule: FillRule, axis: &Axis) -> Option<Vec<PathData>> {
|
||||
if pd.is_closed() {
|
||||
let around = reach(pd);
|
||||
let a = intersect(pd, rule, &axis.half_plane(1.0, around));
|
||||
let b = intersect(pd, rule, &axis.half_plane(-1.0, around));
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(components(&a).into_iter().chain(components(&b)).collect());
|
||||
}
|
||||
// Open paths: the parts on each side (each pass removes the other side).
|
||||
let a = erase_outline(pd, &|q| axis.side(q) < 0.0)?;
|
||||
let b = erase_outline(pd, &|q| axis.side(q) >= 0.0)?;
|
||||
Some(if pd.subpaths.len() == 1 {
|
||||
a.into_iter().chain(b).collect()
|
||||
} else {
|
||||
vec![PathData::new(a.into_iter().chain(b).flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Rectangle Cut ----------
|
||||
|
||||
fn rect_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.rectCut";
|
||||
let rect = match p.get("rect") {
|
||||
Some(v) => {
|
||||
let a: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let [x, y, w, h] = a[..] else { return Err(bad(C, "rect must be [x, y, width, height]")) };
|
||||
Rect::new(x, y, x + w, y + h).abs()
|
||||
}
|
||||
None => Rect::from_points(point_req(p, "from", C)?, point_req(p, "to", C)?),
|
||||
};
|
||||
let finite = [rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
if !finite || rect.width() <= 1e-9 || rect.height() <= 1e-9 {
|
||||
return Err(bad(C, "the rectangle must have a width and a height"));
|
||||
}
|
||||
run_cut(s, C, "Rectangle Cut", &|pd, rule| crop(pd, rule, rect))
|
||||
}
|
||||
|
||||
/// `pd` cropped to `rect`: `None` when it lies inside, no pieces when it lies outside.
|
||||
fn crop(pd: &PathData, rule: FillRule, rect: Rect) -> Option<Vec<PathData>> {
|
||||
let b = pd.bounds()?;
|
||||
if rect.inflate(1e-9, 1e-9).contains_rect(b) {
|
||||
return None;
|
||||
}
|
||||
if pd.is_closed() {
|
||||
let r =
|
||||
intersect(pd, rule, &polygon(&[rect.origin(), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)]));
|
||||
return Some(if r.is_empty() { vec![] } else { vec![r] });
|
||||
}
|
||||
let inside = rect.inflate(1e-9, 1e-9);
|
||||
let pieces = erase_outline(pd, &|q| !(q.x >= inside.x0 && q.x <= inside.x1 && q.y >= inside.y0 && q.y <= inside.y1))?;
|
||||
Some(if pd.subpaths.len() == 1 || pieces.is_empty() {
|
||||
pieces
|
||||
} else {
|
||||
vec![PathData::new(pieces.into_iter().flat_map(|p| p.subpaths).collect())]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- Mirror & Cut ----------
|
||||
|
||||
fn mirror_cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "path.mirrorCut";
|
||||
let kind = str_param(p, "axis").unwrap_or("vertical");
|
||||
let at = || -> Result<Point> {
|
||||
match p.get("from") {
|
||||
Some(_) => point_req(p, "from", C),
|
||||
None => {
|
||||
let roots = selected_roots(s)?;
|
||||
let b = s.doc()?.doc.bounds_of(&roots, false).ok_or_else(|| bad(C, "the selection has no bounds"))?;
|
||||
Ok(b.center())
|
||||
}
|
||||
}
|
||||
};
|
||||
let axis = match kind {
|
||||
"vertical" => {
|
||||
let a = at()?;
|
||||
Axis { p: a, d: Vec2::new(0.0, 1.0) }
|
||||
}
|
||||
"horizontal" => {
|
||||
let a = at()?;
|
||||
Axis { p: a, d: Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
"free" => Axis::through(point_req(p, "from", C)?, point_req(p, "to", C)?).ok_or_else(|| bad(C, "`from` and `to` must differ"))?,
|
||||
other => return Err(bad(C, format!("unknown axis `{other}` (vertical|horizontal|free)"))),
|
||||
};
|
||||
let sign = keep_sign(&axis, str_param(p, "keep")).map_err(|e| bad(C, e))?;
|
||||
run_cut(s, C, "Mirror & Cut", &|pd, rule| Some(mirror(pd, rule, &axis, sign)))
|
||||
}
|
||||
|
||||
/// The sign of [`Axis::side`] on the side `keep` names (default: left, or top when the axis runs
|
||||
/// more across than down).
|
||||
fn keep_sign(axis: &Axis, keep: Option<&str>) -> std::result::Result<f64, String> {
|
||||
let keep = keep.unwrap_or(if axis.d.x.abs() > axis.d.y.abs() { "top" } else { "left" });
|
||||
let toward = match keep {
|
||||
"left" => Vec2::new(-1.0, 0.0),
|
||||
"right" => Vec2::new(1.0, 0.0),
|
||||
"top" => Vec2::new(0.0, -1.0),
|
||||
"bottom" => Vec2::new(0.0, 1.0),
|
||||
other => return Err(format!("unknown side `{other}` (left|right|top|bottom)")),
|
||||
};
|
||||
let s = axis.d.cross(toward);
|
||||
if s.abs() < 1e-6 {
|
||||
return Err(format!("`{keep}` is not a side of this axis"));
|
||||
}
|
||||
Ok(s.signum())
|
||||
}
|
||||
|
||||
/// The kept half of `pd` and its reflection, joined (no pieces when nothing is on the kept side).
|
||||
fn mirror(pd: &PathData, rule: FillRule, axis: &Axis, sign: f64) -> Vec<PathData> {
|
||||
let m = axis.reflection();
|
||||
if pd.is_closed() {
|
||||
let kept = intersect(pd, rule, &axis.half_plane(sign, reach(pd)));
|
||||
if kept.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let joined = po::boolean(&kept, FillRule::NonZero, &kept.transformed(m), FillRule::NonZero, BoolOp::Union);
|
||||
return if joined.is_empty() { vec![] } else { vec![joined] };
|
||||
}
|
||||
let kept = match erase_outline(pd, &|q| axis.side(q) * sign < 0.0) {
|
||||
Some(pieces) => pieces.into_iter().flat_map(|p| p.subpaths).collect(),
|
||||
None => pd.subpaths.clone(),
|
||||
};
|
||||
let subs: Vec<SubPath> = kept.iter().flat_map(|sp| mirror_open(sp, axis, m)).collect();
|
||||
if subs.is_empty() { vec![] } else { vec![PathData::new(subs)] }
|
||||
}
|
||||
|
||||
/// An open run and its reflection, joined where its ends touch the axis (closed when both do);
|
||||
/// the two apart when neither does.
|
||||
fn mirror_open(sp: &SubPath, axis: &Axis, m: Affine) -> Vec<SubPath> {
|
||||
let on = |a: Option<&Anchor>| a.is_some_and(|a| axis.side(a.p).abs() <= AXIS_TOL);
|
||||
let (start_on, end_on) = (on(sp.anchors.first()), on(sp.anchors.last()));
|
||||
if sp.closed || sp.anchors.len() < 2 || !(start_on || end_on) {
|
||||
return vec![sp.clone(), SubPath::new(sp.anchors.iter().map(|a| a.transform(m)).collect(), sp.closed)];
|
||||
}
|
||||
// The run ends on the axis; the reflection comes back from there.
|
||||
let mut run = sp.clone();
|
||||
if !end_on {
|
||||
run.reverse();
|
||||
}
|
||||
let Some((last, body)) = run.anchors.split_last() else { return vec![] };
|
||||
let q = axis.project(last.p);
|
||||
let mut anchors = body.to_vec();
|
||||
anchors.push(Anchor::with_handles(q, last.h_in, m * last.h_in));
|
||||
anchors.extend(body.iter().rev().map(|a| Anchor::with_handles(m * a.p, m * a.h_out, m * a.h_in)));
|
||||
let closed = start_on && end_on && anchors.len() > 2;
|
||||
if closed && let (Some(first), Some(back)) = (anchors.first().copied(), anchors.pop()) {
|
||||
// The start and its reflection meet on the axis: one closed shape.
|
||||
if let Some(f) = anchors.first_mut() {
|
||||
*f = Anchor::with_handles(axis.project(first.p), back.h_in, first.h_out);
|
||||
}
|
||||
}
|
||||
vec![SubPath::new(anchors, closed)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reflection_maps_across_the_axis() {
|
||||
let a = Axis::through(Point::new(0.0, 0.0), Point::new(10.0, 10.0)).unwrap();
|
||||
let q = a.reflection() * Point::new(5.0, 0.0);
|
||||
assert!((q.x - 0.0).abs() < 1e-9 && (q.y - 5.0).abs() < 1e-9, "{q:?}");
|
||||
let v = Axis { p: Point::new(100.0, 0.0), d: Vec2::new(0.0, 1.0) };
|
||||
let q = v.reflection() * Point::new(90.0, 7.0);
|
||||
assert!((q.x - 110.0).abs() < 1e-9 && (q.y - 7.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_names_a_side_of_the_axis() {
|
||||
let v = Axis { p: Point::ZERO, d: Vec2::new(0.0, 1.0) };
|
||||
let left = keep_sign(&v, Some("left")).unwrap();
|
||||
assert!(v.side(Point::new(-5.0, 0.0)) * left > 0.0);
|
||||
assert_eq!(keep_sign(&v, None).unwrap(), left);
|
||||
assert!(keep_sign(&v, Some("top")).is_err());
|
||||
let h = Axis { p: Point::ZERO, d: Vec2::new(1.0, 0.0) };
|
||||
let top = keep_sign(&h, None).unwrap();
|
||||
assert!(h.side(Point::new(0.0, -5.0)) * top > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_half_mirrors_into_a_closed_shape() {
|
||||
// An arch from the axis and back to it: its reflection closes it.
|
||||
let axis = Axis { p: Point::new(50.0, 0.0), d: Vec2::new(0.0, 1.0) };
|
||||
let sp = SubPath::new(
|
||||
vec![Anchor::corner(Point::new(50.0, 0.0)), Anchor::corner(Point::new(10.0, 50.0)), Anchor::corner(Point::new(50.0, 100.0))],
|
||||
false,
|
||||
);
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!(out[0].closed);
|
||||
assert_eq!(out[0].anchors.len(), 4);
|
||||
assert!(out[0].anchors.iter().any(|a| (a.p.x - 90.0).abs() < 1e-9));
|
||||
// One end on the axis: one open path through it.
|
||||
let sp = SubPath::new(vec![Anchor::corner(Point::new(10.0, 0.0)), Anchor::corner(Point::new(50.0, 40.0))], false);
|
||||
let out = mirror_open(&sp, &axis, axis.reflection());
|
||||
assert_eq!((out.len(), out[0].closed, out[0].anchors.len()), (1, false, 3));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Commands backing the drawing tools (pencil, curvature, anchor tools, scissors, knife, eraser,
|
||||
//! blob brush, smooth, path eraser, join).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::{ParamCurve, ParamCurveNearest};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -224,19 +226,43 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A copy of path node `id` (fresh id, no live shape) with geometry `pd`.
|
||||
/// The members of a compound path with geometry `pd`: one path per subpath, with fresh ids.
|
||||
fn compound_members(d: &mut Document, pd: PathData) -> Vec<Arc<Node>> {
|
||||
pd.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect()
|
||||
}
|
||||
|
||||
/// Give path or compound path `id` the geometry `pd` (dropping a live shape; a compound's members
|
||||
/// are rebuilt, one per subpath).
|
||||
pub(super) fn set_geometry(d: &mut Document, id: NodeId, pd: PathData) -> Result<()> {
|
||||
if matches!(d.node(id).map(|n| &n.kind), Some(NodeKind::Compound { .. })) {
|
||||
let members = compound_members(d, pd);
|
||||
if let Some(NodeKind::Compound { children, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
*children = members;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
*path_mut(d, id)? = pd;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A copy of path or compound path node `id` (fresh ids, no live shape) with geometry `pd`.
|
||||
fn sibling_with(d: &mut Document, id: NodeId, pd: PathData) -> Result<Node> {
|
||||
let mut n = d.node(id).ok_or(EngineError::NoNode(id))?.clone();
|
||||
n.id = d.alloc_id();
|
||||
if let NodeKind::Path { path, live, .. } = &mut n.kind {
|
||||
*path = pd;
|
||||
*live = None;
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*path = pd;
|
||||
*live = None;
|
||||
}
|
||||
NodeKind::Compound { children, .. } => *children = compound_members(d, pd),
|
||||
_ => {}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Replace path `id` with `pieces` (first keeps the id, the rest go right above it). Returns ids.
|
||||
fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
/// Replace path or compound path `id` with `pieces` (first keeps the id, the rest go right above
|
||||
/// it; none removes it). Returns ids.
|
||||
pub(super) fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> Result<Vec<NodeId>> {
|
||||
let mut it = pieces.into_iter();
|
||||
let Some(first) = it.next() else {
|
||||
d.remove(id)?;
|
||||
@@ -248,16 +274,16 @@ fn replace_with_pieces(d: &mut Document, id: NodeId, pieces: Vec<PathData>) -> R
|
||||
let n = sibling_with(d, id, pd)?;
|
||||
ids.push(d.insert(parent, idx + 1 + k, n)?);
|
||||
}
|
||||
*path_mut(d, id)? = first;
|
||||
set_geometry(d, id, first)?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
pub(super) fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// Split a filled path into connected pieces (each outer contour with its holes).
|
||||
fn components(pd: &PathData) -> Vec<PathData> {
|
||||
pub(super) fn components(pd: &PathData) -> Vec<PathData> {
|
||||
let subs: Vec<&SubPath> = pd.subpaths.iter().filter(|s| s.closed && s.anchors.len() >= 2).collect();
|
||||
if subs.len() <= 1 {
|
||||
return if pd.is_empty() { vec![] } else { vec![pd.clone()] };
|
||||
@@ -382,7 +408,7 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
|
||||
|
||||
/// Erase from an outline path (open or closed) where `remove` holds. Returns the new geometry per
|
||||
/// output object (one object per piece when the path had a single subpath), or None if unchanged.
|
||||
fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
|
||||
pub(super) fn erase_outline(pd: &PathData, remove: &dyn Fn(Point) -> bool) -> Option<Vec<PathData>> {
|
||||
let mut changed = false;
|
||||
let mut subs = vec![];
|
||||
for sp in &pd.subpaths {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"file.new",
|
||||
"New…",
|
||||
["File"],
|
||||
Some("Cmd+N"),
|
||||
None,
|
||||
"{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} → {index, previewMode}; the size is remembered in file.newPresets' Recent",
|
||||
always,
|
||||
super::newdoc::file_new
|
||||
@@ -116,8 +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> {
|
||||
s.cancel_interaction()?;
|
||||
// What is still in progress (typing, a drag) is the step to undo: keep it first, so Undo takes
|
||||
// back only that and Redo brings it back, instead of dropping it and undoing the step before.
|
||||
// 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\"), 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
|
||||
),
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effects",
|
||||
[],
|
||||
None,
|
||||
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}], applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
|
||||
"{} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -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,9 +152,23 @@ 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()
|
||||
.chain(effects::plugin_effects())
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster,
|
||||
"lengths": {"always": e.lengths.always, "absolute": e.lengths.absolute}})
|
||||
@@ -321,11 +344,25 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects:
|
||||
/// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects
|
||||
/// are baked, raster effects become an embedded image, and the object keeps its transparency.
|
||||
fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec<NodeId>) {
|
||||
let Some(n) = d.node(id).cloned() else { return };
|
||||
let container = is_container(&n);
|
||||
let Some(mut n) = d.node(id).cloned() else { return };
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
}
|
||||
// Colour adjustments: the colours inside become the adjusted ones (an embedded image a
|
||||
// recoloured copy), then the rest of the appearance expands.
|
||||
if effects::has_adjustment(&n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n)
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m.clone();
|
||||
out.push(id);
|
||||
n = m;
|
||||
if !expandable(&n) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// (A symbol instance has become its art, a group.)
|
||||
let container = is_container(&n);
|
||||
// Crop Marks: a group of the object and its marks, then the object expands on its own.
|
||||
if let Some(mut m) = effects::crop_marks_art(&n) {
|
||||
let mut seen = std::collections::HashSet::from([id]);
|
||||
@@ -385,7 +422,9 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush
|
||||
&& let Some(slot) = d.node_mut(id)
|
||||
{
|
||||
*slot = m;
|
||||
out.push(id);
|
||||
if out.last() != Some(&id) {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
if container {
|
||||
let children: Vec<NodeId> = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
@@ -490,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, _| {
|
||||
|
||||
@@ -135,11 +135,7 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, d
|
||||
/// The flattener options `p` asks for (`flattenerPreset` and `flattener` over it), built-in
|
||||
/// presets and `saved` ones; discarded overprints are dropped from the flattened art too.
|
||||
fn flattener(p: &Params, saved: &[super::super::FlattenerPreset]) -> Result<FlattenOptions> {
|
||||
let mut q = json!({ "options": p.flattener.clone().unwrap_or(Value::Null) });
|
||||
if let Some(name) = &p.flattener_preset {
|
||||
q["preset"] = json!(name);
|
||||
}
|
||||
FlattenOptions::from_params_with(&q, saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
|
||||
FlattenOptions::for_export(p.flattener_preset.as_deref(), p.flattener.as_ref(), saved).map_err(|e| bad(C, format!("EPS flattener: {e}")))
|
||||
}
|
||||
|
||||
fn params(p: &Value) -> Result<Params> {
|
||||
@@ -176,7 +172,7 @@ fn parse(p: &Value) -> Result<Settings> {
|
||||
/// `saved` it names written out, as the encoder needs it.
|
||||
pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::result::Result<Value, String> {
|
||||
let run = || -> Result<Value> {
|
||||
let q = expanded(p, saved)?.unwrap_or_else(|| p.clone());
|
||||
let q = expanded(C, p, saved)?.unwrap_or_else(|| p.clone());
|
||||
parse(&q)?;
|
||||
Ok(q)
|
||||
};
|
||||
@@ -185,10 +181,10 @@ pub fn resolve(p: &Value, saved: &[super::super::FlattenerPreset]) -> std::resul
|
||||
|
||||
/// `p` with a saved flattener preset it names written out as `flattener` options, for the
|
||||
/// encoder, which knows only the built-in presets; `None` when `p` names none.
|
||||
fn expanded(p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
||||
fn expanded(cmd: &str, p: &Value, saved: &[super::super::FlattenerPreset]) -> Result<Option<Value>> {
|
||||
match str_param(p, "flattenerPreset") {
|
||||
Some(name) if FlattenOptions::preset(name).is_none() => {
|
||||
let o = flattener(¶ms(p)?, saved)?;
|
||||
Some(name) if !name.trim().is_empty() && FlattenOptions::preset(name).is_none() => {
|
||||
let o = FlattenOptions::for_export(Some(name), p.get("flattener"), saved).map_err(|e| bad(cmd, format!("flattener: {e}")))?;
|
||||
let mut q = p.clone();
|
||||
q["flattener"] = serde_json::to_value(o).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(m) = q.as_object_mut() {
|
||||
@@ -299,8 +295,8 @@ fn export_eps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// `p` with a saved flattener preset of the session's written out (see [`expanded`]).
|
||||
pub(super) fn with_flattener<'a>(s: &Session, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
||||
Ok(match expanded(&p, &s.prefs.flattener_presets)? {
|
||||
pub(super) fn with_flattener<'a>(s: &Session, cmd: &str, p: Cow<'a, Value>) -> Result<Cow<'a, Value>> {
|
||||
Ok(match expanded(cmd, &p, &s.prefs.flattener_presets)? {
|
||||
Some(q) => Cow::Owned(q),
|
||||
None => p,
|
||||
})
|
||||
|
||||
@@ -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)?;
|
||||
@@ -104,8 +104,7 @@ pub(super) fn without_artboards(p: &Value) -> Value {
|
||||
/// `p` with the saved presets it names (PDF, flattener) written out as options, for the encoders,
|
||||
/// which know only the built-in ones.
|
||||
fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
|
||||
let pdf = super::pdf::expand_preset(s, cmd, p)?;
|
||||
super::eps::with_flattener(s, pdf)
|
||||
super::pdf::expand_preset(s, cmd, p)
|
||||
}
|
||||
|
||||
/// The document an export of `f` writes: the active one (see [`export_source`]), or for text
|
||||
@@ -152,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"];
|
||||
|
||||
@@ -623,6 +635,7 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Print presets", super::printpresets::PRESET_EXTS)))
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
|
||||
@@ -901,8 +914,19 @@ mod tests_pdfoutput;
|
||||
#[cfg(test)]
|
||||
mod tests_tiffbmp;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfx;
|
||||
#[cfg(test)]
|
||||
mod tests_psd;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_epsimport;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdflayers;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfwebview;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdf13;
|
||||
@@ -3,12 +3,14 @@
|
||||
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, FormatOption, write_or_return};
|
||||
use crate::EngineError;
|
||||
@@ -30,8 +32,18 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "\"VectorCraft Default\"",
|
||||
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
|
||||
},
|
||||
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
|
||||
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
|
||||
FormatOption {
|
||||
name: "standard",
|
||||
ty: "string",
|
||||
default: "\"none\"",
|
||||
description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "compatibility",
|
||||
ty: "string",
|
||||
default: "\"1.7\"",
|
||||
description: "PDF version: 1.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0",
|
||||
},
|
||||
FormatOption {
|
||||
name: "preserveEditing",
|
||||
ty: "boolean",
|
||||
@@ -62,7 +74,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
name: "security",
|
||||
ty: "object",
|
||||
default: "null",
|
||||
description: "passwords and permissions (the file is encrypted: RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
|
||||
description: "passwords and permissions (the file is encrypted: RC4 40-bit at 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "includeNonPrinting",
|
||||
@@ -70,6 +82,24 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "createLayers",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "top-level layers as PDF layers (optional content groups with their visibility, print state and lock; PDF 1.5 or later)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "flattenerPreset",
|
||||
ty: "string",
|
||||
default: "\"\"",
|
||||
description: "the flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 too) flatten transparency with: empty (High Resolution), high, medium, low or a saved one",
|
||||
},
|
||||
FormatOption {
|
||||
name: "flattener",
|
||||
ty: "object",
|
||||
default: "null",
|
||||
description: "flattener options over flattenerPreset (as object.flattenTransparency)",
|
||||
},
|
||||
];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -79,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each top-level layer but template layers as a PDF layer, an optional content group: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: true (false: type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet c
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
@@ -145,9 +175,10 @@ pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||||
|
||||
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
|
||||
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
|
||||
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
|
||||
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
|
||||
/// aren't PDF options (path, format…) are ignored.
|
||||
/// Preserve Editing off, the layers off where it has none, and sets the latest compatibility it
|
||||
/// allows, unless `p` asks for those. Checked as a preset is
|
||||
/// ([`PdfSettings::check_values`]): profiles that aren't there pass. Keys that aren't PDF options
|
||||
/// (path, format…) are ignored.
|
||||
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
|
||||
let base = preset_settings(cmd, p, saved)?;
|
||||
if !p.is_object() {
|
||||
@@ -156,8 +187,17 @@ pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings>
|
||||
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
merge(&mut v, p);
|
||||
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
|
||||
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
|
||||
s.preserve_editing = false;
|
||||
let unasked = |key: &str| p.get(key).is_none_or(Value::is_null);
|
||||
if s.standard != base.standard {
|
||||
if s.standard != Standard::None && unasked("preserveEditing") {
|
||||
s.preserve_editing = false;
|
||||
}
|
||||
if !s.standard.allows_layers() && unasked("createLayers") {
|
||||
s.create_layers = false;
|
||||
}
|
||||
if !s.standard.allows(s.compatibility) && unasked("compatibility") {
|
||||
s.compatibility = s.standard.version();
|
||||
}
|
||||
}
|
||||
s.check_values().map_err(|e| pdf_error(cmd, e))?;
|
||||
Ok(s)
|
||||
@@ -175,10 +215,11 @@ pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
|
||||
settings_with(cmd, p, &[])
|
||||
}
|
||||
|
||||
/// `p` with a saved preset it names written out as options, for the encoders, which know only the
|
||||
/// built-in presets. Params naming no preset, or a built-in one, come back as they are.
|
||||
/// `p` with the saved presets it names (a PDF preset, a flattener preset: see
|
||||
/// [`super::eps::with_flattener`]) written out as options, for the encoders, which know only the
|
||||
/// built-in presets. Params naming none come back as they are.
|
||||
pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
|
||||
match str_param(p, "preset") {
|
||||
let expanded = match str_param(p, "preset") {
|
||||
Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => {
|
||||
let set = settings_with(cmd, p, &s.prefs.pdf_presets)?;
|
||||
let mut q = p.clone();
|
||||
@@ -186,10 +227,11 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("preset");
|
||||
}
|
||||
Ok(Cow::Owned(q))
|
||||
Cow::Owned(q)
|
||||
}
|
||||
_ => Ok(Cow::Borrowed(p)),
|
||||
}
|
||||
_ => Cow::Borrowed(p),
|
||||
};
|
||||
super::eps::with_flattener(s, cmd, expanded)
|
||||
}
|
||||
|
||||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
|
||||
@@ -214,6 +256,9 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
|
||||
/// native document with its save options, whose pages may be left blank).
|
||||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let mut opts = options(cmd, doc, p)?;
|
||||
if opts.settings.thumbnails {
|
||||
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
if opts.settings.preserve_editing {
|
||||
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
|
||||
@@ -223,14 +268,92 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
|
||||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||||
}
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) {
|
||||
warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into());
|
||||
let set = &opts.settings;
|
||||
let flat = flatten_for(cmd, doc, set, p)?;
|
||||
if flat.is_some() {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
Ok((r.bytes, warnings))
|
||||
}
|
||||
|
||||
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
|
||||
pub const DEFAULT_FLATTENER: &str = "High Resolution";
|
||||
|
||||
/// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3,
|
||||
/// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener`
|
||||
/// options over it; images with see-through pixels count as transparency, rasterized areas are
|
||||
/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or
|
||||
/// the file keeps transparency).
|
||||
fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<Option<Document>> {
|
||||
if !set.pdf13() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER);
|
||||
let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?;
|
||||
o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve;
|
||||
o.no_transparency = Some(see_through_images(doc));
|
||||
// Without soft masks a rasterized area can't be a rectangle with see-through pixels around
|
||||
// its art: it is clipped to its regions (opaque inside them).
|
||||
o.clip_complex_regions = true;
|
||||
flatten_document(doc, &o)
|
||||
}
|
||||
|
||||
/// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side:
|
||||
/// the thumbnails Embed Page Thumbnails embeds. Layers the pages leave out (non-printing ones,
|
||||
/// unless asked for) are left out of them too.
|
||||
fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thumbnail>, PdfError> {
|
||||
let areas = vectorcraft_pdf::page_areas(doc, opts)?;
|
||||
let set = &opts.settings;
|
||||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||||
let shown = printed.map(|layers| {
|
||||
let mut d = doc.clone();
|
||||
d.layers = layers;
|
||||
d
|
||||
});
|
||||
let doc = shown.as_ref().unwrap_or(doc);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
Ok(areas
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let img = r.render_region(doc, a, f64::from(THUMBNAIL_SIZE) / a.width().max(a.height()).max(1.0), true);
|
||||
// Drawn on white: every pixel is opaque.
|
||||
let rgb = img.pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
Thumbnail { width: img.width, height: img.height, rgb }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are
|
||||
/// none.
|
||||
fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||||
let mut out: Option<Vec<Arc<Node>>> = None;
|
||||
for (i, l) in layers.iter().enumerate() {
|
||||
let NodeKind::Layer { printable, children, .. } = &l.kind else { continue };
|
||||
let new = if !printable {
|
||||
l.visible.then(|| {
|
||||
let mut n = (**l).clone();
|
||||
n.visible = false;
|
||||
Arc::new(n)
|
||||
})
|
||||
} else {
|
||||
without_non_printing(children).map(|c| {
|
||||
let mut n = (**l).clone();
|
||||
if let NodeKind::Layer { children, .. } = &mut n.kind {
|
||||
*children = c;
|
||||
}
|
||||
Arc::new(n)
|
||||
})
|
||||
};
|
||||
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) {
|
||||
*slot.0 = slot.1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let expanded = expand_preset(s, C, p)?;
|
||||
let p = &*expanded;
|
||||
|
||||
@@ -146,7 +146,41 @@ fn open_exts_cover_every_readable_format() {
|
||||
}
|
||||
let filters: Vec<_> = open_filters().collect();
|
||||
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
|
||||
assert_eq!(filters.len(), 5 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets");
|
||||
assert_eq!(filters.len(), 6 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener, PDF and print presets, plug-ins");
|
||||
assert_eq!(filters.last(), Some(&("Plug-ins", crate::cmd::plugin::EXTS)), "File › Open installs plug-ins");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -60,13 +60,15 @@ fn export_pdf_writes_the_compatibility_header_and_the_range() {
|
||||
fn bad_options_are_refused() {
|
||||
let mut s = session(2);
|
||||
for p in [
|
||||
json!({"compatibility": "1.3"}),
|
||||
json!({"compatibility": "1.2"}),
|
||||
json!({"compatibility": "1.3", "standard": "pdfA2b"}),
|
||||
json!({"compatibility": "1.3", "flattenerPreset": "Nope"}),
|
||||
json!({"compatibility": 1.5}),
|
||||
json!({"range": "1-3"}),
|
||||
json!({"range": "0"}),
|
||||
json!({"artboards": []}),
|
||||
json!({"preset": "Nope"}),
|
||||
json!({"standard": "pdfX4"}),
|
||||
json!({"standard": "pdfX4", "compatibility": "1.7"}),
|
||||
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
|
||||
json!({"compression": {"color": {"ppi": 0}}}),
|
||||
json!({"compression": "zip"}),
|
||||
@@ -105,9 +107,11 @@ fn pdf_a_refuses_pdf_2() {
|
||||
#[test]
|
||||
fn options_not_applied_yet_and_document_features_warn() {
|
||||
let mut s = session(1);
|
||||
let w = warnings(&export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}})));
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("fast web view")), "printer's marks are drawn: no warning");
|
||||
// Printer's marks are drawn, thumbnails embedded and the file linearised: nothing to report.
|
||||
let v = export(&mut s, json!({"thumbnails": true, "fastWebView": true, "marks": {"trim": true}}));
|
||||
assert_eq!(warnings(&v), Vec::<String>::new());
|
||||
let text = String::from_utf8_lossy(&b64(&v)).into_owned();
|
||||
assert!(text.contains("/Thumb ") && text.contains("/Linearized 1"));
|
||||
// A pattern stroke is written as its tiles clipped to the stroke: nothing to report.
|
||||
let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [tile]})).unwrap();
|
||||
@@ -156,7 +160,7 @@ fn pdf_settings_summarise_changes_and_warnings() {
|
||||
let changed: Vec<&str> = v["changed"].as_array().unwrap().iter().map(|c| c["option"].as_str().unwrap()).collect();
|
||||
assert_eq!(changed, ["compatibility", "compression.compressText", "createLayers", "security.copy"], "key order");
|
||||
assert_eq!(v["changed"][0]["value"], "1.5");
|
||||
assert_eq!(v["warnings"].as_array().unwrap().len(), 2, "layers and permissions: {}", v["warnings"]);
|
||||
assert_eq!(v["warnings"].as_array().unwrap().len(), 1, "permissions (PDF layers are written at 1.5): {}", v["warnings"]);
|
||||
assert!(!v.to_string().contains("Password"), "passwords never come back");
|
||||
assert!(s.execute("document.pdfSettings", &json!({"compatibility": "9"})).is_err());
|
||||
// With the document: the in-memory export adds the document's own warnings.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! `document.exportPdf {compatibility: "1.3", flattenerPreset?, flattener?}`: a PDF 1.3 file has
|
||||
//! no transparency (no soft masks, blend modes, constant opacity or transparency groups): it is
|
||||
//! flattened with the chosen preset, and reads back looking like the canvas.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::{Rendered, Renderer};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document with overlapping transparency: a half-opaque rectangle, a Multiply ellipse, a
|
||||
/// gradient fading out, a drop shadow and a PNG with see-through pixels, in an isolated page.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
let run = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 160}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 90, "height": 70}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#2060e0"}));
|
||||
run(&mut s, "shape.rectangle", json!({"x": 50, "y": 40, "width": 90, "height": 70}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#f02010"}));
|
||||
run(&mut s, "transparency.set", json!({"opacity": 50}));
|
||||
run(&mut s, "shape.ellipse", json!({"x": 90, "y": 20, "width": 80, "height": 80}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#20c040"}));
|
||||
run(&mut s, "transparency.set", json!({"blendMode": "multiply"}));
|
||||
let shadowed = run(&mut s, "shape.rectangle", json!({"x": 20, "y": 100, "width": 50, "height": 40}))["id"].clone();
|
||||
run(&mut s, "paint.setFill", json!({"color": "#e0c020"}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [shadowed]}));
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
run(&mut s, "file.place", json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 120]}));
|
||||
s
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<String>) {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(bytes, v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect())
|
||||
}
|
||||
|
||||
/// The transparency `pdf` has, in its dictionaries.
|
||||
fn transparency(pdf: &[u8]) -> Vec<&'static str> {
|
||||
let text = String::from_utf8_lossy(pdf);
|
||||
let mut found = vec![];
|
||||
for (what, there) in [
|
||||
("soft masks", text.contains("/SMask") && !text.contains("/SMask/None")),
|
||||
("blend modes", text.contains("/BM") && !text.contains("/BM/Normal")),
|
||||
("transparency groups", text.contains("/S/Transparency")),
|
||||
("opacity", text.contains("/ca 0.") || text.contains("/CA 0.")),
|
||||
] {
|
||||
if there {
|
||||
found.push(what);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn render(d: &vectorcraft_doc::Document) -> Rendered {
|
||||
Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 160.0), 1.0, true)
|
||||
}
|
||||
|
||||
/// Mean difference of the colour channels of two renders, as a share of full scale.
|
||||
fn mean_diff(a: &Rendered, b: &Rendered) -> f64 {
|
||||
let sum: u64 = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||||
sum as f64 / a.pixels.len() as f64 / 255.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_1_3_flattens_all_transparency_and_looks_the_same() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
// The same document at PDF 1.4 has transparency.
|
||||
let (pdf, _) = export(&mut s, json!({"compatibility": "1.4", "compression": {"compressText": false}, "preserveEditing": false}));
|
||||
assert!(transparency(&pdf).len() >= 3, "{:?}", transparency(&pdf));
|
||||
let (pdf, warnings) = export(&mut s, json!({"compatibility": "1.3", "compression": {"compressText": false}, "preserveEditing": false}));
|
||||
assert!(pdf.starts_with(b"%PDF-1.3"));
|
||||
assert!(String::from_utf8_lossy(&pdf).contains("<pdf:PDFVersion>1.3<"), "the metadata says 1.3 too");
|
||||
assert_eq!(transparency(&pdf), Vec::<&str>::new());
|
||||
assert!(warnings.iter().any(|w| w.contains("flattened") && w.contains("PDF 1.3")), "{warnings:?}");
|
||||
// It reads back looking like the canvas.
|
||||
let canvas = render(&before);
|
||||
let diff = mean_diff(&canvas, &render(&vectorcraft_pdf::import(&pdf).unwrap()));
|
||||
assert!(diff < 0.02, "read back {:.2}% off", diff * 100.0);
|
||||
// The document itself is untouched.
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// The writer refuses a document that isn't flat; a flat one with isolated page blending gets
|
||||
// no page group.
|
||||
let opts = vectorcraft_pdf::PdfOptions { settings: serde_json::from_value(json!({"compatibility": "1.3"})).unwrap(), ..Default::default() };
|
||||
let e = vectorcraft_pdf::export(&before, &opts).unwrap_err();
|
||||
assert!(matches!(&e, vectorcraft_pdf::PdfError::Unsupported(m) if m.contains("PDF 1.3")), "{e}");
|
||||
let mut flat = vectorcraft_doc::Document::new(100.0, 100.0);
|
||||
flat.page_isolate = true;
|
||||
let l = flat.default_layer().unwrap();
|
||||
let id = flat.alloc_id();
|
||||
let rect = vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0));
|
||||
let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
flat.insert(Some(l), 0, vectorcraft_doc::Node::path(id, rect, vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0)))
|
||||
.unwrap();
|
||||
assert!(transparency(&vectorcraft_pdf::export(&flat, &opts).unwrap()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flattener_preset_and_options_apply() {
|
||||
let mut s = session();
|
||||
let images = |pdf: &[u8]| String::from_utf8_lossy(pdf).matches("/Subtype/Image").count();
|
||||
let p = |extra: Value| {
|
||||
let mut p = json!({"compatibility": "1.3", "preserveEditing": false});
|
||||
super::pdf::merge(&mut p, &extra);
|
||||
p
|
||||
};
|
||||
let (high, _) = export(&mut s, p(json!({})));
|
||||
let (default, _) = export(&mut s, p(json!({"flattenerPreset": "High Resolution"})));
|
||||
assert_eq!(high.len(), default.len(), "the default is High Resolution");
|
||||
let (low, _) = export(&mut s, p(json!({"flattenerPreset": "low"})));
|
||||
assert!(low.len() < high.len(), "lower resolution rasters");
|
||||
// All raster: one image a flattened group at most, and still flat.
|
||||
let (raster, _) = export(&mut s, p(json!({"flattener": {"balance": 0}})));
|
||||
assert!(images(&raster) >= 1 && transparency(&raster).is_empty());
|
||||
// A saved preset by name, through every PDF path; an unknown one is refused.
|
||||
s.execute("flattener.presets.save", &json!({"name": "All Raster", "preset": "high", "balance": 0})).unwrap();
|
||||
let (saved, _) = export(&mut s, p(json!({"flattenerPreset": "All Raster"})));
|
||||
assert_eq!(saved.len(), raster.len());
|
||||
let v = s
|
||||
.execute("document.export", &json!({"format": "pdf", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
|
||||
.unwrap();
|
||||
assert_eq!(vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap().len(), raster.len());
|
||||
s.execute("pdf.preset.save", &json!({"name": "Old Press", "compatibility": "1.3", "flattenerPreset": "All Raster", "preserveEditing": false}))
|
||||
.unwrap();
|
||||
assert_eq!(export(&mut s, json!({"preset": "Old Press"})).0.len(), raster.len(), "a PDF preset keeps its flattener preset");
|
||||
assert!(s.execute("document.exportPdf", &p(json!({"flattenerPreset": "Nope"}))).is_err());
|
||||
// Other versions keep their transparency, whatever the preset.
|
||||
let (kept, warnings) = export(&mut s, json!({"flattenerPreset": "low", "compression": {"compressText": false}}));
|
||||
assert!(!transparency(&kept).is_empty() && !warnings.iter().any(|w| w.contains("flattened")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_1_3_files_are_encrypted_with_40_bit_rc4_and_can_be_linearised() {
|
||||
let mut s = session();
|
||||
let (pdf, warnings) = export(
|
||||
&mut s,
|
||||
json!({"compatibility": "1.3", "fastWebView": true, "thumbnails": true, "security": {"openPassword": "open", "permissionsPassword": "owner", "plaintextMetadata": false}}),
|
||||
);
|
||||
assert!(pdf.starts_with(b"%PDF-1.3") && warnings.iter().all(|w| !w.contains("metadata")), "{warnings:?}");
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert!(text.contains("/V 1/R 2/Length 40") && text.contains("/Linearized 1") && text.contains("/Thumb "));
|
||||
for password in ["open", "owner"] {
|
||||
s.execute("document.close", &json!({"force": true})).ok();
|
||||
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": password})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "{password}");
|
||||
}
|
||||
assert!(
|
||||
s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "old.pdf", "password": "nope"})).is_err()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! `document.exportPdf {createLayers}` writes PDF layers that reopen as layers, and
|
||||
//! `advanced.overprint` keeps overprinting in the file or drops it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn warnings(v: &Value) -> Vec<String> {
|
||||
v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// A document with a rectangle on "Layer 1" and one on a hidden, non-printing layer "Notes".
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let notes = s.execute("layer.new", &json!({"name": "Notes"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": notes, "visible": false, "printable": false})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_create_layers_writes_layers_that_reopen_as_layers() {
|
||||
let mut s = session();
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "preserveEditing": false})).unwrap();
|
||||
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
|
||||
let back = vectorcraft_pdf::import(&b64(&v)).unwrap();
|
||||
let layers: Vec<(String, bool, bool)> = back
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| (l.name.clone().unwrap_or_default(), l.visible, matches!(l.kind, vectorcraft_doc::NodeKind::Layer { printable: true, .. })))
|
||||
.collect();
|
||||
assert_eq!(layers, [("Layer 1".to_string(), true, true), ("Notes".to_string(), false, false)]);
|
||||
// The default preset carries the editing data too.
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true})).unwrap();
|
||||
assert!(warnings(&v).is_empty(), "{:?}", warnings(&v));
|
||||
assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact));
|
||||
// At PDF 1.4 there are no PDF layers.
|
||||
let v = s.execute("document.exportPdf", &json!({"createLayers": true, "compatibility": "1.4"})).unwrap();
|
||||
assert!(warnings(&v).iter().any(|w| w.contains("PDF 1.5")), "{:?}", warnings(&v));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_overprint_is_preserved_or_discarded() {
|
||||
let mut s = session();
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
// The rectangles' strokes are black (their white fills would knock out: discardWhiteOverprint).
|
||||
s.execute("object.setOverprint", &json!({"stroke": true})).unwrap();
|
||||
let overprints = |s: &mut Session, p: Value| {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap();
|
||||
assert!(warnings(&v).iter().all(|w| !w.contains("overprint")), "{:?}", warnings(&v));
|
||||
String::from_utf8_lossy(&b64(&v)).matches("/OP true/op true/OPM 1").count()
|
||||
};
|
||||
assert_eq!(overprints(&mut s, json!({"compression": {"compressText": false}})), 1);
|
||||
assert_eq!(overprints(&mut s, json!({"advanced": {"overprint": "discard"}})), 0);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `document.exportPdf {thumbnails, fastWebView}`: each page's thumbnail is the page drawn small
|
||||
//! (its bleed and marks too, without the layers the page leaves out), and the file is linearised.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn export(s: &mut Session, p: Value) -> (Vec<u8>, Vec<Value>) {
|
||||
let v = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
(bytes, v["warnings"].as_array().unwrap().clone())
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
||||
hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
|
||||
}
|
||||
|
||||
/// The page thumbnails of `pdf`, in file order: (width, height, RGB pixels).
|
||||
fn thumbnails(pdf: &[u8]) -> Vec<(u32, u32, Vec<u8>)> {
|
||||
let mut out = vec![];
|
||||
let mut from = 0;
|
||||
while let Some(at) = find(pdf, b"<</Width ", from) {
|
||||
from = at + 1;
|
||||
let head = String::from_utf8_lossy(&pdf[at..find(pdf, b">>", at).unwrap()]).into_owned();
|
||||
if !head.contains("/ColorSpace/DeviceRGB/BitsPerComponent 8/Filter/FlateDecode/Length ") {
|
||||
continue;
|
||||
}
|
||||
let num = |key: &str| -> usize {
|
||||
let rest = &head[head.find(key).unwrap() + key.len()..];
|
||||
rest[..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len())].parse().unwrap()
|
||||
};
|
||||
let data = find(pdf, b"stream\n", at).unwrap() + 7;
|
||||
let mut rgb = vec![];
|
||||
flate2::read::ZlibDecoder::new(&pdf[data..data + num("/Length ")]).read_to_end(&mut rgb).unwrap();
|
||||
out.push((num("/Width ") as u32, num("/Height ") as u32, rgb));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_show_the_pages() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect;
|
||||
// A red first page, a green second one.
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": second.x0, "y": second.y0, "width": 200, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
// A non-printing layer covering the first page in blue.
|
||||
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
|
||||
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
|
||||
let (pdf, warnings) = export(&mut s, json!({"thumbnails": true}));
|
||||
assert_eq!(warnings, Vec::<Value>::new());
|
||||
let thumbs = thumbnails(&pdf);
|
||||
assert_eq!(thumbs.len(), 2, "one a page");
|
||||
for ((w, h, rgb), colour) in thumbs.iter().zip([[255, 0, 0], [0, 255, 0]]) {
|
||||
assert_eq!((*w, *h), (vectorcraft_pdf::THUMBNAIL_SIZE, 53), "the page's shape, 106 px long");
|
||||
// The middle of the left half: the page's colour (the non-printing layer is left out).
|
||||
let at = ((*h / 2 * w + w / 4) * 3) as usize;
|
||||
assert_eq!(rgb[at..at + 3], colour);
|
||||
}
|
||||
// Non-printing layers included: the thumbnail shows them too.
|
||||
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "includeNonPrinting": true}));
|
||||
let (w, h, rgb) = &thumbnails(&pdf)[0];
|
||||
let at = ((*h / 2 * w + w / 4) * 3) as usize;
|
||||
assert_eq!(rgb[at..at + 3], [0, 0, 255]);
|
||||
// With the bleed the page grows, and so does its thumbnail.
|
||||
let (pdf, _) = export(&mut s, json!({"thumbnails": true, "bleed": {"top": 50, "bottom": 50, "left": 0, "right": 0}}));
|
||||
assert_eq!(thumbnails(&pdf).iter().map(|t| (t.0, t.1)).collect::<Vec<_>>(), [(106, 106), (106, 106)]);
|
||||
// Off: none.
|
||||
assert!(thumbnails(&export(&mut s, json!({})).0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_web_view_through_every_pdf_path() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
let linearised = |pdf: &[u8]| String::from_utf8_lossy(&pdf[..200.min(pdf.len())]).contains("/Linearized 1");
|
||||
let (pdf, warnings) = export(&mut s, json!({"fastWebView": true, "thumbnails": true}));
|
||||
assert!(linearised(&pdf) && warnings.is_empty(), "{warnings:?}");
|
||||
// Reopened: the pages, and the document the editing data carries.
|
||||
s.execute("document.close", &json!({"force": true})).ok();
|
||||
let v = s.execute("document.open", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf), "name": "web.pdf"})).unwrap();
|
||||
assert!(v.is_object());
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), 3);
|
||||
assert!(s.doc().unwrap().doc.layers[0].children().unwrap().iter().any(|n| n.path_data().is_some()), "the ellipse is back");
|
||||
// document.export takes the same options; without them the file isn't linearised.
|
||||
let v = s.execute("document.export", &json!({"format": "pdf", "fastWebView": true, "range": "2"})).unwrap();
|
||||
assert!(linearised(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()));
|
||||
assert!(!linearised(&export(&mut s, json!({})).0));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! `document.exportPdf {standard}` with the PDF/X standards: choosing one sets its version and
|
||||
//! turns off what it forbids, PDF/X-1a and PDF/X-3 flatten transparency (images with see-through
|
||||
//! pixels too) before writing, PDF/X-4 keeps it, and passwords are refused.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document with a CMYK rectangle under a half-opaque RGB one, and a PNG with see-through
|
||||
/// pixels over both.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 30, "width": 60, "height": 60})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.2, "b": 0.0}})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 50]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// `document.exportPdf` with `p` (content streams readable) → (the file's text, warnings).
|
||||
fn pdf(s: &mut Session, mut p: Value) -> (String, Vec<String>) {
|
||||
p["compression"] = json!({"compressText": false});
|
||||
let v = s.execute("document.exportPdf", &p).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let warnings = v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
|
||||
(String::from_utf8_lossy(&bytes).into_owned(), warnings)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfx_1a_and_x3_flatten_transparency_and_x4_keeps_it() {
|
||||
let mut s = session();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for (standard, header) in [("pdfX1a", "%PDF-1.3"), ("pdfX3", "%PDF-1.3")] {
|
||||
let (text, warnings) = pdf(&mut s, json!({"standard": standard}));
|
||||
assert!(text.starts_with(header), "{standard}");
|
||||
assert!(warnings.iter().any(|w| w.contains("flattened")), "{standard}: {warnings:?}");
|
||||
for no in ["/SMask", "/S/Transparency", "/ca 0.5"] {
|
||||
assert!(!text.contains(no), "{standard}: no {no}");
|
||||
}
|
||||
assert!(text.contains("/S/GTS_PDFX") && text.contains("/GTS_PDFXVersion"), "{standard}");
|
||||
}
|
||||
// PDF/X-1a: CMYK only, the flattened colours too.
|
||||
let (text, _) = pdf(&mut s, json!({"standard": "pdfX1a"}));
|
||||
assert!(!text.contains("/DeviceRGB") && !text.contains("/ICCBased"));
|
||||
assert!(!text.split_whitespace().any(|t| t == "rg"), "no RGB fills");
|
||||
// The document itself is untouched.
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
// PDF/X-4 keeps transparency (and the image's soft mask).
|
||||
let (text, warnings) = pdf(&mut s, json!({"preset": "PDF/X-4:2010"}));
|
||||
assert!(text.starts_with("%PDF-1.6"));
|
||||
assert!(text.contains("/SMask") && !warnings.iter().any(|w| w.contains("flattened")), "{warnings:?}");
|
||||
assert!(text.contains("<pdfxid:GTS_PDFXVersion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
|
||||
let mut s = session();
|
||||
let settings = |s: &mut Session, p: Value| s.execute("document.pdfSettings", &p).unwrap()["settings"].clone();
|
||||
// The default preset is PDF 1.7 and keeps editing data.
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4"}));
|
||||
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.3"), &json!(false)));
|
||||
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
|
||||
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
|
||||
// What is asked for explicitly is checked, not changed.
|
||||
for p in [
|
||||
json!({"standard": "pdfX4", "compatibility": "1.7"}),
|
||||
json!({"standard": "pdfX3", "compatibility": "1.6"}),
|
||||
json!({"standard": "pdfX1a", "createLayers": true}),
|
||||
json!({"standard": "pdfX3", "preserveEditing": true}),
|
||||
json!({"standard": "pdfX4", "security": {"openPassword": "x"}}),
|
||||
json!({"standard": "pdfX1a", "security": {"permissionsPassword": "x"}}),
|
||||
json!({"standard": "pdfX1a", "output": {"outputIntent": vectorcraft_color::cms::SRGB}}),
|
||||
json!({"standard": "pdfX4", "output": {"outputIntent": "No Such Press"}}),
|
||||
] {
|
||||
let e = s.execute("document.exportPdf", &p).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
|
||||
}
|
||||
// The built-in PDF/X presets export as they are.
|
||||
for preset in ["PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
|
||||
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening
|
||||
//! the document would touch ([`FlattenReport`], `flattener.preview`).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -129,6 +130,12 @@ pub struct FlattenOptions {
|
||||
/// Areas showing a single paint keep it (spot and process colours, swatches) with its
|
||||
/// overprint; off, the flattened objects lose their overprints.
|
||||
pub preserve_overprints: bool,
|
||||
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
|
||||
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
|
||||
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
|
||||
/// preset option.
|
||||
#[serde(skip)]
|
||||
pub no_transparency: Option<BTreeSet<String>>,
|
||||
}
|
||||
|
||||
impl Default for FlattenOptions {
|
||||
@@ -175,6 +182,7 @@ impl FlattenOptions {
|
||||
anti_alias,
|
||||
preserve_alpha: false,
|
||||
preserve_overprints: true,
|
||||
no_transparency: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +204,16 @@ impl FlattenOptions {
|
||||
Self::from_params_with(p, &[])
|
||||
}
|
||||
|
||||
/// The options an export flattens with: preset `name` (built-in or one of `saved`; default
|
||||
/// medium) with the export's `flattener` `options` over it.
|
||||
pub fn for_export(name: Option<&str>, options: Option<&Value>, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
|
||||
let mut q = json!({ "options": options.cloned().unwrap_or(Value::Null) });
|
||||
if let Some(name) = name {
|
||||
q["preset"] = json!(name);
|
||||
}
|
||||
Self::from_params_with(&q, saved)
|
||||
}
|
||||
|
||||
/// The options `p` asks for: `preset` (built-in or one of `saved`; default medium) adjusted by
|
||||
/// the option keys given at the top level or in `options`.
|
||||
pub fn from_params_with(p: &Value, saved: &[FlattenerPreset]) -> std::result::Result<Self, String> {
|
||||
@@ -222,6 +240,11 @@ impl FlattenOptions {
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// `n` shows an image whose see-through pixels count as transparency.
|
||||
fn draws_see_through_image(&self, n: &Node) -> bool {
|
||||
self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys))
|
||||
}
|
||||
|
||||
/// Whether a group that splits into `regions` atomic regions is rasterized whole.
|
||||
fn rasterize_all(&self, regions: usize) -> bool {
|
||||
self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0))
|
||||
@@ -792,7 +815,7 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
|
||||
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
|
||||
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
|
||||
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(Node::shows_transparency).collect();
|
||||
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
|
||||
let cmyk = src.color_mode == ColorMode::Cmyk;
|
||||
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
|
||||
for g in overlapping(&reaches) {
|
||||
@@ -927,7 +950,13 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
|
||||
);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
|
||||
if !o.preserve_alpha || !o.anti_alias {
|
||||
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
|
||||
// Without transparency the clip alone shapes the image: it is opaque, over white (its
|
||||
// edge pixels too).
|
||||
for p in img.pixels.as_chunks_mut::<4>().0 {
|
||||
p[3] = 255;
|
||||
}
|
||||
} else if !o.preserve_alpha || !o.anti_alias {
|
||||
// The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial.
|
||||
let cover: Vec<Node> = art
|
||||
.iter()
|
||||
@@ -988,7 +1017,7 @@ fn opaque(n: &mut Node) {
|
||||
/// clipping path stays). `None` when that changes nothing.
|
||||
pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Option<Document>> {
|
||||
let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints;
|
||||
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut roots = vec![];
|
||||
@@ -1197,6 +1226,25 @@ fn carry(from: &Node, mut to: Node) -> Node {
|
||||
to
|
||||
}
|
||||
|
||||
/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque
|
||||
/// everywhere).
|
||||
pub(crate) fn see_through_images(doc: &Document) -> BTreeSet<String> {
|
||||
let see_through = |b: &ImageBlob| {
|
||||
b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255))
|
||||
};
|
||||
doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect()
|
||||
}
|
||||
|
||||
/// `n` shows an image of `keys` (itself or inside).
|
||||
fn draws_image(n: &Node, keys: &BTreeSet<String>) -> bool {
|
||||
!keys.is_empty()
|
||||
&& n.visible
|
||||
&& match &n.kind {
|
||||
NodeKind::Image(im) => keys.contains(&im.key),
|
||||
_ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_fx(fx: &[Effect]) -> bool {
|
||||
fx.iter().any(|e| e.visible)
|
||||
}
|
||||
|
||||
+125
-12
@@ -1,5 +1,6 @@
|
||||
//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with
|
||||
//! another everywhere or in the selection, and selecting the text that uses a font.
|
||||
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
|
||||
//! available (the bundled and installed ones) and rescanning the installed fonts.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -16,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
|
||||
),
|
||||
@@ -25,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
|
||||
),
|
||||
@@ -38,6 +39,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
cmd!(
|
||||
query "text.fontList",
|
||||
"Font List",
|
||||
[],
|
||||
None,
|
||||
"{family?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), 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
|
||||
),
|
||||
cmd!(
|
||||
"text.rescanFonts",
|
||||
"Refresh Font List",
|
||||
[],
|
||||
None,
|
||||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
|
||||
always,
|
||||
rescan
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -77,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);
|
||||
}
|
||||
@@ -90,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))
|
||||
@@ -111,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))
|
||||
@@ -166,6 +197,53 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
match str_param(p, "family") {
|
||||
Some(family) => {
|
||||
if !db.has_family(family) {
|
||||
return Err(bad("text.fontList", format!("font family `{family}` is not available")));
|
||||
}
|
||||
// The name as the font spells it.
|
||||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||||
Ok(json!({ "family": name, "styles": db.styles(family) }))
|
||||
}
|
||||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||||
}
|
||||
}
|
||||
|
||||
fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let faces = db.load_system_fonts();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let faces = 0;
|
||||
// The canvas redraws type in the fonts available now (renderers lay it out again when the
|
||||
// fonts change), and the bounds follow; that isn't an edit to save.
|
||||
for d in &mut s.docs {
|
||||
let mut texts = vec![];
|
||||
d.doc.walk(|n| {
|
||||
if matches!(n.kind, NodeKind::Text(_)) {
|
||||
texts.push(n.id);
|
||||
}
|
||||
});
|
||||
if !texts.is_empty() {
|
||||
let saved = !d.is_dirty();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
for id in texts {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
if saved {
|
||||
d.mark_saved();
|
||||
}
|
||||
}
|
||||
d.revision += 1;
|
||||
}
|
||||
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -195,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();
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or
|
||||
//! shapes sized by its tone.
|
||||
//!
|
||||
//! The art is rendered offscreen and sampled once per halftone cell on a grid turned by the screen
|
||||
//! angle; each cell gets a shape whose area follows the tone there. Mono screens follow lightness
|
||||
//! in one colour; CMYK screens follow the art's ink amounts (as CMYK output separates it), one
|
||||
//! screen per ink at the classic angles, multiplied over each other. The dots can be clipped to the
|
||||
//! art's outline as real geometry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::isolated_doc;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"object.vectorHalftone",
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
}
|
||||
|
||||
/// Most halftone cells per screen.
|
||||
const MAX_CELLS: f64 = 100_000.0;
|
||||
/// Most pixels the tone is sampled from.
|
||||
const MAX_SAMPLE_PIXELS: f64 = 16.0e6;
|
||||
/// Cells lighter than this get no dot.
|
||||
const MIN_TONE: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum DotShape {
|
||||
Circle,
|
||||
Ellipse,
|
||||
Square,
|
||||
Diamond,
|
||||
Line,
|
||||
}
|
||||
|
||||
impl DotShape {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
Some(match s.to_ascii_lowercase().as_str() {
|
||||
"circle" | "round" | "dot" => DotShape::Circle,
|
||||
"ellipse" => DotShape::Ellipse,
|
||||
"square" => DotShape::Square,
|
||||
"diamond" => DotShape::Diamond,
|
||||
"line" | "lines" => DotShape::Line,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen
|
||||
/// running along x.
|
||||
fn path(self, t: f64, s: f64) -> PathData {
|
||||
let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side)));
|
||||
match self {
|
||||
DotShape::Circle => {
|
||||
let r = s * (t / std::f64::consts::PI).sqrt();
|
||||
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r)))
|
||||
}
|
||||
DotShape::Ellipse => {
|
||||
let r = s * (t / std::f64::consts::PI).sqrt();
|
||||
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3)))
|
||||
}
|
||||
DotShape::Square => square(s * t.sqrt()),
|
||||
DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)),
|
||||
// Cells along a row join into a line as thick as the tone.
|
||||
DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One screen: its ink and angle, and how to read its tone from a sampled pixel.
|
||||
struct Screen {
|
||||
paint: Color,
|
||||
angle: f64,
|
||||
/// Index into the sampled pixel (RGBA lightness for mono, CMYK inks for cmyk).
|
||||
channel: usize,
|
||||
}
|
||||
|
||||
/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt.
|
||||
struct Samples {
|
||||
px: Vec<u8>,
|
||||
w: usize,
|
||||
h: usize,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
/// Mono: tones are 1 − lightness of RGBA pixels; CMYK: ink amounts.
|
||||
mono: bool,
|
||||
invert: bool,
|
||||
}
|
||||
|
||||
impl Samples {
|
||||
/// The tone (0..1) of `channel` at document point `p`.
|
||||
fn tone(&self, p: Point, channel: usize) -> f64 {
|
||||
let x = ((p.x - self.region.x0) * self.scale).floor();
|
||||
let y = ((p.y - self.region.y0) * self.scale).floor();
|
||||
if !(x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h) {
|
||||
return if self.invert { 1.0 } else { 0.0 };
|
||||
}
|
||||
let i = (y as usize * self.w + x as usize) * 4;
|
||||
let byte = |k: usize| self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0;
|
||||
let t = if self.mono { 1.0 - (0.2126 * byte(0) + 0.7152 * byte(1) + 0.0722 * byte(2)) } else { byte(channel) };
|
||||
if self.invert { 1.0 - t } else { t }
|
||||
}
|
||||
|
||||
/// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples).
|
||||
fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 {
|
||||
let mut sum = 0.0;
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let off = Point::new((i as f64 - 1.0) * s / 3.0, (j as f64 - 1.0) * s / 3.0);
|
||||
sum += self.tone(c + (rot * off).to_vec2(), channel);
|
||||
}
|
||||
}
|
||||
sum / 9.0
|
||||
}
|
||||
}
|
||||
|
||||
fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.vectorHalftone";
|
||||
let shape = match str_param(p, "shape") {
|
||||
Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?,
|
||||
None => DotShape::Circle,
|
||||
};
|
||||
let frequency = f64_or(p, "frequency", 20.0);
|
||||
if !(1.0..=300.0).contains(&frequency) {
|
||||
return Err(bad(C, "frequency must be between 1 and 300 lines per inch"));
|
||||
}
|
||||
let angle = f64_or(p, "angle", 45.0);
|
||||
if !angle.is_finite() {
|
||||
return Err(bad(C, "invalid angle"));
|
||||
}
|
||||
let mono = match str_param(p, "mode").unwrap_or("mono") {
|
||||
"mono" => true,
|
||||
"cmyk" => false,
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))),
|
||||
};
|
||||
let color = match p.get("color") {
|
||||
Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?,
|
||||
None => Color::BLACK,
|
||||
};
|
||||
let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false));
|
||||
let cell = 72.0 / frequency;
|
||||
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "the selection has no bounds"))?;
|
||||
if b.width() <= 0.0 || b.height() <= 0.0 || !b.area().is_finite() {
|
||||
return Err(bad(C, "the selection has no area"));
|
||||
}
|
||||
// The grid turned by the screen angle covers at most (w + h)² / 2.
|
||||
if (b.width() + b.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS {
|
||||
return Err(bad(C, "too many dots for this size: lower the frequency"));
|
||||
}
|
||||
let nodes: Vec<Node> = roots
|
||||
.iter()
|
||||
.filter_map(|id| st.doc.node(*id).cloned())
|
||||
.map(|mut n| {
|
||||
n.visible = true;
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
let outline = if clip {
|
||||
vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Sample the art at about four pixels per cell.
|
||||
let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / b.area()).sqrt()).clamp(0.01, 16.0);
|
||||
let region = b.inflate(cell, cell);
|
||||
let tmp = isolated_doc(&st.doc, nodes);
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
let (w, h) = vectorcraft_render::region_pixels(region, scale);
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let px = if mono {
|
||||
r.render_region(&tmp, region, scale, true).pixels
|
||||
} else {
|
||||
r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() })
|
||||
};
|
||||
if px.len() < w * h * 4 {
|
||||
return Err(EngineError::Other("the art could not be sampled".into()));
|
||||
}
|
||||
let samples = Samples { px, w, h, region, scale, mono, invert };
|
||||
let screens: Vec<Screen> = if mono {
|
||||
vec![Screen { paint: color, angle, channel: 0 }]
|
||||
} else {
|
||||
// Bottom to top: yellow, magenta, cyan, black.
|
||||
vec![
|
||||
Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 },
|
||||
Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 },
|
||||
Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 },
|
||||
Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 },
|
||||
]
|
||||
};
|
||||
let mut layers = vec![];
|
||||
let mut dots = 0;
|
||||
for sc in &screens {
|
||||
let (path, n) = screen_dots(&samples, b, cell, sc, shape, outline.as_ref());
|
||||
dots += n;
|
||||
if !path.is_empty() {
|
||||
layers.push((path, sc.paint));
|
||||
}
|
||||
}
|
||||
if layers.is_empty() {
|
||||
return Err(EngineError::Other("the art is too light for any dots".into()));
|
||||
}
|
||||
let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
let id = s.edit("Vector Halftone", |d, sel| {
|
||||
let children = layers
|
||||
.into_iter()
|
||||
.map(|(path, ink)| {
|
||||
let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0));
|
||||
if !mono {
|
||||
n.blend = BlendMode::Multiply;
|
||||
}
|
||||
Arc::new(n)
|
||||
})
|
||||
.collect();
|
||||
let gid = d.alloc_id();
|
||||
let mut g = Node::group(gid, children);
|
||||
g.name = Some("Vector Halftone".into());
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
d.insert(par, idx + 1, g)?;
|
||||
if !keep {
|
||||
for r in &roots {
|
||||
d.remove(*r)?;
|
||||
}
|
||||
}
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "dots": dots }))
|
||||
}
|
||||
|
||||
/// The dots of one screen over `bounds` as one path, with their count.
|
||||
fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape, outline: Option<&(BezPath, FillRule)>) -> (PathData, usize) {
|
||||
let rot = Affine::rotate(sc.angle.to_radians());
|
||||
let inv = rot.inverse();
|
||||
// The grid covers the bounds in screen space.
|
||||
let corners =
|
||||
[bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q);
|
||||
let (mut lo, mut hi) = (corners[0], corners[0]);
|
||||
for q in &corners[1..] {
|
||||
lo = Point::new(lo.x.min(q.x), lo.y.min(q.y));
|
||||
hi = Point::new(hi.x.max(q.x), hi.y.max(q.y));
|
||||
}
|
||||
let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1);
|
||||
let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1);
|
||||
let near = bounds.inflate(cell, cell);
|
||||
let (mut inside, mut edge) = (vec![], vec![]);
|
||||
for j in j0..=j1 {
|
||||
for i in i0..=i1 {
|
||||
let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell);
|
||||
if !near.contains(c) {
|
||||
continue;
|
||||
}
|
||||
let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0);
|
||||
if t < MIN_TONE {
|
||||
continue;
|
||||
}
|
||||
let place = Affine::translate(c.to_vec2()) * rot;
|
||||
let dot = shape.path(t, cell).transformed(place);
|
||||
// Dots wholly inside the outline need no clipping.
|
||||
let whole = match (outline, dot.control_bounds()) {
|
||||
(Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)]
|
||||
.iter()
|
||||
.all(|q| fill_contains(bp, *rule, *q)),
|
||||
_ => true,
|
||||
};
|
||||
if whole { inside.push(dot) } else { edge.push(dot) }
|
||||
}
|
||||
}
|
||||
let count = inside.len() + edge.len();
|
||||
let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect();
|
||||
if let Some((bp, rule)) = outline
|
||||
&& !edge.is_empty()
|
||||
{
|
||||
let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect());
|
||||
let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect);
|
||||
subs.extend(clipped.subpaths);
|
||||
} else {
|
||||
subs.extend(edge.into_iter().flat_map(|p| p.subpaths));
|
||||
}
|
||||
(PathData::new(subs), count)
|
||||
}
|
||||
+739
-125
File diff suppressed because it is too large.
Load diff
@@ -86,9 +86,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reset Bounding Box",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{} no-op: VectorCraft bounding boxes are always axis-aligned to the document → {changed: 0}",
|
||||
"{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}",
|
||||
has_selection,
|
||||
|_, _| Ok(json!({ "changed": 0 }))
|
||||
reset_bounding_box
|
||||
),
|
||||
cmd!(
|
||||
"object.rasterize",
|
||||
@@ -177,6 +177,25 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reset Bounding Box ----------
|
||||
|
||||
fn reset_bounding_box(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let turned: Vec<NodeId> = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect();
|
||||
if !turned.is_empty() {
|
||||
s.edit("Reset Bounding Box", |d, _| {
|
||||
for id in &turned {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.bbox_angle = 0.0;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "changed": turned.len() }))
|
||||
}
|
||||
|
||||
// ---------- Lock / Hide ----------
|
||||
|
||||
fn above(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
mod create;
|
||||
mod css;
|
||||
mod cut;
|
||||
pub(crate) mod distortcmds;
|
||||
mod docinfo;
|
||||
mod docmenu;
|
||||
@@ -26,6 +27,7 @@ mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
pub mod help;
|
||||
mod layer;
|
||||
pub mod links;
|
||||
@@ -44,10 +46,14 @@ mod path;
|
||||
mod pathops;
|
||||
mod patterncmds;
|
||||
pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
mod place;
|
||||
pub mod plugin;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
pub(crate) mod printadvanced;
|
||||
pub mod printpresets;
|
||||
pub mod printtiling;
|
||||
pub mod rasterfx;
|
||||
mod recolor;
|
||||
pub mod recovery;
|
||||
@@ -241,6 +247,11 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(assets::specs());
|
||||
v.extend(css::specs());
|
||||
v.extend(printpresets::specs());
|
||||
v.extend(printtiling::specs());
|
||||
v.extend(plugin::specs());
|
||||
v.extend(cut::specs());
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeId, NodeKind, OrientedBox, Scaling};
|
||||
use vectorcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
@@ -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
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rotate…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{angle: deg (counter-clockwise), origin?: [x,y], copy?}",
|
||||
"{angle: deg (counter-clockwise), absolute?: bool (angle is the bounding box's new angle, not an amount), origin?: [x,y] (default: the bounding box centre), copy?} → {ids}",
|
||||
has_selection,
|
||||
rotate
|
||||
),
|
||||
@@ -175,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Bounds",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
|
||||
has_selection,
|
||||
set_bounds
|
||||
),
|
||||
@@ -188,7 +188,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
|
||||
if let Some(o) = point_param(p, "origin") {
|
||||
return Ok(o);
|
||||
}
|
||||
s.transform_bounds(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
|
||||
s.transform_box(ids).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
|
||||
}
|
||||
|
||||
/// What a transform command scales besides geometry: Scale Strokes & Effects and Scale Corners from
|
||||
@@ -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") {
|
||||
@@ -255,8 +270,12 @@ fn about(o: Point, a: Affine) -> Affine {
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let o = origin_of(s, p, &ids)?;
|
||||
// Illustrator angles are counter-clockwise; y is down, so negate.
|
||||
let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians()));
|
||||
let mut angle = f64_or(p, "angle", 0.0);
|
||||
if bool_or(p, "absolute", false) {
|
||||
angle = vectorcraft_geom::normalize_deg(angle - s.doc()?.doc.bbox_angle(&ids));
|
||||
}
|
||||
// Angles are counter-clockwise; y is down, so negate.
|
||||
let a = about(o, Affine::rotate(-angle.to_radians()));
|
||||
apply_transform(s, "Rotate", ids, a, p)
|
||||
}
|
||||
|
||||
@@ -295,6 +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 }))
|
||||
@@ -367,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| {
|
||||
@@ -657,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;
|
||||
@@ -716,6 +745,11 @@ impl Session {
|
||||
pub fn transform_bounds(&self, ids: &[NodeId]) -> Option<Rect> {
|
||||
self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
/// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding
|
||||
/// box, see [`Document::oriented_bounds`]).
|
||||
pub fn transform_box(&self, ids: &[NodeId]) -> Option<OrientedBox> {
|
||||
self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure with preview (visual) bounds? The `bounds` param, else Use Preview Bounds.
|
||||
@@ -876,10 +910,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let g = d.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
|
||||
// the math below runs in its frame.
|
||||
let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
|
||||
let g = frame.rect;
|
||||
// With Use Preview Bounds the values measure the visual box, whose margins around the
|
||||
// geometric box (left, top, right, bottom) scale with the strokes or stay.
|
||||
let v = if s.prefs.use_preview_bounds { d.bounds_of(&ids, true).unwrap_or(g) } else { g };
|
||||
let v = if s.prefs.use_preview_bounds { d.oriented_bounds(&ids, true).map_or(g, |b| b.rect) } else { g };
|
||||
let m = [g.x0 - v.x0, g.y0 - v.y0, v.x1 - g.x1, v.y1 - g.y1];
|
||||
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
|
||||
let rp = vectorcraft_geom::reference_point(v, refi);
|
||||
@@ -906,9 +943,10 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// box's reference point moves with the art, the margin from it to the visual one by `f`.
|
||||
let gp = vectorcraft_geom::reference_point(g, refi);
|
||||
let new_rp = scale * gp + (rp - gp) * f(sx, sy);
|
||||
let tx = f64_or(p, "x", rp.x) - new_rp.x;
|
||||
let ty = f64_or(p, "y", rp.y) - new_rp.y;
|
||||
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, p)
|
||||
// x, y place the reference point on the page.
|
||||
let page = frame.to_doc() * rp;
|
||||
let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y)));
|
||||
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p)
|
||||
}
|
||||
|
||||
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -757,11 +757,7 @@ fn clean_up(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
visit(c, true, false, false, v, opts);
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
if empty_text && t.plain_text().trim().is_empty() {
|
||||
v.push(n.id);
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) if empty_text && t.plain_text().trim().is_empty() => v.push(n.id),
|
||||
NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
visit(c, false, false, false, v, opts);
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
//! View → Perspective Grid → Define Grid: `perspective.grid.get` reads the grid (the model, the
|
||||
//! Define Grid fields and the station point) and `perspective.grid.define` sets it from those
|
||||
//! fields. The model and the camera maths live in `vectorcraft_tools::distort::perspective`.
|
||||
//!
|
||||
//! Presets: the built-in views (generated in code, fitted to the first artboard, protected) and
|
||||
//! the user's, saved with the preferences ([`crate::Prefs::perspective_presets`]) as Define Grid
|
||||
//! fields; `perspective.grid.preset` applies one, `perspective.presets.*` manage them.
|
||||
//!
|
||||
//! View options (Lock Grid, Lock Station Point, Snap to Grid, Show Rulers) are view state on
|
||||
//! the grid: they toggle without an undo step, as Show Grid does.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
use vectorcraft_tools::distort::perspective::Plane;
|
||||
use vectorcraft_tools::distort::perspective::define::{BUILTINS, LETTER, first_artboard, is_builtin};
|
||||
use vectorcraft_tools::distort::perspective::widget::{WidgetCorner, WidgetPlace};
|
||||
use vectorcraft_tools::distort::perspective::{GridDefinition, PerspectiveGrid};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::distortcmds::{grid_of, silent, store_grid};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "perspective.grid.get",
|
||||
"Perspective Grid",
|
||||
[],
|
||||
None,
|
||||
"{} → {grid: the model (as perspective.grid.set takes it), define: the Define Grid fields (as perspective.grid.define takes them), station: {x, y, distance} (the viewer: centre of vision on the horizon, distance to the picture plane, pt), defined: false while the document uses the default grid}",
|
||||
has_doc,
|
||||
grid_get
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.define",
|
||||
"Define Perspective Grid",
|
||||
[],
|
||||
None,
|
||||
"{name?: preset name (\"\" = custom), kind?: 1|2|3, units?: unit name (points, pixels, inches, centimeters, millimeters…), scale?: [artboard, real world], gridline?: gridline every, angle?: viewing angle (0–90°), distance?: viewing distance, horizonHeight?: above the ground level, thirdVp?: [x right, y up] from the centre of vision (3-point), leftColor?, rightColor?, groundColor?: \"#rrggbb\", opacity?: 0–100} lengths are real-world lengths in `units` at `scale`; missing fields keep the grid's (see perspective.grid.get define). Only what changes changes: a new type, angle or distance moves the vanishing points around the station point. One undo step → the new define fields",
|
||||
has_doc,
|
||||
grid_define
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.list",
|
||||
"Perspective Grid Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{builtIn, name, kind, …the perspective.grid.define fields}]} the built-in views (fitted to the first artboard, protected) first, then the saved presets",
|
||||
always,
|
||||
presets_list
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.save",
|
||||
"Save Perspective Grid Preset",
|
||||
[],
|
||||
None,
|
||||
"{name?: (default: a new \"Perspective Preset N\"), newName?: rename it, preset?: the preset to start from (default: the saved preset `name`, else the document's grid), …the perspective.grid.define fields (over it)} create or change a saved preset (Save Grid as Preset: just `name`); built-in ones are protected → {name, created, definition}",
|
||||
always,
|
||||
presets_save
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.delete",
|
||||
"Delete Perspective Grid Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} delete a saved preset (built-in ones stay) → {deleted: name}",
|
||||
always,
|
||||
presets_delete
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.export",
|
||||
"Export Perspective Grid Presets",
|
||||
[],
|
||||
None,
|
||||
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcperspective file (JSON) → {path, count}; without path → {data: the file's text, count}",
|
||||
always,
|
||||
presets_export
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.presets.import",
|
||||
"Import Perspective Grid Presets",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcperspective file (as perspective.presets.export writes) to the saved ones → {imported: [names]}",
|
||||
always,
|
||||
presets_import
|
||||
),
|
||||
cmd!(
|
||||
query "perspective.widget.options",
|
||||
"Perspective Grid Options",
|
||||
[],
|
||||
None,
|
||||
"{show?: bool, position?: topLeft|topRight|bottomLeft|bottomRight} the Plane Switching Widget: shown or not, and the corner of the document window it stays in (kept with the preferences; no params reads them) → {show, position}",
|
||||
always,
|
||||
widget_options
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.lock",
|
||||
"Lock Grid",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool} (no param toggles) View › Perspective Grid › Lock Grid: the grid's widgets can't be dragged (the Plane Switching Widget still works). View state: not an undo step → {on}",
|
||||
has_doc,
|
||||
lock
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.lockStation",
|
||||
"Lock Station Point",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool} (no param toggles) dragging one vanishing point moves the other around the station point (the viewing angle turns, the viewer stays). View state: not an undo step → {on}",
|
||||
has_doc,
|
||||
lock_station
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.snap",
|
||||
"Snap to Grid",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool} (no param toggles) art drawn or moved in perspective lands on gridlines within a quarter cell (on by default). View state: not an undo step → {on}",
|
||||
has_doc,
|
||||
snap
|
||||
),
|
||||
cmd!(
|
||||
"perspective.grid.rulers",
|
||||
"Show Rulers",
|
||||
[],
|
||||
None,
|
||||
"{on?: bool} (no param toggles) a ruler up the line where the planes meet, in the grid's units at its scale. View state: not an undo step → {on}",
|
||||
has_doc,
|
||||
rulers
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `format` of a perspective grid presets file, also its extension.
|
||||
pub const PRESET_FORMAT: &str = "vcperspective";
|
||||
|
||||
/// The extensions of perspective grid presets files (opening one imports it).
|
||||
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
|
||||
|
||||
/// The most presets one file imports.
|
||||
const MAX_IMPORT: usize = 1000;
|
||||
|
||||
/// What `perspective.presets.export` writes and `perspective.presets.import` reads.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PresetFile {
|
||||
format: String,
|
||||
#[serde(default)]
|
||||
presets: Vec<Value>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The artboard presets are fitted to: the active document's first (a Letter page without).
|
||||
fn preset_artboard(&self) -> Rect {
|
||||
self.active().map_or(LETTER, |d| first_artboard(&d.doc))
|
||||
}
|
||||
|
||||
/// Every perspective grid preset: the built-in views, then the saved ones.
|
||||
pub fn perspective_presets(&self) -> Vec<GridDefinition> {
|
||||
let ab = self.preset_artboard();
|
||||
let builtins = BUILTINS.iter().filter_map(|b| PerspectiveGrid::builtin(b.0, ab)).map(|g| g.definition());
|
||||
builtins.chain(self.prefs.perspective_presets.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
/// The preset `name` (any case): a built-in view or a saved one.
|
||||
pub fn perspective_preset(&self, name: &str) -> Option<GridDefinition> {
|
||||
match PerspectiveGrid::builtin(name, self.preset_artboard()) {
|
||||
Some(g) => Some(g.definition()),
|
||||
None => self.persp_saved_index(name).and_then(|i| self.prefs.perspective_presets.get(i)).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid preset `name` makes on `ab`: a built-in view, or a saved preset's fields over the
|
||||
/// normal view of its type.
|
||||
pub fn perspective_preset_grid(&self, name: &str, ab: Rect) -> Option<PerspectiveGrid> {
|
||||
if let Some(g) = PerspectiveGrid::builtin(name, ab) {
|
||||
return Some(g);
|
||||
}
|
||||
let def = self.perspective_preset(name)?;
|
||||
PerspectiveGrid::normal(def.kind, ab).with_definition(&def).ok()
|
||||
}
|
||||
|
||||
/// The first free "Perspective Preset N": the name a new preset gets.
|
||||
pub fn new_perspective_preset_name(&self) -> String {
|
||||
(1..).map(|i| format!("Perspective Preset {i}")).find(|n| !self.persp_preset_taken(n, None)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The index of the saved preset `name` (any case).
|
||||
fn persp_saved_index(&self, name: &str) -> Option<usize> {
|
||||
self.prefs.perspective_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Whether `name` is a built-in view's or a saved preset's other than the one at `except`.
|
||||
fn persp_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
|
||||
is_builtin(name) || self.persp_saved_index(name).is_some_and(|i| Some(i) != except)
|
||||
}
|
||||
|
||||
/// The name of the first preset among `candidates` whose fields `def` has ("" for none).
|
||||
fn preset_named(&self, def: &GridDefinition, candidates: &[&str]) -> String {
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|n| !n.is_empty())
|
||||
.find_map(|n| self.perspective_preset(n).filter(|p| p.same(def)))
|
||||
.map(|p| p.name)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What `perspective.grid.get` reports for `g`.
|
||||
pub(crate) fn grid_info(doc: &vectorcraft_doc::Document, g: &PerspectiveGrid) -> Value {
|
||||
let st = g.station();
|
||||
json!({
|
||||
"grid": g.definition_json(),
|
||||
"define": g.definition(),
|
||||
"station": {"x": st.x, "y": g.horizon, "distance": st.distance},
|
||||
"defined": PerspectiveGrid::from_doc(doc).is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
fn grid_get(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
Ok(grid_info(doc, &grid_of(doc)))
|
||||
}
|
||||
|
||||
fn grid_define(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.grid.define";
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let def: GridDefinition = old.definition().merged(p).map_err(|e| bad(C, e))?;
|
||||
let mut g = old.with_definition(&def).map_err(|e| bad(C, e))?;
|
||||
// The grid is the preset it was given or came from while it has that preset's fields.
|
||||
g.name = s.preset_named(&g.definition(), &[&def.name, &old.name]);
|
||||
if g != old {
|
||||
s.edit("Define Perspective Grid", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!(g.definition()))
|
||||
}
|
||||
|
||||
/// `perspective.grid.preset`: reset the grid to a preset fitted to the first artboard (shown;
|
||||
/// the attached objects stay attached).
|
||||
pub(crate) fn grid_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.grid.preset";
|
||||
let name = match (str_param(p, "name"), p.get("kind").and_then(Value::as_u64)) {
|
||||
(Some(n), _) => n.to_string(),
|
||||
(None, Some(k @ 1..=3)) => PerspectiveGrid::normal(k as u8, LETTER).name,
|
||||
_ => return Err(bad(C, "kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list)")),
|
||||
};
|
||||
let old = grid_of(&s.doc()?.doc);
|
||||
let ab = s.doc()?.doc.artboards.first().map(|a| a.rect).ok_or_else(|| EngineError::Other("no artboard".into()))?;
|
||||
let g = s.perspective_preset_grid(&name, ab).ok_or_else(|| bad(C, format!("no perspective grid preset named `{name}`")))?;
|
||||
// The objects stay attached and the view options stay as they were.
|
||||
let g = PerspectiveGrid { attached: old.attached, locked: old.locked, lock_station: old.lock_station, snap: old.snap, rulers: old.rulers, ..g };
|
||||
s.edit("Perspective Grid Preset", |d, _| {
|
||||
store_grid(d, &g);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(g.definition_json())
|
||||
}
|
||||
|
||||
fn presets_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let rows: Vec<Value> = s
|
||||
.perspective_presets()
|
||||
.into_iter()
|
||||
.map(|d| {
|
||||
let mut v = json!(d);
|
||||
v["builtIn"] = json!(is_builtin(&d.name));
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "presets": rows }))
|
||||
}
|
||||
|
||||
fn presets_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.save";
|
||||
let name = match str_param(p, "name").map(str::trim) {
|
||||
Some("") => return Err(bad(C, "`name` is empty")),
|
||||
Some(n) => n.to_string(),
|
||||
None => s.new_perspective_preset_name(),
|
||||
};
|
||||
if is_builtin(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and can't change: save it under another name")));
|
||||
}
|
||||
let at = s.persp_saved_index(&name);
|
||||
let saved = at.and_then(|i| s.prefs.perspective_presets.get(i)).cloned();
|
||||
// A saved preset changes from its own fields unless `preset` says where to start.
|
||||
let base = match (str_param(p, "preset"), &saved) {
|
||||
(Some(b), _) => {
|
||||
s.perspective_preset(b).ok_or_else(|| bad(C, format!("no perspective grid preset named `{b}` (see perspective.presets.list)")))?
|
||||
}
|
||||
(None, Some(old)) => old.clone(),
|
||||
(None, None) => s.active().map(|d| grid_of(&d.doc).definition()).unwrap_or_default(),
|
||||
};
|
||||
let mut def = base.merged(p).map_err(|e| bad(C, e))?;
|
||||
def.name = match str_param(p, "newName").map(str::trim) {
|
||||
Some("") => return Err(bad(C, "`newName` is empty")),
|
||||
Some(n) if s.persp_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
|
||||
Some(n) => n.to_string(),
|
||||
None => saved.map_or(name, |old| old.name),
|
||||
};
|
||||
let r = json!({ "name": def.name, "created": at.is_none(), "definition": def });
|
||||
match at.and_then(|i| s.prefs.perspective_presets.get_mut(i)) {
|
||||
Some(slot) => *slot = def,
|
||||
None => s.prefs.perspective_presets.push(def),
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn presets_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.delete";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
if is_builtin(name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
|
||||
}
|
||||
let i = s.persp_saved_index(name).ok_or_else(|| bad(C, format!("no saved preset named `{name}`")))?;
|
||||
Ok(json!({ "deleted": s.prefs.perspective_presets.remove(i).name }))
|
||||
}
|
||||
|
||||
fn presets_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.export";
|
||||
let presets = match p.get("names").and_then(Value::as_array) {
|
||||
Some(names) => {
|
||||
let find = |n: &Value| {
|
||||
let n = n.as_str().unwrap_or_default();
|
||||
s.perspective_preset(n).ok_or_else(|| bad(C, format!("no perspective grid preset named `{n}`")))
|
||||
};
|
||||
names.iter().map(find).collect::<Result<Vec<_>>>()?
|
||||
}
|
||||
None => s.prefs.perspective_presets.clone(),
|
||||
};
|
||||
if presets.is_empty() {
|
||||
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
|
||||
}
|
||||
let count = presets.len();
|
||||
let presets = presets.iter().map(|d| json!(d)).collect();
|
||||
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
super::fileio::write_file(path, text.as_bytes())?;
|
||||
Ok(json!({"path": path, "count": count}))
|
||||
}
|
||||
None => Ok(json!({"data": text, "count": count})),
|
||||
}
|
||||
}
|
||||
|
||||
fn presets_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.presets.import";
|
||||
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
|
||||
(Some(path), ..) => super::fileio::read_file(path)?,
|
||||
(None, Some(text), _) => text.as_bytes().to_vec(),
|
||||
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
|
||||
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
|
||||
};
|
||||
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a perspective grid presets file: {e}")))?;
|
||||
if file.format != PRESET_FORMAT {
|
||||
return Err(bad(C, format!("not a perspective grid presets file (format `{}`)", file.format)));
|
||||
}
|
||||
if file.presets.len() > MAX_IMPORT {
|
||||
return Err(bad(C, format!("more than {MAX_IMPORT} presets")));
|
||||
}
|
||||
// Every preset is checked as Define Grid checks its fields before any is added.
|
||||
let base = GridDefinition::default();
|
||||
let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?"))));
|
||||
let presets = file.presets.iter().map(check).collect::<Result<Vec<_>>>()?;
|
||||
let replace = bool_or(p, "replace", false);
|
||||
let mut imported = vec![];
|
||||
for mut preset in presets {
|
||||
// A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays.
|
||||
let name = preset.name.trim().trim_start_matches('[').trim_end_matches(']').trim();
|
||||
let base = Some(name).filter(|n| !n.is_empty()).unwrap_or("Perspective Preset").to_string();
|
||||
match s.persp_saved_index(&base).filter(|_| replace) {
|
||||
Some(i) => {
|
||||
if let Some(slot) = s.prefs.perspective_presets.get_mut(i) {
|
||||
preset.name = slot.name.clone();
|
||||
imported.push(preset.name.clone());
|
||||
*slot = preset;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
preset.name = unique_name(&base, |n| s.persp_preset_taken(n, None));
|
||||
imported.push(preset.name.clone());
|
||||
s.prefs.perspective_presets.push(preset);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({ "imported": imported }))
|
||||
}
|
||||
|
||||
/// Toggle (or set, with `on`) a view option of the grid.
|
||||
fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result<Value> {
|
||||
let want = p.get("on").and_then(Value::as_bool);
|
||||
let mut g = silent(s, |g| {
|
||||
let f = field(g);
|
||||
*f = want.unwrap_or(!*f);
|
||||
})?;
|
||||
Ok(json!({ "on": *field(&mut g) }))
|
||||
}
|
||||
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.locked)
|
||||
}
|
||||
|
||||
fn lock_station(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.lock_station)
|
||||
}
|
||||
|
||||
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.snap)
|
||||
}
|
||||
|
||||
fn rulers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
view_option(s, p, |g| &mut g.rulers)
|
||||
}
|
||||
|
||||
/// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`):
|
||||
/// the plane and depth of the first one and the plane-space correction that lands the edge of the
|
||||
/// joint bounds of the objects there nearer a gridline on it.
|
||||
pub(crate) fn snap_fix(doc: &Document, ids: &[NodeId], p: &Value, (from, to): (Point, Point)) -> Option<(Plane, f64, Vec2)> {
|
||||
let g = grid_of(doc);
|
||||
if !bool_or(p, "snap", g.snap) {
|
||||
return None;
|
||||
}
|
||||
let (plane, depth, b) = g.plane_bounds(doc, ids)?;
|
||||
let dv = g.plane_delta(plane, depth, from, to)?;
|
||||
Some((plane, depth, g.snap_offset(b, dv) - dv))
|
||||
}
|
||||
|
||||
fn widget_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "perspective.widget.options";
|
||||
let mut o = s.prefs.perspective_widget;
|
||||
if let Some(v) = p.get("show") {
|
||||
o.show = v.as_bool().ok_or_else(|| bad(C, "show must be true or false"))?;
|
||||
}
|
||||
if let Some(v) = p.get("position") {
|
||||
o.position =
|
||||
v.as_str().and_then(WidgetCorner::parse).ok_or_else(|| bad(C, "position must be topLeft, topRight, bottomLeft or bottomRight"))?;
|
||||
}
|
||||
s.prefs.perspective_widget = o;
|
||||
Ok(json!(o))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The Plane Switching Widget's part of a pointer event: a press on it picks the plane (its
|
||||
/// drag and release go nowhere); None when the event isn't the widget's.
|
||||
pub(crate) fn plane_widget_pointer(&mut self, ev: &PointerEvent, view: crate::ViewInfo) -> Option<Result<Vec<crate::UiRequest>>> {
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let w = self.prefs.perspective_widget;
|
||||
let st = self.active().filter(|_| w.show)?;
|
||||
let g = PerspectiveGrid::current(&st.doc);
|
||||
if !g.shown(self.tool_id()) {
|
||||
return None;
|
||||
}
|
||||
let place = WidgetPlace { screen: view.screen.as_ref(), corner: w.position };
|
||||
let plane = g.widget_hit_at(&st.doc, 1.0 / view.zoom.max(1e-9), place, ev.pos)?;
|
||||
self.plane_widget_press = true;
|
||||
Some(self.execute("perspective.plane.set", &json!({ "plane": plane.id() })).map(|_| vec![]))
|
||||
}
|
||||
PointerKind::Drag if self.plane_widget_press => Some(Ok(vec![])),
|
||||
PointerKind::Up if std::mem::take(&mut self.plane_widget_press) => Some(Ok(vec![])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The plane digit key `key` picks while the grid is shown: 1 left, 2 horizontal (ground),
|
||||
/// 3 right, 4 none.
|
||||
pub(crate) fn plane_key(&self, key: ToolKey) -> Option<Plane> {
|
||||
let plane = match key {
|
||||
ToolKey::Digit(1) => Plane::Left,
|
||||
ToolKey::Digit(2) => Plane::Ground,
|
||||
ToolKey::Digit(3) => Plane::Right,
|
||||
ToolKey::Digit(4) => Plane::None,
|
||||
_ => return None,
|
||||
};
|
||||
PerspectiveGrid::current(&self.active()?.doc).shown(self.tool_id()).then_some(plane)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! `plugin.*` commands: sandboxed WebAssembly plug-ins (`vectorcraft-plugins`).
|
||||
//!
|
||||
//! Plug-ins are installed per process (`plugin.install`, or every `*.wasm` in the Additional
|
||||
//! Plug-ins Folder preference) and listed with `plugin.list` / `plugin.info`. Object filters run
|
||||
//! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a
|
||||
//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any
|
||||
//! effect (`effect.apply {effect: "plugin.<id>"}`) and drawn by `vectorcraft-effects`.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_plugins::{Kind, Plugin, effect, objects, registry};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// What a plug-in error looks like to the user.
|
||||
fn err(e: vectorcraft_plugins::Error) -> EngineError {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
|
||||
/// JSON description of an installed plug-in.
|
||||
pub fn describe(p: &Plugin) -> Value {
|
||||
let m = p.manifest();
|
||||
let mut v = json!({
|
||||
"id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description,
|
||||
"params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()),
|
||||
"size": p.size(), "source": p.source(),
|
||||
});
|
||||
if m.kind == Kind::Effect {
|
||||
v["effect"] = json!(effect::effect_id(&m.id));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The plug-in's own parameters from command params: `params` if given, else the top-level keys
|
||||
/// minus the ones the command itself uses.
|
||||
fn plugin_params(p: &Value) -> Value {
|
||||
if let Some(v) = p.get("params") {
|
||||
return v.clone();
|
||||
}
|
||||
match p {
|
||||
Value::Object(m) => {
|
||||
Value::Object(m.iter().filter(|(k, _)| !matches!(k.as_str(), "id" | "ids")).map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
_ => Value::Object(Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_by_id(cmd: &str, p: &Value) -> Result<Arc<Plugin>> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad(cmd, "missing `id` (see plugin.list)"))?;
|
||||
registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into())))
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.run";
|
||||
let plugin = plugin_by_id(C, p)?;
|
||||
let m = plugin.manifest();
|
||||
if m.kind != Kind::Filter {
|
||||
return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id))));
|
||||
}
|
||||
// Bad parameters fail before anything runs.
|
||||
let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?);
|
||||
let st = s.doc()?;
|
||||
let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone());
|
||||
let inputs = objects::filter_targets(&st.doc, &selected);
|
||||
let parent = st.insertion_parent();
|
||||
let bounds = st.doc.bounds_of(&inputs, false);
|
||||
let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first());
|
||||
let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect))});
|
||||
let input = objects::encode_input(&st.doc, &inputs);
|
||||
let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?;
|
||||
let out = objects::decode(&plugin.run(&input, ¶ms).map_err(err)?).map_err(err)?;
|
||||
let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| {
|
||||
let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?;
|
||||
sel.set(ids.iter().copied());
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Largest base64 payload `plugin.install` accepts (the module limit, encoded).
|
||||
const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4;
|
||||
|
||||
/// The `.wasm` extension plug-in files have (File › Open installs them).
|
||||
pub const EXTS: &[&str] = &["wasm"];
|
||||
|
||||
fn install(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let plugin = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) {
|
||||
(_, Some(data)) => {
|
||||
if data.len() > MAX_DATA_CHARS {
|
||||
return Err(bad(C, "`dataBase64` is larger than the module limit"));
|
||||
}
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?;
|
||||
let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?;
|
||||
match str_param(p, "name") {
|
||||
Some(name) => plugin.with_source(name),
|
||||
None => plugin,
|
||||
}
|
||||
}
|
||||
(Some(path), None) => load_path(path)?,
|
||||
(None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")),
|
||||
};
|
||||
if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_path(path: &str) -> Result<Plugin> {
|
||||
registry::load_file(std::path::Path::new(path), vectorcraft_plugins::Limits::default()).map_err(err)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_path(_: &str) -> Result<Plugin> {
|
||||
Err(EngineError::Other("installing from a path is not available on the web; pass the module as `dataBase64`".into()))
|
||||
}
|
||||
|
||||
/// The plug-in folder last loaded from the preferences, and what happened.
|
||||
static FOLDER: Mutex<Option<(String, Value)>> = Mutex::new(None);
|
||||
|
||||
/// Loads the Additional Plug-ins Folder preference (`*.wasm`) when it is set and has changed since
|
||||
/// the last load. Runs whenever preferences are applied (startup, `prefs.set`). Native only.
|
||||
pub(crate) fn sync_prefs(s: &Session) {
|
||||
let folder = s.prefs.plugins_folder.trim();
|
||||
if folder.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut last = FOLDER.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if last.as_ref().is_some_and(|(f, _)| f == folder) {
|
||||
return;
|
||||
}
|
||||
*last = Some((folder.to_string(), load_folder(folder)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
match registry::load_folder(std::path::Path::new(folder)) {
|
||||
Ok(r) => {
|
||||
json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::<Vec<_>>()})
|
||||
}
|
||||
Err(e) => json!({"folder": folder, "error": e.to_string()}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn load_folder(folder: &str) -> Value {
|
||||
json!({"folder": folder, "error": "plug-in folders are not available on the web"})
|
||||
}
|
||||
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.reload";
|
||||
let folder = match p.get("path") {
|
||||
Some(v) => v.as_str().map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "`path` must be a folder"))?.to_string(),
|
||||
None => s.prefs.plugins_folder.trim().to_string(),
|
||||
};
|
||||
if folder.is_empty() {
|
||||
return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder"));
|
||||
}
|
||||
let report = load_folder(&folder);
|
||||
if let Some(e) = report.get("error").and_then(Value::as_str) {
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
*FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, report.clone()));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, r)| r.clone());
|
||||
Ok(json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder}))
|
||||
}
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plugin = plugin_by_id("plugin.info", p)?;
|
||||
let mut v = describe(&plugin);
|
||||
v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null);
|
||||
v["defaults"] = Value::Object(plugin.manifest().defaults());
|
||||
v["lastError"] = json!(effect::last_error(plugin.id()));
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn remove(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "plugin.list",
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.info",
|
||||
"Plug-in Info",
|
||||
[],
|
||||
None,
|
||||
"{id} → plugin.list's entry plus {manifest, defaults, lastError (a live effect's last failure, null)}",
|
||||
always,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"plugin.run",
|
||||
"Run Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id, params?: {…} (the plug-in's parameters; or give them as top-level keys; missing ones take their defaults, see plugin.info), ids?: [..] (default: the selection)} run an object filter on the selected paths and compound paths (inside selected groups and layers too; hidden, locked, guide and clipping paths are left out): it may change, delete or add objects, as one undo step; the result is selected. With nothing selected it gets no objects (generators add theirs to the current layer) → {plugin, ids}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.install",
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
|
||||
cmd!(
|
||||
query "plugin.reload",
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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)),
|
||||
@@ -320,6 +325,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
// Native saves (`document.save {compress}`)
|
||||
p!("useCompression", "File Handling", "Files", "Use Compression", bool),
|
||||
// The W/H link of the Transform panel, the Properties panel and the Control bar
|
||||
p!("constrainProportions", "General", "Transform Panel", "Constrain Width and Height Proportions", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
@@ -327,10 +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> {
|
||||
@@ -339,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}`")),
|
||||
}
|
||||
}
|
||||
@@ -378,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")),
|
||||
@@ -453,6 +475,8 @@ impl Session {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
// Plug-ins in a newly set Additional Plug-ins Folder are installed.
|
||||
super::plugin::sync_prefs(self);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ use vectorcraft_pdf::{PrintOptions, PrintSettings};
|
||||
|
||||
use super::fileio::pdf::{merge, pdf_error};
|
||||
use super::fileio::{extension, write_or_return};
|
||||
use super::flatten::{FlattenOptions, flatten_document};
|
||||
use super::*;
|
||||
|
||||
const SETUP: &str = "print.setup";
|
||||
@@ -22,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values in separations)}}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
"{settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has (null keeps a value), as one undo step; no settings → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
@@ -53,7 +52,7 @@ fn saved(doc: &Document) -> Value {
|
||||
}
|
||||
|
||||
/// The settings of `doc` with `p.settings` over them, checked.
|
||||
fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
pub(crate) fn settings(cmd: &str, doc: &Document, p: &Value) -> Result<PrintSettings> {
|
||||
settings_over(cmd, saved(doc), p)
|
||||
}
|
||||
|
||||
@@ -69,7 +68,7 @@ pub(crate) fn settings_over(cmd: &str, mut v: Value, p: &Value) -> Result<PrintS
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
pub(crate) fn to_json(set: &PrintSettings) -> Result<Value> {
|
||||
serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -90,6 +89,7 @@ fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "print.preview";
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(C, doc, p)?;
|
||||
super::printadvanced::flattener(C, &set, &s.prefs.flattener_presets)?;
|
||||
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
let mut v = serde_json::to_value(pv).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
v["settings"] = to_json(&set)?;
|
||||
@@ -110,25 +110,25 @@ fn to_postscript(p: &Value) -> Result<bool> {
|
||||
|
||||
fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let postscript = to_postscript(p)?;
|
||||
let flattener = if postscript {
|
||||
let preset = str_param(p, "flattenerPreset").unwrap_or("medium");
|
||||
Some(
|
||||
FlattenOptions::find_preset(preset, &s.prefs.flattener_presets)
|
||||
.ok_or_else(|| bad(PRINT, format!("flattenerPreset `{preset}`: high, medium, low or a saved one (see flattener.presets.list)")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let set = settings(PRINT, doc, p)?;
|
||||
let mut set = settings(PRINT, doc, p)?;
|
||||
if postscript {
|
||||
// PostScript has no transparency: `flattenerPreset`, else the Advanced preset, else medium.
|
||||
let given = str_param(p, "flattenerPreset").filter(|n| !n.trim().is_empty());
|
||||
let preset = given.or(Some(set.advanced.flattener_preset.as_str()).filter(|n| !n.trim().is_empty())).unwrap_or("medium");
|
||||
set.advanced.flattener_preset = preset.to_string();
|
||||
}
|
||||
// Raster effects print as images at the document's raster effects resolution.
|
||||
let flat = super::rasterfx::flatten_raster_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
// Print as Bitmap and the flattener preset (Advanced) render and flatten it first.
|
||||
let prepared = super::printadvanced::prepare(PRINT, doc, &set, &s.prefs.flattener_presets)?;
|
||||
let path = str_param(p, "path");
|
||||
let Some(flattener) = flattener else {
|
||||
let r = vectorcraft_pdf::print(doc, &PrintOptions { settings: set, ..Default::default() }).map_err(|e| pdf_error(PRINT, e))?;
|
||||
if !postscript {
|
||||
let r = vectorcraft_pdf::print(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
return write_or_return(path, &r.bytes, json!({ "pages": r.pages, "format": "pdf", "warnings": r.warnings }));
|
||||
};
|
||||
}
|
||||
let level = match p.get("level").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let t = super::fileio::dxf::text(v);
|
||||
@@ -136,14 +136,16 @@ fn print(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => Level::default(),
|
||||
};
|
||||
// PostScript has no transparency.
|
||||
let flat = flatten_document(doc, &flattener)?;
|
||||
let mut warnings = vec![];
|
||||
if flat.is_some() && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
let bitmap = set.advanced.print_as_bitmap && set.output.mode == vectorcraft_pdf::OutputMode::Composite;
|
||||
if !bitmap && doc.layers.iter().any(|l| l.shows_transparency()) {
|
||||
warnings.push("transparency is flattened into opaque art and images (PostScript has none): see flattenerPreset".to_string());
|
||||
}
|
||||
if !set.color.profile.trim().is_empty() && set.output.mode == vectorcraft_pdf::OutputMode::Composite {
|
||||
warnings.push("the printer profile converts the colours of PDF output: PostScript prints the document's colours".to_string());
|
||||
}
|
||||
let flatness = (!set.graphics.auto_flatness).then_some(set.graphics.flatness);
|
||||
let plan = vectorcraft_pdf::plan(flat.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
let plan = vectorcraft_pdf::plan(prepared.as_ref().unwrap_or(doc), &PrintOptions { settings: set, postscript: true, ..Default::default() })
|
||||
.map_err(|e| pdf_error(PRINT, e))?;
|
||||
let pages: Vec<PrintPage> = plan
|
||||
.sheets
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! File → Print › Advanced, what needs the renderer and the flattener (the PDF writer does the
|
||||
//! rest): Print as Bitmap renders each printed region as one image, and a flattener preset
|
||||
//! flattens transparency, both on the document as it prints (only the layers that print,
|
||||
//! overprints discarded or simulated in composite output) before it is laid out on paper.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::marks::outset;
|
||||
use vectorcraft_doc::{Document, ImageBlob, ImageObject, LayerColor, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
use vectorcraft_pdf::{OutputMode, PrintOverprints, PrintSettings};
|
||||
use vectorcraft_render::{AntiAlias, RenderOptions};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
use super::flatten::{FlattenOptions, FlattenerPreset, flatten_document};
|
||||
use super::menucmds::{MAX_PIXELS, unique_key};
|
||||
use super::*;
|
||||
|
||||
/// The flattener options of `set`'s preset (`None`: transparency prints live); an error naming
|
||||
/// `cmd` for a preset that is neither built in nor in `saved`.
|
||||
pub(crate) fn flattener(cmd: &str, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<FlattenOptions>> {
|
||||
let name = set.advanced.flattener_preset.trim();
|
||||
if name.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let o = FlattenOptions::find_preset(name, saved)
|
||||
.ok_or_else(|| bad(cmd, format!("flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list)")))?;
|
||||
Ok(Some(o))
|
||||
}
|
||||
|
||||
/// `doc` as Print as Bitmap and the flattener preset of `set` print it (from `saved` presets), or
|
||||
/// `None` when neither applies. Print as Bitmap is for composite output.
|
||||
pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[FlattenerPreset]) -> Result<Option<Document>> {
|
||||
let composite = set.output.mode == OutputMode::Composite;
|
||||
let bitmap = set.advanced.print_as_bitmap && composite;
|
||||
let flattener = flattener(cmd, set, saved)?;
|
||||
if !bitmap && flattener.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let printed = vectorcraft_pdf::printed_document(doc, set);
|
||||
if bitmap {
|
||||
return bitmap_document(cmd, &printed, set).map(Some);
|
||||
}
|
||||
let Some(mut o) = flattener else { return Ok(None) };
|
||||
// Composite overprints that were discarded or simulated aren't preserved either.
|
||||
o.preserve_overprints &= !composite || set.advanced.overprints == PrintOverprints::Preserve;
|
||||
Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned())))
|
||||
}
|
||||
|
||||
/// Leave guide paths out of `nodes` (the renderer draws them; they never print).
|
||||
fn drop_guides(nodes: &mut Vec<Arc<Node>>) {
|
||||
fn has_guides(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c)))
|
||||
}
|
||||
nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. }));
|
||||
for n in nodes.iter_mut().filter(|n| has_guides(n)) {
|
||||
if let Some(children) = Arc::make_mut(n).children_mut() {
|
||||
drop_guides(children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` (as it prints) with its art replaced by one image of each printed region (an artboard
|
||||
/// with its bleed, or all the art), at the document's raster effects resolution; regions where
|
||||
/// nothing is drawn get none.
|
||||
fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings) -> Result<Document> {
|
||||
let regions = vectorcraft_pdf::print_regions(doc, set).map_err(|e| pdf_error(cmd, e))?;
|
||||
let bleed = set.bleed.of(doc);
|
||||
let mut art = doc.clone();
|
||||
drop_guides(&mut art.layers);
|
||||
let anti_alias = if doc.raster_effects.anti_alias { AntiAlias::Art } else { AntiAlias::None };
|
||||
let opts = RenderOptions { skip_templates: true, anti_alias, ..Default::default() };
|
||||
let mut out = doc.clone();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut images = vec![];
|
||||
for (_, r) in regions {
|
||||
let area = outset(r, bleed);
|
||||
// At most MAX_PIXELS, and 65535 pixels a side.
|
||||
let side = area.width().max(area.height()).max(1.0);
|
||||
let scale = (doc.raster_effects_ppi / 72.0).min((MAX_PIXELS / (area.width() * area.height()).max(1.0)).sqrt()).min(65_535.0 / side);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
continue;
|
||||
}
|
||||
// Whole pixels.
|
||||
let region = Rect::new(
|
||||
area.x0,
|
||||
area.y0,
|
||||
area.x0 + (area.width() * scale).ceil().max(1.0) / scale,
|
||||
area.y0 + (area.height() * scale).ceil().max(1.0) / scale,
|
||||
);
|
||||
let img = renderer.render_region_with(&art, region, scale, &opts);
|
||||
if img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0) {
|
||||
continue;
|
||||
}
|
||||
let key = unique_key(&out, "print-bitmap");
|
||||
out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?));
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
|
||||
images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(image))));
|
||||
}
|
||||
let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0));
|
||||
if let Some(children) = layer.children_mut() {
|
||||
*children = images;
|
||||
}
|
||||
out.layers = vec![Arc::new(layer)];
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! View → Show Print Tiling (`view.printTiling`) and the Print Tiling tool (`print.tiling.set`):
|
||||
//! the pages of the document's print settings drawn on the canvas, from `print.preview`'s layout
|
||||
//! (`print.tiling`), and where the tool put them ([`vectorcraft_pdf::TileOrigin`], saved with
|
||||
//! the print settings).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_pdf::{TileOrigin, TilingPage};
|
||||
|
||||
use super::fileio::pdf::pdf_error;
|
||||
use super::print::{settings, to_json};
|
||||
use super::*;
|
||||
|
||||
const SET: &str = "print.tiling.set";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "view.printTiling",
|
||||
"Show Print Tiling",
|
||||
["View"],
|
||||
None,
|
||||
"{on?: bool (default: toggle)} show the pages of the document's print settings on the canvas (each document has its own setting; the Print Tiling tool shows them too) → {on}",
|
||||
has_doc,
|
||||
show
|
||||
),
|
||||
cmd!(
|
||||
"print.tiling.set",
|
||||
"Print Tiling",
|
||||
[],
|
||||
None,
|
||||
"{origin: [x, y] (document point: the top-left corner of the first page's imageable area), artboard? (0-based: the artboard it is measured from; default the printed artboard containing origin, else the first; ignored with artboards: ignore)} | {reset: true} place the printed pages as the Print Tiling tool does, saved with the print settings (tileOrigin: {placed, x, y}, from the artboard's top-left corner; it replaces the placement, and tiles start there), as one undo step; reset → the placement places them again → {tileOrigin}",
|
||||
has_doc,
|
||||
set_origin
|
||||
),
|
||||
cmd!(
|
||||
query "print.tiling",
|
||||
"Print Tiling Pages",
|
||||
[],
|
||||
None,
|
||||
"{settings?: {…print.setup settings} (over the document's)} the pages as View → Show Print Tiling draws them, from print.preview's layout → {tileOrigin, pages: [{artboard (0-based; null with artboards ignored), number (1-based tile across then down, or page), printed (tiles outside tileRange don't), page: [x0, y0, x1, y1] (the paper's edge), imageable: [x0, y0, x1, y1] (inside the unprintable margin)}] (document space; every tile when tiling, else each page once)}",
|
||||
has_doc,
|
||||
tiling
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
st.print_tiling = p.get("on").and_then(Value::as_bool).unwrap_or(!st.print_tiling);
|
||||
Ok(json!({ "on": st.print_tiling }))
|
||||
}
|
||||
|
||||
/// The pages of `doc`'s print settings in document space (what View → Show Print Tiling draws);
|
||||
/// an error when they can't print.
|
||||
pub fn pages(doc: &Document) -> Result<Vec<TilingPage>> {
|
||||
pages_with(doc, &json!({}))
|
||||
}
|
||||
|
||||
fn pages_with(doc: &Document, p: &Value) -> Result<Vec<TilingPage>> {
|
||||
const C: &str = "print.tiling";
|
||||
let set = settings(C, doc, p)?;
|
||||
let pv = vectorcraft_pdf::preview(doc, &set).map_err(|e| pdf_error(C, e))?;
|
||||
Ok(vectorcraft_pdf::tiling(&pv, &set))
|
||||
}
|
||||
|
||||
fn tiling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let origin = settings("print.tiling", doc, p)?.tile_origin;
|
||||
Ok(json!({ "tileOrigin": origin, "pages": pages_with(doc, p)? }))
|
||||
}
|
||||
|
||||
/// `[x, y]` at `key` of `p`.
|
||||
fn point(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
fn set_origin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = &s.doc()?.doc;
|
||||
let current = settings(SET, doc, &json!({}))?;
|
||||
let mut set = current.clone();
|
||||
set.tile_origin = if bool_or(p, "reset", false) {
|
||||
TileOrigin::default()
|
||||
} else {
|
||||
let at = point(p, "origin").filter(|q| q.x.is_finite() && q.y.is_finite());
|
||||
let at = at.ok_or_else(|| bad(SET, "give origin: [x, y], or reset: true"))?;
|
||||
let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?;
|
||||
let picked = match p.get("artboard").filter(|v| !v.is_null()) {
|
||||
Some(v) => {
|
||||
let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize;
|
||||
regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))?
|
||||
}
|
||||
None => regions.iter().find(|(_, r)| r.contains(at)).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?,
|
||||
};
|
||||
let corner = picked.1.origin();
|
||||
TileOrigin { placed: true, x: at.x - corner.x, y: at.y - corner.y }
|
||||
};
|
||||
set.check().map_err(|e| pdf_error(SET, e))?;
|
||||
if set != current {
|
||||
let stored = to_json(&set)?;
|
||||
s.edit("Print Tiling", |d, _| {
|
||||
d.print_setup = Some(stored);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "tileOrigin": set.tile_origin }))
|
||||
}
|
||||
@@ -273,7 +273,9 @@ pub(crate) fn put_below(d: &mut Document, m: &mut Node, image: Node) {
|
||||
let mut inner = m.clone();
|
||||
inner.id = d.alloc_id();
|
||||
(inner.name, inner.opacity, inner.blend, inner.isolate, inner.mask) = (None, 1.0, Default::default(), false, None);
|
||||
inner.knockout = Default::default();
|
||||
// Knockout is between the object's own parts (a blend's steps): it stays with them,
|
||||
// so the image doesn't knock out the art over it.
|
||||
m.knockout = Default::default();
|
||||
inner.knockout_shape = false;
|
||||
m.appearance = Default::default();
|
||||
m.kind = NodeKind::Group { children: vec![Arc::new(image), Arc::new(inner)], clip: false };
|
||||
|
||||
@@ -188,7 +188,8 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
/// `[[subpath, anchor]…]` anchor references (malformed entries are left out).
|
||||
pub(crate) fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
|
||||
v.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area / Path Type",
|
||||
[],
|
||||
None,
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -58,6 +58,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
fit_headline
|
||||
),
|
||||
cmd!(
|
||||
"text.discardEmpty",
|
||||
"Discard Empty Type",
|
||||
[],
|
||||
None,
|
||||
"{id} remove text object `id` if it has no characters (what the Type tool does with point type it placed when editing ends). When nothing but that text changed since the step that created it, the steps since are dropped instead, leaving no trace in the history → {removed}",
|
||||
has_doc,
|
||||
discard_empty
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -359,6 +368,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<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 }],
|
||||
@@ -395,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(),
|
||||
@@ -449,3 +460,31 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "tracking": applied}))
|
||||
}
|
||||
|
||||
fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.discardEmpty";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?;
|
||||
if edit::runs_len(&text_ref(s, id)?.runs) > 0 {
|
||||
return Ok(json!({"removed": false}));
|
||||
}
|
||||
// The newest step whose document lacks the text created it: when the document now is that one
|
||||
// plus the text (and its id), roll back to it without a trace.
|
||||
let st = s.doc_mut()?;
|
||||
if st.interaction.is_none()
|
||||
&& let Some(k) = st.history.undo.iter().rposition(|e| e.doc.node(id).is_none())
|
||||
&& let Some(created) = st.history.undo.get(k)
|
||||
{
|
||||
let mut now = (*st.doc).clone();
|
||||
let mut before = (*created.doc).clone();
|
||||
before.alloc_id();
|
||||
if now.remove(id).is_ok() && now == before {
|
||||
st.doc = created.doc.clone();
|
||||
st.history.undo.truncate(k);
|
||||
st.selection.prune(&st.doc);
|
||||
st.revision += 1;
|
||||
return Ok(json!({"removed": true}));
|
||||
}
|
||||
}
|
||||
s.edit("Discard Empty Type", |d, _| d.remove(id).map(|_| ()).map_err(|_| EngineError::NoNode(id)))?;
|
||||
Ok(json!({"removed": true}))
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::PathData;
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::pathops::{len_param, num_param, shape_node};
|
||||
@@ -13,6 +13,18 @@ use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("type.orientation.vertical", "Vertical", ["Type", "Orientation"], None, "{ids?|id?} set vertical writing", has_selection, |s, p| {
|
||||
orientation(s, p, true)
|
||||
}),
|
||||
cmd!(
|
||||
"type.orientation.horizontal",
|
||||
"Horizontal",
|
||||
["Type", "Orientation"],
|
||||
None,
|
||||
"{ids?|id?} set horizontal writing",
|
||||
has_selection,
|
||||
|s, p| orientation(s, p, false)
|
||||
),
|
||||
cmd!(
|
||||
"type.createOutlines",
|
||||
"Create Outlines",
|
||||
@@ -36,10 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
cmd!(
|
||||
"text.reshapeArea",
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
cmd!(
|
||||
"text.fitHeadline",
|
||||
"Fit Headline",
|
||||
@@ -61,6 +82,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
let ids = text_targets(s, p, "Text Orientation")?;
|
||||
s.edit("Text Orientation", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
};
|
||||
t.vertical = vertical;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Recompute the layout bounds cache after a text edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
@@ -238,25 +274,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],
|
||||
/// [`TextObject::move_area_anchors`]) and lay its text out again. False, changing nothing, when
|
||||
/// `f` fails or the frame would reach past [`crate::MAX_COORD`] (the text flows over its height).
|
||||
pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObject) -> bool) -> bool {
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| match &t.kind {
|
||||
vectorcraft_doc::TextKind::Area { frame } => {
|
||||
frame.bounds().is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !(f(t) && within(t)) {
|
||||
t.kind = before;
|
||||
return false;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
|
||||
/// Area type's text, if `n` is area type.
|
||||
fn area_text(n: Option<&Node>) -> Option<&TextObject> {
|
||||
match n.map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The width and height of area type's frame in points, along the type's own axes, and the frame's
|
||||
/// bounds in text space.
|
||||
fn area_size(t: &TextObject) -> Option<(f64, f64, vectorcraft_geom::Rect)> {
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
|
||||
let b = frame.bounds()?;
|
||||
let [a, bb, c, d, _, _] = t.xf.as_coeffs();
|
||||
Some((b.width() * a.hypot(bb), b.height() * c.hypot(d), b))
|
||||
}
|
||||
|
||||
/// Size area type's frame to `w` × `h` points (None: keep that side) from its top-left corner,
|
||||
/// and lay its text out again. False when the frame doesn't change.
|
||||
fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
let Some((cw, ch, b)) = area_size(t) else { return false };
|
||||
let factor = |to: Option<f64>, cur: f64| match to {
|
||||
Some(to) if cur > 1e-9 && (to - cur).abs() > 1e-9 => to / cur,
|
||||
_ => 1.0,
|
||||
};
|
||||
let (sx, sy) = (factor(w, cw), factor(h, ch));
|
||||
if sx == 1.0 && sy == 1.0 {
|
||||
return false;
|
||||
}
|
||||
let o = Affine::translate(b.origin().to_vec2());
|
||||
let a = t.xf * o * Affine::scale_non_uniform(sx, sy) * o.inverse() * t.xf.inverse();
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
text_targets(s, p, C)?
|
||||
.into_iter()
|
||||
.filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })))
|
||||
.collect()
|
||||
text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect()
|
||||
};
|
||||
let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.area.clone(),
|
||||
_ => return Err(bad(C, "select area type (text in a frame)")),
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
let mut v = serde_json::to_value(¤t).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut changed = false;
|
||||
let mut v = options(s)?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some();
|
||||
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||
for (k, val) in src {
|
||||
if o.contains_key(k) && k != "ids" {
|
||||
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
@@ -271,23 +364,66 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||
opts.inset = opts.inset.clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
|
||||
let out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
s.edit("Area Type Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
t.area = opts.clone();
|
||||
refresh_bounds(t);
|
||||
// A resized frame has laid its text out again; otherwise the new options do here.
|
||||
if !size_area(t, w, h) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
options(s)
|
||||
}
|
||||
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::parse_refs(p.get("anchors"));
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod area_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vertical_point_type_keeps_its_anchor_on_the_column_centre_line() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 100, "y": 50, "text": "§§§§ abc"})).unwrap()["id"].as_u64().unwrap();
|
||||
let bounds = |s: &Session| s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||||
let wide = bounds(&s);
|
||||
assert!(wide.width() > wide.height());
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("type.orientation.vertical", &json!({})).unwrap();
|
||||
let tall = bounds(&s);
|
||||
assert!(tall.height() > tall.width(), "{tall:?}");
|
||||
assert!((tall.center().x - 100.0).abs() < 6.0, "the anchor stays on the column's centre line: {tall:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(bounds(&s), wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_options_columns_change_layout() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -25,7 +25,25 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
if n.opacity < 1.0 {
|
||||
v["opacity"] = json!(n.opacity);
|
||||
}
|
||||
if !n.is_container() {
|
||||
// The angle of its rotated bounding box (counter-clockwise degrees).
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
}
|
||||
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());
|
||||
@@ -64,6 +82,8 @@ pub fn document(s: &Session) -> Value {
|
||||
"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)),
|
||||
// The angle the selection's bounding box stands at (counter-clockwise degrees).
|
||||
"selectionRotation": d.bbox_angle(&st.selection.objects),
|
||||
// The layer, group or object targeted through the Layers panel (`layer.target`).
|
||||
"target": st.selection.target.map(|t| t.0),
|
||||
"history": st.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
|
||||
|
||||
+129
-1
@@ -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.
|
||||
@@ -129,6 +133,11 @@ pub struct DocState {
|
||||
pub recovered: bool,
|
||||
/// The document's Data Recovery copy, once one was written ([`cmd::recovery`]).
|
||||
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);
|
||||
@@ -160,6 +169,8 @@ impl DocState {
|
||||
view,
|
||||
recovered: false,
|
||||
recovery: None,
|
||||
print_tiling: false,
|
||||
last_perspective: None,
|
||||
}
|
||||
}
|
||||
/// Unsaved changes: the document differs from the saved one (selection changes don't count).
|
||||
@@ -380,9 +391,18 @@ pub struct Prefs {
|
||||
pub large_tabs: bool,
|
||||
pub ui_scaling: f64,
|
||||
pub scale_cursor_with_ui: bool,
|
||||
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
|
||||
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
|
||||
pub interface_language: String,
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
/// Which graphics processor the desktop app asks for at startup (it takes effect after a
|
||||
/// restart): `powerSaving` (the integrated GPU on hybrid-graphics laptops) or `highPerformance`
|
||||
/// (the discrete one). The canvas is rasterized on the CPU and only composited on the GPU, so
|
||||
/// the integrated GPU is plenty; presenting from the discrete GPU through the integrated one
|
||||
/// made some hybrid laptops flicker (#306). Single-GPU machines are unaffected.
|
||||
pub gpu_preference: String,
|
||||
pub history_states: u32,
|
||||
pub real_time_drawing: bool,
|
||||
/// Rasterizer worker threads; -1 = automatic.
|
||||
@@ -469,6 +489,30 @@ pub struct Prefs {
|
||||
/// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub print_presets: Vec<cmd::printpresets::PrintPreset>,
|
||||
/// Constrain Width and Height Proportions: the link between W and H in the Transform panel,
|
||||
/// the Properties panel and the Control bar (their size fields pass `proportional` to
|
||||
/// `object.setBounds`).
|
||||
pub constrain_proportions: bool,
|
||||
/// The tools' persistent options by store (a tool id, or a store a family shares: `liquify`
|
||||
/// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]:
|
||||
/// kept across tool switches and saved with the preferences. Not a Preferences dialog field:
|
||||
/// resetting the preferences keeps them; `tool.setOption` edits them.
|
||||
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub tool_settings: std::collections::BTreeMap<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 {
|
||||
@@ -557,8 +601,10 @@ impl Default for Prefs {
|
||||
large_tabs: false,
|
||||
ui_scaling: 1.0,
|
||||
scale_cursor_with_ui: false,
|
||||
interface_language: s("auto"),
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("powerSaving"),
|
||||
history_states: 500,
|
||||
real_time_drawing: true,
|
||||
render_threads: -1,
|
||||
@@ -601,6 +647,11 @@ impl Default for Prefs {
|
||||
web_export_presets: vec![],
|
||||
web_export_settings: None,
|
||||
print_presets: vec![],
|
||||
constrain_proportions: false,
|
||||
tool_settings: Default::default(),
|
||||
perspective_presets: vec![],
|
||||
blend_options: None,
|
||||
perspective_widget: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,6 +716,17 @@ pub struct Session {
|
||||
pub(crate) batch_stash: Option<Vec<DocState>>,
|
||||
/// Where Data Recovery keeps its copies ([`cmd::recovery`]).
|
||||
pub recovery: cmd::recovery::Recovery,
|
||||
/// While the active tool's actions run: their commands aren't "a command from outside the
|
||||
/// tool" ([`Session::after_command`]).
|
||||
pub(crate) in_tool_actions: bool,
|
||||
/// Envelope Options with no envelope selected: the options and fidelity new envelopes get
|
||||
/// (`None`: the reference app's defaults, fidelity 50); not saved.
|
||||
pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>,
|
||||
/// The Liquify stroke the last live preview applied, which the next sample of the drag goes on
|
||||
/// from ([`cmd::distortcmds::LiquifyStroke`]).
|
||||
pub(crate) liquify_stroke: Option<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 {
|
||||
@@ -705,6 +767,10 @@ impl Session {
|
||||
note_depth: 1,
|
||||
batch_stash: None,
|
||||
recovery: Default::default(),
|
||||
in_tool_actions: false,
|
||||
envelope_defaults: None,
|
||||
liquify_stroke: None,
|
||||
plane_widget_press: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -744,7 +810,8 @@ impl Session {
|
||||
if self.batch_stash.is_none() {
|
||||
let _ = self.cancel_interaction();
|
||||
}
|
||||
self.tool = vectorcraft_tools::create(self.tool.id());
|
||||
self.keep_tool_settings();
|
||||
self.tool = self.make_tool(self.tool.id());
|
||||
}
|
||||
pub fn set_active(&mut self, index: usize) -> bool {
|
||||
if index < self.docs.len() {
|
||||
@@ -858,6 +925,9 @@ impl Session {
|
||||
let p = self.noted(params);
|
||||
self.journal.push((id.to_string(), p));
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.after_command();
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -1002,6 +1072,10 @@ impl Session {
|
||||
f(&st.doc, &mut st.selection);
|
||||
st.selection.prune(&st.doc);
|
||||
st.revision += 1;
|
||||
// Puppet Warp pins belong to the art they were placed on: another selection starts afresh.
|
||||
if st.doc.puppet.as_ref().is_some_and(|p| p.ids != st.selection.objects) {
|
||||
cmd::distortcmds::drop_puppet_pins(st);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1019,6 +1093,7 @@ impl Session {
|
||||
preview: None,
|
||||
active_layer: st.active_layer,
|
||||
isolation: st.isolation,
|
||||
perspective_again: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1047,14 +1122,19 @@ impl Session {
|
||||
let st = self.doc_mut()?;
|
||||
let Some(mut it) = st.interaction.take() else { return Ok(()) };
|
||||
let Some(preview) = it.preview.take() else { return Ok(()) };
|
||||
let perspective_again = it.perspective_again.take();
|
||||
st.keep_interaction(it);
|
||||
if preview.0 == "object.transform" {
|
||||
let m = cmd::matrix_param(&preview.1, "matrix");
|
||||
let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false);
|
||||
if let Some(m) = m {
|
||||
st.last_transform = Some((m, copy));
|
||||
st.last_perspective = None;
|
||||
}
|
||||
}
|
||||
if perspective_again.is_some() {
|
||||
st.last_perspective = perspective_again;
|
||||
}
|
||||
self.journal.push(preview);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1085,12 +1165,22 @@ impl Session {
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_adjust;
|
||||
#[cfg(test)]
|
||||
mod tests_appearance;
|
||||
#[cfg(test)]
|
||||
mod tests_assets;
|
||||
#[cfg(test)]
|
||||
mod tests_attributes;
|
||||
#[cfg(test)]
|
||||
mod tests_bboxrotate;
|
||||
#[cfg(test)]
|
||||
mod tests_blendfidelity;
|
||||
#[cfg(test)]
|
||||
mod tests_blendopts;
|
||||
#[cfg(test)]
|
||||
mod tests_blendspine;
|
||||
#[cfg(test)]
|
||||
mod tests_brushsym;
|
||||
#[cfg(test)]
|
||||
mod tests_build;
|
||||
@@ -1121,6 +1211,8 @@ mod tests_containers;
|
||||
#[cfg(test)]
|
||||
mod tests_css;
|
||||
#[cfg(test)]
|
||||
mod tests_cut;
|
||||
#[cfg(test)]
|
||||
mod tests_dashalign;
|
||||
#[cfg(test)]
|
||||
mod tests_distort;
|
||||
@@ -1133,6 +1225,14 @@ mod tests_editcolors;
|
||||
#[cfg(test)]
|
||||
mod tests_effectedit;
|
||||
#[cfg(test)]
|
||||
mod tests_emptytype;
|
||||
#[cfg(test)]
|
||||
mod tests_envelope;
|
||||
#[cfg(test)]
|
||||
mod tests_envelope_distort;
|
||||
#[cfg(test)]
|
||||
mod tests_envelope_edit;
|
||||
#[cfg(test)]
|
||||
mod tests_expand;
|
||||
#[cfg(test)]
|
||||
mod tests_eyedropper;
|
||||
@@ -1149,12 +1249,16 @@ mod tests_flatten;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fontlist;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_gradpanel;
|
||||
#[cfg(test)]
|
||||
mod tests_halftone;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_knockout;
|
||||
@@ -1169,6 +1273,8 @@ mod tests_links;
|
||||
#[cfg(test)]
|
||||
mod tests_linkspanel;
|
||||
#[cfg(test)]
|
||||
mod tests_liquify;
|
||||
#[cfg(test)]
|
||||
mod tests_live;
|
||||
#[cfg(test)]
|
||||
mod tests_maskview;
|
||||
@@ -1207,22 +1313,38 @@ mod tests_pdfpresets;
|
||||
#[cfg(test)]
|
||||
mod tests_pdfraster;
|
||||
#[cfg(test)]
|
||||
mod tests_persp_planes;
|
||||
#[cfg(test)]
|
||||
mod tests_persp_select;
|
||||
#[cfg(test)]
|
||||
mod tests_persp_text;
|
||||
#[cfg(test)]
|
||||
mod tests_perspgrid;
|
||||
#[cfg(test)]
|
||||
mod tests_place;
|
||||
#[cfg(test)]
|
||||
mod tests_plugins;
|
||||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_previewbounds;
|
||||
#[cfg(test)]
|
||||
mod tests_print;
|
||||
#[cfg(test)]
|
||||
mod tests_printadvanced;
|
||||
#[cfg(test)]
|
||||
mod tests_printpresets;
|
||||
#[cfg(test)]
|
||||
mod tests_printpreview;
|
||||
#[cfg(test)]
|
||||
mod tests_printps;
|
||||
#[cfg(test)]
|
||||
mod tests_printtiling;
|
||||
#[cfg(test)]
|
||||
mod tests_proxyitems;
|
||||
#[cfg(test)]
|
||||
mod tests_puppetwarp;
|
||||
#[cfg(test)]
|
||||
mod tests_rastersettings;
|
||||
#[cfg(test)]
|
||||
mod tests_recolor;
|
||||
@@ -1269,8 +1391,12 @@ mod tests_tileedge;
|
||||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
#[cfg(test)]
|
||||
mod tests_toolsettings;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
#[cfg(test)]
|
||||
mod tests_typearea;
|
||||
#[cfg(test)]
|
||||
mod tests_units;
|
||||
#[cfg(test)]
|
||||
mod tests_webexport;
|
||||
@@ -1279,4 +1405,6 @@ mod tests_widthpoints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_widthtool;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
@@ -285,6 +285,39 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// Type reports the paint its characters show, and its own object-level paint apart from it.
|
||||
#[test]
|
||||
fn inspect_reports_the_paint_of_types_characters() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 50, "y": 60, "text": "HI", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||||
let node = |s: &mut Session| {
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap()
|
||||
};
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#ff0000"), Some("None"), Some(0.0)), "{t}");
|
||||
assert!(t.get("objectFill").is_none(), "no object-level paint: {t}");
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [id]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 3, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
|
||||
// An object-level fill on the type stays distinguishable from its characters' paint.
|
||||
s.execute("appearance.addFill", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00", "item": 0, "ids": [id]})).unwrap();
|
||||
let t = node(&mut s);
|
||||
assert_eq!(t["objectFill"], "#ffff00", "{t}");
|
||||
assert_eq!(t["fill"], "#0000ff", "{t}");
|
||||
// A path keeps reporting its own appearance.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [r]})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let rect = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == r).cloned().unwrap();
|
||||
assert_eq!(rect["fill"], "#ff0000");
|
||||
assert!(rect.get("objectFill").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_has_unique_id_and_doc() {
|
||||
let mut ids: Vec<&str> = command_specs().iter().map(|c| c.id).collect();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Live colour adjustments (Effect → Color Adjustments) through the commands: applied, listed,
|
||||
//! exported, expanded and kept in the native file.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn red_rect(s: &mut Session) -> NodeId {
|
||||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn fill_hex(n: &Node) -> String {
|
||||
n.appearance.fill_paint().color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
/// A selected embedded 4×4 red image.
|
||||
fn red_image(s: &mut Session) -> NodeId {
|
||||
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let im = vectorcraft_doc::ImageObject {
|
||||
key: "pic".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: vectorcraft_geom::Affine::scale(10.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(im)))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_effects_are_in_the_catalog_under_color_adjustments() {
|
||||
let mut s = session();
|
||||
let v = s.execute("effect.list", &json!({})).unwrap();
|
||||
let ids: Vec<&str> =
|
||||
v["catalog"].as_array().unwrap().iter().filter(|e| e["menu"][1] == "Color Adjustments").map(|e| e["id"].as_str().unwrap()).collect();
|
||||
for id in vectorcraft_render::effects::ADJUSTMENTS {
|
||||
assert!(ids.contains(&id), "{id} in {ids:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adjustment_is_live_and_one_undo_step() {
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
// Non-destructive: the fill stays red, the effect sits on the object.
|
||||
let n = node(&s, id);
|
||||
assert_eq!(fill_hex(&n), "#ff0000");
|
||||
assert_eq!(n.appearance.effects.len(), 1);
|
||||
// Drawn green.
|
||||
let img =
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
|
||||
assert_eq!(img.pixel(50, 50), [0, 255, 0, 255]);
|
||||
// Editing the parameters changes it live.
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"hue": -120}})).unwrap();
|
||||
let img =
|
||||
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, true);
|
||||
assert_eq!(img.pixel(50, 50), [0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_and_expand_write_the_adjusted_colours() {
|
||||
let mut s = session();
|
||||
let id = red_rect(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.levels", "params": {"outputWhite": 128, "channel": "red"}})).unwrap();
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(svg.contains("#800000") && !svg.contains("#ff0000"), "{svg}");
|
||||
// Expand Appearance makes it permanent.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(n.appearance.effects.is_empty());
|
||||
assert_eq!(fill_hex(&n), "#800000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_image_is_adjusted_for_export_without_changing_its_pixels() {
|
||||
let mut s = session();
|
||||
let id = red_image(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let baked = vectorcraft_render::effects::bake_document(&doc).unwrap();
|
||||
let NodeKind::Image(im) = &baked.node(id).unwrap().kind else { panic!("not an image") };
|
||||
assert_ne!(im.key, "pic", "a recoloured copy");
|
||||
let px = image::load_from_memory(&baked.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(px.get_pixel(1, 1).0, [0, 255, 0, 255]);
|
||||
assert_eq!(doc.images.len(), 1, "the document keeps its one image");
|
||||
// Through Expand Appearance too.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let NodeKind::Image(im) = &node(&s, id).kind else { panic!("not an image") };
|
||||
let px = image::load_from_memory(&s.doc().unwrap().doc.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(px.get_pixel(0, 0).0, [0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjustments_survive_the_native_file() {
|
||||
let mut s = session();
|
||||
red_rect(&mut s);
|
||||
s.execute("effect.apply", &json!({"effect": "adjust.curves", "params": {"points": "0,0 64,32 255,255", "channel": "green"}})).unwrap();
|
||||
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
|
||||
let mut o = Session::new();
|
||||
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
|
||||
let mut found = None;
|
||||
o.doc().unwrap().doc.walk(|n| {
|
||||
if let Some(e) = n.appearance.effects.first() {
|
||||
found = Some(e.clone());
|
||||
}
|
||||
});
|
||||
let e = found.expect("the effect is kept");
|
||||
assert_eq!(e.id, "adjust.curves");
|
||||
assert_eq!(e.params["points"], "0,0 64,32 255,255");
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Rotated bounding boxes (#39): a rotation keeps the objects' angle, the bounding box and its
|
||||
//! handles turn with them, sizes are measured along their own axes, and Reset Bounding Box squares
|
||||
//! the box to the page again.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::OrientedBox;
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn angle(s: &Session, id: NodeId) -> f64 {
|
||||
s.doc().unwrap().doc.node(id).unwrap().bbox_angle
|
||||
}
|
||||
|
||||
fn selection_box(s: &Session) -> OrientedBox {
|
||||
s.transform_box(&s.doc().unwrap().selection.objects).unwrap()
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-6
|
||||
}
|
||||
|
||||
/// A 100 × 50 rectangle centred on (200, 200), selected and turned to 45°.
|
||||
fn turned_rect(s: &mut Session) -> NodeId {
|
||||
let a = rect(s, 150.0, 175.0, 100.0, 50.0);
|
||||
sel(s, &[a]);
|
||||
s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap();
|
||||
a
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_to_45_keeps_45() {
|
||||
let mut s = session();
|
||||
let n = undo_len(&s);
|
||||
let a = turned_rect(&mut s);
|
||||
assert_eq!(undo_len(&s), n + 2, "the rectangle, then one step for the rotation");
|
||||
// The panel's angle and the inspector's.
|
||||
let b = selection_box(&s);
|
||||
assert!(near(b.angle, 45.0) && near(angle(&s, a), 45.0), "{b:?}");
|
||||
assert!(near(s.execute("document.inspect", &json!({})).unwrap()["selectionRotation"].as_f64().unwrap(), 45.0));
|
||||
// The box hugs the rectangle: its own sides, its own centre.
|
||||
assert!(near(b.rect.width(), 100.0) && near(b.rect.height(), 50.0), "{b:?}");
|
||||
assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6);
|
||||
// Absolute angles: entering 45 again changes nothing, 90 turns it by 45 more.
|
||||
s.execute("object.rotate", &json!({"angle": 45, "absolute": true})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
s.execute("object.rotate", &json!({"angle": 90, "absolute": true})).unwrap();
|
||||
assert!(near(angle(&s, a), 90.0));
|
||||
let b = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||||
assert!(near(b.width(), 50.0) && near(b.height(), 100.0), "{b:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_turn_with_the_object_and_scale_along_its_axes() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let b = selection_box(&s);
|
||||
// The Right handle sits on the turned box, 50 pt from the centre along 45° (up and right).
|
||||
let right = b.to_doc() * Handle::Right.pos(b.rect);
|
||||
let d = std::f64::consts::FRAC_1_SQRT_2 * 50.0;
|
||||
assert!(right.distance(Point::new(200.0 + d, 200.0 - d)) < 1e-6, "{right:?}");
|
||||
// Drag it 50 pt further out along the object's own x axis.
|
||||
let to = Point::new(right.x + d, right.y - d);
|
||||
let n = undo_len(&s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for (kind, p) in [(PointerKind::Down, right), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||||
s.pointer(&PointerEvent::new(kind, p.x, p.y).with_mods(Mods::default()), v).unwrap();
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
// 150 × 50 along its own axes, still at exactly 45°, the left side where it was.
|
||||
assert_eq!(angle(&s, a), b.angle);
|
||||
let nb = selection_box(&s);
|
||||
assert!(near(nb.rect.width(), 150.0) && near(nb.rect.height(), 50.0), "{nb:?}");
|
||||
let left = |b: &OrientedBox| b.to_doc() * Handle::Left.pos(b.rect);
|
||||
assert!(left(&nb).distance(left(&b)) < 1e-6);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(selection_box(&s).rect.width(), 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_panel_sizes_follow_the_turned_box() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.setBounds", &json!({"width": 200, "reference": 4})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let b = selection_box(&s);
|
||||
assert!(near(b.rect.width(), 200.0) && near(b.rect.height(), 50.0) && near(b.angle, 45.0), "{b:?}");
|
||||
assert!(b.center().distance(Point::new(200.0, 200.0)) < 1e-6);
|
||||
// X/Y place the box's own reference point on the page.
|
||||
s.execute("object.setBounds", &json!({"x": 300, "y": 100, "reference": 0})).unwrap();
|
||||
let b = selection_box(&s);
|
||||
assert!(b.reference_point(0).distance(Point::new(300.0, 100.0)) < 1e-6, "{b:?}");
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_bounding_box_squares_the_box_in_one_step() {
|
||||
let mut s = session();
|
||||
let a = turned_rect(&mut s);
|
||||
let geometry = s.doc().unwrap().doc.bounds_of(&[a], false).unwrap();
|
||||
let n = undo_len(&s);
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
// The art stays; the box is square to the page around it.
|
||||
let b = selection_box(&s);
|
||||
assert_eq!(b.angle, 0.0);
|
||||
assert_eq!(b.rect, geometry);
|
||||
// Nothing left to reset: no step.
|
||||
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_flips_keep_an_upright_box_upright() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert_eq!(angle(&s, a), 0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 30})).unwrap();
|
||||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||||
assert!(near(angle(&s, a), -30.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_type_and_multiple_selections_keep_their_angle() {
|
||||
let mut s = session();
|
||||
// Objects turned together share the box.
|
||||
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
|
||||
let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
|
||||
assert!(near(selection_box(&s).angle, 20.0) && near(angle(&s, a), 20.0) && near(angle(&s, b), 20.0));
|
||||
// Mixed angles: the box is square to the page.
|
||||
let c = rect(&mut s, 100.0, 300.0, 50.0, 50.0);
|
||||
sel(&mut s, &[a, c]);
|
||||
assert_eq!(selection_box(&s).angle, 0.0);
|
||||
// A group turns as one; its members keep their own angles inside it.
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
assert_eq!(selection_box(&s).angle, 0.0);
|
||||
s.execute("object.rotate", &json!({"angle": 30, "absolute": true})).unwrap();
|
||||
assert!(near(angle(&s, g), 30.0) && near(angle(&s, a), 50.0) && near(angle(&s, c), 30.0));
|
||||
s.execute("object.ungroup", &json!({})).unwrap();
|
||||
sel(&mut s, &[c]);
|
||||
let cb = selection_box(&s);
|
||||
assert!(near(cb.angle, 30.0) && near(cb.rect.width(), 50.0) && near(cb.rect.height(), 50.0), "{cb:?}");
|
||||
// Type keeps its angle too.
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Turn"})).unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
let before = selection_box(&s).rect;
|
||||
s.execute("object.rotate", &json!({"angle": 60, "absolute": true})).unwrap();
|
||||
let tb = selection_box(&s);
|
||||
assert!(near(tb.angle, 60.0), "{tb:?}");
|
||||
assert!(near(tb.rect.width(), before.width()) && near(tb.rect.height(), before.height()), "{tb:?} vs {before:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_files_keep_the_angle() {
|
||||
let mut s = session();
|
||||
let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let a = turned_rect(&mut s);
|
||||
let data = s.execute("document.save", &json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
let text = String::from_utf8(vectorcraft_format::base64_decode(&data).unwrap()).unwrap();
|
||||
// Only turned objects write it.
|
||||
assert_eq!(text.matches("bbox_angle").count(), 1);
|
||||
s.execute("document.open", &json!({"name": "turned.vectorcraft", "dataBase64": data})).unwrap();
|
||||
assert!(near(angle(&s, a), 45.0));
|
||||
assert_eq!(angle(&s, plain), 0.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert!(near(selection_box(&s).rect.width(), 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_box_rect_matches_page_bounds_when_upright() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 20.0, 30.0, 40.0);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(selection_box(&s), OrientedBox::aligned(Rect::new(10.0, 20.0, 40.0, 60.0)));
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Tests for the cutting commands: Mirror & Cut, Line Cut and Rectangle Cut.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn path(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn geometry(s: &Session, id: NodeId) -> (PathData, FillRule) {
|
||||
let n = s.doc().unwrap().doc.node(id).cloned().unwrap();
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => (path.clone(), *rule),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
(PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule)
|
||||
}
|
||||
_ => panic!("not a path"),
|
||||
}
|
||||
}
|
||||
|
||||
fn area(s: &Session, id: NodeId) -> f64 {
|
||||
let (p, r) = geometry(s, id);
|
||||
vectorcraft_pathops::area(&p, r)
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
geometry(s, id).0.bounds().unwrap()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.05)
|
||||
}
|
||||
|
||||
fn undo_depth(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_splits_a_shape_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
let out = s.execute("path.lineCut", &json!({"from": [30, -10], "to": [30, 10]})).unwrap();
|
||||
let pieces = ids(&out);
|
||||
assert_eq!(pieces.len(), 2);
|
||||
let mut areas: Vec<f64> = pieces.iter().map(|id| area(&s, *id)).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 3000.0).abs() < 1.0 && (areas[1] - 7000.0).abs() < 1.0, "{areas:?}");
|
||||
// The pieces are selected, and the cut is one undo step.
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
assert_eq!(undo_depth(&s), before + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_keeps_compound_holes_and_splits_open_paths() {
|
||||
let mut s = session();
|
||||
let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
// Wound against the outer rectangle, so the non-zero compound has a hole.
|
||||
let hole = path(&mut s, &[(40.0, 40.0), (40.0, 60.0), (60.0, 60.0), (60.0, 40.0)], true);
|
||||
select(&mut s, &[outer, hole]);
|
||||
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
assert!((area(&s, c) - 9600.0).abs() < 1.0);
|
||||
let line = path(&mut s, &[(0.0, 200.0), (100.0, 200.0)], false);
|
||||
select(&mut s, &[c, line]);
|
||||
// A vertical line at x = 20 misses the hole: the right piece keeps it.
|
||||
let out = s.execute("path.lineCut", &json!({"from": [20, 0], "to": [20, 300]})).unwrap();
|
||||
let pieces = ids(&out);
|
||||
assert_eq!(pieces.len(), 4, "two compound pieces and two line pieces");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let compounds: Vec<NodeId> = pieces.iter().copied().filter(|id| matches!(doc.node(*id).unwrap().kind, NodeKind::Compound { .. })).collect();
|
||||
assert_eq!(compounds.len(), 2, "both pieces of a compound path stay compound paths");
|
||||
let mut areas: Vec<f64> = compounds.iter().map(|id| area(&s, *id)).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 2000.0).abs() < 1.0 && (areas[1] - 7600.0).abs() < 1.0, "{areas:?}");
|
||||
let open: Vec<Rect> = pieces.iter().filter(|id| !compounds.contains(id)).map(|id| bounds(&s, *id)).collect();
|
||||
assert!(open.iter().any(|b| (b.x1 - 20.0).abs() < 0.01) && open.iter().any(|b| (b.x0 - 20.0).abs() < 0.01), "{open:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_that_misses_changes_nothing() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
let out = s.execute("path.lineCut", &json!({"from": [200, 0], "to": [200, 10]})).unwrap();
|
||||
assert!(ids(&out).is_empty());
|
||||
assert_eq!(undo_depth(&s), before);
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5], "to": [5, 5]})).is_err());
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [5, 5]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_cut_crops_to_real_geometry() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let outside = rect(&mut s, 300.0, 300.0, 10.0, 10.0);
|
||||
let inside = rect(&mut s, 60.0, 20.0, 10.0, 10.0);
|
||||
let line = path(&mut s, &[(0.0, 50.0), (200.0, 50.0)], false);
|
||||
select(&mut s, &[a, outside, inside, line]);
|
||||
let out = s.execute("path.rectCut", &json!({"rect": [50, 0, 100, 200]})).unwrap();
|
||||
assert_eq!(out["removed"], 1);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(outside).is_none(), "art wholly outside is deleted");
|
||||
assert!(matches!(doc.node(a).unwrap().kind, NodeKind::Path { .. }), "no clipping mask");
|
||||
assert!(near(bounds(&s, a), Rect::new(50.0, 0.0, 100.0, 100.0)));
|
||||
assert!((area(&s, a) - 5000.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, inside), Rect::new(60.0, 20.0, 70.0, 30.0)), "art inside is untouched");
|
||||
assert!(near(bounds(&s, line), Rect::new(50.0, 50.0, 150.0, 50.0)));
|
||||
// Two corners work too.
|
||||
select(&mut s, &[a]);
|
||||
s.execute("path.rectCut", &json!({"from": [100, 100], "to": [75, 50]})).unwrap();
|
||||
assert!(near(bounds(&s, a), Rect::new(75.0, 50.0, 100.0, 100.0)));
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 0, 10]})).is_err());
|
||||
assert!(s.execute("path.rectCut", &json!({"rect": [0, 0, 10]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_makes_a_symmetric_shape() {
|
||||
let mut s = session();
|
||||
// A right triangle: mirrored at x = 50 keeping the left, it becomes a house-like pentagon.
|
||||
let t = path(&mut s, &[(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)], true);
|
||||
select(&mut s, &[t]);
|
||||
let out = s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
|
||||
assert_eq!(ids(&out), vec![t]);
|
||||
let (pd, _) = geometry(&s, t);
|
||||
assert_eq!(pd.subpaths.len(), 1, "both halves are joined into one closed path");
|
||||
assert!(pd.subpaths[0].closed);
|
||||
assert!((area(&s, t) - 7500.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// Keeping the right half instead.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0], "keep": "right"})).unwrap();
|
||||
assert!((area(&s, t) - 2500.0).abs() < 1.0);
|
||||
assert!(near(bounds(&s, t), Rect::new(0.0, 0.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_axes_and_sides() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 100.0);
|
||||
let gone = rect(&mut s, 300.0, 0.0, 10.0, 10.0);
|
||||
select(&mut s, &[a, gone]);
|
||||
// A free axis at x = 50 (drawn upward): art on the discarded side is deleted.
|
||||
let out = s.execute("path.mirrorCut", &json!({"axis": "free", "from": [50, 100], "to": [50, 0], "keep": "left"})).unwrap();
|
||||
assert_eq!(out["removed"], 1);
|
||||
assert!(s.doc().unwrap().doc.node(gone).is_none());
|
||||
// Wholly on the kept side: the shape and its reflection, one object.
|
||||
let (pd, _) = geometry(&s, a);
|
||||
assert_eq!(pd.subpaths.len(), 2);
|
||||
assert!(near(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// A horizontal axis through the selection's centre keeps the top by default.
|
||||
let b = rect(&mut s, 0.0, 200.0, 100.0, 60.0);
|
||||
select(&mut s, &[b]);
|
||||
s.execute("path.mirrorCut", &json!({"axis": "horizontal"})).unwrap();
|
||||
assert!(near(bounds(&s, b), Rect::new(0.0, 200.0, 100.0, 260.0)));
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "vertical", "keep": "top"})).is_err());
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "diagonal"})).is_err());
|
||||
assert!(s.execute("path.mirrorCut", &json!({"axis": "free", "from": [1, 1], "to": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_cut_closes_an_open_half() {
|
||||
let mut s = session();
|
||||
let p = path(&mut s, &[(50.0, 0.0), (0.0, 50.0), (50.0, 100.0)], false);
|
||||
select(&mut s, &[p]);
|
||||
s.execute("path.mirrorCut", &json!({"axis": "vertical", "from": [50, 0]})).unwrap();
|
||||
let (pd, _) = geometry(&s, p);
|
||||
assert_eq!(pd.subpaths.len(), 1);
|
||||
assert!(pd.subpaths[0].closed);
|
||||
assert_eq!(pd.subpaths[0].anchors.len(), 4);
|
||||
assert!(near(bounds(&s, p), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_commands_need_paths() {
|
||||
let mut s = session();
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err(), "nothing selected");
|
||||
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 10, "text": "A"})).unwrap());
|
||||
select(&mut s, &[t]);
|
||||
assert!(s.execute("path.lineCut", &json!({"from": [0, 0], "to": [1, 1]})).is_err());
|
||||
}
|
||||
|
||||
mod tools {
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
fn view() -> ViewInfo {
|
||||
ViewInfo { smart_guides: false, ..Default::default() }
|
||||
}
|
||||
|
||||
fn press(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::new(kind, x, y).with_mods(Mods::default()), view()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_cut_tool_previews_live_and_commits_one_step() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
let before = undo_depth(&s);
|
||||
s.select_tool("lineCut", view()).unwrap();
|
||||
press(&mut s, PointerKind::Down, 50.0, -20.0);
|
||||
press(&mut s, PointerKind::Drag, 50.0, 40.0);
|
||||
// The preview already shows the cut.
|
||||
assert!(s.in_interaction());
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
press(&mut s, PointerKind::Drag, 50.0, 120.0);
|
||||
press(&mut s, PointerKind::Up, 50.0, 120.0);
|
||||
assert!(!s.in_interaction());
|
||||
assert_eq!(undo_depth(&s), before + 1);
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.lineCut"));
|
||||
assert!((area(&s, r) - 5000.0).abs() < 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((area(&s, r) - 10_000.0).abs() < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_and_mirror_tools_cut_and_escape_restores() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
select(&mut s, &[r]);
|
||||
s.select_tool("rectCut", view()).unwrap();
|
||||
press(&mut s, PointerKind::Down, 50.0, 50.0);
|
||||
press(&mut s, PointerKind::Drag, 150.0, 150.0);
|
||||
assert!(near(bounds(&s, r), Rect::new(50.0, 50.0, 100.0, 100.0)));
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), view()).unwrap();
|
||||
assert!(!s.in_interaction());
|
||||
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
// Mirror & Cut with a vertical axis placed by a click at x = 80.
|
||||
s.select_tool("mirrorCut", view()).unwrap();
|
||||
s.set_tool_option("axis", &json!("vertical"));
|
||||
press(&mut s, PointerKind::Down, 80.0, 50.0);
|
||||
press(&mut s, PointerKind::Up, 80.0, 50.0);
|
||||
assert!(near(bounds(&s, r), Rect::new(0.0, 0.0, 160.0, 100.0)));
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("path.mirrorCut"));
|
||||
}
|
||||
}
|
||||
@@ -207,13 +207,19 @@ fn puppet_tool_drag_warps_selection() {
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("puppetWarp", v).unwrap();
|
||||
// First click seeds pins and adds one at the right end, then drag it.
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 150.0), v).unwrap();
|
||||
// The automatic pins show at once, one in the middle of each end: drag the right end's up.
|
||||
let pins = s.execute("object.puppetWarp.pins", &json!({})).unwrap()["moved"].clone();
|
||||
let right = pins
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap()))
|
||||
.fold((0.0, 0.0), |a, q| if q.0 > a.0 { q } else { a });
|
||||
assert!(right.0 > 350.0, "{pins}");
|
||||
let n = undo_len(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 385.0, 150.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 385.0, 120.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 385.0, 120.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, right.0, right.1), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, right.0, right.1 - 30.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, right.0, right.1 - 30.0), v).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.journal.last().unwrap().0, "object.puppetWarp");
|
||||
assert!(path(&s, a).bounds().unwrap().y0 < 95.0);
|
||||
@@ -290,6 +296,8 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() {
|
||||
let mut s = session();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap();
|
||||
// Snap to Grid (on by default) would move the dragged corners onto gridlines.
|
||||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
gesture(&mut s, "rectangle", &[(300.0, 320.0), (330.0, 360.0), (350.0, 400.0)], Mods::default());
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
@@ -313,6 +321,8 @@ fn perspective_selection_moves_within_the_plane() {
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0);
|
||||
s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap();
|
||||
// Snap to Grid (on by default) would land the moved edges on gridlines.
|
||||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||||
let g = grid(&s);
|
||||
let before: Vec<Point> = path(&s, a).anchors().map(|(_, _, an)| g.to_plane(Plane::Right, an.p).unwrap()).collect();
|
||||
let centre = before.iter().fold(vectorcraft_geom::Vec2::ZERO, |a, p| a + p.to_vec2()) / before.len() as f64;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Point type placed with the Type tool and left empty is discarded when editing ends, without a
|
||||
//! trace in the history; frames (dragged area type, type in or on a path) stay.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
let v = ViewInfo::default();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1), v).unwrap();
|
||||
if from != to {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1), v).unwrap();
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
drag(s, (x, y), (x, y));
|
||||
}
|
||||
|
||||
fn key(s: &mut Session, k: ToolKey) {
|
||||
s.tool_key(k, Mods::default(), ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
/// The text objects in the document: (id, kind is point type, plain text).
|
||||
fn texts(s: &Session) -> Vec<(NodeId, bool, String)> {
|
||||
let mut v = vec![];
|
||||
s.doc().unwrap().doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
v.push((n.id, matches!(t.kind, TextKind::Point), t.plain_text()));
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
fn undo_labels(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().history.undo.iter().map(|e| e.label.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_without_typing_leaves_no_text_and_no_history() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
// Click, then Escape.
|
||||
click(&mut s, 100.0, 100.0);
|
||||
assert_eq!(texts(&s).len(), 1, "the click places point type to edit");
|
||||
assert!(s.tool_wants_text());
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click, then switch tools.
|
||||
click(&mut s, 150.0, 100.0);
|
||||
s.select_tool("rectangle", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
// Click in two places: the first is gone when the second is placed.
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 400.0, 300.0);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(!st.is_dirty() && st.selection.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_text_stays_and_typing_it_away_leaves_nothing() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.tool_text("Hi", v).unwrap();
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::<Vec<_>>(), ["Hi"]);
|
||||
let kept = undo_labels(&s);
|
||||
assert_eq!(kept, ["Type", "Typing"]);
|
||||
// Typed, the caret moved (a step of its own), then deleted: all of it goes.
|
||||
click(&mut s, 300.0, 300.0);
|
||||
s.tool_text("a", v).unwrap();
|
||||
key(&mut s, ToolKey::Left);
|
||||
key(&mut s, ToolKey::Delete);
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
assert_eq!(undo_labels(&s), kept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_stay_when_left_empty() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
// A dragged area-type frame.
|
||||
s.select_tool("type", v).unwrap();
|
||||
drag(&mut s, (100.0, 100.0), (300.0, 200.0));
|
||||
key(&mut s, ToolKey::Escape);
|
||||
assert!(matches!(texts(&s).as_slice(), [(_, false, t)] if t.is_empty()));
|
||||
// Area type made from a path.
|
||||
s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 200, "height": 100})).unwrap();
|
||||
s.select_tool("areaType", v).unwrap();
|
||||
click(&mut s, 500.0, 300.0);
|
||||
s.select_tool("selection", v).unwrap();
|
||||
assert_eq!(texts(&s).len(), 2);
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Area Type"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_deselects_the_text_ends_the_edit() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
// Commands on the edited text (the Character panel) keep editing it.
|
||||
let id = texts(&s)[0].0;
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "size": 30})).unwrap();
|
||||
s.execute("document.inspect", &json!({})).unwrap();
|
||||
assert!(s.tool_wants_text());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(!s.tool_wants_text());
|
||||
assert!(texts(&s).is_empty());
|
||||
assert!(undo_labels(&s).is_empty(), "{:?}", undo_labels(&s));
|
||||
// A command that edits something else as well: the text goes in a step of its own.
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.execute("shape.rectangle", &json!({"x": 400, "y": 300, "width": 20, "height": 10})).unwrap();
|
||||
assert!(!s.tool_wants_text());
|
||||
assert!(texts(&s).is_empty());
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle", "Discard Empty Type"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_documents_discards_it_in_the_document_left() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s.set_active(0);
|
||||
assert!(texts(&s).is_empty() && undo_labels(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_empty_command() {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap();
|
||||
assert_eq!(s.execute("text.discardEmpty", &json!({"id": r["id"]})).unwrap(), json!({"removed": false}));
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.execute("text.discardEmpty", &json!({"id": rect["id"]})).is_err(), "not text");
|
||||
assert!(s.execute("text.discardEmpty", &json!({})).is_err(), "id required");
|
||||
// Created by the newest step: that step goes.
|
||||
let e = s.execute("text.create", &json!({"x": 10, "y": 80, "text": ""})).unwrap();
|
||||
assert_eq!(s.execute("text.discardEmpty", &json!({"id": e["id"]})).unwrap(), json!({"removed": true}));
|
||||
assert_eq!(undo_labels(&s), ["Type", "Rectangle"]);
|
||||
assert_eq!(texts(&s).len(), 1);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Loaded 100 of 438 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user