diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml
index 23db4e93..1ad33f98 100644
--- a/.github/workflows/packaging-lint.yml
+++ b/.github/workflows/packaging-lint.yml
@@ -54,4 +54,13 @@ jobs:
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"
- python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.vectorcraft.yml')); assert m['id'] == 'ai.storyteller.vectorcraft' and m['command'] == 'vectorcraft'"
+ python3 - <<'EOF'
+ import yaml
+ src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"))
+ bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"))
+ for m in (src, bundle):
+ assert m["id"] == "ai.storyteller.vectorcraft" and m["command"] == "vectorcraft", m["id"]
+ for key in ("runtime", "runtime-version", "sdk", "finish-args"):
+ assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
+ print("Flatpak manifests ok")
+ EOF
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c43c2f06..8bd5dfee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,9 +1,11 @@
# Release pipeline: every push to `release` builds signed installers for every platform and
# creates or updates the draft GitHub Release "VectorCraft v". See docs/releasing.md.
#
-# All jobs run in the `release` environment (restricted to the `release` branch), which holds
-# the signing secrets. Every secret is optional: missing ones produce unsigned artifacts and a
-# warning, never a failed build.
+# Jobs that sign (macOS, Windows) and the job that creates the release run in the `release`
+# environment (restricted to the `release` branch), which holds the signing secrets. Every secret
+# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The
+# other jobs sign nothing and get no environment, so a `workflow_dispatch` on any branch is a dry
+# run: they build and upload artifacts, the signed jobs are refused, and no release is drafted.
name: Release
on:
@@ -35,7 +37,7 @@ jobs:
version:
name: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: no secrets here.
outputs:
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
@@ -175,7 +177,7 @@ jobs:
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
runs-on: ${{ matrix.runner }}
- environment: release
+ # No `environment: release`: Linux packages are not signed (checksums in SHA256SUMS.txt).
strategy:
fail-fast: false
matrix:
@@ -200,7 +202,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
- desktop-file-utils appstream python3-yaml
+ desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@@ -225,6 +227,12 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
+ # Update information is embedded, and the .zsync for delta updates sits beside it.
+ # No APPIMAGE_EXTRACT_AND_RUN here: with it the runtime passes --appimage-* flags on
+ # to the app instead of answering them itself.
+ "$appimage" --appimage-updateinformation \
+ | grep -F "gh-releases-zsync|${GITHUB_REPOSITORY%%/*}|${GITHUB_REPOSITORY#*/}|latest|vectorcraft-*-linux-${{ matrix.arch }}.AppImage.zsync"
+ test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@@ -232,13 +240,52 @@ jobs:
path: dist/release/*
if-no-files-found: error
+ flatpak:
+ name: Flatpak ${{ matrix.arch }}
+ needs: [version, linux]
+ # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
+ # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
+ runs-on: ${{ matrix.runner }}
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: x86_64, runner: ubuntu-24.04 }
+ - { arch: aarch64, runner: ubuntu-24.04-arm }
+ env:
+ VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
+ VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: release-linux-${{ matrix.arch }}
+ path: dist/linux
+ - name: Install flatpak-builder
+ run: |
+ sudo apt-get update
+ # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
+ # `appstreamcli compose`, which renders the scalable icon and fails without it.
+ sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
+ # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+ - name: Build the Flatpak bundle
+ run: packaging/linux/flatpak-bundle.sh "dist/linux/vectorcraft-$VECTORCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: release-flatpak-${{ matrix.arch }}
+ path: dist/release/*.flatpak
+ 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).
+ # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image and packages as
+ # freebsd.yml).
runs-on: ubuntu-latest
timeout-minutes: 150
- environment: release
+ # No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -262,6 +309,7 @@ jobs:
with:
release: "14.3"
usesh: true
+ cpu: 4
mem: 12288
# Passed through by name; never templated into the script.
envs: "VECTORCRAFT_VERSION VECTORCRAFT_BUILD_DATE VECTORCRAFT_BUILD_SHA"
@@ -276,6 +324,8 @@ jobs:
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
+ tar -tzf dist/release/vectorcraft-*-freebsd-x86_64.tar.gz \
+ | grep -E '/bin/vectorcraft$|/bin/vectorcraft-cli$|/share/applications/ai\.storyteller\.vectorcraft\.desktop$'
- uses: actions/upload-artifact@v4
with:
name: release-freebsd
@@ -286,7 +336,7 @@ jobs:
name: Web (wasm)
needs: version
runs-on: ubuntu-latest
- environment: release
+ # No `environment: release`: no secrets here.
env:
VECTORCRAFT_VERSION: ${{ needs.version.outputs.version }}
VECTORCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -327,7 +377,9 @@ jobs:
release:
name: Draft GitHub Release
- needs: [version, macos, windows, linux, freebsd, web]
+ # Needs the signed jobs, so a branch dry run (where the environment refuses them) never
+ # drafts a release.
+ needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
diff --git a/AGENTS.md b/AGENTS.md
index 3e544b94..ec28c2d4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,13 +6,19 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Start every session here
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
-2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
+2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
## Non-negotiables
-- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…).
+- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
+ - Never launch, run, script, automate (COM, AppleScript, ExtendScript, UI automation), screenshot or measure Illustrator or any other Adobe application, and never compare our output against one. Don't make test files with them, not even "synthetic" ones.
+ - Never read, disassemble or copy anything inside an Adobe install (binaries, plug-ins, presets, procsets, resources, help files, fonts, ICC profiles).
+ - Never copy Adobe-authored code or text into this repo, tests included: no PostScript procsets or prologs (AGM, CoolType, the Illustrator prologs), no sample files. Write test inputs from scratch from the public specifications (PostScript Language Reference, PDF / ISO 32000, TIFF, EPSF/DCS).
+ - Behaviour comes only from public documentation (helpx, published specs, public forum posts), cited by URL, and from files users send us that they own (used locally, never committed). Never copy Adobe icons, artwork, presets or wording beyond feature names.
+ - If a task seems to need any of this, stop and say so instead. `cargo xtask cleanroom` (run by `cargo xtask ci`) fails on Adobe copyright notices and on fixtures made with Adobe applications.
+ - Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Assets: no Adobe iconography or images — ever (absolute rule, from the project owner).**
- Never add, copy, trace, redraw-from, embed or ship any icon, image, artwork, cursor, preset, swatch/brush/symbol/pattern/style library, ICC profile or screenshot from Adobe products or from any other source whose licence doesn't allow it.
- Every image, icon, font or other asset must be one of:
@@ -44,7 +50,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
-- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
+- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with the public documentation (`plan/illustrator/02-ui-ux.md` notes). `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **Performance:** `vectorcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `vectorcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
- MCP: `vectorcraft-cli mcp` (see `docs/mcp.md`).
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
@@ -53,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Roadmap
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
-- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
+- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
## Contributor credits (About window)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0633ac64..932d0b6d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,13 +6,19 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Start every session here
1. Read the [honest assessment in `ROADMAP.md`](ROADMAP.md#honest-assessment-2026-10-05): where we stand by dimension, **where we're lacking** (the prioritized gap list) and **where we're going**. Unless the user gives you a task, pick work from that list.
-2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: `plan/illustrator/*.md`.
+2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
`plan/` is gitignored (local only).
## Non-negotiables
-- **Clean-room.** Never read, disassemble or copy anything inside the Illustrator bundle (names/listings only). Never copy Adobe icons, artwork, presets or wording beyond feature names. Behaviour comes from public docs and black-box observation of the running app with synthetic documents only (screenshots by window id, stored under `plan/illustrator/screenshots/`, never committed). Never copy GPL/AGPL code (Inkscape, lib2geom…).
+- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
+ - Never launch, run, script, automate (COM, AppleScript, ExtendScript, UI automation), screenshot or measure Illustrator or any other Adobe application, and never compare our output against one. Don't make test files with them, not even "synthetic" ones.
+ - Never read, disassemble or copy anything inside an Adobe install (binaries, plug-ins, presets, procsets, resources, help files, fonts, ICC profiles).
+ - Never copy Adobe-authored code or text into this repo, tests included: no PostScript procsets or prologs (AGM, CoolType, the Illustrator prologs), no sample files. Write test inputs from scratch from the public specifications (PostScript Language Reference, PDF / ISO 32000, TIFF, EPSF/DCS).
+ - Behaviour comes only from public documentation (helpx, published specs, public forum posts), cited by URL, and from files users send us that they own (used locally, never committed). Never copy Adobe icons, artwork, presets or wording beyond feature names.
+ - If a task seems to need any of this, stop and say so instead. `cargo xtask cleanroom` (run by `cargo xtask ci`) fails on Adobe copyright notices and on fixtures made with Adobe applications.
+ - Never copy GPL/AGPL code (Inkscape, lib2geom…).
- **Assets: no Adobe iconography or images — ever (absolute rule, from the project owner).**
- Never add, copy, trace, redraw-from, embed or ship any icon, image, artwork, cursor, preset, swatch/brush/symbol/pattern/style library, ICC profile or screenshot from Adobe products or from any other source whose licence doesn't allow it.
- Every image, icon, font or other asset must be one of:
@@ -44,7 +50,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
-- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
+- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with the public documentation (`plan/illustrator/02-ui-ux.md` notes). `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
- **Performance:** `vectorcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `vectorcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
- MCP: `vectorcraft-cli mcp` (see `docs/mcp.md`).
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
@@ -53,5 +59,5 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
## Roadmap
`ROADMAP.md` (committed) is the shared picture of where VectorCraft stands. It holds status, the honest assessment (by dimension, the gap list, the direction), milestones, the parity table and time-to-parity estimates.
- When a task lands, update it in the same PR: the milestone row, the parity-table row (score, missing items, hours), "Shipped so far", and the gap list if the gap closed or shrank.
-- Grade by behaviour against `plan/illustrator/`, not by whether a menu item exists. Scores are self-assessed, so err low.
+- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low.
- Keep the README's Status section in step with the ROADMAP headline.
diff --git a/Cargo.lock b/Cargo.lock
index 8666f4f2..542cf474 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4297,7 +4297,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -4329,7 +4329,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -4341,7 +4341,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4357,7 +4357,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"serde_json",
"vectorcraft-color",
@@ -4373,7 +4373,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -4385,7 +4385,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"jpeg-encoder",
@@ -4402,7 +4402,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"kurbo 0.13.1",
"serde_json",
@@ -4416,7 +4416,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"crc32fast",
"flate2",
@@ -4450,7 +4450,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"flate2",
"image",
@@ -4468,7 +4468,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"flate2",
"proptest",
@@ -4482,7 +4482,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -4494,7 +4494,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"log",
@@ -4513,7 +4513,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4527,7 +4527,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -4539,7 +4539,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"aes",
"flate2",
@@ -4568,7 +4568,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"serde",
"serde_json",
@@ -4582,7 +4582,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"flate2",
"gif",
@@ -4607,7 +4607,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"flate2",
"image",
@@ -4631,7 +4631,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4648,7 +4648,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"harfrust 0.13.3",
"kurbo 0.13.1",
@@ -4666,7 +4666,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4681,7 +4681,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -4694,7 +4694,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"egui",
"egui_extras",
@@ -4723,7 +4723,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5826,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.5.0"
+version = "0.6.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index dd2fb224..430215a8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
-version = "0.5.0"
+version = "0.6.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
diff --git a/README.md b/README.md
index bd714dcd..a551a703 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@
Why VectorCraft ·
Quick start ·
Status ·
+ Downloads ·
The Crafting Apps ·
License
@@ -162,9 +163,11 @@ Japanese fonts come from [storytold/craft-fonts](https://github.com/storytold/cr
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)).
+On machines with two graphics processors, VectorCraft renders on the power-saving (integrated) one by default on
+Windows and macOS, and on the one the desktop runs on under Linux. To choose, use **Preferences › Performance ›
+Graphics Processor** and restart. If a graphics processor can't show the window, VectorCraft starts again on the
+next one. To pick one when starting the app, set `WGPU_POWER_PREF=high` (or `low`), or `WGPU_ADAPTER_NAME` to part of
+its name, such as `WGPU_ADAPTER_NAME=nvidia` (see [`docs/development.md`](docs/development.md#desktop-graphics-processor)).
On Linux under KDE Plasma 6.3 or later with Wayland, a drawing tablet's pen moves the cursor but VectorCraft doesn't
respond to it yet (#491). Start the app under XWayland instead: `WAYLAND_DISPLAY= vectorcraft` (see
@@ -220,6 +223,51 @@ Agent and contributor rules (clean-room, the asset policy, no panics in shipped
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).
+## Downloads
+
+**New to VectorCraft?** Download it from the [VectorCraft page on getartcraft.com](https://getartcraft.com/apps/vectorcraft). That's the easiest way to install it.
+
+**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/vectorcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/vectorcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
+
+### Windows
+
+| Build | Installer | Portable |
+|---|---|---|
+| x64 (64-bit Intel/AMD) | `vectorcraft--windows-x64.msi` | `vectorcraft--windows-x64-portable.zip` |
+| arm64 (Snapdragon and other ARM PCs) | `vectorcraft--windows-arm64.msi` | `vectorcraft--windows-arm64-portable.zip` |
+| x86 (32-bit) | `vectorcraft--windows-x86.msi` | `vectorcraft--windows-x86-portable.zip` |
+
+Installers and executables are code-signed.
+
+### macOS
+
+| Build | File | Notes |
+|---|---|---|
+| App, universal (Apple silicon + Intel) | `vectorcraft--macos-universal.dmg` | Signed and notarized |
+| Command-line tool, universal | `vectorcraft-cli--macos-universal.zip` | Signed and notarized |
+
+### Linux
+
+| Format | x86_64 | aarch64 (ARM64) | Notes |
+|---|---|---|---|
+| AppImage | `vectorcraft--linux-x86_64.AppImage` | `vectorcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
+| Flatpak | `vectorcraft--linux-x86_64.flatpak` | `vectorcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` |
+| Debian/Ubuntu | `vectorcraft--linux-x86_64.deb` | `vectorcraft--linux-aarch64.deb` | |
+| Fedora/RHEL/openSUSE | `vectorcraft--linux-x86_64.rpm` | `vectorcraft--linux-aarch64.rpm` | |
+| Tarball | `vectorcraft--linux-x86_64.tar.gz` | `vectorcraft--linux-aarch64.tar.gz` | Unpack anywhere |
+
+### FreeBSD
+
+| Build | File |
+|---|---|
+| x86_64 | `vectorcraft--freebsd-x86_64.tar.gz` |
+
+### Web (WebAssembly)
+
+| Build | File | Notes |
+|---|---|---|
+| Static site | `vectorcraft-web-.zip` | Runs in a modern browser; host it on any static server |
+
## The Crafting Apps
VectorCraft is one of the **Crafting Apps**: free, open-source creative tools from the
diff --git a/ROADMAP.md b/ROADMAP.md
index de31900d..8d3b8d5e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
-_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493); see [Honest assessment](#honest-assessment-2026-10-05))._
+_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS files from Illustrator 2026, Inkscape/cairo, matplotlib, CorelDRAW, Affinity, LibreOffice and Ghostscript opening as vectors, and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508); see [Honest assessment](#honest-assessment-2026-10-05))._
## Where we are
@@ -42,7 +42,7 @@ since 2026-10-01.
| **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, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
+| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
| **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) |
@@ -100,6 +100,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's tool-specific variants (only the selection tools show it).
- Tool flyouts tear off: dragging or clicking the tear-off bar on a flyout's right side (or releasing the long press over it) floats its group as a strip of tool buttons, moved by the same bar and put back in the toolbar by the × at its top (`window.floatTools`). While a group floats, the presses that would open its flyout raise the strip instead. Floating strips are kept with the preferences and in user workspaces. Not done: turning a strip vertical, docking it.
+ - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on the dock, which lights up under the pointer, its panels go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating panels and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, docking a panel into a dock group it doesn't belong to, reordering tabs by dragging, collapsing a floating group to icons, resizing floating panels.
- The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
- Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese and Spanish ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), 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.
@@ -122,7 +123,8 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **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; Alt-click opens the tool's dialog, and a drag with Alt down at the release, from the press or pressed mid-drag, transforms a copy), Reshape, Free Transform (free and perspective distort and shear, switched by the keys held during a handle drag).
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). 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. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442).
- - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles; drawing then goes on.
+ - **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
+ - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
- **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 (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446), Measure, Type, Hand, Zoom, Rotate View.
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
@@ -153,12 +155,12 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs).
- **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 (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
- - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; 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, an `.ai`'s white page under its layers left out rather than coming in as art, 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). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
+ - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; 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, an `.ai`'s white page under its layers left out rather than coming in as art, 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). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320).
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors. It also runs what other generators write (Discord feedback, #466, #469): Illustrator 2026's procsets (`gcheck`, `rootfont`, `currentcolorrendering`: every Illustrator 2026 EPS fell back to its preview before), cairo/Inkscape (`status`/`flushfile`, images with a mask, `ImageType 3`, reusable streams), matplotlib's Type 3 fonts, Illustrator 8 EPS (stock art's usual format: executable strings such as its gradients' `ed`, `load` by integer keys, #474), Ghostscript's `//name` immediate names, `pdfmark`, `token`, `execform` and user paths. Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
- Affinity `.af` preview import: Open follows the container's indexed version-12 `Thmb` PNG record, verify dimensions and decoded pixels with limits, and report that native vectors, layers, text, effects, pages and colour settings were omitted. The preview can be downscaled; files without this verified record fail. Open drops the source save path, and Affinity export is unsupported. Place (including queued or renamed files) is refused so the preview cannot silently replace native art. This is an initial reader, not editable native support; [scope, provenance and remaining work](crates/affinity/README.md).
- - 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; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
+ - 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; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
- **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. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
+- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
- **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
@@ -218,7 +220,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done) | 1–2 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
-| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 32 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
+| UI chrome (panels, contextual Properties, workspaces, prefs) | 6 | 78% | Variables, SVG Interactivity, Properties per context, Consolidate All Windows, floating panels outside the app window and docked into any group, context menus beyond the canvas (Links, Asset Export, CSS Properties, Attributes and the canvas context menu are done), about 32 Preferences options stored but not yet acted on (#394: Construction and Spacing Guides, Auto Size New Area Type, Show Font Names in English, Highlight Alternate Glyphs, performance, devices…) | 14–22 |
| Libraries, Links, Package | 2 | 50% | a local Libraries panel (no cloud by design); Links and Package are done | 5–8 |
| **Feature parity** | **103** | **~75%** | | **~210–330** |
| Interaction-fidelity pass (side by side with Illustrator: every tool modifier, cursor, dialog, Properties context) | | | | 60–90 |
@@ -238,7 +240,7 @@ lossless SVG/PDF export of live effects with SVG filters, a documented JSON form
command, gesture and dialog drivable by agents (MCP, CLI, control channel).
## Out of scope (by design or by law)
-- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Art off the page that a PDF does draw opens on the pasteboard.
+- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
- **Adobe cloud services** (Libraries sync, Adobe Fonts, Firefly/generative): these are pluggable provider APIs, not built in.
- **Adobe's bundled assets** (swatch/brush/symbol libraries, presets, icons): ours are original.
diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs
index f13b0cbe..41da4abc 100644
--- a/apps/vectorcraft-cli/src/perf.rs
+++ b/apps/vectorcraft-cli/src/perf.rs
@@ -70,6 +70,49 @@ fn load_average() -> Option {
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
}
+/// Milliseconds per pointer move of a Direct Selection drag of a handle, then of a segment, of a
+/// 5,000-anchor wave of smooth anchors added on top of the synthetic `n`-path document.
+fn edit_drags(n: usize) -> Result<(f64, f64), String> {
+ use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
+ use vectorcraft_geom::{Anchor, PathData, SubPath, Vec2};
+ let mut doc = synthetic(n)?;
+ let l = doc.layers.first().map(|l| l.id);
+ let id = doc.alloc_id();
+ // Anchors 2 pt apart, up and down by 20 pt, their handles 0.6 pt long.
+ let anchor = |i: usize| {
+ let p = Point::new(i as f64 * 2.0, if i.is_multiple_of(2) { 500.0 } else { 520.0 });
+ Anchor::smooth(p, p + Vec2::new(0.6, 0.0))
+ };
+ let path = PathData::single(SubPath::new((0..5000).map(anchor).collect(), false));
+ doc.insert(l, usize::MAX, Node::path(id, path, Appearance::default_art())).map_err(|e| e.to_string())?;
+ let mut s = vectorcraft_engine::Session::new();
+ s.add_document(doc, None);
+ let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
+ let err = |e: vectorcraft_engine::EngineError| e.to_string();
+ let mut drag = |down: Point| -> Result {
+ let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
+ s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
+ s.select_tool("directSelection", view).map_err(err)?;
+ let undo = s.doc().map_err(err)?.history.undo.len();
+ s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
+ let mut y = 0.0;
+ let ms = median_ms(9, || {
+ y += 3.0;
+ // A failed move shows as the missing undo step below.
+ let _ = s.pointer(&ev(PointerKind::Drag, y), view);
+ });
+ s.pointer(&ev(PointerKind::Up, y), view).map_err(err)?;
+ if s.doc().map_err(err)?.history.undo.len() != undo + 1 {
+ return Err(format!("the drag from {down:?} made no undo step"));
+ }
+ Ok(ms)
+ };
+ // Anchor 400 is at (800, 500): its outgoing handle's end, then its segment to anchor 401.
+ let handle = drag(Point::new(800.6, 500.0))?;
+ let segment = drag(Point::new(801.0, 510.0))?;
+ Ok((handle, segment))
+}
+
pub fn run(args: &[String]) -> Result<(), String> {
let mut n = 50_000usize;
let mut it = args.iter();
@@ -145,6 +188,15 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
});
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
+
+ // Path editing: one pointer move of a Direct Selection drag (the tool event and the preview it
+ // applies), on a 5,000-anchor path among the synthetic paths.
+ let (handle, segment) = edit_drags(n).unwrap_or_else(|e| {
+ failed = Some(e);
+ (0.0, 0.0)
+ });
+ rows.push(("handle drag, 5,000-anchor path (per move)", handle, 8.0));
+ rows.push(("segment drag, 5,000-anchor path (per move)", segment, 8.0));
if let Some(e) = failed {
return Err(e);
}
diff --git a/apps/vectorcraft/src/gpu.rs b/apps/vectorcraft/src/gpu.rs
new file mode 100644
index 00000000..b195d5ec
--- /dev/null
+++ b/apps/vectorcraft/src/gpu.rs
@@ -0,0 +1,470 @@
+//! Which graphics adapter the window renders with (#306, #502), and what happens when it can't
+//! show the window.
+//!
+//! The canvas is rasterized on the CPU; the GPU only composites it and draws the UI, so any
+//! adapter that can present to the window will do. [`adapter_order`] ranks the adapters: those that
+//! can't present to the window's surface never, then the one `WGPU_ADAPTER_NAME` names, hardware
+//! before software, the native backends (Vulkan, Metal, DX12) before OpenGL, and the power
+//! preference ([`power_preference`]) among the rest. eframe is handed [`selector`], which takes
+//! the first of that order.
+//!
+//! An adapter can report that it presents to the window and still fail once it does: on a hybrid
+//! Linux desktop under Wayland, the compositor runs on one GPU and may refuse the frame buffers
+//! another GPU allocates, which kills the window's Wayland connection; egui-wgpu then panics while
+//! configuring the surface (#502). Nothing in the process can show a window after that, so when
+//! the graphics fail while the window is starting up, [`finish`] starts the app again without
+//! that adapter, as the next one in the order would be tried. Each restart leaves out one more
+//! adapter, so it ends when none is left.
+
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, OnceLock};
+
+use eframe::egui_wgpu::NativeAdapterSelectorMethod;
+use eframe::wgpu::{self, Backend, DeviceType, PowerPreference};
+
+/// The adapters a restart leaves out, those the window failed on, as comma-separated
+/// `backend:vendor:device` keys (`Vulkan:1002:164e`). Set by the app itself when it starts again.
+pub const SKIP_ENV: &str = "VECTORCRAFT_GPU_SKIP";
+
+/// wgpu's variable for choosing an adapter by (part of) its name, any case.
+const NAME_ENV: &str = "WGPU_ADAPTER_NAME";
+
+/// Where to read how to choose another adapter, for the error that ends the app.
+const HELP: &str =
+ "to choose a graphics adapter, see https://github.com/storytold/vectorcraft/blob/main/docs/development.md#desktop-graphics-processor";
+
+/// Frames the UI ran before the failure, below which it was still starting up. The failure in
+/// #502 came on the first or second frame; later, the adapter has proved itself, and a lost
+/// window is something else (such as the compositor restarting).
+const STARTUP_FRAMES: u64 = 10;
+
+/// The preference used when Preferences say Automatic. Windows and macOS show frames from any GPU
+/// (the integrated one avoids the flicker of #306). Elsewhere, the system's own order: Mesa's
+/// device-select layer puts the GPU the desktop runs on first (the integrated one on hybrid
+/// laptops), and a Wayland compositor may not show frames from another GPU (#502).
+pub const AUTOMATIC: PowerPreference = if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { PowerPreference::None };
+
+/// The power preference that orders the adapters: the `WGPU_POWER_PREF` environment variable
+/// (`low`, `high`, `none`) when set, otherwise Preferences › Performance › Graphics Processor
+/// (`gpuPreference`). Automatic, and any value this version doesn't know (0.5.0's default
+/// `powerSaving`, a newer or damaged preference file), is [`AUTOMATIC`].
+pub fn power_preference(pref: Option<&str>, env: Option) -> PowerPreference {
+ match (env, pref) {
+ (Some(p), _) => p,
+ (None, Some("highPerformance")) => PowerPreference::HighPerformance,
+ (None, Some("lowPower")) => PowerPreference::LowPower,
+ (None, _) => AUTOMATIC,
+ }
+}
+
+/// One adapter, as [`adapter_order`] sees it.
+#[derive(Clone, Debug)]
+pub struct Candidate {
+ /// Identifies the adapter across a restart: `backend:vendor:device` ([`key`]).
+ pub key: String,
+ pub name: String,
+ pub backend: Backend,
+ pub device_type: DeviceType,
+ /// Reports that it can present to the window's surface.
+ pub presents: bool,
+}
+
+impl Candidate {
+ fn new(adapter: &wgpu::Adapter, surface: Option<&wgpu::Surface<'_>>) -> Self {
+ let info = adapter.get_info();
+ Self {
+ key: key(info.backend, info.vendor, info.device),
+ presents: surface.is_none_or(|s| adapter.is_surface_supported(s)),
+ name: info.name,
+ backend: info.backend,
+ device_type: info.device_type,
+ }
+ }
+}
+
+/// `backend:vendor:device`, e.g. `Vulkan:1002:164e` (PCI ids, as `MESA_VK_DEVICE_SELECT` takes them).
+fn key(backend: Backend, vendor: u32, device: u32) -> String {
+ format!("{backend:?}:{vendor:04x}:{device:04x}")
+}
+
+/// The order to try `candidates` in (their indices): only those that present to the window and
+/// aren't in `skip`; the one whose name contains `named` (any case) first; hardware before
+/// software; native backends before OpenGL; then by `power`, keeping the system's order among
+/// equals (all of it for [`PowerPreference::None`]).
+pub fn adapter_order(candidates: &[Candidate], power: PowerPreference, named: Option<&str>, skip: &[String]) -> Vec {
+ let named = named.map(str::to_lowercase).filter(|n| !n.is_empty());
+ let mut order: Vec<(usize, &Candidate)> = candidates.iter().enumerate().filter(|(_, c)| c.presents && !skip.contains(&c.key)).collect();
+ // Stable, so equals keep the system's order.
+ order.sort_by_key(|(_, c)| {
+ let unnamed = named.as_ref().is_some_and(|n| !c.name.to_lowercase().contains(n));
+ (unnamed, c.device_type == DeviceType::Cpu, c.backend == Backend::Gl, power_rank(c.device_type, power))
+ });
+ order.into_iter().map(|(i, _)| i).collect()
+}
+
+/// wgpu's own ranking for a power preference: the preferred kind of GPU, the other kind, unknown,
+/// virtual, software.
+fn power_rank(t: DeviceType, power: PowerPreference) -> u8 {
+ match (power, t) {
+ (PowerPreference::None, _) => 0,
+ (PowerPreference::LowPower, DeviceType::IntegratedGpu) | (PowerPreference::HighPerformance, DeviceType::DiscreteGpu) => 0,
+ (_, DeviceType::IntegratedGpu | DeviceType::DiscreteGpu) => 1,
+ (_, DeviceType::Other) => 2,
+ (_, DeviceType::VirtualGpu) => 3,
+ (_, DeviceType::Cpu) => 4,
+ }
+}
+
+/// The adapters [`SKIP_ENV`] lists.
+pub fn skipped() -> Vec {
+ std::env::var(SKIP_ENV).map(|v| parse_skip(&v)).unwrap_or_default()
+}
+
+fn parse_skip(v: &str) -> Vec {
+ v.split(',').map(str::trim).filter(|k| !k.is_empty()).map(String::from).collect()
+}
+
+/// What the window's start-up leaves for [`finish`].
+#[derive(Default)]
+pub struct Startup {
+ /// The key of the adapter [`selector`] picked.
+ adapter: OnceLock,
+ /// The UI's context, which counts the frames run.
+ ui: OnceLock,
+}
+
+impl Startup {
+ /// The app was created on `ctx`.
+ pub fn created(&self, ctx: &egui::Context) {
+ // Set once; the window is created once.
+ let _ = self.ui.set(ctx.clone());
+ }
+}
+
+/// eframe's adapter choice: the first of [`adapter_order`] for `power`, `WGPU_ADAPTER_NAME` and the
+/// adapters a restart left out. Every adapter and the choice are logged, for bug reports.
+pub fn selector(power: PowerPreference, startup: Arc) -> NativeAdapterSelectorMethod {
+ let named = std::env::var(NAME_ENV).ok();
+ let skip = skipped();
+ Arc::new(move |adapters, surface| {
+ let candidates: Vec = adapters.iter().map(|a| Candidate::new(a, surface)).collect();
+ let order = adapter_order(&candidates, power, named.as_deref(), &skip);
+ let listed: Vec = candidates
+ .iter()
+ .map(|c| {
+ let note = if !c.presents {
+ ", can't show the window"
+ } else if skip.contains(&c.key) {
+ ", failed before"
+ } else {
+ ""
+ };
+ format!("{} ({:?}, {:?}, {}{note})", c.name.trim(), c.backend, c.device_type, c.key)
+ })
+ .collect();
+ log::info!("graphics adapters: {}", listed.join("; "));
+ let first = order.first().and_then(|&i| adapters.get(i).zip(candidates.get(i)));
+ let Some((adapter, chosen)) = first else {
+ return Err(format!("no graphics adapter can show the window (power preference {power:?}, {} adapters)", adapters.len()));
+ };
+ // Set once; eframe picks the adapter once.
+ let _ = startup.adapter.set(chosen.key.clone());
+ Ok(adapter.clone())
+ })
+}
+
+/// The last panic came from the graphics stack (see [`watch_panics`]).
+static GRAPHICS_PANIC: AtomicBool = AtomicBool::new(false);
+
+/// Note whether each panic comes from the graphics stack, so [`finish`] can tell a failing adapter
+/// from a bug. The default hook still prints it.
+pub fn watch_panics() {
+ let default = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ GRAPHICS_PANIC.store(info.location().is_some_and(|l| in_graphics_stack(l.file())), Ordering::Relaxed);
+ default(info);
+ }));
+}
+
+/// A source file of wgpu (`wgpu-core-30.0.1/src/…`, `wgpu-hal-…`), egui-wgpu or naga.
+fn in_graphics_stack(file: &str) -> bool {
+ file.split(['/', '\\']).any(|dir| ["wgpu-", "egui-wgpu-", "naga-"].iter().any(|p| dir.starts_with(p)))
+}
+
+/// The adapter to start again without: the one picked, when the graphics failed while the window
+/// was starting up and it wasn't already left out.
+fn restart_without<'a>(graphics_failed: bool, frames: u64, picked: Option<&'a str>, skip: &[String]) -> Option<&'a str> {
+ picked.filter(|p| graphics_failed && frames < STARTUP_FRAMES && !skip.iter().any(|s| s == p))
+}
+
+/// After the window's run: `Ok` when it closed normally or the app started again (see the module
+/// docs), otherwise why it failed. `outcome` is eframe's result, or the message of a panic that
+/// ended it.
+pub fn finish(outcome: Result, startup: &Startup) -> Result<(), String> {
+ let (why, graphics_failed) = match outcome {
+ Ok(Ok(())) => return Ok(()),
+ Ok(Err(e @ eframe::Error::Wgpu(_))) => (e.to_string(), true),
+ Ok(Err(e)) => (e.to_string(), false),
+ Err(panic) => (panic, GRAPHICS_PANIC.load(Ordering::Relaxed)),
+ };
+ let frames = startup.ui.get().map_or(0, egui::Context::cumulative_frame_nr);
+ let mut skip = skipped();
+ let Some(failed) = restart_without(graphics_failed, frames, startup.adapter.get().map(String::as_str), &skip) else {
+ return Err(if graphics_failed { format!("{why} ({HELP})") } else { why });
+ };
+ log::error!("the window failed on graphics adapter {failed} ({why}): starting again without it");
+ skip.push(failed.to_string());
+ restart(&skip.join(",")).map_err(|e| format!("{why}; starting again failed: {e}"))
+}
+
+/// Start the app again with the same arguments, leaving out the adapters in `skip`. On Unix the
+/// new app replaces this process (same process id, so an AppImage keeps its files mounted), and
+/// this returns only on failure; on Windows it starts beside this one, which then exits.
+fn restart(skip: &str) -> std::io::Result<()> {
+ log::logger().flush();
+ let mut c = std::process::Command::new(std::env::current_exe()?);
+ c.args(std::env::args_os().skip(1)).env(SKIP_ENV, skip);
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::CommandExt as _;
+ Err(c.exec())
+ }
+ #[cfg(not(unix))]
+ c.spawn().map(|_| ())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate {
+ Candidate { key: format!("{backend:?}:{name}"), name: name.into(), backend, device_type, presents: true }
+ }
+
+ fn names(c: &[Candidate], order: &[usize]) -> Vec {
+ order.iter().map(|&i| c[i].name.clone()).collect()
+ }
+
+ /// The machine of #502 as Vulkan lists it with Mesa's device-select layer: the GPU KDE's
+ /// compositor runs on (NVIDIA) first, then the Ryzen's integrated GPU, Mesa's software
+ /// renderer, and OpenGL.
+ fn issue_502() -> Vec {
+ vec![
+ gpu("NVIDIA GeForce RTX 5070", Backend::Vulkan, DeviceType::DiscreteGpu),
+ gpu("AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)", Backend::Vulkan, DeviceType::IntegratedGpu),
+ gpu("llvmpipe (LLVM 20.1.8, 256 bits)", Backend::Vulkan, DeviceType::Cpu),
+ gpu("NVIDIA GeForce RTX 5070/PCIe/SSE2", Backend::Gl, DeviceType::Other),
+ ]
+ }
+
+ #[test]
+ fn automatic_keeps_the_systems_order_on_linux_and_saves_power_elsewhere() {
+ let c = issue_502();
+ let order = adapter_order(&c, power_preference(None, None), None, &[]);
+ let first = &c[order[0]].name;
+ if cfg!(any(windows, target_os = "macos")) {
+ assert_eq!(first, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
+ } else {
+ // #502: the integrated GPU couldn't show frames to a compositor on the NVIDIA GPU.
+ assert_eq!(first, "NVIDIA GeForce RTX 5070");
+ }
+ }
+
+ #[test]
+ fn each_preference_orders_the_adapters_then_falls_back_to_opengl_and_software() {
+ let c = issue_502();
+ let order = |power| names(&c, &adapter_order(&c, power, None, &[]));
+ assert_eq!(
+ order(PowerPreference::None),
+ [
+ "NVIDIA GeForce RTX 5070",
+ "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
+ "NVIDIA GeForce RTX 5070/PCIe/SSE2",
+ "llvmpipe (LLVM 20.1.8, 256 bits)"
+ ]
+ );
+ assert_eq!(
+ order(PowerPreference::LowPower),
+ [
+ "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
+ "NVIDIA GeForce RTX 5070",
+ "NVIDIA GeForce RTX 5070/PCIe/SSE2",
+ "llvmpipe (LLVM 20.1.8, 256 bits)"
+ ]
+ );
+ assert_eq!(
+ order(PowerPreference::HighPerformance),
+ [
+ "NVIDIA GeForce RTX 5070",
+ "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
+ "NVIDIA GeForce RTX 5070/PCIe/SSE2",
+ "llvmpipe (LLVM 20.1.8, 256 bits)"
+ ]
+ );
+ }
+
+ /// A restart leaves out the adapter the window failed on, so Power Saving on the machine of
+ /// #502 ends up on the NVIDIA GPU, and once every adapter failed there is nothing to try.
+ #[test]
+ fn a_restart_tries_the_next_adapter() {
+ let c = issue_502();
+ let mut skip = vec![];
+ let mut tried = vec![];
+ while let Some(&i) = adapter_order(&c, PowerPreference::LowPower, None, &skip).first() {
+ tried.push(c[i].name.clone());
+ skip.push(c[i].key.clone());
+ }
+ assert_eq!(
+ tried,
+ [
+ "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)",
+ "NVIDIA GeForce RTX 5070",
+ "NVIDIA GeForce RTX 5070/PCIe/SSE2",
+ "llvmpipe (LLVM 20.1.8, 256 bits)"
+ ]
+ );
+ }
+
+ #[test]
+ fn adapters_that_cannot_present_to_the_window_are_never_tried() {
+ let mut c = issue_502();
+ c[0].presents = false;
+ c[3].presents = false;
+ for power in [PowerPreference::None, PowerPreference::LowPower, PowerPreference::HighPerformance] {
+ let order = adapter_order(&c, power, None, &[]);
+ assert!(!order.contains(&0) && !order.contains(&3), "{power:?}: {order:?}");
+ assert_eq!(order.len(), 2);
+ }
+ for p in &mut c {
+ p.presents = false;
+ }
+ assert!(adapter_order(&c, PowerPreference::None, None, &[]).is_empty());
+ assert!(adapter_order(&[], PowerPreference::HighPerformance, None, &[]).is_empty());
+ }
+
+ #[test]
+ fn wgpu_adapter_name_picks_an_adapter_first_and_the_rest_stay_as_fallbacks() {
+ let c = issue_502();
+ let order = adapter_order(&c, PowerPreference::HighPerformance, Some("radv"), &[]);
+ assert_eq!(c[order[0]].name, "AMD Ryzen 9 7900X (RADV RAPHAEL_MENDOCINO)");
+ assert_eq!(order.len(), 4);
+ // Any case; every match comes before the rest, natively first.
+ let order = adapter_order(&c, PowerPreference::LowPower, Some("GeForce"), &[]);
+ assert_eq!(names(&c, &order[..2]), ["NVIDIA GeForce RTX 5070", "NVIDIA GeForce RTX 5070/PCIe/SSE2"]);
+ // An empty name or one nothing matches changes nothing.
+ for named in ["", "no such gpu"] {
+ assert_eq!(adapter_order(&c, PowerPreference::LowPower, Some(named), &[]), adapter_order(&c, PowerPreference::LowPower, None, &[]));
+ }
+ // A named adapter that failed is left out like any other.
+ let skip = vec![c[1].key.clone()];
+ assert_eq!(adapter_order(&c, PowerPreference::None, Some("radv"), &skip)[0], 0);
+ }
+
+ /// A Windows machine lists every GPU twice (Vulkan and DX12): power saving still takes the
+ /// integrated GPU first, as wgpu's own choice did (#306).
+ #[test]
+ fn hybrid_laptop_on_windows_renders_on_the_integrated_gpu_with_power_saving() {
+ let c = vec![
+ gpu("Intel(R) Arc(TM) A370M", Backend::Vulkan, DeviceType::DiscreteGpu),
+ gpu("Intel(R) Iris(R) Xe Graphics", Backend::Vulkan, DeviceType::IntegratedGpu),
+ gpu("Intel(R) Arc(TM) A370M", Backend::Dx12, DeviceType::DiscreteGpu),
+ gpu("Intel(R) Iris(R) Xe Graphics", Backend::Dx12, DeviceType::IntegratedGpu),
+ gpu("Microsoft Basic Render Driver", Backend::Dx12, DeviceType::Cpu),
+ ];
+ let order = adapter_order(&c, PowerPreference::LowPower, None, &[]);
+ assert_eq!(order, [1, 3, 0, 2, 4]);
+ assert_eq!(adapter_order(&c, PowerPreference::HighPerformance, None, &[]), [0, 2, 1, 3, 4]);
+ }
+
+ #[test]
+ fn power_preference_follows_the_environment_then_the_preference() {
+ assert_eq!(power_preference(None, None), AUTOMATIC);
+ assert_eq!(power_preference(Some("automatic"), None), AUTOMATIC);
+ assert_eq!(power_preference(Some("lowPower"), None), PowerPreference::LowPower);
+ assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
+ // 0.5.0's default, and values this version doesn't know (a newer or damaged file).
+ for old in ["powerSaving", "turbo", ""] {
+ assert_eq!(power_preference(Some(old), None), AUTOMATIC, "{old}");
+ }
+ // WGPU_POWER_PREF wins over the preference, either way.
+ assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
+ assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
+ assert_eq!(power_preference(Some("lowPower"), Some(PowerPreference::None)), PowerPreference::None);
+ assert_eq!(AUTOMATIC, if cfg!(any(windows, target_os = "macos")) { PowerPreference::LowPower } else { 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("automatic"));
+ for (value, power) in [("lowPower", PowerPreference::LowPower), ("highPerformance", PowerPreference::HighPerformance)] {
+ prefs.gpu_preference = value.into();
+ let saved = prefs.to_json();
+ assert_eq!(power_preference(saved.get("gpuPreference").and_then(serde_json::Value::as_str), None), power);
+ }
+ // Every choice Preferences offer means something here.
+ for (value, _) in vectorcraft_engine::cmd::prefscmds::GPU_PREFERENCES {
+ assert!(["automatic", "lowPower", "highPerformance"].contains(value), "{value}");
+ }
+ }
+
+ #[test]
+ fn skipped_adapters_parse_from_a_comma_separated_list() {
+ assert_eq!(parse_skip("Vulkan:1002:164e, Gl:10de:2f04,,"), ["Vulkan:1002:164e", "Gl:10de:2f04"]);
+ assert!(parse_skip("").is_empty());
+ assert_eq!(key(Backend::Vulkan, 0x1002, 0x164e), "Vulkan:1002:164e");
+ assert_eq!(key(Backend::Gl, 0x10de, 0x2f04), "Gl:10de:2f04");
+ }
+
+ #[test]
+ fn panics_in_wgpu_egui_wgpu_and_naga_are_graphics_failures() {
+ for file in [
+ "/home/u/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/egui-wgpu-0.36.2/src/winit.rs",
+ r"C:\Users\u\.cargo\registry\src\index.crates.io-1949cf8c6b5b557f\wgpu-core-30.0.1\src\device\mod.rs",
+ "/rustc/deps/wgpu-hal-30.0.1/src/vulkan/adapter.rs",
+ "naga-30.0.0/src/back/spv/writer.rs",
+ ] {
+ assert!(in_graphics_stack(file), "{file}");
+ }
+ for file in [
+ "apps/vectorcraft/src/main.rs",
+ "/home/wgpu/vectorcraft/crates/ui-egui/src/lib.rs",
+ "egui-0.36.2/src/context.rs",
+ "winit-0.30.12/src/x.rs",
+ ] {
+ assert!(!in_graphics_stack(file), "{file}");
+ }
+ }
+
+ #[test]
+ fn only_a_graphics_failure_while_starting_up_restarts_without_the_adapter() {
+ let amd = Some("Vulkan:1002:164e");
+ assert_eq!(restart_without(true, 0, amd, &[]), amd);
+ assert_eq!(restart_without(true, STARTUP_FRAMES - 1, amd, &[]), amd);
+ // After start-up the adapter has shown frames; a lost window is something else.
+ assert_eq!(restart_without(true, STARTUP_FRAMES, amd, &[]), None);
+ // A bug outside the graphics, or no adapter picked: nothing another adapter would change.
+ assert_eq!(restart_without(false, 0, amd, &[]), None);
+ assert_eq!(restart_without(true, 0, None, &[]), None);
+ // Never the same adapter twice, so restarts end.
+ assert_eq!(restart_without(true, 0, amd, &["Vulkan:1002:164e".into()]), None);
+ }
+
+ #[test]
+ fn a_normal_exit_or_another_failure_does_not_restart() {
+ let startup = Startup::default();
+ assert_eq!(finish(Ok(Ok(())), &startup), Ok(()));
+ // No adapter was picked (none can show the window): the error is returned, nothing restarts.
+ let none =
+ eframe::Error::Wgpu(eframe::egui_wgpu::WgpuError::CustomNativeAdapterSelectionError("no graphics adapter can show the window".into()));
+ assert_eq!(
+ finish(Ok(Err(none)), &startup),
+ Err(format!("WGPU error: Adapter selection failed: no graphics adapter can show the window ({HELP})"))
+ );
+ let _ = startup.adapter.set("Vulkan:1002:164e".into());
+ GRAPHICS_PANIC.store(false, Ordering::Relaxed);
+ assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
+ }
+}
diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs
index 7af8b003..64fc2049 100644
--- a/apps/vectorcraft/src/main.rs
+++ b/apps/vectorcraft/src/main.rs
@@ -16,6 +16,8 @@ compile_error!("the win7 target requires --no-default-features --features window
mod clipboard;
mod control_server;
+#[cfg(feature = "wgpu")]
+mod gpu;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
@@ -27,7 +29,7 @@ mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
-use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
+use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
struct App {
app: VectorcraftApp,
@@ -254,6 +256,10 @@ fn services() -> Services {
write_shared: Some(std::sync::Arc::new(write_file)),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
+ // Linux checks whether Paste has something to take on a background thread: an X11 clipboard
+ // owner that never answers holds a read for up to 4 s. Windows only asks which formats the
+ // clipboard holds and macOS asks the pasteboard server, so they check in line.
+ clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
@@ -297,23 +303,6 @@ fn app_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
}
-/// The power preference wgpu picks the window's graphics adapter with (#306): the
-/// `WGPU_POWER_PREF` environment variable (`low`, `high`, `none`) when set, otherwise Preferences ›
-/// Performance › Graphics Processor (`gpuPreference`). Power saving unless the preference asks for
-/// high performance: the canvas is rasterized on the CPU and the GPU only composites it, which the
-/// integrated GPU of a hybrid-graphics laptop does easily, while presenting frames rendered on the
-/// discrete GPU through the integrated one made the window flicker on some laptops. With a single
-/// GPU both preferences pick it.
-#[cfg(feature = "wgpu")]
-fn power_preference(pref: Option<&str>, env: Option) -> eframe::wgpu::PowerPreference {
- use eframe::wgpu::PowerPreference;
- match (env, pref) {
- (Some(p), _) => p,
- (None, Some("highPerformance")) => PowerPreference::HighPerformance,
- (None, _) => PowerPreference::LowPower,
- }
-}
-
/// "name (backend, kind)" of the adapter the window renders with, for Help › About and bug reports.
#[cfg(feature = "wgpu")]
fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
@@ -324,7 +313,7 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// macOS keeps its traffic lights over the integrated title strip.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
-fn main() -> eframe::Result {
+fn main() -> std::process::ExitCode {
// First, so every start-up warning is recorded (`logging`).
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
@@ -336,7 +325,7 @@ fn main() -> eframe::Result {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
- return Ok(());
+ return std::process::ExitCode::SUCCESS;
}
_ => files.push(a),
}
@@ -360,7 +349,9 @@ fn main() -> eframe::Result {
#[cfg(feature = "wgpu")]
let gpu_pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("gpuPreference")).and_then(serde_json::Value::as_str);
#[cfg(feature = "wgpu")]
- let power = power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
+ let power = gpu::power_preference(gpu_pref, eframe::wgpu::PowerPreference::from_env());
+ #[cfg(feature = "wgpu")]
+ let startup = std::sync::Arc::new(gpu::Startup::default());
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("VectorCraft")
@@ -383,86 +374,94 @@ fn main() -> eframe::Result {
// One frame queued, not two: the canvas is rasterized on the CPU and the GPU only
// composites it, so the window answers the pointer a frame sooner (#444).
options.wgpu_options.surface = eframe::egui_wgpu::SurfaceConfig::LOW_LATENCY;
+ // Only adapters that can show the window, in the order `gpu` gives (#306, #502).
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
- create.power_preference = power;
+ create.native_adapter_selector = Some(gpu::selector(power, startup.clone()));
}
options
};
+ #[cfg(feature = "wgpu")]
+ gpu::watch_panics();
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
- eframe::run_native(
- "VectorCraft",
- options,
- Box::new(move |cc| {
- let mut app = VectorcraftApp::new(Session::new(), services());
- 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);
- let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
- app.session.style_libraries.set_user_dir(styles);
- // Data Recovery copies live next to the preferences too (none for runs without
- // preferences, such as agents' test runs, unless the recoveryFolder preference is set).
- if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
- 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);
- }
- let graphics_loss = GraphicsLoss::default();
- #[cfg(feature = "wgpu")]
- if let Some(rs) = &cc.wgpu_render_state {
- let summary = adapter_summary(&rs.adapter.get_info());
- log::info!("rendering with {summary} (power preference {power:?})");
- app.graphics_adapter = Some(summary);
- let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
- rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
- }
- #[cfg(feature = "windows7")]
- {
- app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
- }
- app.integrated_titlebar = cfg!(target_os = "macos");
- app.custom_titlebar = CUSTOM_TITLEBAR;
- if let Some(port) = control_port {
- let rx = control_server::start(port, cc.egui_ctx.clone());
- app = app.with_control(rx);
- }
- #[cfg(target_os = "macos")]
- open_documents::set_ui(&cc.egui_ctx);
- open_files(&mut app, files);
- Ok(Box::new(App {
- app,
- graphics_loss,
- graphics_lost: false,
+ #[cfg(feature = "wgpu")]
+ let created = startup.clone();
+ // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
+ let outcome = vectorcraft_engine::guard::catch_panic(|| {
+ eframe::run_native(
+ "VectorCraft",
+ options,
+ Box::new(move |cc| {
+ let mut app = VectorcraftApp::new(Session::new(), services());
+ 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);
+ let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
+ app.session.style_libraries.set_user_dir(styles);
+ // Data Recovery copies live next to the preferences too (none for runs without
+ // preferences, such as agents' test runs, unless the recoveryFolder preference is set).
+ if std::env::var_os("VECTORCRAFT_NO_PREFS").is_none() {
+ 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);
+ }
+ let graphics_loss = GraphicsLoss::default();
+ #[cfg(feature = "wgpu")]
+ if let Some(rs) = &cc.wgpu_render_state {
+ let summary = adapter_summary(&rs.adapter.get_info());
+ log::info!("rendering with {summary} (power preference {power:?})");
+ created.created(&cc.egui_ctx);
+ if !gpu::skipped().is_empty() {
+ app.status(format!("The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary}"));
+ }
+ app.graphics_adapter = Some(summary);
+ let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
+ rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
+ }
+ #[cfg(feature = "windows7")]
+ {
+ app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
+ }
+ app.integrated_titlebar = cfg!(target_os = "macos");
+ app.custom_titlebar = CUSTOM_TITLEBAR;
+ if let Some(port) = control_port {
+ let rx = control_server::start(port, cc.egui_ctx.clone());
+ app = app.with_control(rx);
+ }
#[cfg(target_os = "macos")]
- menu: None,
- }))
- }),
- )
+ open_documents::set_ui(&cc.egui_ctx);
+ open_files(&mut app, files);
+ Ok(Box::new(App {
+ app,
+ graphics_loss,
+ graphics_lost: false,
+ #[cfg(target_os = "macos")]
+ menu: None,
+ }))
+ }),
+ )
+ });
+ #[cfg(feature = "wgpu")]
+ let outcome = gpu::finish(outcome, &startup);
+ #[cfg(not(feature = "wgpu"))]
+ let outcome = outcome.and_then(|r| r.map_err(|e| e.to_string()));
+ match outcome {
+ Ok(()) => std::process::ExitCode::SUCCESS,
+ Err(e) => {
+ log::error!("VectorCraft stopped: {e}");
+ std::process::ExitCode::FAILURE
+ }
+ }
}
#[cfg(all(test, feature = "wgpu"))]
mod tests {
use super::*;
- use eframe::wgpu::PowerPreference;
-
- #[test]
- fn power_saving_unless_the_preference_or_the_environment_says_otherwise() {
- assert_eq!(power_preference(None, None), PowerPreference::LowPower);
- assert_eq!(power_preference(Some("powerSaving"), None), PowerPreference::LowPower);
- assert_eq!(power_preference(Some("highPerformance"), None), PowerPreference::HighPerformance);
- // A value this version doesn't know (a newer or damaged preference file) is the default.
- assert_eq!(power_preference(Some("turbo"), None), PowerPreference::LowPower);
- assert_eq!(power_preference(Some(""), None), PowerPreference::LowPower);
- // WGPU_POWER_PREF wins over the preference, either way.
- assert_eq!(power_preference(Some("powerSaving"), Some(PowerPreference::HighPerformance)), PowerPreference::HighPerformance);
- assert_eq!(power_preference(Some("highPerformance"), Some(PowerPreference::LowPower)), PowerPreference::LowPower);
- assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None);
- }
/// The file extensions the macOS bundle declares: its document types and its own exported type.
fn plist_extensions(plist: &str) -> Vec<&str> {
@@ -492,16 +491,4 @@ mod tests {
assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank");
assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned");
}
-
- /// The preference the engine saves is the one the app reads back before the window opens.
- #[test]
- fn the_saved_engine_preference_is_found() {
- let mut prefs = vectorcraft_engine::Prefs::default();
- let saved = prefs.to_json();
- assert_eq!(saved.get("gpuPreference").and_then(serde_json::Value::as_str), Some("powerSaving"));
- prefs.gpu_preference = "highPerformance".into();
- let saved = prefs.to_json();
- let pref = saved.get("gpuPreference").and_then(serde_json::Value::as_str);
- assert_eq!(power_preference(pref, None), PowerPreference::HighPerformance);
- }
}
diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs
index 2ce91936..6d870d77 100644
--- a/crates/engine/src/cmd/draw2.rs
+++ b/crates/engine/src/cmd/draw2.rs
@@ -701,13 +701,16 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result {
return Err(EngineError::Other("no such segment".into()));
}
let n = sp.anchors.len();
- // Moving both inner control points by v moves B(t) by 3t(1-t)·v.
+ // Moving both inner control points by v moves B(t) by 3t(1-t)·v. A smooth anchor stays
+ // smooth: its other handle turns with the moved one.
let v = dv / (3.0 * t * (1.0 - t));
let (i0, i1) = (seg % n, (seg + 1) % n);
- let a = sp.anchors[i0];
- sp.anchors[i0] = Anchor::with_handles(a.p, a.h_in, a.h_out + v);
- let b = sp.anchors[i1];
- sp.anchors[i1] = Anchor::with_handles(b.p, b.h_in + v, b.h_out);
+ if let Some(a) = sp.anchors.get_mut(i0) {
+ a.set_handle(true, a.h_out + v, false);
+ }
+ if let Some(b) = sp.anchors.get_mut(i1) {
+ b.set_handle(false, b.h_in + v, false);
+ }
Ok(())
})?;
ok()
diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs
index 9b0aabc5..0c7ba360 100644
--- a/crates/engine/src/cmd/fileio/mod.rs
+++ b/crates/engine/src/cmd/fileio/mod.rs
@@ -79,7 +79,7 @@ pub fn specs() -> Vec {
"Open Document",
[],
None,
- "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
+ "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, lines wrapped in a frame as area type where they lay out the same, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does, grays? as there; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning), .eps and PostScript .ai (one artboard, the bounding box: an EPS file VectorCraft wrote restores the document it carries, restored: true; other files are read by a PostScript interpreter — paths, colours and spot inks, clips, gradients, images, type in the fonts named — and come in as their TIFF preview, with a warning, when their PostScript can't be read). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs
index 545b062c..b7fb4c38 100644
--- a/crates/engine/src/cmd/prefscmds.rs
+++ b/crates/engine/src/cmd/prefscmds.rs
@@ -82,8 +82,11 @@ 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)")];
+/// Performance › Graphics Processor (`gpuPreference`), read by the desktop app at startup. The
+/// values are WebGPU's power preferences plus `automatic`; 0.5.0's default, `powerSaving`, reads as
+/// `automatic` (#502).
+pub const GPU_PREFERENCES: &[(&str, &str)] =
+ &[("automatic", "Automatic"), ("lowPower", "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")];
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 453b9b1d..beac971a 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -446,10 +446,13 @@ pub struct Prefs {
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.
+ /// restart): `automatic`, `lowPower` (the integrated GPU on hybrid-graphics machines) 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). Automatic is power
+ /// saving on Windows and macOS and the system's default GPU elsewhere, the one the desktop
+ /// runs on: a Wayland compositor may not show frames from another GPU (#502). Single-GPU
+ /// machines are unaffected.
pub gpu_preference: String,
pub history_states: u32,
pub real_time_drawing: bool,
@@ -653,7 +656,7 @@ impl Default for Prefs {
interface_language: s("auto"),
gpu_performance: true,
animated_zoom: true,
- gpu_preference: s("powerSaving"),
+ gpu_preference: s("automatic"),
history_states: 500,
real_time_drawing: true,
render_threads: -1,
@@ -718,6 +721,12 @@ pub struct Session {
/// Executed commands (for actions and debugging).
pub journal: Vec<(String, Value)>,
pub(crate) tool: Box,
+ /// While Cmd lends `tool` (a selection tool) for a drag: the tool it was lent to, which comes
+ /// back as it was at the release ([`Session::pointer`]).
+ pub(crate) lender: Option>,
+ /// The selection tool chosen last (Selection, Direct Selection or Group Selection): the one Cmd
+ /// lends the other tools. None until one is chosen.
+ pub(crate) last_selection_tool: Option<&'static str>,
pub(crate) last_view: ViewInfo,
depth: u32,
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
@@ -797,6 +806,8 @@ impl Session {
clipboard: Clipboard::default(),
journal: vec![],
tool: vectorcraft_tools::create("selection"),
+ lender: None,
+ last_selection_tool: None,
last_view: ViewInfo::default(),
depth: 0,
tool_panic: None,
@@ -855,6 +866,8 @@ impl Session {
return;
}
let view = self.last_view;
+ // The switch goes on whatever the lent tool's last actions did, as for the deactivation.
+ let _ = self.give_back_tool(view);
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let _ = self.apply_actions(acts);
// A batch leaves its interaction open in the document it leaves, to keep or roll back with
diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs
index ccb8f5a5..760c0f07 100644
--- a/crates/engine/src/tests_draw2.rs
+++ b/crates/engine/src/tests_draw2.rs
@@ -57,6 +57,20 @@ fn click(s: &mut Session, tool: &str, x: f64, y: f64) -> Vec {
drag(s, tool, &[(x, y)], Mods::default())
}
+/// A gesture of the active tool, `m` held throughout: press at the first point, drag through the
+/// others, release at the last. The undo steps it made.
+fn gesture(s: &mut Session, pts: &[(f64, f64)], m: Mods) -> usize {
+ let v = view();
+ let undo = undo_steps(s);
+ s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
+ for &(x, y) in &pts[1..] {
+ s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
+ }
+ let l = pts[pts.len() - 1];
+ s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
+ undo_steps(s) - undo
+}
+
/// A wavy stroke sampled every 2 pt.
fn wave(x0: f64, x1: f64, y: f64) -> Vec<(f64, f64)> {
let n = ((x1 - x0) / 2.0) as usize;
@@ -622,16 +636,6 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
let anchors = json!([{"x": 100, "y": 200}, {"x": 200, "y": 100, "in": [150, 100], "out": [250, 100]}, {"x": 300, "y": 200}]);
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
s.select_tool("pen", v).unwrap();
- let gesture = |s: &mut Session, pts: &[(f64, f64)], m: Mods| {
- let undo = undo_steps(s);
- s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1).with_mods(m), v).unwrap();
- for &(x, y) in &pts[1..] {
- s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(m), v).unwrap();
- }
- let l = pts[pts.len() - 1];
- s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m), v).unwrap();
- undo_steps(s) - undo
- };
// Alt-drag the out handle: it moves alone, the in handle stays.
assert_eq!(gesture(&mut s, &[(250.0, 100.0), (260.0, 130.0), (275.0, 165.0)], alt), 1);
assert!(!s.in_interaction());
@@ -659,3 +663,130 @@ fn pen_with_alt_moves_one_handle_and_converts_anchors() {
assert_eq!(path(&s, new).subpaths[0].anchors.len(), 3);
assert_eq!(paths(&s).len(), 2);
}
+
+/// The anchor's handles point opposite ways.
+fn smooth(a: vectorcraft_geom::Anchor) -> bool {
+ let (i, o) = (a.h_in - a.p, a.h_out - a.p);
+ (i.x * o.y - i.y * o.x).abs() < 1e-6 && i.x * o.x + i.y * o.y < 0.0
+}
+
+#[test]
+fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() {
+ // #494: Cmd (Ctrl) held with the Pen drags with the selection tool used last (Direct Selection
+ // until one is chosen): handles, segments and anchors of the path being drawn; released, the
+ // Pen goes on drawing that path. Through pointer events, so agents reach it too.
+ let (none, cmd) = (Mods::default(), Mods { cmd: true, ..Mods::default() });
+ let v = view();
+ let mut s = session();
+ s.select_tool("pen", v).unwrap();
+ gesture(&mut s, &[(100.0, 300.0)], none);
+ gesture(&mut s, &[(200.0, 300.0), (250.0, 300.0)], none);
+ let id = s.doc().unwrap().selection.objects[0];
+ assert_eq!(s.cursor(Point::new(250.0, 300.0), none, v), vectorcraft_tools::Cursor::Pen);
+ assert_eq!(s.cursor(Point::new(250.0, 300.0), cmd, v), vectorcraft_tools::Cursor::ArrowHollow, "Direct Selection's");
+ // The new anchor's outgoing handle: it moves, its incoming one turning with it.
+ s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 300.0).with_mods(cmd), v).unwrap();
+ assert_eq!(s.tool_id(), "directSelection");
+ s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(cmd), v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0).with_mods(cmd), v).unwrap();
+ assert_eq!(s.tool_id(), "pen");
+ let a = path(&s, id).subpaths[0].anchors[1];
+ assert!(a.h_out == Point::new(250.0, 260.0) && smooth(a), "{a:?}");
+ // The segment between the two anchors bends, the smooth anchor staying smooth.
+ let before = path(&s, id).subpaths[0].anchors[1].h_in;
+ gesture(&mut s, &[(120.0, 300.0), (120.0, 270.0)], cmd);
+ let a = path(&s, id).subpaths[0].anchors[1];
+ assert!(a.h_in != before && smooth(a), "{a:?}");
+ assert_eq!(s.tool_id(), "pen");
+ // A plain click goes on drawing the same path.
+ gesture(&mut s, &[(300.0, 350.0)], none);
+ assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 3));
+ // A Cmd-click away from the art deselects: the path is done, the next click starts another.
+ gesture(&mut s, &[(600.0, 100.0)], cmd);
+ gesture(&mut s, &[(400.0, 500.0)], none);
+ assert_eq!(paths(&s).len(), 2);
+ assert_eq!(path(&s, id).subpaths[0].anchors.len(), 3);
+ // With the Selection tool chosen last, Cmd lends it.
+ s.select_tool("selection", v).unwrap();
+ s.select_tool("pen", v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
+ assert_eq!(s.tool_id(), "selection");
+ s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0).with_mods(cmd), v).unwrap();
+ assert_eq!(s.tool_id(), "pen");
+ // Choosing a tool mid-gesture keeps it: the Pen doesn't come back over it.
+ s.pointer(&PointerEvent::new(PointerKind::Down, 600.0, 100.0).with_mods(cmd), v).unwrap();
+ s.select_tool("rectangle", v).unwrap();
+ s.pointer(&PointerEvent::new(PointerKind::Up, 600.0, 100.0), v).unwrap();
+ assert_eq!(s.tool_id(), "rectangle");
+}
+
+#[test]
+fn reshaping_a_segment_keeps_smooth_anchors_smooth() {
+ // #494: the anchors at the ends of a dragged segment keep their kind: a smooth one's other
+ // handle turns with the moved one (at its length), a corner's stays.
+ let mut s = session();
+ let anchors = json!([{"x": 100, "y": 300}, {"x": 200, "y": 300, "in": [160, 300], "out": [240, 300]}, {"x": 300, "y": 300}]);
+ let id = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap());
+ s.execute("path.reshapeSegment", &json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": -30})).unwrap();
+ let sp = &path(&s, id).subpaths[0];
+ let a = sp.anchors[1];
+ assert!(near(a.h_in, Point::new(160.0, 260.0)) && smooth(a), "{a:?}");
+ assert!((a.h_out.distance(a.p) - 40.0).abs() < 1e-9, "its length kept");
+ assert!(near(sp.anchors[0].h_out, Point::new(100.0, 260.0)) && !sp.anchors[0].has_in(), "the corner end: {:?}", sp.anchors[0]);
+}
+
+#[test]
+fn pen_drag_from_the_first_anchor_closes_the_path() {
+ // #501: closing a path by dragging out of its first anchor flickered between a close and a new
+ // path, and released at the wrong moment left the shape open. The press decides: the drag
+ // shapes the closing curve, symmetric, and the release closes the path wherever it ends.
+ let none = Mods::default();
+ let v = view();
+ let mut s = session();
+ s.select_tool("pen", v).unwrap();
+ for (x, y) in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
+ gesture(&mut s, &[(x, y)], none);
+ }
+ let id = s.doc().unwrap().selection.objects[0];
+ let pts: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
+ s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 300.0), v).unwrap();
+ for &(x, y) in &pts[1..] {
+ s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
+ let p = path(&s, id);
+ assert!(p.subpaths[0].closed && paths(&s).len() == 1, "closed throughout the drag, at ({x}, {y})");
+ }
+ s.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 252.0), v).unwrap();
+ assert!(!s.in_interaction());
+ let sp = &path(&s, id).subpaths[0];
+ assert!(sp.closed && sp.anchors.len() == 3, "{sp:?}");
+ let a = sp.anchors[0];
+ assert_eq!((a.h_in, a.h_out), (Point::new(40.0, 348.0), Point::new(160.0, 252.0)), "symmetric");
+ assert_eq!(paths(&s).len(), 1);
+ // The next click starts a new path.
+ gesture(&mut s, &[(500.0, 500.0)], none);
+ assert_eq!(paths(&s).len(), 2);
+}
+
+#[test]
+fn pen_with_alt_converts_the_anchors_of_the_path_being_drawn() {
+ // #504: while drawing, Alt-click on a smooth anchor of the path makes it a corner and Alt-drag
+ // on a corner one pulls out smooth handles; the next click goes on drawing the path.
+ let (none, alt) = (Mods::default(), Mods { alt: true, ..Mods::default() });
+ let v = view();
+ let mut s = session();
+ s.select_tool("pen", v).unwrap();
+ gesture(&mut s, &[(100.0, 300.0)], none);
+ gesture(&mut s, &[(200.0, 300.0), (240.0, 300.0)], none);
+ gesture(&mut s, &[(300.0, 400.0)], none);
+ gesture(&mut s, &[(400.0, 300.0)], none);
+ let id = s.doc().unwrap().selection.objects[0];
+ assert!(smooth(path(&s, id).subpaths[0].anchors[1]));
+ assert_eq!(gesture(&mut s, &[(200.0, 300.0)], alt), 1);
+ let a = path(&s, id).subpaths[0].anchors[1];
+ assert!(!a.has_in() && !a.has_out(), "a corner: {a:?}");
+ assert_eq!(gesture(&mut s, &[(300.0, 400.0), (320.0, 400.0), (340.0, 400.0)], alt), 1);
+ let a = path(&s, id).subpaths[0].anchors[2];
+ assert!(smooth(a) && a.h_out == Point::new(340.0, 400.0), "smooth: {a:?}");
+ gesture(&mut s, &[(500.0, 400.0)], none);
+ assert_eq!((paths(&s).len(), path(&s, id).subpaths[0].anchors.len()), (1, 5));
+}
diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs
index 71fc6bc3..214f5ceb 100644
--- a/crates/engine/src/tests_prefs.rs
+++ b/crates/engine/src/tests_prefs.rs
@@ -123,34 +123,37 @@ fn prefs_serde_round_trip_and_tolerates_missing_fields() {
assert_eq!(partial.corner_radius, 12.0);
}
-/// Performance › Graphics Processor (#306): power saving by default, set by value or label, kept
-/// through a save/load round trip, and a bad value is an error that changes nothing.
+/// Performance › Graphics Processor (#306, #502): automatic by default, set by value or label,
+/// kept through a save/load round trip, and a bad value (0.5.0's `powerSaving` among them) is an
+/// error that changes nothing.
#[test]
-fn gpu_preference_defaults_to_power_saving_and_validates() {
+fn gpu_preference_defaults_to_automatic_and_validates() {
let mut s = Session::new();
- assert_eq!(s.prefs.gpu_preference, "powerSaving");
- assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("powerSaving"));
+ assert_eq!(s.prefs.gpu_preference, "automatic");
+ assert_eq!(s.execute("prefs.get", &json!({"key": "gpuPreference"})).unwrap(), json!("automatic"));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
assert_eq!(s.prefs.gpu_preference, "highPerformance");
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "Power Saving (integrated)"})).unwrap();
- assert_eq!(s.prefs.gpu_preference, "powerSaving");
- for bad in [json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
+ assert_eq!(s.prefs.gpu_preference, "lowPower");
+ s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "automatic"})).unwrap();
+ assert_eq!(s.prefs.gpu_preference, "automatic");
+ for bad in [json!("powerSaving"), json!("turbo"), json!(""), json!(1), json!(null), json!(["highPerformance"])] {
assert!(s.execute("prefs.set", &json!({"key": "gpuPreference", "value": bad})).is_err(), "{bad}");
- assert_eq!(s.prefs.gpu_preference, "powerSaving");
+ assert_eq!(s.prefs.gpu_preference, "automatic");
}
- let p = Prefs { gpu_preference: "highPerformance".into(), ..Default::default() };
+ let p = Prefs { gpu_preference: "lowPower".into(), ..Default::default() };
let back: Prefs = serde_json::from_value(p.to_json()).unwrap();
- assert_eq!(back.gpu_preference, "highPerformance");
+ assert_eq!(back.gpu_preference, "lowPower");
// Preference files written before the preference existed get the default.
let old: Prefs = serde_json::from_value(json!({"gpuPerformance": true})).unwrap();
- assert_eq!(old.gpu_preference, "powerSaving");
+ assert_eq!(old.gpu_preference, "automatic");
let l = s.execute("prefs.list", &json!({})).unwrap();
let row = l.as_array().unwrap().iter().find(|e| e["key"] == "gpuPreference").unwrap().clone();
assert_eq!(row["category"], "Performance");
- assert_eq!(row["options"], json!(["powerSaving", "highPerformance"]));
+ assert_eq!(row["options"], json!(["automatic", "lowPower", "highPerformance"]));
s.execute("prefs.set", &json!({"key": "gpuPreference", "value": "highPerformance"})).unwrap();
s.execute("prefs.reset", &json!({"category": "Performance"})).unwrap();
- assert_eq!(s.prefs.gpu_preference, "powerSaving");
+ assert_eq!(s.prefs.gpu_preference, "automatic");
}
/// Selection & Anchor Display › Tolerance, Object Selection by Path Only and Command Click to
diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs
index b149c31a..e093a28f 100644
--- a/crates/engine/src/tooling.rs
+++ b/crates/engine/src/tooling.rs
@@ -82,6 +82,7 @@ impl Session {
/// Switch to tool `id` without choosing it afresh (back from a temporary tool: a hidden
/// perspective grid stays hidden), finishing any pending tool work first.
pub fn switch_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> {
+ self.give_back_tool(view)?;
if self.tool.id() == id {
return Ok(());
}
@@ -89,9 +90,47 @@ impl Session {
self.apply_actions(acts)?;
self.keep_tool_settings();
self.tool = self.make_tool(id);
+ if vectorcraft_tools::catalog::is_selection_tool(id) {
+ self.last_selection_tool = Some(self.tool.id());
+ }
Ok(())
}
+ /// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is,
+ /// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others.
+ /// None for the selection tools themselves and the view tools.
+ fn lent_tool(&self, id: &str) -> Option<&'static str> {
+ use vectorcraft_tools::catalog::{edits_anchors, is_selection_tool};
+ if is_selection_tool(id) || matches!(id, "hand" | "zoom" | "rotateView") {
+ return None;
+ }
+ Some(self.last_selection_tool.unwrap_or(if edits_anchors(id) { "directSelection" } else { "selection" }))
+ }
+
+ /// A press with Cmd held (Ctrl elsewhere) and any other tool than a selection tool: the
+ /// selection tool [`Self::lent_tool`] takes the gesture, pressing without Cmd (a plain click,
+ /// not Select Behind). The tool lent to stays as it was, unaware, until the release.
+ fn lend_selection_tool(&mut self, ev: &PointerEvent) -> PointerEvent {
+ if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() {
+ return *ev;
+ }
+ let Some(id) = self.lent_tool(self.tool.id()) else { return *ev };
+ let lent = self.make_tool(id);
+ self.lender = Some(std::mem::replace(&mut self.tool, lent));
+ PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }
+ }
+
+ /// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the
+ /// Pen goes on drawing its path).
+ pub(crate) fn give_back_tool(&mut self, view: ViewInfo) -> Result<()> {
+ let Some(lender) = self.lender.take() else { return Ok(()) };
+ let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
+ let r = self.apply_actions(acts);
+ self.keep_tool_settings();
+ self.tool = lender;
+ r.map(drop)
+ }
+
/// A fresh `id` tool with the options it keeps ([`settings`]): its last values, from this
/// session or a saved one.
pub(crate) fn make_tool(&self, id: &str) -> Box {
@@ -212,13 +251,19 @@ impl Session {
if let Some(r) = self.plane_widget_pointer(ev, view) {
return r;
}
+ let ev = &self.lend_selection_tool(ev);
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
- self.take_tool_panic()?;
- // A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
+ let r = self.take_tool_panic().and_then(|()| {
+ // A gesture may change options (Alt-drag sizes a Liquify brush): keep them.
+ if ev.kind == PointerKind::Up {
+ self.keep_tool_settings();
+ }
+ self.apply_actions(acts)
+ });
if ev.kind == PointerKind::Up {
- self.keep_tool_settings();
+ self.give_back_tool(view)?;
}
- self.apply_actions(acts)
+ r
}
/// A guide dragged out of a ruler, whatever the tool: a vertical one out of the left ruler, a
@@ -325,9 +370,20 @@ impl Session {
}
/// The pointer the active tool shows at `p` (General › Use Precise Cursors makes the drawing
- /// tools' a crosshair, [`Cursor::precise`]).
+ /// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a
+ /// press would borrow ([`Self::lend_selection_tool`]).
pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor {
- let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods));
+ let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id()) } else { None };
+ let c = match lent {
+ Some(id) => {
+ let lent = self.make_tool(id);
+ let own = std::mem::replace(&mut self.tool, lent);
+ let c = self.with_tool_cx(view, |t, cx| t.cursor(cx, p, Mods { cmd: false, ..mods }));
+ self.tool = own;
+ c
+ }
+ None => self.with_tool_cx(view, |t, cx| t.cursor(cx, p, mods)),
+ };
if self.prefs.use_precise_cursors { c.precise() } else { c }
}
diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs
index 2d671446..e6f8e449 100644
--- a/crates/engine/tests/import_fuzz.rs
+++ b/crates/engine/tests/import_fuzz.rs
@@ -552,6 +552,10 @@ fn arb_rich_op() -> impl Strategy {
"/Sh0 sh",
"/Sh1 sh",
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
+ // Lines wrapped in a frame, digits set apart by gaps, glyph outlines stroked (#508).
+ "BT /F1 10 Tf 10 80 Td (Wrapped line ) Tj 0 -12 Td (and its next ) Tj 0 -12 Td (end.) Tj ET",
+ "BT /F1 10 Tf 10 40 Td (3) Tj 20 0 Td (5331) Tj 40 0 Td (0106) Tj ET",
+ "0.5 w 10 10 m 14 18 l 18 10 l h S",
"7 Tr",
"q 10 10 50 50 re W n",
"Q",
diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs
index 21b664a4..ed3dc08b 100644
--- a/crates/mcp/src/lib.rs
+++ b/crates/mcp/src/lib.rs
@@ -49,6 +49,8 @@ mod tests_links;
#[cfg(test)]
mod tests_liquify;
#[cfg(test)]
+mod tests_pen;
+#[cfg(test)]
mod tests_persp;
#[cfg(test)]
mod tests_place;
diff --git a/crates/mcp/src/tests_pen.rs b/crates/mcp/src/tests_pen.rs
new file mode 100644
index 00000000..ae89a3b4
--- /dev/null
+++ b/crates/mcp/src/tests_pen.rs
@@ -0,0 +1,66 @@
+//! The Pen over MCP, headless: `pointer_gesture` with the modifiers held as on the desktop.
+
+use serde_json::{Value, json};
+
+use crate::{Backend, Headless, call_tool};
+
+fn text(r: &crate::ToolResult) -> Value {
+ assert!(!r.is_error, "{r:?}");
+ serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
+}
+
+/// A press at the first point, drags through the others, the release at the last.
+fn gesture(pts: &[(f64, f64)]) -> Value {
+ let (first, last) = (pts[0], pts[pts.len() - 1]);
+ let mut events = vec![json!({"kind": "down", "x": first.0, "y": first.1})];
+ events.extend(pts[1..].iter().map(|(x, y)| json!({"kind": "drag", "x": x, "y": y})));
+ events.push(json!({"kind": "up", "x": last.0, "y": last.1}));
+ json!({ "events": events })
+}
+
+/// The anchors of each path, in paint order.
+fn paths(h: &Headless) -> Vec> {
+ let mut v = vec![];
+ h.session.doc().unwrap().doc.walk(|n| {
+ if let Some(p) = n.path_data() {
+ v.push(p.subpaths.iter().flat_map(|s| s.anchors.clone()).collect());
+ }
+ });
+ v
+}
+
+/// #494: Cmd held at the press borrows Direct Selection from the Pen; the path goes on afterwards.
+#[test]
+fn cmd_with_the_pen_edits_the_path_being_drawn() {
+ let mut h = Headless::new();
+ h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
+ text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
+ text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(100.0, 300.0)])));
+ text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(200.0, 300.0), (250.0, 300.0)])));
+ let mut drag = gesture(&[(250.0, 300.0), (250.0, 280.0), (250.0, 263.0)]);
+ drag["mods"] = json!({"cmd": true});
+ let r = text(&call_tool(&mut h, "pointer_gesture", &drag));
+ assert_eq!(r["tool"], "pen", "{r}");
+ assert_eq!(paths(&h)[0][1].h_out, vectorcraft_geom::Point::new(250.0, 263.0));
+ text(&call_tool(&mut h, "pointer_gesture", &gesture(&[(300.0, 350.0)])));
+ assert_eq!(paths(&h).iter().map(Vec::len).collect::>(), [3], "the same path, three anchors");
+}
+
+/// #501: a drag out of the first anchor closes the path, shaping the closing curve.
+#[test]
+fn a_drag_from_the_first_anchor_closes_the_path() {
+ let mut h = Headless::new();
+ h.call("engine.execute", json!({"command": "file.new", "params": {"width": 800, "height": 600}})).unwrap();
+ text(&call_tool(&mut h, "select_tool", &json!({"tool": "pen"})));
+ for p in [(100.0, 300.0), (200.0, 300.0), (200.0, 400.0)] {
+ text(&call_tool(&mut h, "pointer_gesture", &gesture(&[p])));
+ }
+ let close: Vec<(f64, f64)> = (0..=12).map(|i| (100.0 + 5.0 * f64::from(i), 300.0 - 4.0 * f64::from(i))).collect();
+ text(&call_tool(&mut h, "pointer_gesture", &gesture(&close)));
+ let d = &h.session.doc().unwrap().doc;
+ let mut closed = vec![];
+ d.walk(|n| closed.extend(n.path_data().map(|p| p.is_closed())));
+ assert_eq!(closed, [true], "one path, closed");
+ let first = paths(&h)[0][0];
+ assert_eq!((first.h_in, first.h_out), (vectorcraft_geom::Point::new(40.0, 348.0), vectorcraft_geom::Point::new(160.0, 252.0)));
+}
diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs
index 88833c88..f9f6db07 100644
--- a/crates/pdf/src/import.rs
+++ b/crates/pdf/src/import.rs
@@ -23,7 +23,7 @@ use crate::import_color::{Colors, Native};
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
use crate::import_scan::{MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient};
-use crate::import_text::{Families, Look, Placement, TextLine, Upright};
+use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
/// The name of the paths text imports as.
@@ -76,6 +76,33 @@ fn place_group(g: usize, ocgs: &Ocgs, slots: &mut Vec, nesting: &mut Nesti
slots.push(Slot::Group(child));
}
+/// The top-level group that group `g` is listed under (`g` itself when it isn't a sublayer).
+fn top_group(g: usize, ocgs: &Ocgs) -> usize {
+ let mut top = g;
+ for _ in 0..=MAX_NESTING {
+ match ocgs.list.get(top).and_then(|o| o.parent) {
+ Some(parent) => top = parent,
+ None => break,
+ }
+ }
+ top
+}
+
+/// Merge the top-level groups a page marks (`page`, in the order it first marks each, its empty
+/// ones too) into the stacking order of the pages before (`order`, bottom first; `known`: its
+/// groups). A group new to it goes right below the first group the page marks after it that it
+/// has: a layer whose art starts on a later page keeps its place among the others (#508).
+fn merge_order(order: &mut Vec, known: &mut HashSet, page: &[usize]) {
+ let mut above = None;
+ for &g in page.iter().rev() {
+ if known.insert(g) {
+ let at = above.and_then(|a| order.iter().position(|&x| x == a)).unwrap_or(order.len());
+ order.insert(at, g);
+ }
+ above = Some(g);
+ }
+}
+
/// The layer of group `g`, in `color`: its art and sublayers. `None` for a group that isn't read.
fn group_layer(
b: &mut Builder<'_>,
@@ -166,6 +193,8 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = vec![];
let mut group_art: HashMap>> = HashMap::new();
let mut nesting = Nesting::new();
+ // The top-level groups in the order the pages mark them, bottom first.
+ let (mut order, mut known) = (vec![], HashSet::new());
for (i, &number) in picked.iter().enumerate() {
let Some(page) = pages.get(number) else { continue };
// The chosen box sits at (x, 0); the page draws round it.
@@ -175,6 +204,11 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect();
+ merge_order(&mut order, &mut known, &marked);
+ }
b.begin_page(scan);
interpret_page(page, &mut ctx, &mut b);
let mut parts = b.end_page();
@@ -239,8 +273,25 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result = order.iter().enumerate().map(|(i, &g)| (g, i)).collect();
+ let at: Vec = slots.iter().enumerate().filter(|(_, s)| matches!(s, Slot::Group(_))).map(|(i, _)| i).collect();
+ let mut stacked: Vec = slots.iter().filter_map(|s| if let Slot::Group(g) = s { Some(*g) } else { None }).collect();
+ stacked.sort_by_key(|g| rank.get(g).copied().unwrap_or(usize::MAX));
+ for (i, g) in at.into_iter().zip(stacked) {
+ if let Some(s) = slots.get_mut(i) {
+ *s = Slot::Group(g);
+ }
+ }
for (n, slot) in slots.into_iter().enumerate() {
- let layer = match slot {
+ let mut layer = match slot {
Slot::Page(l) => *l,
// Sublayers take their top-level layer's colour.
Slot::Group(g) => match group_layer(&mut b, g, &ocgs, &nesting, &mut group_art, LayerColor::Preset((n % 27) as u8)) {
@@ -248,6 +299,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result continue,
},
};
+ if let Some(art) = layer.children_mut() {
+ crate::import_lines::rebuild(art, &b.lines);
+ }
doc.layers.push(Arc::new(layer));
}
for (k, blob) in b.images.drain() {
@@ -567,6 +621,8 @@ struct Builder<'p> {
nested: u32,
/// The last filled path: its node and its outline (a stroke of the same outline joins it).
last_fill: Option<(NodeId, BezPath)>,
+ /// What each line of type made tells beyond its text object ([`crate::import_lines`]).
+ lines: HashMap,
}
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
@@ -701,6 +757,7 @@ impl<'p> Builder<'p> {
pending: false,
nested: 0,
last_fill: None,
+ lines: HashMap::new(),
}
}
@@ -786,10 +843,11 @@ impl<'p> Builder<'p> {
let current = self.current_group();
let Some(f) = self.stack.last_mut() else { return };
f.children.push(n);
+ // A frame's art goes where its first art was drawn (a clip can end in a later group).
if root {
self.root_groups.push(group.or(current));
} else if f.group.is_none() {
- f.group = group;
+ f.group = group.or(current);
}
}
@@ -955,9 +1013,10 @@ impl<'p> Builder<'p> {
}
fn flush_text(&mut self) {
- let Some((t, opacity)) = self.text.take().and_then(TextLine::finish) else { return };
+ let Some((t, opacity, facts)) = self.text.take().and_then(TextLine::finish) else { return };
let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t)));
n.opacity = opacity;
+ self.lines.insert(n.id, facts);
self.push_node(n);
}
@@ -1227,6 +1286,14 @@ impl<'p> Builder<'p> {
place(&mut line);
self.text = Some(line);
}
+ // Its ink: a stroke the file draws as outlines of the glyphs is told by it.
+ let mut ink = o.outline();
+ if let Some(line) = &mut self.text
+ && !ink.elements().is_empty()
+ {
+ ink.apply_affine(m);
+ line.ink.push(ink.bounding_box());
+ }
true
}
}
@@ -1570,8 +1637,10 @@ impl<'a> Device<'a> for Builder<'_> {
if self.nested > 0 {
return;
}
- if self.marked.pop().flatten().is_some() {
+ // The runs gathered within a layer's sequence are its art: finish them before it ends.
+ if self.marked.last().copied().flatten().is_some() {
self.flush();
}
+ self.marked.pop();
}
}
diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs
new file mode 100644
index 00000000..2009355c
--- /dev/null
+++ b/crates/pdf/src/import_lines.rs
@@ -0,0 +1,254 @@
+//! What a file's lines of type were, beyond what each line says on its own (#508). An editor
+//! writes what PDF text can't say as more art next to the text, and the type it wrapped in a
+//! frame as lines:
+//! - a stroke added to a type object (above or below its characters) comes as the outline of each
+//! glyph stroked, one path per glyph, right after the type (or right before it): it is the
+//! type's stroke again, so the type stays one object;
+//! - area type comes as a line of point type per line, each wrapped line ending in a space. Lines
+//! in a row sharing a left edge, font, size and leading (and paragraph spacing) are one area
+//! type object again, aligned left or justified, where laying its text out in the frame breaks
+//! it where the file does: else, as where its font isn't installed, they stay lines.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use kurbo::{Point, Rect, Shape};
+use vectorcraft_color::BlendMode;
+use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
+use vectorcraft_geom::shapes;
+
+use crate::import_text::LineFacts;
+
+/// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`).
+pub(crate) fn rebuild(nodes: &mut Vec>, lines: &HashMap) {
+ if lines.is_empty() {
+ return;
+ }
+ for n in nodes.iter_mut() {
+ if n.children().is_some_and(|c| !c.is_empty())
+ && let Some(children) = Arc::make_mut(n).children_mut()
+ {
+ rebuild(children, lines);
+ }
+ }
+ join_strokes(nodes, lines);
+ join_paragraphs(nodes, lines);
+}
+
+/// The stroke paths `nodes` draw when they are the outlines of the glyphs whose ink boxes are
+/// `ink`, stroked one by one alike (no fill, mask or blending, fully opaque).
+fn outline_stroke(nodes: &[Arc], ink: &[Rect]) -> Option {
+ let first = nodes.first()?;
+ let [stroke @ AppearanceItem::Stroke(_)] = first.appearance.items.as_slice() else { return None };
+ let on_glyph = |n: &Node, r: &Rect| {
+ let tol = 0.01 * (r.width() + r.height()) + 0.01;
+ let NodeKind::Path { path, clipping: false, guide: false, live: None, .. } = &n.kind else { return false };
+ let b = path.to_bezpath().bounding_box();
+ n.appearance == first.appearance
+ && n.mask.is_none()
+ && n.opacity >= 0.999
+ && n.blend == BlendMode::Normal
+ && [(b.x0, r.x0), (b.y0, r.y0), (b.x1, r.x1), (b.y1, r.y1)].iter().all(|(a, b)| (a - b).abs() <= tol)
+ };
+ (nodes.len() == ink.len() && nodes.iter().zip(ink).all(|(n, r)| on_glyph(n, r))).then(|| stroke.clone())
+}
+
+/// Each type object among `nodes` whose glyphs' outlines are stroked by the paths right after it
+/// (or right before it) takes that stroke above (or below) its characters, and the paths go.
+fn join_strokes(nodes: &mut Vec>, lines: &HashMap) {
+ let mut i = 0;
+ while i < nodes.len() {
+ let ink = nodes
+ .get(i)
+ .filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.appearance.items.is_empty())
+ .and_then(|n| lines.get(&n.id))
+ .map_or(&[][..], |f| f.ink.as_slice());
+ let k = ink.len();
+ if k > 0 {
+ if let Some(stroke) = nodes.get(i + 1..i + 1 + k).and_then(|after| outline_stroke(after, ink)) {
+ nodes.drain(i + 1..i + 1 + k);
+ if let Some(t) = nodes.get_mut(i) {
+ Arc::make_mut(t).appearance.items.push(stroke);
+ }
+ } else if let Some(stroke) = i.checked_sub(k).and_then(|from| nodes.get(from..i)).and_then(|before| outline_stroke(before, ink)) {
+ nodes.drain(i - k..i);
+ i -= k;
+ if let Some(t) = nodes.get_mut(i) {
+ let ap = &mut Arc::make_mut(t).appearance;
+ ap.items.insert(0, stroke);
+ ap.set_contents_at(1);
+ }
+ }
+ }
+ i += 1;
+ }
+}
+
+/// `n` as a line that can be part of area type: horizontal point type, with what its line told.
+fn line<'a>(n: &'a Node, lines: &'a HashMap) -> Option<(&'a TextObject, &'a LineFacts)> {
+ let NodeKind::Text(t) = &n.kind else { return None };
+ let point = matches!(t.kind, TextKind::Point) && !t.vertical && n.mask.is_none() && !t.runs.is_empty();
+ point.then_some(())?;
+ Some((t, lines.get(&n.id)?))
+}
+
+/// Lines of type in a row that may be one area type object.
+struct Block {
+ /// The step from a wrapped line's baseline to the next.
+ leading: f64,
+ /// The extra step after a line ending a paragraph.
+ space: f64,
+ /// Each line's baseline below the first (text space).
+ ys: Vec,
+}
+
+/// The lines from `nodes`' first on that share its left edge, angle, font and size, its look
+/// (opacity, blending, appearance) and steps down by one leading after a wrapped line and by
+/// another (the leading and paragraph spacing) after a line ending a paragraph: how many, and
+/// the block they make unless none of them was wrapped (0 and `None`: the first isn't a line).
+fn block(nodes: &[Arc], lines: &HashMap) -> (usize, Option) {
+ let Some((head, (t0, f0))) = nodes.first().and_then(|n| Some((n, line(n, lines)?))) else { return (0, None) };
+ let st = t0.first_style();
+ let size = st.size;
+ let inv = t0.xf.inverse();
+ let lin = |t: &TextObject| {
+ let [a, b, c, d, ..] = t.xf.as_coeffs();
+ [a, b, c, d]
+ };
+ let (mut ys, mut wraps) = (vec![0.0], f0.wraps);
+ let (mut leading, mut para): (Option, Option) = (None, None);
+ for n in nodes.iter().skip(1) {
+ let Some((t, f)) = line(n, lines) else { break };
+ let s = t.first_style();
+ let alike = n.opacity == head.opacity
+ && n.blend == head.blend
+ && n.appearance == head.appearance
+ && s.font_family == st.font_family
+ && s.font_style == st.font_style
+ && (s.size - size).abs() <= size * 0.01
+ && t.para == t0.para
+ && lin(t).iter().zip(lin(t0)).all(|(a, b)| (a - b).abs() < 1e-6);
+ let at = inv * (t.xf * Point::ZERO);
+ let step = at.y - ys.last().copied().unwrap_or_default();
+ if !alike || at.x.abs() > size * 0.02 || !(step > size * 0.5 && step < size * 4.0) {
+ break;
+ }
+ let slot = if wraps { &mut leading } else { &mut para };
+ match *slot {
+ Some(s) if (s - step).abs() > size * 0.01 => break,
+ Some(_) => {}
+ None => *slot = Some(step),
+ }
+ ys.push(at.y);
+ wraps = f.wraps;
+ }
+ let count = ys.len();
+ let Some(leading) = leading else { return (count, None) };
+ let space = para.map_or(0.0, |p| p - leading);
+ (count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys }))
+}
+
+/// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it
+/// out breaks its lines where the file does.
+fn area_type(nodes: &[Arc], lines: &HashMap, b: &Block) -> Option {
+ let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::