Merge main into fix/batch-override-export-for-screens-folder-r

This commit is contained in:
dotCooCoo
2026-10-31 19:39:33 -07:00
1107 changed files with 122639 additions and 12743 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: vectorcraft
app-dir: VectorCraft
-54
View File
@@ -1,54 +0,0 @@
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
name: Windows 7 x64 compatibility
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "apps/vectorcraft/Cargo.toml"
- "apps/vectorcraft/src/printing.rs"
- "apps/vectorcraft/src/printing/**"
- "crates/ui-egui/src/print.rs"
- "crates/ui-egui/src/tests/printps.rs"
- "packaging/windows/windows7.ps1"
- ".github/workflows/windows7.yml"
schedule:
- cron: "23 5 * * 1" # weekly
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
build:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
with:
arch: x64
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: nightly-2026-10-01
components: rust-src
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: windows7-x64
- name: Build, check and test the portable package
shell: pwsh
run: ./packaging/windows/windows7.ps1 -Test
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows7-x64-portable
path: dist/release/vectorcraft-windows7-x64-portable.zip
if-no-files-found: error
+10 -8
View File
@@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
@@ -46,7 +46,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
@@ -59,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap and progress docs
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
The progress docs:
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
@@ -72,17 +72,19 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+4 -1
View File
@@ -6,7 +6,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay.
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
@@ -345,6 +345,9 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` |
| `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` |
| `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` |
## Assets embedded by the PDF backend
+9 -3
View File
@@ -28,13 +28,14 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
@@ -46,7 +47,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
@@ -59,7 +60,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap and progress docs
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
The progress docs:
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
@@ -68,3 +69,8 @@ The progress docs follow craftrules' [`standards/progress-docs.md`](https://gith
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
- Keep the README's Status section in step with the ROADMAP headline.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+122 -73
View File
@@ -303,7 +303,7 @@ dependencies = [
"futures-lite",
"parking",
"polling",
"rustix 1.1.5",
"rustix 1.1.6",
"slab",
"windows-sys 0.61.2",
]
@@ -334,7 +334,7 @@ dependencies = [
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -360,7 +360,7 @@ dependencies = [
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.5",
"rustix 1.1.6",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
@@ -665,7 +665,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"rustix 1.1.6",
"tracing",
]
@@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-client",
]
@@ -705,6 +705,17 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfb"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b"
dependencies = [
"fnv",
"uuid",
"web-time",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -763,9 +774,9 @@ dependencies = [
[[package]]
name = "clipboard-win"
version = "5.4.1"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -1017,7 +1028,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13af
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.9.1",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"tempfile",
"winprint",
"winsafe",
@@ -1047,13 +1058,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1310,7 +1322,6 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
"glow",
"glutin",
"glutin-winit",
"image",
@@ -1888,7 +1899,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
"rustix 1.1.6",
"windows-link",
]
@@ -1940,6 +1951,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl 0.1.12",
]
[[package]]
name = "gif"
version = "0.14.2"
@@ -2233,7 +2254,7 @@ dependencies = [
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -2609,14 +2630,14 @@ dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"gif 0.14.2",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -2837,7 +2858,7 @@ dependencies = [
"bumpalo",
"flate2",
"float-cmp",
"gif",
"gif 0.14.2",
"image-webp",
"imagesize 0.14.0",
"indexmap",
@@ -2852,7 +2873,7 @@ dependencies = [
"tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -3283,6 +3304,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
@@ -3899,7 +3921,7 @@ dependencies = [
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -4379,12 +4401,15 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
dependencies = [
"gif 0.13.3",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes 0.15.3",
"tiny-skia",
"usvg 0.45.1",
"zune-jpeg 0.4.21",
]
[[package]]
@@ -4445,9 +4470,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
@@ -4488,9 +4513,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.5"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -5096,9 +5121,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.3"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
dependencies = [
"serde",
]
@@ -5141,7 +5166,7 @@ dependencies = [
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
@@ -5416,7 +5441,7 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -5489,7 +5514,7 @@ dependencies = [
"half",
"quick-error 2.0.1",
"weezl 0.1.12",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -5992,7 +6017,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"pico-args",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"simplecss",
"siphasher",
"skrifa 0.44.0",
@@ -6036,7 +6061,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -6053,14 +6078,18 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-text",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"serde_json",
"smithay-clipboard",
"socket2",
"vectorcraft-engine",
"vectorcraft-render",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wayland-backend",
@@ -6077,7 +6106,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"crc32fast",
"flate2",
@@ -6088,7 +6117,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -6100,7 +6129,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6116,7 +6145,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -6138,7 +6167,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -6150,7 +6179,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"chrono",
"hayro-ccitt",
@@ -6164,12 +6193,12 @@ dependencies = [
"tiff",
"vectorcraft-color",
"vectorcraft-geom",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
name = "vectorcraft-effects"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6185,17 +6214,19 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cfb",
"craft-docpath",
"crc32fast",
"flate2",
"hayro-jpeg2000",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"roxmltree 0.21.1",
"rustix 1.1.5",
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
"serde",
"serde_json",
@@ -6224,7 +6255,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"fax",
"flate2",
@@ -6248,11 +6279,11 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"flate2",
"proptest",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -6263,7 +6294,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -6275,7 +6306,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"log",
@@ -6294,7 +6325,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6308,7 +6339,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -6320,19 +6351,20 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"aes",
"chrono",
"craft-pdf",
"flate2",
"getrandom 0.4.3",
"image",
"jpeg-encoder",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"serde",
"serde_json",
"sha2",
@@ -6351,7 +6383,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"serde",
"serde_json",
@@ -6365,10 +6397,10 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"flate2",
"gif",
"gif 0.14.2",
"image",
"jpeg-encoder",
"log",
@@ -6377,6 +6409,7 @@ dependencies = [
"psd",
"serde_json",
"tiff",
"unicode-general-category",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
@@ -6394,14 +6427,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -6426,7 +6459,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"fax",
"image",
@@ -6444,7 +6477,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -6465,7 +6498,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -6473,12 +6506,13 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
@@ -6489,7 +6523,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6501,7 +6535,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"bytemuck",
"craft-keymap",
@@ -6512,7 +6546,7 @@ dependencies = [
"egui_kittest",
"image",
"log",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"toml",
@@ -6539,7 +6573,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"eframe",
"egui",
@@ -6805,7 +6839,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.5",
"rustix 1.1.6",
"scoped-tls",
"smallvec",
"wayland-sys",
@@ -6818,7 +6852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.2",
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-scanner",
]
@@ -6840,7 +6874,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
dependencies = [
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-client",
"xcursor",
]
@@ -7515,7 +7549,7 @@ dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
@@ -7574,7 +7608,7 @@ dependencies = [
"libc",
"libloading",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"x11rb-protocol",
]
@@ -7616,7 +7650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -7670,7 +7704,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"serde_json",
]
@@ -7720,7 +7754,7 @@ dependencies = [
"hex",
"libc",
"ordered-stream",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_repr",
"tracing",
@@ -7904,9 +7938,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.0"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
@@ -7943,19 +7977,34 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core 0.4.12",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
"zune-core 0.5.3",
]
[[package]]
+6 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.17.0"
version = "0.19.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -19,7 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -116,6 +117,9 @@ webbrowser = "1"
unsafe_code = "deny"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
# VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see
# AGENTS.md › Robustness). Tests may unwrap (clippy.toml).
-5
View File
@@ -30,11 +30,6 @@ based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
other build uses the unmodified crate from crates.io.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
+5 -7
View File
@@ -205,10 +205,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse and the app reads its
pressure: set the same preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use
XWayland only when a pen is attached as VectorCraft starts, and restart; or start the app with
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft can't get a drawing tablet's pen
input on native Wayland yet (#491, #2028). When a pen is attached as VectorCraft starts, the window opens through
XWayland, where the pen works as a mouse and the app reads its pressure (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps
Wayland). For a pen attached later, set the same preference to **X11 (XWayland)** and restart, or start the app with
`WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
@@ -227,8 +227,6 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-roo
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
## Status
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension
@@ -249,7 +247,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech, Georgian and Catalan;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
+27 -5
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -34,9 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel",
# DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs,
# shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR
# Apache-2.0.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] }
# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's
# src/windows_cores.rs, shared, uses to count the cores the process may run on.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
+9 -2
View File
@@ -46,9 +46,13 @@ mod perf;
#[path = "../../vectorcraft/src/prefs_dir.rs"]
mod prefs_dir;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
#[path = "../../vectorcraft/src/system_fonts.rs"]
mod system_fonts;
/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores.
#[cfg(windows)]
#[path = "../../vectorcraft/src/windows_cores.rs"]
mod windows_cores;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
@@ -161,8 +165,11 @@ fn help() -> Result<(), String> {
}
fn main() -> ExitCode {
// Before the first render: the cores the process may run on.
#[cfg(windows)]
windows_cores::install();
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
+2 -2
View File
@@ -116,7 +116,7 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
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<f64, String> {
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, 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();
@@ -215,7 +215,7 @@ fn deep(n: usize) -> Result<(), String> {
let first = s.doc().map_err(err)?.selection.objects.first().copied();
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
if let Some(down) = at {
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 };
s.select_tool("selection", view).map_err(err)?;
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut dx = 0.0;
+110 -74
View File
@@ -447,113 +447,158 @@ fn mcp_protocol_over_stdio() {
let mut stdin = child.stdin.take().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let mut notes = vec![];
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
writeln!(stdin, "{v}").unwrap();
exercise_mcp_core(&mut stdin, &mut lines, &mut notes);
exercise_mcp_resource_errors(&mut stdin, &mut lines, &mut notes);
exercise_mcp_document_resources(&mut stdin, &mut lines, &mut notes);
exercise_mcp_logging(&mut stdin, &mut lines, &mut notes);
exercise_mcp_parse_error(&mut stdin, &mut lines, &mut notes);
drop(stdin);
for line in lines {
let v: Value = serde_json::from_str(&line.unwrap()).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
notes.push(v);
}
// Next reply with this id; notifications (no id) are stashed for later.
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
notes.push(v);
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
}
fn mcp_send(stdin: &mut std::process::ChildStdin, v: Value) {
writeln!(stdin, "{v}").unwrap();
}
fn mcp_recv(lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
notes.push(v);
}
}
send(
&mut stdin,
fn exercise_mcp_core(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
let r = recv(&mut lines, &mut notes, 1);
let r = mcp_recv(lines, notes, 1);
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
mcp_send(stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
let r = recv(&mut lines, &mut notes, 2);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
let r = mcp_recv(lines, notes, 2);
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
let r = recv(&mut lines, &mut notes, 3);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
let r = mcp_recv(lines, notes, 3);
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
send(
&mut stdin,
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
);
let r = recv(&mut lines, &mut notes, 4);
let r = mcp_recv(lines, notes, 4);
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
send(
&mut stdin,
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
);
let r = recv(&mut lines, &mut notes, 7);
let r = mcp_recv(lines, notes, 7);
assert_eq!(r["result"]["isError"], false, "{r}");
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
let r = recv(&mut lines, &mut notes, 8);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
let r = mcp_recv(lines, notes, 8);
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
}
fn exercise_mcp_resource_errors(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// Unknown resources (-32002) and bad template values (-32602) stay apart.
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
assert_eq!(mcp_recv(lines, notes, 9)["error"]["code"], -32002);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
assert_eq!(mcp_recv(lines, notes, 10)["error"]["code"], -32602);
}
fn exercise_mcp_document_resources(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// Large containers slice through run_command document.node: a truncated level reports
// childCount, and a bad limit is a tool error, not a crash or a full read.
send(
&mut stdin,
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}),
);
assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false);
let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
send(&mut stdin, run(21, "select.all", json!({})));
recv(&mut lines, &mut notes, 21);
send(&mut stdin, run(22, "object.group", json!({})));
let r = recv(&mut lines, &mut notes, 22);
assert_eq!(mcp_recv(lines, notes, 20)["result"]["isError"], false);
mcp_send(stdin, run_command(21, "select.all", json!({})));
mcp_recv(lines, notes, 21);
mcp_send(stdin, run_command(22, "object.group", json!({})));
let r = mcp_recv(lines, notes, 22);
let group = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
let r = recv(&mut lines, &mut notes, 23);
mcp_send(stdin, run_command(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
let r = mcp_recv(lines, notes, 23);
let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}");
assert_eq!(sliced["childCount"], 2, "{sliced}");
send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true);
mcp_send(stdin, run_command(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
assert_eq!(mcp_recv(lines, notes, 24)["result"]["isError"], true);
// Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and
// document.find locates nodes without dumping the tree.
send(&mut stdin, run(25, "document.inspect", json!({"depth": 0})));
let r = recv(&mut lines, &mut notes, 25);
mcp_send(stdin, run_command(25, "document.inspect", json!({"depth": 0})));
let r = mcp_recv(lines, notes, 25);
let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}");
send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"})));
let r = recv(&mut lines, &mut notes, 26);
mcp_send(stdin, run_command(26, "document.find", json!({"kind": "ellipse"})));
let r = mcp_recv(lines, notes, 26);
let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(found["total"], 1, "{found}");
assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}");
send(&mut stdin, run(27, "document.find", json!({})));
assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true);
mcp_send(stdin, run_command(27, "document.find", json!({})));
assert_eq!(mcp_recv(lines, notes, 27)["result"]["isError"], true);
}
fn exercise_mcp_logging(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// The binary installed its logger: records arrive as notifications/message, and only
// after the client asked for them.
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
assert!(mcp_recv(lines, notes, 11).get("error").is_none());
mcp_send(stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
assert!(mcp_recv(lines, notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
}
fn exercise_mcp_parse_error(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// Malformed input is a parse error, and the server keeps serving afterwards.
stdin.write_all(b"{not json\n").unwrap();
let parse_err: Value = loop {
@@ -566,21 +611,12 @@ fn mcp_protocol_over_stdio() {
};
assert_eq!(parse_err["error"]["code"], -32700);
assert!(parse_err["id"].is_null(), "{parse_err}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
drop(stdin);
// Drain whatever the debug window queued; every line off the protocol stream parses.
for line in lines.by_ref() {
let line = line.unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
notes.push(v);
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
mcp_send(stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
assert!(mcp_recv(lines, notes, 13).get("error").is_none());
}
fn run_command(id: u64, command: &str, params: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
}
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
+7
View File
@@ -9,6 +9,13 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="theme-color" content="#262626">
<title>VectorCraft</title>
<!-- Tab and home-screen icons: copied from assets/app-icon (one folder per size, the PNG names are equal). -->
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/scalable/apps/ai.storyteller.vectorcraft.svg" data-target-path="icons" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/32" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/256" />
<link rel="icon" type="image/svg+xml" href="icons/ai.storyteller.vectorcraft.svg">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32/ai.storyteller.vectorcraft.png">
<link rel="apple-touch-icon" href="icons/256/ai.storyteller.vectorcraft.png">
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
+20 -10
View File
@@ -13,8 +13,6 @@ workspace = true
default = ["wgpu", "accessibility"]
wgpu = ["eframe/wgpu", "dep:winsafe"]
accessibility = ["eframe/accesskit"]
# Build with --no-default-features --features windows7 and the win7 Rust target.
windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
@@ -37,9 +35,6 @@ image = { workspace = true }
webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
craft-print = { workspace = true }
[target.'cfg(windows)'.dependencies]
@@ -49,6 +44,9 @@ clipboard-win = { workspace = true }
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
# Waits for Direct3D 12's adapter list before the window starts (src/gpu.rs, #4150). MIT OR
# Apache-2.0, already in the build through eframe.
pollster = "1"
# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font
# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build
@@ -56,15 +54,19 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
# read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has
# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the
# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which
# src/caps_lock.rs uses to read whether Caps Lock is on.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# The renderer's worker count follows those cores (src/windows_cores.rs).
vectorcraft-render = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
@@ -76,6 +78,10 @@ raw-window-handle = "0.6"
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs).
# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features.
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] }
objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
@@ -85,12 +91,16 @@ pollster = "1"
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a
# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build
# through winit with the `xinput` feature.
x11rb = { version = "0.13", default-features = false, features = ["xinput"] }
# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs).
x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
wayland-client = "0.31"
# Copy and Paste of text through the compositor's core data-device protocol on the window's
# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak
# on a Wayland session). MIT, already in the build through egui-winit.
smithay-clipboard = "0.7"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
+35 -17
View File
@@ -1,5 +1,13 @@
use super::prefs::save_prefs;
use super::*;
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
use super::gpu;
use super::prefs::{save_prefs, save_prefs_soon};
#[cfg(not(target_os = "macos"))]
use super::single_instance;
#[cfg(all(windows, not(target_vendor = "win7")))]
use super::windows_stylus;
use super::{App, VectorcraftApp, file_access, window};
#[cfg(target_os = "macos")]
use super::{mac_menu, mac_window, open_documents};
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
@@ -10,12 +18,12 @@ pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option<vectorcraft_ui_e
app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook)
}
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
Some(std::sync::Arc::new(windows_stylus::raw_input_hook))
}
#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))]
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
None
}
@@ -41,9 +49,13 @@ impl eframe::App for App {
mac_menu::raw_input_hook(raw);
}
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
// that no mouse button is down.
#[cfg(target_os = "macos")]
self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0);
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -53,11 +65,20 @@ impl eframe::App for App {
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
// macOS converts the frame from sRGB to the display's profile (#2249).
#[cfg(all(target_os = "macos", feature = "wgpu"))]
super::mac_color_space::tag(ui);
if let Some(on) = self.host_caps_lock.read_transition() {
self.app.set_caps_lock(on);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
} else {
// The workspace, panels and other UI settings change without the dialog (#4625).
file_access::unconfined(|| save_prefs_soon(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
@@ -65,15 +86,10 @@ impl eframe::App for App {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
@@ -94,11 +110,13 @@ impl App {
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
#[cfg(target_os = "macos")]
// Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file
// dialog is open: what asked for it runs again on its document when it answers.
if !self.app.file_dialog_open() {
#[cfg(target_os = "macos")]
let files = open_documents::take();
#[cfg(not(target_os = "macos"))]
let files = single_instance::take();
if !files.is_empty() {
bring_window_forward(ctx);
}
@@ -136,15 +154,15 @@ fn end_before_teardown(frames: u64) {
}
}
/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
/// Show the window when files are opened from the Finder or the Dock, or handed over by a later
/// launch (#4892): a minimized window comes back and the app takes focus, as a document-based app
/// does (#3769).
pub(crate) fn bring_window_forward(ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
+216
View File
@@ -0,0 +1,216 @@
//! Read the host's Caps Lock state, which gives the drawing tools the precise crosshair while it is on.
/// Only host transitions replace a value set by a script through `view.capsLock`.
#[derive(Default)]
pub struct HostCapsLock {
previous: Option<bool>,
#[cfg(target_os = "linux")]
x11: Option<X11CapsLock>,
}
#[cfg(target_os = "linux")]
struct X11CapsLock {
connection: x11rb::rust_connection::RustConnection,
locked: bool,
initial_sequence: u16,
}
impl HostCapsLock {
pub fn new(window: Option<&winit::window::Window>) -> Self {
#[cfg(target_os = "linux")]
let x11 = window.and_then(x11_connection);
#[cfg(not(target_os = "linux"))]
let _ = window;
Self {
previous: None,
#[cfg(target_os = "linux")]
x11,
}
}
pub fn read_transition(&mut self) -> Option<bool> {
#[cfg(target_os = "linux")]
let current = match self.x11.as_mut() {
Some(state) => match state.poll() {
Some(locked) => Some(locked),
None => {
self.x11 = None;
None
}
},
None => {
// Native Wayland does not expose the seat's XKB lock bit through winit or egui.
None
}
};
#[cfg(any(target_os = "macos", windows))]
let current = Some(read());
// This platform has no lock-state reader.
#[cfg(not(any(target_os = "macos", windows, target_os = "linux")))]
let current = None;
self.transition(current)
}
fn transition(&mut self, reading: Option<bool>) -> Option<bool> {
reading.and_then(|current| self.observe(current))
}
fn observe(&mut self, current: bool) -> Option<bool> {
if self.previous.replace(current) == Some(current) { None } else { Some(current) }
}
}
#[cfg(target_os = "linux")]
fn x11_connection(window: &winit::window::Window) -> Option<X11CapsLock> {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
use x11rb::protocol::xkb;
if !matches!(window.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) {
return None;
}
let (connection, _) = x11rb::connect(None).ok()?;
let supported = x11rb::protocol::xkb::use_extension(&connection, 1, 0).ok()?.reply().ok()?.supported;
if !supported {
return None;
}
// Subscribe before the initial read so changes during setup remain queued.
xkb::select_events(
&connection,
xkb::ID::USE_CORE_KBD.into(),
xkb::EventType::default(),
xkb::EventType::default(),
xkb::MapPart::default(),
xkb::MapPart::default(),
&xkb::SelectEventsAux {
state_notify: Some(xkb::SelectEventsAuxStateNotify {
affect_state: xkb::StatePart::MODIFIER_LOCK,
state_details: xkb::StatePart::MODIFIER_LOCK,
}),
..Default::default()
},
)
.ok()?
.check()
.ok()?;
let (locked, initial_sequence) = read_x11(&connection)?;
Some(X11CapsLock { connection, locked, initial_sequence })
}
#[cfg(target_os = "linux")]
impl X11CapsLock {
fn poll(&mut self) -> Option<bool> {
use x11rb::connection::Connection as _;
use x11rb::protocol::Event;
while let Some(event) = self.connection.poll_for_event().ok()? {
if let Event::XkbStateNotify(state) = event
// Events queued before the initial GetState reply must not replace that newer snapshot.
&& state.sequence.wrapping_sub(self.initial_sequence) < 0x8000
{
self.locked = lock_modifier_is_set(state.locked_mods);
}
}
Some(self.locked)
}
}
#[cfg(target_os = "linux")]
fn read_x11(connection: &x11rb::rust_connection::RustConnection) -> Option<(bool, u16)> {
use x11rb::protocol::xkb;
let state = xkb::get_state(connection, xkb::ID::USE_CORE_KBD.into()).ok()?.reply().ok()?;
Some((lock_modifier_is_set(state.locked_mods), state.sequence))
}
#[cfg(target_os = "linux")]
fn lock_modifier_is_set(modifiers: x11rb::protocol::xproto::ModMask) -> bool {
use x11rb::protocol::xproto::ModMask;
u16::from(modifiers) & u16::from(ModMask::LOCK) != 0
}
#[cfg(target_os = "macos")]
const MAC_CAPS_LOCK: usize = 1 << 16;
#[cfg(target_os = "macos")]
fn mac_flags_have_caps_lock(flags: usize) -> bool {
flags & MAC_CAPS_LOCK != 0
}
#[cfg(target_os = "macos")]
pub fn read() -> bool {
mac_flags_have_caps_lock(objc2_app_kit::NSEvent::modifierFlags_class().bits())
}
#[cfg(windows)]
fn windows_state_has_caps_lock(state: i16) -> bool {
state & 1 != 0
}
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn read() -> bool {
// GetKeyState reports the toggle bit for the calling UI thread's keyboard state.
use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CAPITAL};
// SAFETY: `GetKeyState` takes a virtual-key code by value and reads no caller memory.
windows_state_has_caps_lock(unsafe { GetKeyState(i32::from(VK_CAPITAL.0)) })
}
// Querying XWayland for a native Wayland window would read a different keyboard.
#[cfg(test)]
mod tests {
#[test]
fn no_host_reader_preserves_scripted_value_and_next_reading_transitions() {
let mut host = super::HostCapsLock::default();
let mut session = vectorcraft_engine::Session::new();
session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap();
if let Some(on) = host.transition(None) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(session.caps_lock);
assert_eq!(host.transition(Some(false)), Some(false));
}
#[cfg(target_os = "linux")]
#[test]
fn no_x11_reader_has_no_transition() {
assert_eq!(super::HostCapsLock::default().read_transition(), None);
}
#[test]
fn scripted_value_survives_unchanged_host_until_transition() {
let mut host = super::HostCapsLock::default();
let mut session = vectorcraft_engine::Session::new();
if let Some(on) = host.observe(false) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap();
if let Some(on) = host.observe(false) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(session.caps_lock);
session.execute("view.capsLock", &serde_json::json!({"on": false})).unwrap();
if let Some(on) = host.observe(true) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(session.caps_lock);
if let Some(on) = host.observe(false) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(!session.caps_lock);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_modifier_flags_map_caps_lock() {
assert!(!super::mac_flags_have_caps_lock(0));
assert!(!super::mac_flags_have_caps_lock(1 << 17));
assert!(super::mac_flags_have_caps_lock(1 << 16));
assert!(super::mac_flags_have_caps_lock((1 << 16) | (1 << 17)));
}
#[cfg(windows)]
#[test]
fn windows_key_state_maps_toggle_bit() {
assert!(!super::windows_state_has_caps_lock(0));
assert!(!super::windows_state_has_caps_lock(i16::MIN));
assert!(super::windows_state_has_caps_lock(1));
}
}
+536 -24
View File
@@ -9,7 +9,10 @@
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! copied), falling back to X11 where the compositor lacks the protocol, and where there is no
//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection,
//! text only. A clipboard that can't be opened is tried again after a wait that grows to a
//! minute, not each time Paste asks.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
@@ -19,12 +22,16 @@ use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::SystemClipboard;
/// This platform's system clipboard.
pub fn system_clipboard() -> Box<dyn SystemClipboard> {
/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
pub fn system_clipboard(window: Option<std::sync::Arc<winit::window::Window>>) -> Box<dyn SystemClipboard> {
#[cfg(windows)]
let cb = Box::<win::Clipboard>::default();
let cb = {
let _ = window;
Box::<win::Clipboard>::default()
};
#[cfg(not(windows))]
let cb = Box::<portable::Clipboard>::default();
let cb = Box::new(portable::Clipboard::new(window));
cb
}
@@ -164,7 +171,9 @@ mod win {
use clipboard_win::{formats, options::NoClear, raw};
use super::*;
use super::{
BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
};
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
/// other or a `CF_BITMAP` is: the system converts between them.
@@ -304,12 +313,16 @@ mod win {
}
}
/// arboard: one format at a time. Built everywhere so it is checked everywhere.
/// arboard, or on a Wayland compositor without its data-control protocol the core protocol's text
/// clipboard: one format at a time. Built everywhere so it is checked everywhere.
#[cfg_attr(windows, allow(dead_code))]
mod portable {
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use super::*;
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
/// What our last write put on the clipboard.
enum Ours {
@@ -317,19 +330,216 @@ mod portable {
Image(usize, usize),
}
#[derive(Default)]
/// An open clipboard connection.
enum Backend {
Arboard(arboard::Clipboard),
/// The compositor's core data-device protocol on the window's connection (text only).
#[cfg(target_os = "linux")]
Wayland(super::wayland::TextClipboard),
}
/// What a write of a picture says where the clipboard carries text only.
#[cfg(target_os = "linux")]
const TEXT_ONLY: &str = "this clipboard carries text only";
impl Backend {
/// arboard (Wayland data-control, else X11), else the core protocol on `window`'s
/// Wayland connection. Err: why each failed.
fn open(window: Option<&Arc<winit::window::Window>>) -> Result<Self, String> {
first_open(
|| arboard::Clipboard::new().map(Self::Arboard).map_err(|e| e.to_string()),
|| {
#[cfg(target_os = "linux")]
return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window");
#[cfg(not(target_os = "linux"))]
{
let _ = window;
Err("not on Linux")
}
},
)
}
fn name(&self) -> &'static str {
match self {
Self::Arboard(_) => "arboard",
#[cfg(target_os = "linux")]
Self::Wayland(_) => "the Wayland core protocol (text only)",
}
}
fn get_text(&mut self) -> Result<String, String> {
match self {
Self::Arboard(cb) => cb.get_text().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(cb) => cb.get_text(),
}
}
fn set_text(&mut self, text: &str) -> Result<(), String> {
match self {
Self::Arboard(cb) => cb.set_text(text).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(cb) => {
cb.set_text(text);
Ok(())
}
}
}
fn get_image(&mut self) -> Result<arboard::ImageData<'static>, String> {
match self {
Self::Arboard(cb) => cb.get_image().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(_) => Err(TEXT_ONLY.into()),
}
}
fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> {
match self {
Self::Arboard(cb) => cb.set_image(img).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(_) => Err(TEXT_ONLY.into()),
}
}
fn clear(&mut self) -> Result<(), String> {
match self {
Self::Arboard(cb) => cb.clear().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(cb) => {
cb.set_text("");
Ok(())
}
}
}
/// Files copied in a file manager (none where the backend can't read a file list).
fn file_list(&mut self) -> Vec<std::path::PathBuf> {
match self {
Self::Arboard(cb) => cb.get().file_list().unwrap_or_default(),
#[cfg(target_os = "linux")]
Self::Wayland(_) => vec![],
}
}
}
/// The first of `arboard` and `wayland` that opens, tried in that order. Err: why each failed.
fn first_open<B>(arboard: impl FnOnce() -> Result<B, String>, wayland: impl FnOnce() -> Result<B, &'static str>) -> Result<B, String> {
let arboard = match arboard() {
Ok(cb) => return Ok(cb),
Err(e) => e,
};
wayland().map_err(|wayland| format!("arboard: {arboard}; Wayland core protocol: {wayland}"))
}
/// Who asks for the clipboard: the background paste check waits out the whole wait after a
/// failure, a Copy or Paste of the user's [`USER_RETRY`] at most.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Asker {
Probe,
User,
}
/// The wait after the first failure to open the clipboard; it doubles with each failed retry.
const FIRST_WAIT: Duration = Duration::from_secs(1);
/// The longest wait between retries.
const MAX_WAIT: Duration = Duration::from_secs(60);
/// A Copy or Paste of the user's retries at most this often.
const USER_RETRY: Duration = Duration::from_secs(1);
/// Whether "unavailable" has been logged and not yet answered by "available again": the
/// clipboard and its background check hold a handle each, and their failures are one outage.
static UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false);
/// A failure to open, and when to try again.
struct Failed {
at: Instant,
wait: Duration,
}
/// A handle opened on first use and kept, or after a failure opened again only once its wait is
/// over: where neither backend opens, asking a few times a second must not reconnect each time.
pub(super) struct Reopen<B> {
handle: Option<B>,
failed: Option<Failed>,
/// The outage flag shared with the other handles ([`UNAVAILABLE_LOGGED`]).
logged: &'static AtomicBool,
}
impl<B> Reopen<B> {
pub(super) fn new(logged: &'static AtomicBool) -> Self {
Self { handle: None, failed: None, logged }
}
/// The open handle, opened with `open` when there is none and `asker` may try at `now`.
/// `name` names an opened handle in the log.
pub(super) fn get(
&mut self,
asker: Asker,
now: Instant,
open: impl FnOnce() -> Result<B, String>,
name: impl Fn(&B) -> &'static str,
) -> Option<&mut B> {
if self.handle.is_none() {
if let Some(f) = &self.failed {
let wait = match asker {
Asker::Probe => f.wait,
Asker::User => f.wait.min(USER_RETRY),
};
if now.saturating_duration_since(f.at) < wait {
return None;
}
}
match open() {
Ok(handle) => {
if self.logged.swap(false, Ordering::Relaxed) {
log::info!("the system clipboard is available again, through {}", name(&handle));
} else {
log::debug!("system clipboard opened through {}", name(&handle));
}
self.failed = None;
self.handle = Some(handle);
}
Err(why) => {
let wait = match &self.failed {
Some(f) => (f.wait * 2).min(MAX_WAIT),
None => FIRST_WAIT,
};
if self.logged.swap(true, Ordering::Relaxed) {
log::debug!("the system clipboard is still unavailable ({why}); next try in {wait:?}");
} else {
log::warn!("the system clipboard is unavailable ({why}): Copy and Paste stay inside VectorCraft");
}
self.failed = Some(Failed { at: now, wait });
}
}
}
self.handle.as_mut()
}
/// The handle if one is open, without trying to open one.
pub(super) fn opened(&mut self) -> Option<&mut B> {
self.handle.as_mut()
}
}
pub struct Clipboard {
/// Kept open: on Linux the copied data lives as long as the handle does.
cb: Option<arboard::Clipboard>,
cb: Reopen<Backend>,
/// The app window, for the Wayland core protocol (on its connection).
window: Option<Arc<winit::window::Window>>,
ours: Option<Ours>,
}
impl Clipboard {
fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> {
if self.cb.is_none() {
self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string())
pub fn new(window: Option<Arc<winit::window::Window>>) -> Self {
Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), window, ours: None }
}
fn cb(&mut self, asker: Asker) -> Result<&mut Backend, String> {
let window = self.window.as_ref();
self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string())
}
}
@@ -340,19 +550,19 @@ mod portable {
Some(_) => None,
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
};
let cb = self.cb()?;
let cb = self.cb(Asker::User)?;
let ours = match (text, image) {
(Some(t), _) => {
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
cb.set_text(t.as_str())?;
Some(Ours::Text(t))
}
(None, Some(img)) => {
let (width, height) = (img.width() as usize, img.height() as usize);
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) })?;
Some(Ours::Image(width, height))
}
(None, None) => {
cb.clear().map_err(|e| e.to_string())?;
cb.clear()?;
None
}
};
@@ -361,7 +571,7 @@ mod portable {
}
fn holds_ours(&mut self) -> bool {
let Some(cb) = self.cb.as_mut() else { return false };
let Some(cb) = self.cb.opened() else { return false };
match &self.ours {
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
@@ -370,8 +580,8 @@ mod portable {
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
let cb = self.cb().ok()?;
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
let cb = self.cb(Asker::User).ok()?;
if let Some(f) = copied_file(cb.file_list(), mimes) {
return Some(f);
}
mimes.iter().find_map(|m| {
@@ -400,7 +610,309 @@ mod portable {
if super::mac::has(mimes) {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
/// A clock that starts now and moves only when told.
struct Clock(Cell<Instant>);
impl Clock {
fn new() -> Self {
Self(Cell::new(Instant::now()))
}
fn now(&self) -> Instant {
self.0.get()
}
fn advance(&self, by: Duration) {
self.0.set(self.0.get() + by);
}
}
fn fail() -> Result<u8, String> {
Err("no compositor".into())
}
fn name(_: &u8) -> &'static str {
"fake"
}
#[test]
fn the_paste_check_retries_with_a_growing_wait() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
// Every 250 ms for a minute, as `clipboard_probe` asks.
for _ in 0..240 {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
assert!(cb.get(Asker::Probe, clock.now(), open, name).is_none());
clock.advance(Duration::from_millis(250));
}
// 0, 1, 3, 7, 15 and 31 s: not one connection a check.
assert_eq!(attempts.get(), 6);
assert!(LOGGED.load(Ordering::Relaxed), "the outage is logged");
}
#[test]
fn the_wait_stops_growing_at_a_minute() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
for _ in 0..(20 * 60) {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
let _ = cb.get(Asker::Probe, clock.now(), open, name);
clock.advance(Duration::from_secs(1));
}
// 0, 1, 3, 7, 15, 31, 63 s, then a minute apart until 20 minutes are up.
assert_eq!(attempts.get(), 7 + (20 * 60 - 63 - 1) / 60);
}
#[test]
fn a_copy_or_paste_retries_at_most_once_a_second() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
let mut ask = |asker| {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
let _ = cb.get(asker, clock.now(), open, name);
};
// Long after the paste check gave up for a minute, a held Ctrl+V for 3 s at 30 a second.
for _ in 0..8 {
ask(Asker::Probe);
clock.advance(Duration::from_secs(70));
}
let before = attempts.get();
for _ in 0..90 {
ask(Asker::User);
clock.advance(Duration::from_millis(33));
}
assert_eq!(attempts.get() - before, 3);
}
#[test]
fn a_success_is_kept_and_ends_the_outage() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let clock = Clock::new();
let mut cb = Reopen::new(&LOGGED);
assert!(cb.get(Asker::Probe, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
clock.advance(FIRST_WAIT);
assert_eq!(cb.get(Asker::Probe, clock.now(), || Ok(7), name).copied(), Some(7));
assert!(!LOGGED.load(Ordering::Relaxed), "the recovery is logged");
// Kept: not opened again, even where opening would now fail.
assert_eq!(cb.get(Asker::Probe, clock.now(), fail, name).copied(), Some(7));
assert_eq!(cb.opened().copied(), Some(7));
}
#[test]
fn arboard_is_tried_before_the_wayland_core_protocol() {
let wayland_tried = Cell::new(false);
let wayland = |ok| {
wayland_tried.set(true);
if ok { Ok("wayland") } else { Err("not a Wayland window") }
};
// Where arboard opens (data-control, X11) the core protocol isn't tried.
assert_eq!(first_open(|| Ok("arboard"), || wayland(true)), Ok("arboard"));
assert!(!wayland_tried.get());
// The Flatpak on GNOME: no data-control, no X11.
assert_eq!(first_open(|| Err("no X11".into()), || wayland(true)), Ok("wayland"));
assert!(wayland_tried.get());
// Neither: every reason is kept for the log.
assert_eq!(
first_open(|| Err("no X11".into()), || wayland(false)),
Err::<&str, _>("arboard: no X11; Wayland core protocol: not a Wayland window".into())
);
}
#[test]
fn one_outage_is_logged_once_across_handles() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let clock = Clock::new();
let (mut ui, mut probe) = (Reopen::new(&LOGGED), Reopen::new(&LOGGED));
assert!(probe.get(Asker::Probe, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
// The other handle's failure is the same outage: `swap` reports it already logged.
assert!(ui.get(Asker::User, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
assert!(probe.opened().is_none() && ui.opened().is_none());
}
}
}
/// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the
/// window's own Wayland connection: what Copy and Paste use where the compositor lacks the
/// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on
/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the
/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's
/// data with no limit, so reads run on a worker of their own.
#[cfg(target_os = "linux")]
mod wayland {
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
/// The longest a read waits for the selection's owner to send its text.
const LOAD_WAIT: Duration = Duration::from_secs(2);
/// What a read says once its worker has stopped.
const GONE: &str = "the clipboard's reader stopped";
pub struct TextClipboard {
/// Writes: a store only hands the text to smithay-clipboard's thread.
cb: smithay_clipboard::Clipboard,
/// Reads, on a worker with a second clipboard on the same connection.
loads: Loads,
/// Held as long as the clipboards: their connection is the window's.
_window: Arc<winit::window::Window>,
}
/// The read worker's clipboard and the window whose connection it uses, dropped in that order.
struct Loader {
cb: smithay_clipboard::Clipboard,
_window: Arc<winit::window::Window>,
}
impl Loader {
fn load(&self) -> Result<String, String> {
self.cb.load().map_err(|e| e.to_string())
}
}
impl TextClipboard {
/// None when `window` isn't a Wayland window or the read worker can't start.
#[allow(unsafe_code)]
pub fn new(window: Arc<winit::window::Window>) -> Option<Self> {
use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
let display = d.display.as_ptr();
// SAFETY: the display is winit's open `wl_display`, which outlives `window`; each
// clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard
// makes its own event queue on the display and doesn't close it.
let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) };
let loader = Loader { cb: reader, _window: window.clone() };
// `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names,
// and the worker must hold the window as long as its clipboard.
let loads = Loads::spawn(move || loader.load())?;
Some(Self { cb, loads, _window: window })
}
pub fn get_text(&mut self) -> Result<String, String> {
self.loads.get(LOAD_WAIT)
}
pub fn set_text(&self, text: &str) {
self.cb.store(text);
}
}
/// Reads made on a worker thread and waited for a bounded time: a selection owner that never
/// sends its data holds the worker, not the caller.
struct Loads {
requests: mpsc::Sender<()>,
answers: mpsc::Receiver<Result<String, String>>,
/// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it.
pending: bool,
}
impl Loads {
fn spawn(mut load: impl FnMut() -> Result<String, String> + Send + 'static) -> Option<Self> {
let (requests, asked) = mpsc::channel::<()>();
let (answer, answers) = mpsc::channel();
let work = move || {
while asked.recv().is_ok() {
if answer.send(load()).is_err() {
break;
}
}
};
std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?;
Some(Self { requests, answers, pending: false })
}
/// The text, or why there is none, after `wait` at most.
fn get(&mut self, wait: Duration) -> Result<String, String> {
if self.pending {
match self.answers.try_recv() {
// The late answer of the read that timed out: stale by now.
Ok(_) => self.pending = false,
Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()),
Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()),
}
}
self.requests.send(()).map_err(|_| GONE.to_string())?;
match self.answers.recv_timeout(wait) {
Ok(text) => text,
Err(mpsc::RecvTimeoutError::Timeout) => {
self.pending = true;
Err(format!("the clipboard's owner didn't send its text within {wait:?}"))
}
Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()),
}
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use super::*;
#[test]
fn a_read_that_answers_in_time_gives_the_text() {
let mut loads = Loads::spawn(|| Ok("<svg/>".into())).unwrap();
assert_eq!(loads.get(Duration::from_secs(5)), Ok("<svg/>".to_string()));
assert_eq!(loads.get(Duration::from_secs(5)), Ok("<svg/>".to_string()));
}
#[test]
fn an_owner_that_never_answers_holds_the_worker_not_the_caller() {
let (release, gate) = mpsc::channel::<()>();
let mut calls = 0;
let mut loads = Loads::spawn(move || {
calls += 1;
if calls == 1 {
// The first owner sends nothing until released.
let _ = gate.recv();
return Ok("stale".into());
}
Ok("fresh".into())
})
.unwrap();
let start = Instant::now();
assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out");
// While it is unanswered, a read doesn't queue behind it or wait at all.
assert!(loads.get(Duration::from_secs(60)).is_err());
assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound");
release.send(()).unwrap();
// Once the late answer is in, it is dropped and a new read is made.
let text = (0..200).find_map(|_| {
std::thread::sleep(Duration::from_millis(10));
loads.get(Duration::from_secs(5)).ok()
});
assert_eq!(text.as_deref(), Some("fresh"));
}
#[test]
fn a_read_after_the_worker_stops_says_so() {
let mut loads = Loads::spawn(|| Ok(String::new())).unwrap();
// The worker ends when its answers can't be sent: drop them by hand.
let (_, dead) = mpsc::channel();
drop(std::mem::replace(&mut loads.answers, dead));
assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string()));
}
}
}
@@ -413,7 +925,7 @@ mod mac {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::{NSArray, NSString};
use super::*;
use super::{Flavour, pasteboard_types};
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
pub fn has(mimes: &[&'static str]) -> bool {
+27 -16
View File
@@ -4,12 +4,18 @@ fn short_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
}
/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a
/// busy machine either thread can pause for more than 150 ms between connecting and sending, or
/// between starting a reply and writing it, and a short budget then closes the connection with no
/// reply.
/// How long a test waits for the server to reply or to close a connection. The wait only ends a
/// test whose server never does either; the budgets under test are 120 and 150 ms, and 1 s in the
/// persistent test. On a busy machine the server's thread can pause for seconds before or between
/// its reads and writes, and a wait of a few seconds then fails a server that closes the
/// connection on time.
const WAIT: Duration = Duration::from_secs(20);
/// The short line deadline, with idle and reply budgets longer than [`WAIT`]. On a busy machine
/// either thread can pause for more than 150 ms between connecting and sending, or between
/// starting a reply and writing it, and a short budget then closes the connection with no reply.
fn line_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() }
ConnectionTimeouts { idle: Duration::from_secs(30), write: Duration::from_secs(30), ..short_timeouts() }
}
fn socket_fixture(
@@ -27,12 +33,12 @@ fn socket_fixture(
});
let client = TcpStream::connect(addr).unwrap();
socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
client.set_read_timeout(Some(WAIT)).unwrap();
(client, done, worker)
}
fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) {
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
let stopped = done.recv_timeout(WAIT).is_ok();
// Always unblock the old implementation too, so a failed regression leaves no worker behind.
let _ = client.shutdown(std::net::Shutdown::Both);
drop(client);
@@ -93,7 +99,7 @@ fn trickled_control_line_has_an_absolute_deadline() {
std::thread::sleep(Duration::from_millis(20));
}
});
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
let stopped = done.recv_timeout(WAIT).is_ok();
stop.store(true, Ordering::SeqCst);
// Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well.
let _ = client.shutdown(std::net::Shutdown::Both);
@@ -110,26 +116,31 @@ fn a_nonreading_control_peer_cannot_retain_the_writer() {
expect_socket_deadline(client, done, worker);
}
/// Each request gets its own line and reply budgets. The handler takes longer than both budgets,
/// so a deadline kept from the previous request runs out. The line and reply budgets are 1 s, and
/// the idle budget is 30 s, longer than the client's [`WAIT`]: on a busy machine either thread can
/// pause for more than the short budgets of [`short_timeouts`] between starting a budget and the
/// read or write it covers.
#[test]
fn persistent_control_requests_get_fresh_transport_deadlines() {
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() };
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(30), line: Duration::from_secs(1), write: Duration::from_secs(1) };
let (mut client, done, worker) = socket_fixture(timeouts, |_, _| {
// Application processing is not transport time; begin the reply budget afterwards.
std::thread::sleep(Duration::from_millis(200));
std::thread::sleep(Duration::from_millis(1250));
Some(json!({"ok": true}))
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
// between two of them closes the connection.
// small writes, and when the machine is busy, a pause of more than the line deadline between
// two of them closes the connection.
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
}
client.shutdown(std::net::Shutdown::Write).unwrap();
done.recv_timeout(Duration::from_secs(3)).unwrap();
done.recv_timeout(WAIT).unwrap();
worker.join().unwrap();
}
@@ -155,7 +166,7 @@ fn timed_out_worker_returns_its_connection_slot() {
let (tx, _rx) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap();
let start = std::time::Instant::now();
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) {
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < WAIT {
std::thread::sleep(Duration::from_millis(10));
}
let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS;
@@ -322,7 +333,7 @@ fn tcp_connection_is_closed_after_junk() {
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.set_read_timeout(Some(WAIT)).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
@@ -373,7 +384,7 @@ fn extra_connections_are_refused() {
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
extra.set_read_timeout(Some(WAIT)).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
+62 -12
View File
@@ -126,20 +126,70 @@ pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) ->
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
}
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
/// `igvk64.dll`, #806) crashed the app before its window appeared. DX12 is Windows' own backend,
/// as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool) -> Backends {
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
/// The graphics backends the window starts with: on Windows Direct3D 12, with OpenGL beside it
/// only when `opengl` ([`windows_opengl`]), unless `WGPU_BACKEND` names others (`env_set`;
/// `default` then holds them). Creating a Vulkan instance loads every installed Vulkan driver into
/// the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window
/// appeared. DX12 is Windows' own backend, as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool, opengl: bool) -> Backends {
match (cfg!(windows) && !env_set, opengl) {
(true, true) => Backends::DX12 | Backends::GL,
(true, false) => Backends::DX12,
(false, _) => default,
}
}
/// The largest texture side to ask the device for, which bounds the window's surface: the
/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than
/// that keeps its surface (#1459). Never below `default`, which is what was asked for before.
pub fn surface_limit(default: u32, adapter: u32) -> u32 {
default.max(adapter)
/// Whether a Windows start lists OpenGL's adapters beside Direct3D 12's: only when it leaves an
/// adapter out (an earlier start crashed on it, or a restart failed on it), when
/// `WGPU_ADAPTER_NAME` names one (it may be an OpenGL adapter), or when DX12 lists no hardware
/// adapter (Windows 7, or a GPU without a DX12 driver, which then draws through OpenGL as before
/// rather than on the CPU with WARP). wgpu's OpenGL backend keeps a hidden window on a thread that
/// pumps no messages, and Windows waits on it when the input language changes, so the whole app
/// hung on a switch to another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
pub fn windows_opengl(left_out: &LeftOut) -> bool {
opengl_needed(left_out, !skipped().is_empty(), std::env::var_os(NAME_ENV).is_some(), dx12_lists_hardware)
}
/// [`windows_opengl`], with a restart leaving adapters out (`skipping`) and an adapter named
/// (`named`): `dx12_hardware` is asked only when nothing else decides.
fn opengl_needed(left_out: &LeftOut, skipping: bool, named: bool, dx12_hardware: impl FnOnce() -> bool) -> bool {
left_out.opengl_only || !left_out.adapters.is_empty() || skipping || named || !dx12_hardware()
}
/// Whether Direct3D 12 lists a hardware adapter; WARP, its software one, doesn't count. A driver
/// that crashes here crashes a start while it lists the adapters, which the next start answers with
/// OpenGL alone.
#[cfg(windows)]
fn dx12_lists_hardware() -> bool {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
descriptor.backends = Backends::DX12;
// The shader compiler the window's own instance takes (#1194).
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::from_env().unwrap_or(wgpu::Dx12Compiler::Fxc);
let instance = wgpu::Instance::new(descriptor);
let kinds: Vec<DeviceType> = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).iter().map(|a| a.get_info().device_type).collect();
log::info!("Direct3D 12 lists these graphics adapters: {kinds:?}");
any_hardware(&kinds)
}
#[cfg(not(windows))]
fn dx12_lists_hardware() -> bool {
false
}
/// Is one of the adapters of these kinds hardware, not software such as WARP?
#[cfg(any(windows, test))]
fn any_hardware(kinds: &[DeviceType]) -> bool {
kinds.iter().any(|k| *k != DeviceType::Cpu)
}
/// The limits to ask the device for: what eframe `asked` for, lowered to what the `adapter` offers
/// wherever it offers less, so a GPU short of one of them still gets a device (#2105). The texture
/// side, which bounds the window's surface, is the adapter's own, above eframe's 8192 px when the
/// adapter allows more, so a window larger than that keeps its surface (#1459).
pub fn device_limits(asked: wgpu::Limits, adapter: &wgpu::Limits) -> wgpu::Limits {
let mut limits = asked.or_worse_values_from(adapter);
limits.max_texture_dimension_2d = adapter.max_texture_dimension_2d;
limits
}
/// One adapter, as [`adapter_order`] sees it.
+78 -15
View File
@@ -5,11 +5,34 @@ fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate {
}
/// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that
/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing.
/// takes 16384 px textures.
#[test]
fn the_surface_may_be_as_large_as_the_adapter_allows() {
assert_eq!(surface_limit(8192, 16384), 16384);
assert_eq!(surface_limit(8192, 4096), 8192);
let adapter = wgpu::Limits { max_texture_dimension_2d: 16384, ..wgpu::Limits::default() };
let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
assert_eq!(device_limits(asked, &adapter).max_texture_dimension_2d, 16384);
}
/// #2105: a GPU that offers less than eframe asks for on any limit (here a Raspberry Pi 5's V3D 7.1:
/// 15 inter-stage variables on Vulkan, 4096 px textures on OpenGL) gets a device with what it
/// offers, instead of failing and leaving the window to the software rasterizer. A GPU that offers
/// more is asked for no more than before, except for the texture side.
#[test]
fn the_device_asks_for_no_more_than_the_adapter_offers() {
let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
let vulkan = wgpu::Limits { max_inter_stage_shader_variables: 15, max_texture_dimension_2d: 7680, ..wgpu::Limits::default() };
let limits = device_limits(asked.clone(), &vulkan);
assert_eq!((limits.max_inter_stage_shader_variables, limits.max_texture_dimension_2d), (15, 7680));
assert!(limits.check_limits(&vulkan), "{limits:?}");
let gl = wgpu::Limits { max_texture_dimension_2d: 4096, ..wgpu::Limits::downlevel_webgl2_defaults() };
let webgl2 = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::downlevel_webgl2_defaults() };
let limits = device_limits(webgl2.clone(), &gl);
assert_eq!(limits.max_texture_dimension_2d, 4096);
assert!(limits.check_limits(&gl), "{limits:?}");
let ample = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
assert_eq!(device_limits(asked.clone(), &ample), asked);
let better = wgpu::Limits { max_inter_stage_shader_variables: 31, max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
assert_eq!(device_limits(asked.clone(), &better), asked, "never more than eframe asked for");
}
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
@@ -451,13 +474,50 @@ fn a_windows_gpu_renders_through_dx12_before_vulkan() {
#[test]
fn windows_never_loads_vulkan_drivers_unless_asked() {
let all = Backends::all();
let start = backends(all, false);
let start = backends(all, false, false);
let fallback = backends(all, false, true);
if cfg!(windows) {
assert_eq!(start, Backends::DX12 | Backends::GL, "#806");
assert_eq!(start, Backends::DX12, "#806, and no OpenGL unless needed (#4150)");
assert_eq!(fallback, Backends::DX12 | Backends::GL);
} else {
assert_eq!(start, all);
assert_eq!((start, fallback), (all, all));
}
assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
assert_eq!(backends(Backends::VULKAN, true, false), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
assert_eq!(backends(Backends::GL, true, false), Backends::GL, "WGPU_BACKEND=gl still picks it");
}
#[test]
fn windows_lists_opengl_once_a_start_leaves_an_adapter_out() {
let crashed = LeftOut { adapters: vec!["Dx12:8086:3ea0".into()], ..LeftOut::default() };
assert!(windows_opengl(&crashed), "#2243: the next start can fall back to OpenGL");
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
assert!(windows_opengl(&listing));
assert_eq!(listing.backends(backends(Backends::all(), false, true)), Backends::GL);
}
/// OpenGL stays where it drew before #4150's fix: a GPU without a Direct3D 12 driver (DX12 lists
/// WARP alone) and an adapter `WGPU_ADAPTER_NAME` names; a start with a DX12 GPU lists DX12 alone.
#[test]
fn windows_lists_opengl_without_a_dx12_gpu_or_with_an_adapter_named() {
let start = LeftOut::default();
let asked = std::cell::Cell::new(false);
let hardware = |found: bool| {
asked.set(true);
found
};
assert!(!opengl_needed(&start, false, false, || hardware(true)), "a DX12 GPU: DX12 alone (#4150)");
assert!(asked.replace(false));
assert!(opengl_needed(&start, false, false, || hardware(false)), "WARP alone: the GPU draws through OpenGL, not the CPU");
assert!(opengl_needed(&start, false, true, || hardware(true)), "a named adapter may be an OpenGL one");
assert!(opengl_needed(&start, true, false, || hardware(true)), "a restart left an adapter out");
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
asked.set(false);
assert!(opengl_needed(&listing, false, false, || hardware(true)));
assert!(!asked.get(), "DX12 isn't listed once OpenGL must be");
assert!(any_hardware(&[DeviceType::Cpu, DeviceType::IntegratedGpu]));
assert!(any_hardware(&[DeviceType::VirtualGpu]));
assert!(!any_hardware(&[DeviceType::Cpu]), "WARP");
assert!(!any_hardware(&[]), "Windows 7");
}
#[test]
@@ -548,11 +608,14 @@ fn a_normal_exit_or_another_failure_does_not_restart() {
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
}
/// A log folder of its own for each test.
fn start_dir(tag: &str) -> std::path::PathBuf {
/// A log folder of its own for each test, and the guard that keeps other tests from starting a
/// program while it runs: the test releases the start file's lock and takes it again, and a child
/// starting meanwhile would keep it held (`crate::no_spawns`).
fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) {
let spawns = crate::no_spawns();
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
(d, spawns)
}
/// What a start in `dir` leaves out; it then ends without crashing.
@@ -578,7 +641,7 @@ fn issue_2243() -> Vec<Candidate> {
/// it, without a status-bar note.
#[test]
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
let dir = start_dir("crash");
let (dir, _spawns) = start_dir("crash");
let c = issue_2243();
let first = Startup::default();
assert_eq!(first.track(&dir, None, false), LeftOut::default());
@@ -609,7 +672,7 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
/// an error, a restart on another adapter), leaves nothing out of the next.
#[test]
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
let dir = start_dir("shown");
let (dir, _spawns) = start_dir("shown");
let c = issue_2243();
for end in [Startup::presented as fn(&Startup), Startup::ended] {
let first = Startup::default();
@@ -633,7 +696,7 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
/// macOS, where wgpu has none.
#[test]
fn a_crash_while_listing_lists_opengl_alone() {
let dir = start_dir("listing");
let (dir, _spawns) = start_dir("listing");
let first = Startup::default();
first.track(&dir, None, false);
drop(first);
@@ -660,7 +723,7 @@ fn a_crash_while_listing_lists_opengl_alone() {
/// start shows its first frame on the adapter that crashed, the adapter counts again.
#[test]
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
let dir = start_dir("explicit");
let (dir, _spawns) = start_dir("explicit");
let c = issue_2243();
let first = Startup::default();
first.track(&dir, None, false);
@@ -681,7 +744,7 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
/// are ignored: the start goes on with nothing left out.
#[test]
fn a_damaged_or_unreadable_start_file_is_ignored() {
let dir = start_dir("damaged");
let (dir, _spawns) = start_dir("damaged");
std::fs::create_dir_all(&dir).unwrap();
let damaged: [&[u8]; 9] = [
b"",
+14 -4
View File
@@ -1,4 +1,12 @@
use super::*;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
use super::portal_save;
#[cfg(target_os = "linux")]
use super::tablet;
#[cfg(all(windows, not(target_vendor = "win7")))]
use super::windows_stylus;
use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing};
#[cfg(target_os = "macos")]
use super::{mac_save_format, mac_screen_color, mac_tablet};
/// The window file dialogs belong to.
pub(crate) type Parent = Option<std::sync::Arc<winit::window::Window>>;
@@ -247,6 +255,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
.as_ref()
.and_then(|w| w.window_handle().ok())
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (clip, clip_probe) = (parent.clone(), parent.clone());
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
@@ -266,7 +275,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
// Windows reads it from the pen's pointer messages (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
stylus: Some(windows_stylus::start()),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
@@ -278,11 +287,12 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
})),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
system_clipboard: Some(clipboard::system_clipboard(clip)),
// 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),
// The probe's handle is made on its own thread, from the window (`Send`), not its raw display.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
+139
View File
@@ -0,0 +1,139 @@
//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to
//! the display's color profile (#2249, #3161).
//!
//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a
//! color space is present, Core Animation performs any necessary color space transformations when
//! compositing this content. The default value is `nil`, indicating that the rendered content
//! isn’t color-matched"
//! (<https://developer.apple.com/documentation/quartzcore/cametallayer/colorspace>). Without a
//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as
//! the display's most saturated red, brighter than the same color in an exported PNG.
//!
//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on
//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui
//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`]
//! adds a paint callback that draws nothing. egui calls it after the configuration and before the
//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A
//! layer that already has a color space keeps it.
//!
//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu};
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2::{DowncastTarget, Message};
use objc2_app_kit::NSApplication;
use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB};
use objc2_quartz_core::{CALayer, CAMetalLayer};
/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its
/// top-left corner, in the background layer, with nothing drawn.
pub fn tag(ui: &egui::Ui) {
let ctx = ui.ctx();
let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0));
ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers));
}
struct TagLayers;
impl CallbackTrait for TagLayers {
fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) {
// AppKit's windows and views are read on the main thread only, where eframe paints.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
for window in NSApplication::sharedApplication(mtm).windows() {
if let Some(root) = window.contentView().and_then(|view| view.layer()) {
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
}
}
}
}
/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu
/// adds to it when it is a plain layer.
fn metal_layers(root: &CALayer) -> Vec<Retained<CAMetalLayer>> {
if let Some(layer) = downcast(root) {
return vec![layer];
}
// SAFETY: the returned array is a copy; the layers in it are retained while it lives.
let Some(sublayers) = (unsafe { root.sublayers() }) else {
return Vec::new();
};
sublayers.iter().filter_map(|layer| downcast(&layer)).collect()
}
fn downcast<T: DowncastTarget>(layer: &CALayer) -> Option<Retained<T>> {
layer.retain().downcast::<T>().ok()
}
/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set.
fn tag_srgb(layer: &CAMetalLayer) -> bool {
if layer.colorspace().is_some() {
return false;
}
// SAFETY: a constant that CoreGraphics exports.
let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else {
return false;
};
layer.setColorspace(Some(&srgb));
true
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFString;
use objc2_core_graphics::kCGColorSpaceDisplayP3;
use super::*;
fn space_name(layer: &CAMetalLayer) -> Option<String> {
CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string())
}
fn name(constant: &CFString) -> String {
constant.to_string()
}
/// A layer without a color space is tagged sRGB once; the second call leaves it as it is.
#[test]
fn an_untagged_layer_becomes_srgb() {
let layer = CAMetalLayer::new();
assert_eq!(space_name(&layer), None, "a new layer has no color space");
assert!(tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
}
/// A layer with a color space of its own keeps it.
#[test]
fn a_layer_with_a_color_space_keeps_it() {
let layer = CAMetalLayer::new();
let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap();
layer.setColorspace(Some(&p3));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 })));
}
/// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal
/// layers are returned.
#[test]
fn metal_layers_are_found_under_the_view_layer() {
let metal = CAMetalLayer::new();
assert_eq!(metal_layers(&metal), vec![metal.clone()]);
let root = CALayer::new();
assert!(metal_layers(&root).is_empty());
root.addSublayer(&CALayer::new());
root.addSublayer(&metal);
assert_eq!(metal_layers(&root), vec![metal.clone()]);
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB })));
}
}
+77 -28
View File
@@ -1,7 +1,10 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given
//! (Windows and Linux; `single_instance`).
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
@@ -15,14 +18,8 @@
//! keyboard isn't confined.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod automation_args;
mod caps_lock;
mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
@@ -33,6 +30,8 @@ mod gpu;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(all(target_os = "macos", feature = "wgpu"))]
mod mac_color_space;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
@@ -50,11 +49,15 @@ mod open_documents;
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(not(target_os = "macos"))]
mod single_instance;
#[cfg(windows)]
mod system_fonts;
mod tablet;
mod window;
mod window_frame;
#[cfg(windows)]
mod windows_cores;
mod windows_stylus;
mod xwayland;
@@ -92,6 +95,7 @@ use titlebar::{custom_titlebar, native_options};
struct App {
app: VectorcraftApp,
host_caps_lock: caps_lock::HostCapsLock,
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
@@ -123,7 +127,9 @@ Options:
(may be given more than once)
--in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit
--new-instance Open the files in a new window even when VectorCraft
already runs (Windows and Linux)
--version Print the version and exit
-h, --help Print this help and exit
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
@@ -133,7 +139,7 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
fn main() -> std::process::ExitCode {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::VECTORCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&vectorcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
@@ -147,6 +153,7 @@ fn main() -> std::process::ExitCode {
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut new_instance = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
@@ -159,6 +166,7 @@ fn main() -> std::process::ExitCode {
}
match a.as_str() {
"--in-window-menus" => in_window_menus = true,
"--new-instance" => new_instance = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
@@ -188,9 +196,21 @@ fn main() -> std::process::ExitCode {
};
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
// Files opened from the file manager while VectorCraft runs open in its window (#4892). Before
// the log file is attached: a launch that hands its files over leaves the running app's log be.
#[cfg(not(target_os = "macos"))]
let instance = match single_instance::start(control_port.is_some(), new_instance, &files) {
single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS,
single_instance::Start::Running(primary) => primary,
};
#[cfg(target_os = "macos")]
let _ = new_instance;
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first render: the cores the process may run on.
#[cfg(windows)]
windows_cores::install();
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
@@ -270,15 +290,15 @@ fn main() -> std::process::ExitCode {
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
// and the other choices do so when a pen is attached, where the pen draws (#2028, #4967).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
if pref == Some("x11WithPen") {
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
if pref != Some("x11") {
log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
} else {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
}
@@ -295,7 +315,7 @@ fn main() -> std::process::ExitCode {
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
// is down; the message hook notes each pointer contact and whether it starts with the barrel
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
let mut options = options;
@@ -318,21 +338,23 @@ fn main() -> std::process::ExitCode {
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends =
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
let env_set = std::env::var_os("WGPU_BACKEND").is_some();
let opengl = cfg!(windows) && !env_set && gpu::windows_opengl(&left_out);
create.instance_descriptor.backends = left_out.backends(gpu::backends(create.instance_descriptor.backends, env_set, opengl));
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
create.instance_descriptor.backend_options.dx12.shader_compiler =
eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
// The window's surface may be as large as the adapter allows, not eframe's fixed
// 8192 px: a window stretched over several monitors, or grown by a display scale
// change, otherwise fails to configure its surface and the app closes (#1459).
// The device asks for no more than the adapter offers, so a GPU short of one of
// eframe's limits still gets a device (#2105). The window's surface may be as large as
// the adapter allows, not eframe's fixed 8192 px: a window stretched over several
// monitors, or grown by a display scale change, otherwise fails to configure its
// surface and the app closes (#1459).
let device = create.device_descriptor.clone();
create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| {
let mut descriptor = device(adapter);
let limit = &mut descriptor.required_limits.max_texture_dimension_2d;
*limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d);
descriptor.required_limits = gpu::device_limits(descriptor.required_limits, &adapter.limits());
descriptor
});
}
@@ -436,10 +458,6 @@ fn main() -> std::process::ExitCode {
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.custom_titlebar = custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -460,7 +478,10 @@ fn main() -> std::process::ExitCode {
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
{
let _ = in_window_menus;
single_instance::set_ui(&cc.egui_ctx);
}
app.services.native_input_hook = native_input_hook(&app);
file_access::unconfined(|| open_files(&mut app, files));
// The first frame is due from now (`gpu::watch_first_frame`).
@@ -469,6 +490,7 @@ fn main() -> std::process::ExitCode {
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App {
host_caps_lock: caps_lock::HostCapsLock::new(winit_window.as_deref()),
app,
graphics_loss,
graphics_lost: false,
@@ -481,6 +503,10 @@ fn main() -> std::process::ExitCode {
}),
)
});
// The window is gone: later launches start their own app, including the one a graphics
// failure starts again below.
#[cfg(not(target_os = "macos"))]
drop(instance);
#[cfg(feature = "wgpu")]
let outcome = gpu::finish(outcome, &startup);
#[cfg(not(feature = "wgpu"))]
@@ -494,5 +520,28 @@ fn main() -> std::process::ExitCode {
}
}
/// Tests only: while a test starts another program, the child briefly holds a copy of every file
/// this process has open, until it runs that program. A file lock held through such a copy outlives
/// its own handle, so a GPU start test that released the start file and opened it again found it
/// still locked, as if another copy of the app were running, and failed. A test that starts a
/// program holds [`spawning`]; a test that depends on the start file's lock holds [`no_spawns`].
#[cfg(all(
test,
any(feature = "wgpu", any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))
))]
static CHILD_SPAWN: std::sync::RwLock<()> = std::sync::RwLock::new(());
/// Held while a test starts another program (see [`CHILD_SPAWN`]).
#[cfg(all(test, any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))]
fn spawning() -> std::sync::RwLockWriteGuard<'static, ()> {
CHILD_SPAWN.write().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Held by a test that releases a locked file and opens it again (see [`CHILD_SPAWN`]).
#[cfg(all(test, feature = "wgpu"))]
fn no_spawns() -> std::sync::RwLockReadGuard<'static, ()> {
CHILD_SPAWN.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(all(test, feature = "wgpu"))]
mod tests;
+98 -37
View File
@@ -169,28 +169,37 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<V
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
use std::sync::atomic::{AtomicU32, Ordering};
use zbus::blocking::Proxy;
use zbus::blocking::{MessageIterator, Proxy};
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
// Listen before asking, so that an answer that comes at once is not missed.
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
// Listen before asking, on every request path: a portal older than version 0.9 ignores
// `handle_token` and answers on the request path it returns, and its answer can come before
// the call's reply. The stream keeps every `Response` from the time it is made.
let rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface(REQUEST))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
// returns.
let reply = if handle.as_str() == path {
replies.next()
} else {
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
}
.ok_or("the save dialog closed without an answer")?;
let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
// The answer is the `Response` on the path returned, from the connection that returned it.
let portal = call.header().sender().map(|s| s.as_str().to_string());
let reply = responses
.filter_map(Result::ok)
.find(|m| {
let header = m.header();
header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
})
.ok_or("the save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
@@ -420,20 +429,34 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
/// is no `dbus-daemon` to run.
/// How the mock portal answers `SaveFile`.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum Portal {
/// On the request path that `handle_token` names, after its reply.
Current,
/// As a portal older than 0.9: on a request path of its own, 300 ms after its reply, as a
/// person answers the dialog.
Old,
/// As `Old`, with the `Response` sent before the reply that names its path.
OldEarly,
}
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
/// `None` when there is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead as _;
use zbus::zvariant::OwnedObjectPath;
let mut daemon = std::process::Command::new("dbus-daemon")
let spawning = crate::spawning();
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn()
.ok()?;
.spawn();
drop(spawning);
let mut daemon = daemon.ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
@@ -441,9 +464,12 @@ mod tests {
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
let log = seen.clone();
// Made before the client exists: the connection drops a call that comes while no stream
// reads it.
let incoming = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
for msg in incoming {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
@@ -460,16 +486,12 @@ mod tests {
l.len()
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
let token = match portal {
Portal::Current => text("handle_token").unwrap_or_default(),
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
};
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
if server.reply(&header, &handle).is_err() {
break;
}
if own_paths {
// As a person answers the dialog: after the caller listened on the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
@@ -477,7 +499,18 @@ mod tests {
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
let to = header.sender().cloned().map(zbus::names::BusName::from);
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
let respond = || server.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &results));
if portal == Portal::OldEarly && respond().is_err() {
break;
}
if server.reply(&header, &handle).is_err() {
break;
}
if portal == Portal::Old {
// As a person answers the dialog: after the caller has the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
if portal != Portal::OldEarly && respond().is_err() {
break;
}
}
@@ -486,20 +519,32 @@ mod tests {
Some((client, seen, daemon))
}
/// [`save_file`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails
/// the test instead of leaving it waiting.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn save_within(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
let (tx, rx) = std::sync::mpsc::channel();
let (conn, request) = (conn.clone(), request.clone());
std::thread::spawn(move || {
let _ = tx.send(save_file(&conn, &request));
});
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds")
}
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
/// name, and the answer names the file and the type picked; Cancel answers no file.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
let saved = save_file(&conn, &request).unwrap();
let saved = save_within(&conn, &request).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
assert_eq!(save_file(&conn, &request), Ok(None));
assert_eq!(save_within(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
let _ = daemon.kill();
@@ -510,12 +555,28 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
let _ = daemon.kill();
}
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
/// kept until the path is known, and the next dialog does not take it.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn an_answer_that_comes_before_the_path_is_kept() {
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(save_within(&conn, &request), Ok(None));
let _ = daemon.kill();
}
+184 -8
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{VectorcraftApp, fileio, prefs_dir};
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
@@ -84,24 +84,200 @@ pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
// What this window started from: only what changes after it is this window's to save.
set_saved_base(prefs_value(app).ok());
}
/// Save what this window changed since it last read or saved the preferences, merged into the
/// file as it is now ([`merge_changes`]): another VectorCraft window open at the same time, which
/// read the file before this one saved, then keeps this window's changes when it saves or quits
/// (#4625). Nothing is written when nothing changed.
pub(crate) fn save_prefs(app: &VectorcraftApp) {
if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = prefs_bytes(app) {
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
let Some(p) = prefs_path() else { return };
let Ok(mine) = prefs_value(app) else { return };
let base = saved_base();
if base.as_ref().is_some_and(|b| same_lasting(b, &mine)) {
return;
}
// The file as another window may have saved it since (none, or unreadable: this one's own).
let on_disk = std::fs::read(&p).ok().and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
let merged = match (base.as_ref(), on_disk) {
(Some(base), Some(on_disk)) => merge_changes(base, &mine, on_disk),
_ => mine.clone(),
};
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
let Ok(bytes) = serde_json::to_vec_pretty(&merged) else { return };
// Preferences are best effort: a failed write keeps the previous file, and the changes are
// tried again on the next save.
if fileio::write_atomic(&p, &bytes).is_ok() {
set_saved_base(Some(mine));
}
}
/// How often [`save_prefs_soon`] looks for changes to save.
const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(2);
/// Save the preferences at most every [`SAVE_EVERY`] while the app runs, when something changed:
/// the workspace, panels, toolbars and the other UI settings kept only in the UI state were saved
/// only on quitting, so a session ended any other way (the window closed by the system, the
/// process ended or crashing) started the next one from the settings before it (#4625).
pub(crate) fn save_prefs_soon(app: &VectorcraftApp) {
static LAST: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
let Ok(mut last) = LAST.lock() else { return };
if last.is_some_and(|t| t.elapsed() < SAVE_EVERY) {
return;
}
*last = Some(std::time::Instant::now());
drop(last);
save_prefs(app);
}
/// The preferences as this window last read or saved them (`None`: not read yet).
static SAVED_BASE: std::sync::Mutex<Option<serde_json::Value>> = std::sync::Mutex::new(None);
fn saved_base() -> Option<serde_json::Value> {
SAVED_BASE.lock().ok().and_then(|b| b.clone())
}
fn set_saved_base(value: Option<serde_json::Value>) {
if let Ok(mut b) = SAVED_BASE.lock() {
*b = value;
}
}
/// Fields of the UI state that a start resets ([`vectorcraft_ui_egui::UiState::sanitized`]): a
/// change to them alone isn't saved, and they are never merged over another window's.
const PASSING: &[&str] = &["status", "dialog", "flyout", "palette_open", "palette_query", "about", "open_panel"];
/// Do `a` and `b` hold the same preferences, leaving out what a start resets ([`PASSING`])?
fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool {
match (a.as_object(), b.as_object()) {
(Some(a), Some(b)) => {
let keys: std::collections::BTreeSet<&String> = a.keys().chain(b.keys()).filter(|k| !PASSING.contains(&k.as_str())).collect();
keys.into_iter().all(|k| a.get(k) == b.get(k))
}
_ => a == b,
}
}
/// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI
/// state, and each engine preference (`engine_prefs`) on its own, so two windows that change
/// different settings keep both. A field both changed takes this window's value (it saves last).
fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else {
return mine.clone();
};
let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect();
for key in keys {
if PASSING.contains(&key.as_str()) {
if let Some(v) = mine.get(key) {
out.entry(key.clone()).or_insert_with(|| v.clone());
}
continue;
}
let (was, now) = (base.get(key), mine.get(key));
if was == now {
continue;
}
if key == "engine_prefs"
&& let (Some(Value::Object(was)), Some(Value::Object(now)), Some(Value::Object(theirs))) = (was, now, out.get_mut(key))
{
let names: std::collections::BTreeSet<&String> = was.keys().chain(now.keys()).collect();
for name in names {
match (was.get(name), now.get(name)) {
(a, b) if a == b => {}
(_, Some(v)) => {
theirs.insert(name.clone(), v.clone());
}
(_, None) => {
theirs.remove(name);
}
}
}
continue;
}
match now {
Some(v) => {
out.insert(key.clone(), v.clone());
}
None => {
out.remove(key);
}
}
}
Value::Object(out)
}
/// The preferences file's contents: the UI state with the engine preferences.
#[cfg(test)]
pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
serde_json::to_vec_pretty(&prefs_value(app)?)
}
/// [`prefs_bytes`] as JSON.
fn prefs_value(app: &VectorcraftApp) -> serde_json::Result<serde_json::Value> {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
serde_json::to_vec_pretty(&ui)
serde_json::to_value(&ui)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{merge_changes, same_lasting};
/// Two windows open at once (#4625): the one that saves last keeps the other's changes and
/// adds its own, instead of putting back the preferences it started from.
#[test]
fn a_window_saves_only_what_it_changed_over_another_windows_save() {
let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}});
// The other window switched the workspace and the accent, and saved.
let on_disk = json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.0}});
// This one changed only the UI scaling.
let mine = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.25}});
let merged = merge_changes(&base, &mine, on_disk);
assert_eq!(merged, json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.25}}));
}
#[test]
fn a_setting_both_windows_changed_takes_the_last_saved() {
let base = json!({"workspace": "Essentials", "engine_prefs": {"uiAccent": "blue"}});
let on_disk = json!({"workspace": "Painting", "engine_prefs": {"uiAccent": "violet"}});
let mine = json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}}));
}
#[test]
fn fields_added_or_removed_by_this_window_are_saved() {
let base = json!({"docking": {"root": 1}, "engine_prefs": {"old": 1}});
let on_disk = json!({"docking": {"root": 1}, "recent_files": ["a.svg"], "engine_prefs": {"old": 1}});
let mine = json!({"dock_width": 300.0, "engine_prefs": {"new": 2}});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"dock_width": 300.0, "recent_files": ["a.svg"], "engine_prefs": {"new": 2}}));
}
/// The status line and open dialogs reset at start: they don't make a save, and they don't
/// replace another window's.
#[test]
fn what_a_start_resets_is_left_out() {
let base = json!({"status": "", "dialog": null, "workspace": "Essentials"});
let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials"});
assert!(same_lasting(&base, &mine));
let on_disk = json!({"status": "Opened", "dialog": null, "workspace": "Painting"});
let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials", "toolbar": false});
let merged = merge_changes(&base, &mine, on_disk);
assert_eq!(merged, json!({"status": "Opened", "dialog": null, "workspace": "Painting", "toolbar": false}));
assert!(!same_lasting(&base, &mine));
}
#[test]
fn a_file_that_is_not_an_object_is_replaced() {
let base = json!({"workspace": "Essentials"});
let mine = json!({"workspace": "Painting"});
assert_eq!(merge_changes(&base, &mine, json!([1, 2])), mine);
}
}
+3 -11
View File
@@ -1,15 +1,8 @@
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
//! Windows.Data.Pdf is unavailable there.
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
//! craft-print owns discovery and spooling.
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
mod windows7;
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
use windows7 as system;
pub struct SystemPrint;
impl PrintService for SystemPrint {
fn printers(&mut self) -> Vec<Printer> {
@@ -36,9 +29,8 @@ impl PrintService for SystemPrint {
}
}
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
mod system {
use super::*;
use super::{Command, PrintJob, Printer};
pub fn printers() -> Vec<Printer> {
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
}
@@ -95,7 +87,7 @@ mod system {
}
}
#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))]
#[cfg(all(test, windows))]
mod tests {
use super::system::setup_command;
use std::ffi::OsStr;
-129
View File
@@ -1,129 +0,0 @@
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
use std::path::PathBuf;
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, Printer};
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
// truncate an existing path, and remove incomplete files or refused launches immediately.
use std::io::Write as _;
let mut file =
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
drop(file);
let _ = std::fs::remove_file(&path);
return Err(format!("can't write {}: {e}", path.display()));
}
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new(
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe"),
);
c.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
])
.creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
pub fn print(job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let result = submit(job, &file);
if result.is_err() {
// No viewer accepted the launch, so no asynchronous reader needs this document.
let _ = std::fs::remove_file(file);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn powershell_lines_and_quotes_are_preserved() {
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf_for_the_async_viewer() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
std::fs::remove_file(path).unwrap();
}
}
+386
View File
@@ -0,0 +1,386 @@
//! One app per user (#4892): a file opened from the file manager (double-click, Open With, a drop
//! on the executable) while VectorCraft runs opens as a new document tab in the running app, which
//! comes to the front, instead of in a second app.
//!
//! The running app (the primary) listens on a loopback port and records the port and two random
//! tokens in `instance.json` next to the preferences (owner-only on Unix; on Windows the settings
//! folder is the user's own). A later launch with files connects; the primary greets it with
//! `hello`, which only a process that could read the file (or talk to the primary) knows; only then
//! does the launch send `token` and the absolute paths of its files, one JSON line. The primary
//! checks the token, keeps the paths that are existing files, acknowledges and opens them; the
//! launch exits. Anything else (no file, no listener, a wrong greeting, no answer) starts the launch
//! as its own app, which replaces a stale file. Nothing received is ever run.
//!
//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward
//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files
//! opened from Finder to the running app itself (`open_documents`), so this is off there.
use std::fs::{self, OpenOptions};
use std::hash::{BuildHasher as _, RandomState};
use std::io::{self, BufRead as _, BufReader, Read as _, Write as _};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::Duration;
use serde_json::{Value, json};
use super::prefs::{prefs_enabled, prefs_path};
/// The endpoint file, next to the preferences.
const FILE_NAME: &str = "instance.json";
/// The longest message either side reads, and the longest endpoint file.
const MAX_MESSAGE: u64 = 64 * 1024;
/// The most files one launch hands over.
const MAX_FILES: usize = 256;
/// A live primary's kernel accepts at once, even while its window is busy; a stale port on Windows
/// would otherwise retry for about two seconds.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
/// How long the primary waits on one launch; it serves launches one at a time.
const SERVER_TIMEOUT: Duration = Duration::from_secs(2);
/// How long a launch waits on the primary: longer than a launch served before it can take.
const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);
/// Paths handed over by later launches that the UI hasn't opened yet.
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = OnceLock::new();
/// What this launch does about an app already running.
pub enum Start {
/// The running app took the files: this launch exits.
Forwarded,
/// This launch runs; `Some` while it is the app later launches hand their files to.
Running(Option<Primary>),
}
/// Hand `files` to the app already running, or become the app later launches hand theirs to.
/// `automation`: started with `--control`.
pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start {
if automation || !prefs_enabled() {
return Start::Running(None);
}
let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) };
// With nothing to hand over, the same exchange only asks whether an app is running.
let send: &[String] = if new_instance { &[] } else { files };
match forward(&path, send) {
Ok(()) if send.is_empty() => Start::Running(None),
Ok(()) => Start::Forwarded,
Err(e) => {
log::debug!("no running VectorCraft to hand files to ({e})");
Start::Running(listen(&path, queue))
}
}
}
/// Wake `ctx` when files arrive from later launches.
pub fn set_ui(ctx: &egui::Context) {
// Set once; a second call would hand over the same context.
let _ = UI.set(ctx.clone());
}
/// The paths later launches handed over since the last call.
pub fn take() -> Vec<String> {
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
}
fn queue(files: Vec<String>) {
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files);
if let Some(ctx) = UI.get() {
ctx.request_repaint();
}
}
/// This app is the one recorded in the endpoint file; dropping it removes the file, unless a later
/// app has replaced it since.
pub struct Primary {
path: PathBuf,
token: String,
}
impl Drop for Primary {
fn drop(&mut self) {
if read_endpoint(&self.path).is_some_and(|e| e.token == self.token) {
// Best effort: a file left behind is stale, and the next launch replaces it.
let _ = fs::remove_file(&self.path);
}
}
}
struct Endpoint {
port: u16,
token: String,
hello: String,
}
fn invalid(why: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, why)
}
fn read_endpoint(path: &Path) -> Option<Endpoint> {
let mut text = String::new();
fs::File::open(path).ok()?.take(MAX_MESSAGE).read_to_string(&mut text).ok()?;
let v: Value = serde_json::from_str(&text).ok()?;
let port = u16::try_from(v.get("port")?.as_u64()?).ok().filter(|p| *p != 0)?;
Some(Endpoint { port, token: v.get("token")?.as_str()?.to_owned(), hello: v.get("hello")?.as_str()?.to_owned() })
}
/// Hand `files` (made absolute against this launch's folder) to the app recorded at `path`; `Ok`
/// once it acknowledged them.
fn forward(path: &Path, files: &[String]) -> io::Result<()> {
let endpoint = read_endpoint(path).ok_or_else(|| invalid("no endpoint file"))?;
let files = files.iter().map(|f| std::path::absolute(f).map(|p| p.to_string_lossy().into_owned())).collect::<io::Result<Vec<_>>>()?;
let stream = TcpStream::connect_timeout(&(Ipv4Addr::LOCALHOST, endpoint.port).into(), CONNECT_TIMEOUT)?;
stream.set_read_timeout(Some(CLIENT_TIMEOUT))?;
stream.set_write_timeout(Some(CLIENT_TIMEOUT))?;
// The token and the paths go only to a listener that knows the greeting.
if !same(read_message(&stream)?.get("hello"), &endpoint.hello) {
return Err(invalid("the listener isn't VectorCraft"));
}
write_message(&stream, &json!({ "token": endpoint.token, "files": files }))?;
match read_message(&stream)?.get("ok") {
Some(Value::Bool(true)) => Ok(()),
_ => Err(invalid("refused")),
}
}
/// Listen for later launches and record this app at `path`; each launch's files go to `deliver`.
/// `None` when that fails (logged): the app then runs on its own, as before.
fn listen(path: &Path, deliver: impl Fn(Vec<String>) + Send + 'static) -> Option<Primary> {
try_listen(path, deliver).inspect_err(|e| log::warn!("files opened while VectorCraft runs will open in another window: {e}")).ok()
}
fn try_listen(path: &Path, deliver: impl Fn(Vec<String>) + Send + 'static) -> io::Result<Primary> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
let (token, hello) = random_tokens();
write_endpoint(path, &json!({ "port": port, "token": token, "hello": hello }))?;
// From here on a failure removes the file again.
let primary = Primary { path: path.to_path_buf(), token: token.clone() };
std::thread::Builder::new().name("open-files-listener".into()).spawn(move || {
for stream in listener.incoming() {
match stream.and_then(|s| receive(&s, &hello, &token)) {
Ok(files) if files.is_empty() => {}
Ok(files) => deliver(files),
Err(e) => {
log::warn!("refused files from another launch: {e}");
// An accept that keeps failing (out of handles) doesn't spin.
std::thread::sleep(Duration::from_millis(50));
}
}
}
})?;
Ok(primary)
}
/// Serve one launch: greet it, check its token, keep the paths that are existing files.
fn receive(stream: &TcpStream, hello: &str, token: &str) -> io::Result<Vec<String>> {
stream.set_read_timeout(Some(SERVER_TIMEOUT))?;
stream.set_write_timeout(Some(SERVER_TIMEOUT))?;
write_message(stream, &json!({ "hello": hello }))?;
let message = read_message(stream)?;
if !same(message.get("token"), token) {
return Err(invalid("wrong token"));
}
let files = message.get("files").and_then(Value::as_array).ok_or_else(|| invalid("no file list"))?;
if files.len() > MAX_FILES {
return Err(invalid("too many files"));
}
let files = files
.iter()
.filter_map(Value::as_str)
.filter(|f| {
let p = Path::new(f);
let ok = p.is_absolute() && p.is_file();
if !ok {
log::warn!("another launch named {f}, which isn't a file");
}
ok
})
.map(str::to_owned)
.collect();
write_message(stream, &json!({ "ok": true }))?;
Ok(files)
}
/// One JSON line of at most [`MAX_MESSAGE`] bytes. Each side sends one line and waits for the
/// other's, so nothing read past it is lost.
fn read_message(stream: &TcpStream) -> io::Result<Value> {
let mut line = Vec::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_until(b'\n', &mut line)?;
if line.last() != Some(&b'\n') {
return Err(invalid("message too long or cut short"));
}
serde_json::from_slice(&line).map_err(|e| invalid(&e.to_string()))
}
fn write_message(mut stream: &TcpStream, message: &Value) -> io::Result<()> {
let mut line = serde_json::to_vec(message).map_err(io::Error::other)?;
line.push(b'\n');
stream.write_all(&line)
}
/// `given` is the string `expected`, compared in constant time.
fn same(given: Option<&Value>, expected: &str) -> bool {
given
.and_then(Value::as_str)
.is_some_and(|g| g.len() == expected.len() && g.bytes().zip(expected.bytes()).fold(0, |acc, (a, b)| acc | (a ^ b)) == 0)
}
/// Two unrelated 128-bit hex tokens. `RandomState` is keyed from the system's random numbers, and
/// its keyed hash of distinct values gives unpredictable, independent output (no crate needed).
fn random_tokens() -> (String, String) {
let keyed = RandomState::new();
let token = |a: u8, b: u8| format!("{:016x}{:016x}", keyed.hash_one(a), keyed.hash_one(b));
(token(1, 2), token(3, 4))
}
/// Replace `path` with `endpoint` at once (a temporary file renamed over it), readable only by the
/// user on Unix.
fn write_endpoint(path: &Path, endpoint: &Value) -> io::Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
// A leftover of an earlier app with the same process id; `create_new` sets the mode below.
let _ = fs::remove_file(&tmp);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec(endpoint).map_err(io::Error::other)?;
let written = options.open(&tmp).and_then(|mut f| f.write_all(&bytes)).and_then(|()| fs::rename(&tmp, path));
if written.is_err() {
// Best effort: the temporary file is all there is to clean up.
let _ = fs::remove_file(&tmp);
}
written
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use super::*;
/// A fresh folder for one test, with an existing file in it.
fn folder(name: &str) -> (PathBuf, PathBuf, String) {
let dir = std::env::temp_dir().join(format!("vc-single-instance-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("a b.svg");
fs::write(&file, "<svg/>").unwrap();
let file = file.to_string_lossy().into_owned();
(dir.join(FILE_NAME), dir, file)
}
fn primary(path: &Path) -> (Primary, mpsc::Receiver<Vec<String>>) {
let (tx, rx) = mpsc::channel();
let primary = listen(path, move |files| tx.send(files).unwrap()).unwrap();
(primary, rx)
}
/// Connects as a launch would and sends `message` after the greeting; what came back.
fn send_raw(path: &Path, message: &[u8]) -> io::Result<Value> {
let endpoint = read_endpoint(path).unwrap();
let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, endpoint.port)).unwrap();
stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap();
assert!(same(read_message(&stream).unwrap().get("hello"), &endpoint.hello));
(&stream).write_all(message)?;
read_message(&stream)
}
#[test]
fn a_launch_hands_its_files_to_the_running_app() {
let (path, dir, file) = folder("forward");
let (primary, rx) = primary(&path);
let missing = dir.join("missing.svg").to_string_lossy().into_owned();
forward(&path, &[file.clone(), missing]).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file), "the missing file is left out");
// An empty list only asks whether the app runs: nothing is delivered.
forward(&path, &[]).unwrap();
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
drop(primary);
assert!(!path.exists(), "the endpoint file goes with the app");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_wrong_token_garbage_and_oversized_messages_are_refused() {
let (path, dir, file) = folder("refused");
let (_primary, rx) = primary(&path);
let token = read_endpoint(&path).unwrap().token;
let wrong = format!("{}\n", json!({ "token": "0".repeat(token.len()), "files": [file] }));
assert!(send_raw(&path, wrong.as_bytes()).is_err());
assert!(send_raw(&path, b"not json\n").is_err());
assert!(send_raw(&path, &vec![b'a'; MAX_MESSAGE as usize + 10]).is_err());
let many = format!("{}\n", json!({ "token": token, "files": vec![file.clone(); MAX_FILES + 1] }));
assert!(send_raw(&path, many.as_bytes()).is_err());
let relative = format!("{}\n", json!({ "token": token, "files": ["a b.svg", 7] }));
assert_eq!(send_raw(&path, relative.as_bytes()).unwrap(), json!({ "ok": true }));
assert!(rx.try_recv().is_err(), "nothing was delivered");
// The listener still serves a launch with the right token.
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_stale_endpoint_file_is_ignored_and_replaced() {
let (path, dir, file) = folder("stale");
// No listener on the recorded port any more.
let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap();
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
// A listener that doesn't know the greeting gets neither the token nor the paths.
let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = impostor.local_addr().unwrap().port();
write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap();
let heard = std::thread::spawn(move || {
let (stream, _) = impostor.accept().unwrap();
write_message(&stream, &json!({ "hello": "x" })).unwrap();
stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap();
read_message(&stream).ok()
});
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
assert!(heard.join().unwrap().is_none());
// Garbage in the file is no endpoint either.
fs::write(&path, b"\xff not json").unwrap();
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
// A new primary replaces the stale file.
let (_primary, rx) = primary(&path);
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_replaced_endpoint_file_stays_when_the_earlier_app_ends() {
let (path, dir, _) = folder("replaced");
let (first, _rx1) = primary(&path);
let (_second, _rx2) = primary(&path);
let second_token = read_endpoint(&path).unwrap().token;
drop(first);
assert_eq!(read_endpoint(&path).map(|e| e.token), Some(second_token));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn tokens_are_random_and_distinct() {
let (a, b) = random_tokens();
let (c, _) = random_tokens();
assert_eq!(a.len(), 32);
assert!(a != b && a != c);
}
#[cfg(unix)]
#[test]
fn the_endpoint_file_is_the_users_alone() {
use std::os::unix::fs::PermissionsExt as _;
let (path, dir, _) = folder("mode");
let _primary = primary(&path);
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = fs::remove_dir_all(dir);
}
}
+3 -3
View File
@@ -1,4 +1,6 @@
use super::*;
use super::window;
#[cfg(target_os = "linux")]
use super::xwayland;
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
@@ -113,8 +115,6 @@ pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker
//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor
//! in the machine, also when the process may run on fewer (started with `start /affinity`, or
//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the
//! CLI shares this file.
//!
//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code`
//! allowance.
#![allow(unsafe_code)]
use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask};
/// Tells the renderer how many cores the process may run on.
pub fn install() {
vectorcraft_render::set_usable_cores(usable_cores());
}
/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a
/// process with threads in more than one processor group).
fn usable_cores() -> Option<usize> {
let (mut process, mut system) = (0usize, 0usize);
// SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers
// point at live `usize`s for the length of the call.
unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?;
(process != 0).then(|| process.count_ones() as usize)
}
#[cfg(test)]
mod tests {
/// The mask Windows reports for this process gives at least one core and no more than the
/// machine has. (A child process started with `start /affinity` would test a one-core mask
/// end to end, but a process spawned while other tests hold sockets inherits them and resets
/// their connections; `automatic_threads` covers the one-core count.)
#[test]
fn the_affinity_mask_gives_the_cores_the_process_may_use() {
let cores = super::usable_cores().unwrap();
let all = std::thread::available_parallelism().unwrap().get();
assert!((1..=all).contains(&cores), "{cores} of {all}");
}
}
+2 -2
View File
@@ -16,7 +16,7 @@
//! mouse message that Windows did not make from a pen or a touch
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
//! clears the feed.
#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
#![cfg_attr(not(windows), allow(dead_code))]
use std::cell::RefCell;
use std::sync::OnceLock;
@@ -68,7 +68,7 @@ pub fn start() -> StylusFeed {
/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
/// and whether the barrel button was held, and writes the pen's pressure and end into the feed.
/// It never consumes the message.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO};
+32 -21
View File
@@ -1,8 +1,10 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
//! #1151). Automatic and X11 (XWayland) When a Pen Is Attached do so only when a pen is attached
//! as the app starts, as PhotoCraft does: winit 0.30 doesn't bind the Wayland tablet protocol, so
//! on native Wayland the pen moves only the compositor's cursor, and XWayland hands the pen to X11
//! windows as a pointer (#2028, #4967). `VECTORCRAFT_NATIVE_WAYLAND=1` keeps a pen's window on
//! Wayland. Only when XWayland can run the window: an X display is set and
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
//! so a missing library never keeps the app from starting.
//!
@@ -21,16 +23,17 @@ const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
/// `x11WithPen`, after the session checks.
/// any other choice (`automatic`, `x11WithPen`, none saved) with `pen` finding a pen and
/// `VECTORCRAFT_NATIVE_WAYLAND` unset (or `0`); a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only after
/// the other checks.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
let native_wayland = var("VECTORCRAFT_NATIVE_WAYLAND").is_some_and(|v| !v.is_empty() && v != "0");
match pref {
Some("x11") => session,
Some("x11WithPen") => session && pen(),
_ => false,
_ => session && !native_wayland && pen(),
}
}
@@ -149,27 +152,35 @@ mod tests {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland, no_probe));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("automatic"), &wayland, no_probe));
assert!(!wanted(None, &wayland, no_probe));
assert!(!wanted(Some("wayland"), &wayland, no_probe));
assert!(!wanted(Some("automatic"), &wayland, || false));
assert!(!wanted(None, &wayland, || false));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
}
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
/// display when a pen is found, the session's own display server otherwise. The pen check
/// runs only on such a session.
/// Automatic and X11 (XWayland) When a Pen Is Attached (#2028, #4967): XWayland on a Wayland
/// session with an X display when a pen is found, where the pen reaches the window, the
/// session's own display server otherwise. The pen check runs only on such a session, and
/// `VECTORCRAFT_NATIVE_WAYLAND` keeps Wayland.
#[test]
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
fn a_pen_opens_xwayland_unless_native_wayland_is_asked_for() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11WithPen"), &wayland, || true));
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
let native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "1")]);
let not_native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "0")]);
for pref in [Some("automatic"), None, Some("x11WithPen")] {
assert!(wanted(pref, &wayland, || true), "{pref:?}");
assert!(wanted(pref, env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true), "{pref:?}");
assert!(!wanted(pref, &wayland, || false), "{pref:?}");
assert!(!wanted(pref, env(&[("DISPLAY", ":0")]), no_probe), "{pref:?}");
assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe), "{pref:?}");
assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe), "{pref:?}");
assert!(!wanted(pref, &native, no_probe), "{pref:?}");
assert!(wanted(pref, &not_native, || true), "{pref:?}");
}
// The X11 choice opens XWayland with or without a pen; the opt-out is for the pen only.
assert!(wanted(Some("x11"), &native, no_probe));
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
+1 -3
View File
@@ -16,9 +16,7 @@ head-and-shoulders portrait on a full-bleed field, no frame or roundel, with the
**Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112`, clipped. The macOS files add Apple's
transparent margin (tile = 824/1024 of the canvas); Windows and Linux use the tile edge to edge.
**Provenance:** the owner's original ArtCraft drawing (2880 px, keyed to the palette), vectorised with
craftrules `assets/logo-options/_tools/vectorize_tile.py`. The drawing is kept in craftrules at
`craftrules/assets/app-icons/vectorcraft/source.png`, not here. `vectorcraft.svg` is the canonical
**Provenance:** the owner's original ArtCraft drawing, vectorised. `vectorcraft.svg` is the canonical
artwork. Licence: see `LICENSE.txt`.
## Files
+1
View File
@@ -6,3 +6,4 @@ allow-panic-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+25 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -17,6 +14,11 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.el-j]
real_name = "Fabian Althaus"
display_name = "faff (el-j)"
sync_github_name = false
[people.lollinoo]
sync_github_name = true
@@ -169,3 +171,21 @@ sync_github_name = false
real_name = "Andrés Ortega Montoya"
display_name = "Andrés Ortega Montoya"
sync_github_name = false
[people.krosk93]
sync_github_name = true
[people.ashokram-si]
real_name = "Ashok Ram"
[people.dylanmtaylor]
sync_github_name = true
[people.marshiyar]
display_name = "Arshiya"
real_name = "Arshiya Rahgozar"
sync_github_name = false
[people.WadahBadawi]
display_name = "Wadah Badawi"
sync_github_name = true
+1 -1
View File
@@ -17,7 +17,7 @@ Affinity files.
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| A shape, curve or text with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| Vector masks (`AdCh` curves and shapes) | clip group |
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
+1 -1
View File
@@ -1,5 +1,5 @@
use super::read::MAX_TREE_DEPTH;
use super::*;
use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag};
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
+32 -1
View File
@@ -1,5 +1,8 @@
use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union};
use super::*;
use super::{
Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream,
Tag, paint, stream,
};
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
const MAX_PROFILE_BYTES: usize = 32 << 20;
@@ -438,8 +441,34 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
_ => self.warn("layer effects of other kinds (left out)"),
}
}
for a in s.objs(id, b"AdCh") {
if let Some(effect) = self.attached_recolor(a) {
out.push(effect);
}
}
out
}
fn attached_recolor(&mut self, a: ObjId) -> Option<Effect> {
let s = self.s;
if !s.is(a, b"RcRA") {
return None;
}
if s.bool(a, b"Visi") == Some(false) {
return None;
}
let adjp = s.obj(a, b"AdjP")?;
if !s.is(adjp, b"RecP") {
return None;
}
let h = s.f64(adjp, b"RecH").unwrap_or(0.0);
let sat = s.f64(adjp, b"RecS").unwrap_or(1.0);
let l = s.f64(adjp, b"RecL").unwrap_or(0.5);
let (r, g, b) = crate::paint::hsl(h, sat, l);
let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 };
let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
let blend = self.effect_blend(a);
Some(Effect::ColorOverlay { color, opacity, blend })
}
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
fn effect_blend(&mut self, e: ObjId) -> Blend {
@@ -476,6 +505,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
} else if class == Tag::of(b"RcRA") {
// Handled in `effects` as a colour overlay.
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
+15 -1
View File
@@ -194,7 +194,7 @@ fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
})
}
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
@@ -355,3 +355,17 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsl_lightness_1_is_white_and_0_is_black() {
assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0));
assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0));
assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0));
let (r, g, b) = hsl(0.0, 1.0, 0.5);
assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6);
}
}
+19 -13
View File
@@ -98,20 +98,26 @@ pub fn utf8(text: &str) -> F {
}
pub fn document(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec<u8> {
let text = F::Obj(
tag(if frame { b"TxtF" } else { b"TxtA" }),
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
(
tag(b"TxtH"),
F::Obj(
tag(if frame { b"FrFr" } else { b"ArFr" }),
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
),
document_with_children(blocks, frame, dpi, transform, vec![])
}
/// [`document`] whose text holds `children` (objects nested inside the text).
pub fn document_with_children(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6], children: Vec<F>) -> Vec<u8> {
let mut fields = vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
(
tag(b"TxtH"),
F::Obj(
tag(if frame { b"FrFr" } else { b"ArFr" }),
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
],
);
),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
];
if !children.is_empty() {
fields.push((tag(b"Chld"), F::Shared(children)));
}
let text = F::Obj(tag(if frame { b"TxtF" } else { b"TxtA" }), fields);
let mut root = vec![
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])),
(
+3 -2
View File
@@ -138,9 +138,10 @@ pub fn stain(inst: &Node) -> Option<(Color, f32)> {
Some((f.paint.color()?, f.opacity))
}
/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]).
/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic
/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]).
pub fn instance_art(art: &Node, inst: &Node) -> Node {
let mut a = art.clone();
let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst);
if let Some((c, amount)) = stain(inst) {
tint_node(&mut a, &c, amount);
}
+91
View File
@@ -343,19 +343,85 @@ pub enum PatternFit {
Approximate,
}
/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile
/// made from the side tile.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CornerSource {
/// No corner tile: the side tiles run up to the corner.
None,
/// One side tile stretched around the corner and centered on it.
AutoCentered,
/// A side tile on each side reaching into the corner, stretched so the two meet on the
/// corner's diagonal.
AutoBetween,
/// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame.
AutoSliced,
/// A side tile on each side running past the corner, the two overlapping there.
AutoOverlap,
}
impl CornerSource {
pub const ALL: [CornerSource; 5] =
[CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
/// The id in a brush definition and in `brush.options` params.
pub fn id(self) -> &'static str {
match self {
CornerSource::None => "none",
CornerSource::AutoCentered => "autoCentered",
CornerSource::AutoBetween => "autoBetween",
CornerSource::AutoSliced => "autoSliced",
CornerSource::AutoOverlap => "autoOverlap",
}
}
pub fn parse(s: &str) -> Option<CornerSource> {
CornerSource::ALL.into_iter().find(|a| a.id() == s)
}
}
/// Pattern brush: tiles repeated along the path, with corner and end tiles.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PatternBrush {
pub side: Node,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_corner_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_corner_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_corner_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_corner_swatch: Option<String>,
/// What the outer corners take when there is no `outer_corner` art. Without art or a source,
/// they take the `inner_corner` art.
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_source: Option<CornerSource>,
/// What the inner corners take when there is no `inner_corner` art. Without art or a source,
/// they take the `outer_corner` art.
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_source: Option<CornerSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_swatch: Option<String>,
/// Scale in percent.
pub scale: f64,
/// Space between tiles in percent of the tile width.
@@ -376,10 +442,22 @@ impl Default for PatternBrush {
fn default() -> Self {
Self {
side: defaults::stitch_tile(),
side_frame: None,
side_swatch: None,
outer_corner: None,
outer_corner_frame: None,
outer_corner_swatch: None,
inner_corner: None,
inner_corner_frame: None,
inner_corner_swatch: None,
outer_source: None,
inner_source: None,
start: None,
start_frame: None,
start_swatch: None,
end: None,
end_frame: None,
end_swatch: None,
scale: 100.0,
spacing: 0.0,
fit: PatternFit::Stretch,
@@ -452,6 +530,14 @@ fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f6
(within(a, range, fallback), within(b, range, fallback))
}
fn valid_tile_frame(frame: Rect) -> bool {
[frame.x0, frame.y0, frame.x1, frame.y1].into_iter().all(f64::is_finite)
&& frame.width().is_finite()
&& frame.height().is_finite()
&& frame.width() > 1e-9
&& frame.height() > 1e-9
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
@@ -552,6 +638,11 @@ impl Brush {
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
for frame in [&mut p.side_frame, &mut p.outer_corner_frame, &mut p.inner_corner_frame, &mut p.start_frame, &mut p.end_frame] {
if frame.is_some_and(|f| !valid_tile_frame(f)) {
*frame = None;
}
}
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
+525 -23
View File
@@ -1,6 +1,6 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Point, Rect, shapes};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes};
use super::*;
@@ -27,10 +27,16 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
/// Points along every outline under `n`, its anchors among them. Bent art keeps its curves and
/// long lines since #3926, so its anchors alone no longer trace it.
fn all_points(n: &Node, out: &mut Vec<Point>) {
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
out.extend(p.anchors().map(|(_, _, a)| a.p));
for sp in c.path_data().into_iter().flat_map(|p| &p.subpaths) {
out.extend(sp.anchors.iter().map(|a| a.p));
for i in 0..sp.segment_count() {
let seg = sp.segment(i);
out.extend((1..32).map(|j| kurbo::ParamCurve::eval(&seg, j as f64 / 32.0)));
}
}
});
}
@@ -185,9 +191,12 @@ fn art_brush_follows_curves() {
}
}
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
// The long edges bend as curves (#3926), where they used to be cut into some 400 chords. The
// path is run along in 0.05 pt chords, which the curves follow, so a few dozen remain.
let (anchors, curved) = anchors_and_curved(&out[0]);
assert!(anchors < 100 && curved > anchors / 2, "long edges bend as curves: {curved} of {anchors} anchors with handles");
let mut pts = vec![];
all_points(&out[0], &mut pts);
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
for p in pts {
let r = p.distance(Point::new(100.0, 100.0));
assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre");
@@ -273,15 +282,399 @@ fn pattern_tile_count_by_fit() {
fn pattern_places_corner_tiles() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let corners = out
.iter()
.filter(
|n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0),
)
.count();
assert_eq!(corners, 4);
// The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them
// apart. Anchor count no longer does: the side tiles keep their own four anchors now that a
// straight run is not flattened (#3926), where they used to arrive as many-anchor polylines.
let curved = |n: &Node| {
matches!(&n.kind, NodeKind::Path { path, .. }
if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p)))
};
let corners = out.iter().filter(|n| curved(n)).count();
assert_eq!(corners, 4, "the four corner ellipses, still curves");
// Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each.
assert_eq!(out.len() - corners, 36);
for n in out.iter().filter(|n| !curved(n)) {
let NodeKind::Path { path, .. } = &n.kind else { continue };
assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors");
}
}
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
fn auto_corner_brush(outer: Option<CornerSource>, inner: Option<CornerSource>) -> Brush {
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.outer_source = outer;
p.inner_source = inner;
}
b
}
const AUTO: [CornerSource; 4] = [CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
/// Whether `n` paints a fill over `q`: inside one of its paths, and inside the mask of every
/// clipping group it is in.
fn covers(n: &Node, q: Point) -> bool {
let inside = |n: &Node| n.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), q) != 0);
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().is_some_and(|m| inside(m)) && children.iter().skip(1).any(|c| covers(c, q)),
NodeKind::Path { .. } => inside(n),
_ => n.children().is_some_and(|c| c.iter().any(|c| covers(c, q))),
}
}
fn points_of(nodes: &[Node]) -> Vec<Point> {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts
}
/// The corners of a 100 pt square and, for each, the outer corner of the 4 pt band around it.
const SQUARE_CORNERS: [((f64, f64), (f64, f64)); 4] =
[((0.0, 0.0), (-2.0, -2.0)), ((100.0, 0.0), (102.0, -2.0)), ((100.0, 100.0), (102.0, 102.0)), ((0.0, 100.0), (-2.0, 102.0))];
/// Whether some point of `pts` is within `tol` of `(x, y)`.
fn reaches(pts: &[Point], (x, y): (f64, f64), tol: f64) -> bool {
pts.iter().any(|p| p.distance(Point::new(x, y)) <= tol)
}
/// Distance from `p` to the outline of the square (0, 0)–(100, 100).
fn from_square_outline(p: Point) -> f64 {
let dx = (p.x - p.x.clamp(0.0, 100.0)).abs();
let dy = (p.y - p.y.clamp(0.0, 100.0)).abs();
if dx > 0.0 || dy > 0.0 { dx.hypot(dy) } else { p.x.min(100.0 - p.x).min(p.y).min(100.0 - p.y) }
}
#[test]
fn pattern_auto_corners_fill_each_corner_of_a_square() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let mut outputs = vec![];
for kind in AUTO {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &sq, &stroke(1.0));
let pts = points_of(&out);
for (corner, outside) in SQUARE_CORNERS {
assert!(reaches(&pts, outside, 0.8), "{kind:?}: the art reaches the band's outer corner at {corner:?}");
}
let far = pts.iter().copied().map(from_square_outline).fold(0.0, f64::max);
assert!(far <= 2.0 * 2f64.sqrt() + 0.05, "{kind:?}: every point stays in the 4 pt band, {far}");
// The band is filled up to each corner along both of its sides.
let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y)));
for ((cx, cy), _) in SQUARE_CORNERS {
let (sx, sy) = ((50.0 - cx).signum(), (50.0 - cy).signum());
for d in [0.5, 2.5, 4.5, 6.5, 8.5] {
assert!(filled(cx + sx * d, cy) && filled(cx, cy + sy * d), "{kind:?}: filled {d} pt from ({cx}, {cy})");
}
}
outputs.push(out);
}
for i in 0..outputs.len() {
for j in i + 1..outputs.len() {
assert_ne!(outputs[i], outputs[j], "{:?} and {:?} draw differently", AUTO[i], AUTO[j]);
}
}
}
/// A side tile three times as tall as it is long still fills a square's corners without a gap:
/// the two sides meet on the corner's diagonal.
#[test]
fn pattern_auto_corners_of_a_tall_tile_meet_on_the_diagonal() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
for kind in AUTO {
let mut b = auto_corner_brush(Some(kind), Some(kind));
if let BrushKind::Pattern(p) = &mut b.kind {
p.side = rect_art(10.0, 30.0);
}
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y)));
for (cx, cy) in [(0.0_f64, 0.0_f64), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)] {
let (sx, sy) = ((100.0 - cx).signum(), (100.0 - cy).signum());
for d in [1.0, 5.0, 10.0, 14.0] {
// Along the diagonal, outside and inside the square, and along both sides.
assert!(filled(cx - sx * d, cy - sy * d), "{kind:?}: outside ({cx}, {cy}) by {d}");
assert!(filled(cx + sx * d, cy + sy * d), "{kind:?}: inside ({cx}, {cy}) by {d}");
assert!(filled(cx + sx * 3.0 * d, cy) && filled(cx, cy + sy * 3.0 * d), "{kind:?}: {} pt from ({cx}, {cy})", 3.0 * d);
}
}
}
}
/// Where a curve meets a sharp corner, the corner tile reaches the side tiles on the curve: the
/// middle of the band is filled all the way along the path.
#[test]
fn pattern_auto_corners_join_a_curve_into_the_corner() {
// A straight run into a quarter circle of radius 10, then a sharp turn.
let mut bp = BezPath::new();
bp.move_to((-50.0, 0.0));
bp.line_to((10.0, 0.0));
bp.curve_to((15.52, 0.0), (20.0, 4.48), (20.0, 10.0));
bp.line_to((-80.0, 10.0));
let track = &crate::track::tracks(&bp, 0.05)[0];
let l = track.len();
for kind in AUTO {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
let mut s = 0.25;
while s < l - 0.25 {
let q = track.map(s, 0.0);
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} of {l:.2} ({q:?})");
s += 0.25;
}
}
}
/// Where a curve meets a sharp corner, the two halves of a corner tile still meet on the corner's
/// diagonal: the band is filled across its width near the corner, and both Auto-Between halves end
/// at the diagonal's two ends.
#[test]
fn pattern_auto_corners_after_a_curve_meet_across_the_band() {
// A quarter circle of radius 4 straight into a sharp turn.
let mut bp = BezPath::new();
bp.move_to((-50.0, 0.0));
bp.line_to((10.0, 0.0));
bp.curve_to((12.21, 0.0), (14.0, 1.79), (14.0, 4.0));
bp.line_to((-80.0, 4.0));
let track = &crate::track::tracks(&bp, 0.05)[0];
let corner = track.corner_positions().first().map(|c| c.1).unwrap();
for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
let mut s = corner - 3.0;
while s < corner + 3.0 {
for j in -19..=19 {
let y = j as f64 / 10.0;
let q = track.map(s, y);
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} ({y} across, {q:?})");
}
s += 0.05;
}
}
// The path leaves the corner at (14, 4) heading left, so the 4 pt band's diagonal ends on the
// lines y = 2 and y = 6, where both Auto-Between halves end at the same point.
let both = auto_corner_brush(Some(CornerSource::AutoBetween), Some(CornerSource::AutoBetween));
let out = stroke_pieces(&Document::new(1.0, 1.0), &both, &bp, &stroke(1.0));
for edge in [2.0, 6.0] {
let on_edge: Vec<Vec<Point>> = out
.iter()
.map(|n| points_of(std::slice::from_ref(n)).into_iter().filter(|q| near(q.y, edge, 1e-6) && near(q.x, 14.0, 3.0)).collect())
.collect();
let shared = on_edge.iter().enumerate().any(|(i, a)| on_edge.iter().skip(i + 1).any(|b| a.iter().any(|p| reaches(b, (p.x, p.y), 1e-6))));
assert!(shared, "both halves end at the same point on y = {edge}");
}
}
/// On a path shorter than the side tile, a corner tile is squeezed into the path it has: it does
/// not run past the ends, nor past half the way to the next corner.
#[test]
fn pattern_auto_corners_stay_within_a_short_path() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((2.0, 0.0));
bp.line_to((2.0, 2.0));
let mut zigzag = BezPath::new();
zigzag.move_to((0.0, 0.0));
for j in 1..=6 {
zigzag.line_to((3.0 * j as f64, if j % 2 == 1 { 3.0 } else { 0.0 }));
}
for kind in AUTO {
for (path, (x0, y0, x1, y1)) in [(&bp, (0.0, 0.0, 2.0, 2.0)), (&zigzag, (0.0, 0.0, 18.0, 3.0))] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), path, &stroke(1.0));
assert!(!out.is_empty(), "{kind:?}: something is drawn");
// The band is 4 pt wide, and the diagonal of a sharp turn reaches further from it.
let reach = 2.0 * 3.0;
for p in points_of(&out) {
assert!(p.x >= x0 - reach && p.x <= x1 + reach && p.y >= y0 - reach && p.y <= y1 + reach, "{kind:?}: {p:?} is past the path's ends");
}
}
}
}
/// A corner squeezed into a path shorter than the band is wide still fills the band up to the
/// corner's diagonal from both sides, with no gap between the two halves.
#[test]
fn pattern_auto_corners_on_a_short_path_meet_on_the_diagonal() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((1.0, 0.0));
bp.line_to((1.0, 1.0));
for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
// The diagonal runs from the corner (1, 0) to the band's outer corner (3, -2).
for f in [0.25, 0.5, 0.75] {
let (dx, dy) = (1.0 + 2.0 * f, -2.0 * f);
for q in [Point::new(dx - 0.05, dy), Point::new(dx, dy - 0.05)] {
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {q:?}");
}
}
}
}
#[test]
fn pattern_without_corner_tiles_or_auto_corners_leaves_corners_empty() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let doc = Document::new(1.0, 1.0);
let plain = stroke_pieces(&doc, &auto_corner_brush(None, None), &sq, &stroke(1.0));
assert_eq!(plain.len(), 40, "ten side tiles a side");
let pts = points_of(&plain);
for (corner, outside) in SQUARE_CORNERS {
assert!(!reaches(&pts, outside, 1.5), "no art at the band's outer corner at {corner:?}");
}
// A square turns the same way at every corner, so only one of the two kinds of corner tile
// draws on it; the other leaves the stroke as it is without automatic corners.
let outer = stroke_pieces(&doc, &auto_corner_brush(Some(CornerSource::AutoBetween), None), &sq, &stroke(1.0));
let inner = stroke_pieces(&doc, &auto_corner_brush(None, Some(CornerSource::AutoBetween)), &sq, &stroke(1.0));
assert!((outer == plain) != (inner == plain), "exactly one kind of corner draws on a square");
}
#[test]
fn pattern_corner_art_wins_over_corner_sources() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let doc = Document::new(1.0, 1.0);
let saved = pattern_brush(PatternFit::Stretch, true);
let mut with_auto = saved.clone();
if let BrushKind::Pattern(p) = &mut with_auto.kind {
p.outer_source = Some(CornerSource::AutoSliced);
p.inner_source = Some(CornerSource::None);
}
assert_eq!(stroke_pieces(&doc, &with_auto, &sq, &stroke(1.0)), stroke_pieces(&doc, &saved, &sq, &stroke(1.0)));
}
/// A kind of corner without art takes the other kind's art, as saved brushes draw, unless its
/// source is None.
#[test]
fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let doc = Document::new(1.0, 1.0);
let draw = |b: &Brush| stroke_pieces(&doc, b, &sq, &stroke(1.0));
let (saved, plain) = (draw(&pattern_brush(PatternFit::Stretch, true)), draw(&auto_corner_brush(None, None)));
let without = |outer: bool, source: Option<CornerSource>| {
let mut b = pattern_brush(PatternFit::Stretch, true);
if let BrushKind::Pattern(p) = &mut b.kind {
if outer {
(p.outer_corner, p.outer_source) = (None, source);
} else {
(p.inner_corner, p.inner_source) = (None, source);
}
}
draw(&b)
};
assert!(without(true, None) == saved && without(false, None) == saved, "the other kind's art fills in");
let (outer, inner) = (without(true, Some(CornerSource::None)), without(false, Some(CornerSource::None)));
assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty");
}
#[test]
fn pattern_without_start_tile_runs_side_to_path_start() {
let path = line(0.0, 0.0, 100.0, 0.0);
let doc = Document::new(1.0, 1.0);
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.start = Some(rect_art(20.0, 4.0));
}
let with_start = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(with_start[0].geometric_bounds().unwrap().width(), 20.0);
if let BrushKind::Pattern(p) = &mut b.kind {
p.start = None;
}
let without_start = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(without_start.len(), 10);
assert!(near(without_start[0].geometric_bounds().unwrap().x0, 0.0, 1e-6));
assert!(near(without_start[0].geometric_bounds().unwrap().width(), 10.0, 1e-6));
}
#[test]
fn pattern_without_end_tile_runs_side_to_path_end() {
let path = line(0.0, 0.0, 100.0, 0.0);
let doc = Document::new(1.0, 1.0);
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.end = Some(rect_art(20.0, 4.0));
}
let with_end = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert!(with_end.iter().any(|n| near(n.geometric_bounds().unwrap().width(), 20.0, 1e-6)));
if let BrushKind::Pattern(p) = &mut b.kind {
p.end = None;
}
let without_end = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(without_end.len(), 10);
assert!(near(without_end.last().unwrap().geometric_bounds().unwrap().x1, 100.0, 1e-6));
}
#[test]
fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() {
let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap();
assert!(saved.get("outer_source").is_none() && saved.get("inner_source").is_none(), "{saved}");
let BrushKind::Pattern(p) = Brush::from_json(saved).unwrap().kind else { panic!("a pattern brush") };
assert_eq!((p.outer_source, p.inner_source), (None, None));
let v = serde_json::to_value(auto_corner_brush(Some(CornerSource::AutoCentered), Some(CornerSource::None))).unwrap();
assert_eq!((v["outer_source"].as_str(), v["inner_source"].as_str()), (Some("autoCentered"), Some("none")));
let BrushKind::Pattern(p) = Brush::from_json(v).unwrap().kind else { panic!("a pattern brush") };
assert_eq!((p.outer_source, p.inner_source), (Some(CornerSource::AutoCentered), Some(CornerSource::None)));
}
/// Auto-Sliced masks each side tile at the corner's diagonal instead of cutting its paths, so a
/// tile's outline gains no stroke along the cut.
#[test]
fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() {
let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced));
if let BrushKind::Pattern(p) = &mut b.kind {
p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 0.5).into();
}
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
assert_eq!(clips.len(), 8, "two masked tiles at each corner");
for g in clips {
let kids = g.children().unwrap();
assert_eq!(kids.len(), 2);
assert!(kids[0].appearance.items.is_empty(), "the mask paints nothing");
let mask = kids[0].path_data().unwrap().to_bezpath();
let tile = kids[1].path_data().unwrap();
assert!(
tile.anchors().any(|(_, _, a)| kurbo::Shape::winding(&mask, a.p) == 0),
"the tile keeps its outline past the diagonal, where the mask hides it"
);
}
}
/// Auto-Sliced on a corner whose legs are too short for the side tile squeezes the tile along,
/// but not its stroke: the mask still takes in the stroke past the tile's far end.
#[test]
fn pattern_auto_sliced_masks_keep_the_stroke_of_a_squeezed_tile() {
let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced));
if let BrushKind::Pattern(p) = &mut b.kind {
p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0).into();
}
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
assert_eq!(clips.len(), 8, "two masked tiles at each corner");
let center = Point::new(4.0, 4.0);
for g in clips {
let kids = g.children().unwrap();
let mask = kids[0].path_data().unwrap().to_bezpath();
let half = kids[1].appearance.stroke_width() / 2.0;
assert!(half > 0.0);
let tile = kids[1].path_data().unwrap();
let anchors: Vec<Point> = tile.anchors().map(|(_, _, a)| a.p).collect();
let mid = anchors.iter().fold(Point::ZERO, |m, a| m + a.to_vec2() / anchors.len() as f64);
// The tile's corner, and its diagonal through the square's center; each mask keeps one
// side of the diagonal.
let p = [(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (0.0, 8.0)]
.map(|(x, y)| Point::new(x, y))
.into_iter()
.min_by(|a, b| a.distance(mid).total_cmp(&b.distance(mid)))
.unwrap();
let cross = |q: Point| (q - p).cross(center - p);
let side = cross(mid).signum();
for a in &anchors {
// The points of the stroke round the anchor, along and across the tile's leg.
for d in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] {
let q = *a + kurbo::Vec2::new(d.0, d.1) * half * 0.9;
if cross(q) * side > 0.01 && (q - p).hypot() < 7.0 {
assert!(kurbo::Shape::winding(&mask, q) != 0, "the mask takes in the stroke at {q:?} (half width {half})");
}
}
}
}
}
#[test]
@@ -741,7 +1134,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
@@ -800,19 +1193,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
let a1 = anchors(&one);
assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}");
// Sixteen tracks would be sixteen times that without a total budget.
// Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the
// budget must not bite before the per-factor caps have actually multiplied past it.
let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));
assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");
assert_eq!(anchors(&four), 4 * a1);
// Two tracks are well inside the budget, so nothing is cut: it must not bite before the
// per-factor caps have actually multiplied past it.
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0));
assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece");
assert_eq!(anchors(&two), 2 * a1);
// Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));
// Enough tracks that the per-run caps alone would pass the budget. Derived from what one
// track costs rather than written down, because how many anchors a tile holds is not fixed:
// a tile on a straight run keeps its own anchors instead of being flattened (#3926), which
// moved this by a factor of four.
let tracks = 2_000_000 / a1 + 2;
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &stroke(1.0));
let an = anchors(&many);
assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");
assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");
assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}");
assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}");
assert!(many.len() < tracks * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
}
/// The same for scatter, whose per-track guard is also 20,000 copies.
@@ -843,3 +1239,109 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() {
assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
assert!(anchors(&out) < 2_000_000);
}
/// Anchors under `n`, and how many of them keep a handle (so the segment is a curve).
fn anchors_and_curved(n: &Node) -> (usize, usize) {
let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) };
let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect();
(all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count())
}
/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the
/// tile's curves survive. They used to be flattened into chords by the bending code, which is
/// visible in the stroke itself and leaves no handles after Expand Brush Strokes.
#[test]
fn a_pattern_tile_keeps_its_curves_on_a_straight_path() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let (tile_anchors, tile_curved) = anchors_and_curved(&circle);
assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle");
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
assert!(!out.is_empty(), "the brush drew something");
for n in &out {
assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon");
}
// The ink is in the same place: a circle tile still covers the path it was laid along.
let b = bounds(&out);
assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width());
assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height());
}
/// The other half of the same behaviour: where the path really does bend, an affine cannot
/// reproduce the bend, so the tile is bent, and it still comes out as curves: cubics fitted to the
/// bent art rather than the chords it used to be flattened into (#3926). Here a 4-anchor circle
/// tile runs round a circle of radius 200, so its outline stays in the band 20 either side of it.
#[test]
fn a_pattern_tile_on_a_bent_path_keeps_its_curves() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
// A circle to run along: every tile sits on a curving section.
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0));
assert!(out.len() > 20, "tiles all the way round: {}", out.len());
let (mut lo, mut hi) = (f64::MAX, f64::MIN);
for n in &out {
let (anchors, curved) = anchors_and_curved(n);
assert!((4..=16).contains(&anchors), "a bent circle tile is a few curves, not a polygon: {anchors} anchors");
assert!(curved == anchors, "every anchor keeps a handle: {curved} of {anchors}");
let mut pts = vec![];
all_points(n, &mut pts);
for q in pts {
let r = q.distance(Point::new(200.0, 200.0));
(lo, hi) = (lo.min(r), hi.max(r));
}
}
// The path is a cubic circle (0.06 out at most) run along in chords of 0.05, and the fit holds
// a quarter of the 0.25 flattening tolerance, so the band holds to 0.2.
assert!(lo > 179.8 && hi < 220.2, "the curves stay in the tile's band round the path: {lo:.3}..{hi:.3}");
assert!(lo < 180.2 && hi > 219.8, "and reach both edges of it: {lo:.3}..{hi:.3}");
}
/// The kind of every node in a piece of brushed art, in paint order.
fn kinds(n: &Node) -> Vec<&'static str> {
let mut out = vec![match &n.kind {
NodeKind::Path { .. } => "path",
NodeKind::Group { .. } => "group",
NodeKind::SymbolInstance { .. } => "symbol",
_ => "other",
}];
for c in n.children().into_iter().flatten() {
out.extend(kinds(c));
}
out
}
/// The straight-run route differs from the bending route in the tile's curves and in nothing else,
/// which includes the node kinds it keeps. A tile holding a kind neither route can map (here a
/// symbol instance, whose art lives in the symbol and not in the node) loses it on a straight run
/// exactly as it does on a bend: an affine is not a way for unmappable art to arrive untouched.
#[test]
fn a_straight_run_keeps_the_same_node_kinds_as_a_bend() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let sym = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
let tile = Node::group(NodeId(2), vec![Arc::new(circle), Arc::new(sym)]);
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: tile, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
let doc = Document::new(1.0, 1.0);
let straight = stroke_pieces(&doc, &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let bent = stroke_pieces(&doc, &brush, &round, &stroke(1.0));
assert!(!straight.is_empty() && !bent.is_empty(), "both paths drew something");
for (what, out) in [("a straight run", &straight), ("a bend", &bent)] {
for n in out {
assert_eq!(kinds(n), ["group", "path"], "{what} keeps the path and drops the symbol instance");
}
}
}
+100
View File
@@ -29,6 +29,12 @@ fn unit(v: Vec2) -> Vec2 {
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l }
}
/// Whether two unit vectors point the same way, to within the slack a unit vector carries after
/// a few multiplications (a bend of even a thousandth of a degree fails this).
fn aligned(a: Vec2, b: Vec2) -> bool {
a.dot(b) > 1.0 - 1e-12
}
/// Left-hand normal in y-down document space (for a path running +x it points +y).
pub fn normal(t: Vec2) -> Vec2 {
Vec2::new(-t.y, t.x)
@@ -154,6 +160,59 @@ impl Track {
let (p, t) = self.frame(s);
p + normal(t) * d
}
/// Whether [`Self::frame`] holds one constant tangent over arc lengths `s0..s1`, so mapping
/// through it is an affine of the art and nothing more.
///
/// Read off the track, not sampled. A bend in the span is two segments with different
/// directions, however short the bent part is, so this cannot miss one; a sampled check can,
/// because a wiggle narrower than the sample spacing passes between the samples (#3926).
///
/// Fails closed: a span that needs reasoning this does not do (a closed track's seam, a
/// non-finite bound, a track with no segments) is reported as not straight, and the caller
/// bends it the long way.
pub fn is_straight(&self, s0: f64, s1: f64) -> bool {
// Checked before the min/max, which quietly drop a NaN rather than propagate it.
let n = self.segments();
if !(s0.is_finite() && s1.is_finite()) || n == 0 {
return false;
}
let (lo, hi) = (s0.min(s1), s0.max(s1));
let l = self.len();
// A closed track wraps, so a span reaching past either end covers segments twice.
if self.closed && (lo < -1e-9 || hi > l + 1e-9) {
return false;
}
// Past an open track's ends `frame` extrapolates along the end segment's direction, so
// those segments are part of the span too.
let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)];
let touched = (0..n).filter(|&i| {
let (a, b) = (self.cum.get(i), self.cum.get(i + 1));
matches!((a, b), (Some(a), Some(b)) if *a < hi + 1e-9 && *b > lo - 1e-9)
});
let mut dir: Option<Vec2> = None;
let mut any = false;
for i in touched.chain(ends.into_iter().flatten()) {
let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) =
(self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1))
else {
return false;
};
if dir.is_some_and(|first: Vec2| !aligned(first, d)) {
return false;
}
dir = Some(d);
any = true;
// `frame` blends the two vertex tangents across the segment unless the vertex is a
// corner, where it uses the segment's own direction. So a neighbour outside the span
// can still bend the tangent inside it, and checking the directions alone would miss
// that: segments d, d, e leave vertex 2's tangent at unit(d + e).
if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) {
return false;
}
}
any
}
}
/// The most flattened elements of a path kept.
@@ -253,6 +312,47 @@ pub fn seed_of(bp: &BezPath, salt: &str) -> u64 {
mod tests {
use super::*;
/// A straight run is straight; a bend in the span is not, however short it is. This is the
/// property a sampled check cannot offer, so it is worth pinning on its own (#3926 review).
#[test]
fn straightness_is_read_off_the_track_not_sampled() {
let line = |pts: &[(f64, f64)], closed: bool| {
let mut bp = BezPath::new();
bp.move_to(pts[0]);
for p in &pts[1..] {
bp.line_to(*p);
}
if closed {
bp.close_path();
}
tracks(&bp, 0.1).remove(0)
};
let straight = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false);
assert!(straight.is_straight(0.0, 200.0), "two collinear segments are one straight run");
// Open tracks extrapolate along the end directions, which is still the same line.
assert!(straight.is_straight(-50.0, 250.0), "and so is the extrapolation past both ends");
// A detour of a fifth of a point, 0.1% of the run: far below any sampling grid this code
// would choose, and still a bend.
let wiggle = line(&[(0.0, 0.0), (99.9, 0.0), (100.0, 0.2), (100.1, 0.0), (200.0, 0.0)], false);
assert!(!wiggle.is_straight(0.0, 200.0), "a 0.2 pt detour is a bend");
assert!(!wiggle.is_straight(99.0, 101.0), "and is caught when the span is only around it");
assert!(wiggle.is_straight(0.0, 50.0), "while a span before it is straight");
// `frame` blends vertex tangents, so the segment before a bend is not straight all the way
// to the vertex: the tangent there is already turning. Pinning this because checking the
// segment directions alone would call that span straight and be wrong.
let kink = line(&[(0.0, 0.0), (100.0, 0.0), (160.0, 60.0)], false);
assert!(!kink.is_straight(0.0, 100.0), "the last segment's tangent is already turning at its far vertex");
assert!(kink.is_straight(0.0, 50.0), "a span clear of the turning vertex is straight");
// Fails closed where it does not reason: a closed track's seam, and nonsense bounds.
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
assert!(!ring.is_straight(-10.0, 10.0), "a span across a closed track's seam is refused");
assert!(ring.is_straight(10.0, 90.0), "a span inside one of its sides is straight");
assert!(!straight.is_straight(f64::NAN, 10.0), "a non-finite bound is refused");
}
#[test]
fn straight_track_frames() {
let mut bp = BezPath::new();
+759 -80
View File
@@ -1,16 +1,18 @@
//! Art, scatter and pattern brushes: art bent along the path by arc length.
//!
//! Art coordinates `(x, y)` map to the path point at arc length `s(x)` offset by
//! `(y - centre) × cross` along the path's left normal. Art outlines are flattened and
//! subdivided first so long straight edges bend with the path instead of cutting chords.
//! `(y - centre) × cross` along the path's left normal. Art outlines come out as cubics fitted
//! to the bent art, so curves stay curves and long straight edges bend with the path (#3926);
//! where that would take more anchors than chords, they are flattened and subdivided instead.
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
use kurbo::ParamCurveNearest;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
use crate::track::{Rng, Track, seed_of, tracks};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@@ -31,23 +33,30 @@ const MAX_SUB: usize = 4000;
const MAX_ANCHORS: usize = 2_000_000;
/// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget.
const MAX_WALK: usize = 10_000;
/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with.
const MASK_STEPS: usize = 32;
/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and
/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by
/// `width_scale`. Text, images and symbol instances can't bend and are dropped.
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, width_scale: f64) -> Option<Node> {
/// Bend every path under `n` through `f`. Each art segment comes out as cubics that follow it
/// through the bend to within [`fit_tol`] of `doc_tol`, the flattening tolerance in document
/// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than
/// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord
/// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`.
/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask
/// bends with the object it masks.
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option<Node> {
let mut out = n.clone();
out.appearance.scale_strokes(width_scale);
match &mut out.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
*path = warp_path(path, f, flat_tol, max_du);
*path = warp_path(path, f, flat_tol, max_du, fit_tol(doc_tol));
if path.is_empty() {
return None;
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect();
let kids: Vec<Arc<Node>> =
children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, doc_tol, width_scale)).map(Arc::new).collect();
if kids.is_empty() {
return None;
}
@@ -55,57 +64,171 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max
}
_ => return None,
}
map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale));
Some(out)
}
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64) -> PathData {
let bp = p.to_bezpath();
/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where
/// `map` draws nothing, the mask's art becomes an empty group.
fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option<Node>) {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
let id = m.art.id;
m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![])));
}
}
/// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords
/// would, else the chords. Points `f` cannot place (not finite) are left out of the chords.
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData {
let mut subs = vec![];
let mut cur: Vec<Point> = vec![];
let mut els = vec![];
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e));
let finish = |cur: &mut Vec<Point>, closed: bool, subs: &mut Vec<SubPath>| {
let pts = std::mem::take(cur);
if pts.len() < 2 {
return;
for sp in &p.subpaths {
let Some(dense) = densified(sp, flat_tol, max_du) else { continue };
if let Some(fit) = fitted(sp, f, fit_tol, dense.len()) {
subs.push(fit);
continue;
}
let mut dense = vec![pts[0]];
let n = pts.len();
let segs = if closed { n } else { n - 1 };
for i in 0..segs {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
for j in 1..=k {
let q = a.lerp(b, j as f64 / k as f64);
if closed && i == segs - 1 && j == k {
break;
}
dense.push(q);
}
}
let mapped: Vec<Point> = dense.into_iter().map(f).collect();
subs.push(SubPath::polyline(&mapped, closed));
};
for e in els {
match e {
PathEl::MoveTo(q) => {
finish(&mut cur, false, &mut subs);
cur.push(q);
}
PathEl::LineTo(q) => cur.push(q),
PathEl::ClosePath => {
if cur.len() > 1 && cur[0].distance(cur[cur.len() - 1]) < 1e-9 {
cur.pop();
}
finish(&mut cur, true, &mut subs);
}
_ => {}
let mapped: Vec<Point> = dense.into_iter().map(f).filter(|q| q.is_finite()).collect();
if mapped.len() >= 2 {
subs.push(SubPath::polyline(&mapped, sp.closed));
}
}
finish(&mut cur, false, &mut subs);
PathData::new(subs)
}
/// `sp` flattened with `flat_tol` and its chords subdivided so none is longer than `max_du` along
/// x, in art space: the points the chord route maps. `None` below two points.
fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option<Vec<Point>> {
let mut bp = BezPath::new();
sp.to_bezpath_into(&mut bp);
let mut pts = vec![];
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| {
if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e {
pts.push(q);
}
});
if sp.closed && pts.len() > 1 && pts.first()?.distance(*pts.last()?) < 1e-9 {
pts.pop();
}
if pts.len() < 2 {
return None;
}
let segs = if sp.closed { pts.len() } else { pts.len() - 1 };
let mut dense = vec![*pts.first()?];
for (i, (&a, &b)) in pts.iter().zip(pts.iter().cycle().skip(1)).take(segs).enumerate() {
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
for j in 1..=k {
if sp.closed && i == segs - 1 && j == k {
break;
}
dense.push(a.lerp(b, j as f64 / k as f64));
}
}
Some(dense)
}
/// Times an art segment is halved at most while looking for cubics that fit it.
const FIT_DEPTH: u32 = 12;
/// Parameter step of the finite differences that stand in for the bend's derivative.
const FIT_DT: f64 = 1e-6;
/// Sections a fitted piece is checked at the ends of (the ends themselves are exact).
const FIT_CHECKS: usize = 8;
/// Share of the flattening tolerance the fitted curves hold to. The chords were exact at their
/// points and a few hundredths off between them, so a straight edge bends about as closely.
const FIT_SHARE: f64 = 0.25;
/// Tolerance paths are flattened to before art is laid along them.
const TRACK_TOL: f64 = 0.05;
/// The distance (document space) fitted curves keep to the bent art, for flattening tolerance
/// `doc_tol`: never below [`TRACK_TOL`], since following the flattened path more closely than it
/// follows the real one only traces its chords.
fn fit_tol(doc_tol: f64) -> f64 {
(doc_tol * FIT_SHARE).max(TRACK_TOL)
}
/// `sp` through `f` as cubics within `tol` (document space), one or more per art segment so its
/// anchors, corners included, stay anchors. `None` when that takes more than `most` anchors, when
/// a piece still does not fit after [`FIT_DEPTH`] halvings, or when `f` cannot place a point.
///
/// Each piece is the cubic with the bent piece's ends and their derivatives, which is the piece
/// itself where the bend is affine and converges fast where it is not. It is checked against the
/// bend at the same parameters, so the distance measured is never less than the true one. The
/// halving is our own rather than kurbo's `fit_to_bezpath`, which halves until the floats run out
/// on a map that never fits; the depth and the anchor count bound this one.
fn fitted(sp: &SubPath, f: &dyn Fn(Point) -> Point, tol: f64, most: usize) -> Option<SubPath> {
if !(tol.is_finite() && tol > 0.0) {
return None;
}
let start = f(sp.anchors.first()?.p);
if !start.is_finite() {
return None;
}
let mut bp = BezPath::new();
bp.move_to(start);
// Pieces allowed: an open subpath's first anchor is one more, a closed one's last piece ends on it.
let mut room = if sp.closed { most } else { most.checked_sub(1)? };
for i in 0..sp.segment_count() {
let c = sp.segment(i);
let line = sp.segment_is_line(i);
// A line's handles sit on its ends, which stalls its parameter there; thirds keep it even.
let c = if line { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c };
let at = |t: f64| Some(f(c.eval(t))).filter(|q| q.is_finite());
fit_into(&at, (0.0, 1.0), line, tol, FIT_DEPTH, &mut bp, &mut room)?;
}
if sp.closed {
bp.close_path();
}
// The last piece ends on the first anchor's image, so a closed subpath merges it back in; the
// count is checked again for a map that jumps right there and keeps the two apart.
PathData::from_bezpath(&bp).subpaths.into_iter().next().filter(|s| s.anchors.len() <= most)
}
/// Pieces for parameters `t0..t1` of the bent segment `at` onto `bp`, which ends at `at(t0)`:
/// halved until each is within `tol`, at most `depth` times and `room` pieces. A `line` that is
/// still straight within `tol` stays a line.
fn fit_into(
at: &dyn Fn(f64) -> Option<Point>,
(t0, t1): (f64, f64),
line: bool,
tol: f64,
depth: u32,
bp: &mut BezPath,
room: &mut usize,
) -> Option<()> {
let span = t1 - t0;
let (p0, p3) = (at(t0)?, at(t1)?);
// Derivatives read from inside the piece, so a kink at either end (a corner, a turn of the
// track) stays one.
let d0 = (at(t0 + FIT_DT)? - p0) * (span / FIT_DT);
let d1 = (p3 - at(t1 - FIT_DT)?) * (span / FIT_DT);
let c = CubicBez::new(p0, p0 + d0 / 3.0, p3 - d1 / 3.0, p3);
let chord = kurbo::Line::new(p0, p3);
let (mut curve_fits, mut line_fits) = (c.is_finite(), line);
for k in 1..FIT_CHECKS {
if !(curve_fits || line_fits) {
break;
}
let u = k as f64 / FIT_CHECKS as f64;
let q = at(t0 + span * u)?;
curve_fits &= q.distance(c.eval(u)) <= tol;
line_fits &= chord.nearest(q, 1e-9).distance_sq <= tol * tol;
}
if line_fits || curve_fits {
*room = room.checked_sub(1)?;
if line_fits {
bp.line_to(p3);
} else {
bp.curve_to(c.p1, c.p2, p3);
}
return Some(());
}
let depth = depth.checked_sub(1)?;
let mid = t0 + span / 2.0;
fit_into(at, (t0, mid), line, tol, depth, bp, room)?;
fit_into(at, (mid, t1), line, tol, depth, bp, room)
}
/// Art turned so it runs left → right, flipped as requested; returns it with its bounds.
fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
let mut a = art.clone();
@@ -124,6 +247,16 @@ fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) ->
(b.width() > 1e-9).then_some((a, b))
}
/// Orient a tile's repeat frame with its art. The frame can be smaller than the art.
fn oriented_tile(art: &Node, frame: Option<Rect>, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
let (node, bounds) = oriented(art, Direction::LeftToRight, flip_along, flip_across)?;
let Some(frame) = frame else { return Some((node, bounds)) };
let x = if flip_along { (-frame.x1, -frame.x0) } else { (frame.x0, frame.x1) };
let y = if flip_across { (-frame.y1, -frame.y0) } else { (frame.y0, frame.y1) };
let frame = Rect::new(x.0, y.0, x.1, y.1);
(frame.width() > 1e-9 && frame.height() > 1e-9).then_some((node, frame))
}
/// Piecewise-linear map from art x (offset from the art's left edge) to arc length.
#[derive(Clone, Copy, Debug)]
pub(crate) struct AlongMap {
@@ -153,12 +286,113 @@ impl AlongMap {
let w = self.xs[i + 1] - self.xs[i];
if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w }
}
/// Whether `at` is one linear function across the art, so it adds no bend of its own.
///
/// [`AlongMap::linear`] always is: its outer two pieces have no width and the middle one
/// spans everything. Between Guides is not, once its three pieces really do differ, which is
/// the whole point of it.
fn is_linear(&self) -> bool {
let mut slope: Option<f64> = None;
for i in 0..3 {
// A piece with no width constrains nothing: no art x lands inside it.
if self.xs[i + 1] - self.xs[i] <= 1e-12 {
continue;
}
let s = self.slope(i);
if slope.is_some_and(|first: f64| (first - s).abs() > 1e-9 * first.abs().max(1.0)) {
return false;
}
slope = Some(s);
}
slope.is_some()
}
/// The arc lengths the art spans, low to high.
fn span(&self) -> (f64, f64) {
(self.ss[0].min(self.ss[3]), self.ss[0].max(self.ss[3]))
}
/// Largest along-scale (arc length per art unit) of the pieces.
fn max_scale(&self) -> f64 {
(0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max)
}
}
/// The affine that reproduces `f` over `b` to within `tol`, if one does.
///
/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the
/// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it
/// into chords throws its curves away for nothing (#3926).
///
/// Derived from three corners and then checked against a grid. The check is a second opinion, not
/// the gate: `bend` only calls this once the track and the along map have been read and found
/// straight, because sampling can reject a bend and cannot promise there is none (#3926 review).
fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
let (w, h) = (b.width(), b.height());
if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) {
return None;
}
let origin = Point::new(b.x0, b.y0);
let q0 = f(origin);
// Images of the unit x and y directions.
let u = (f(Point::new(b.x1, b.y0)) - q0) / w;
let v = (f(Point::new(b.x0, b.y1)) - q0) / h;
if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) {
return None;
}
let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2());
// Five by five including the corners the fit came from, so a map that merely agrees at those
// three is rejected.
for i in 0..=4 {
for j in 0..=4 {
let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0);
let (want, got) = (f(p), a * p);
if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol {
return None;
}
}
}
Some(a)
}
/// [`warp_node`] for a map that is affine: the art is transformed rather than flattened, so its
/// curves survive.
///
/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between
/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every
/// path, an empty path or a group left with no children is dropped, and strokes are scaled by
/// `width_scale` at every level rather than by the transform's own factor, the art of linked
/// opacity masks included.
fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> {
let mut out = prepared(n, width_scale)?;
out.transform(a, false);
Some(out)
}
/// The part of [`affine_node`] that mirrors [`warp_node`]'s walk, before the single transform.
fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
let mut out = n.clone();
out.appearance.scale_strokes(width_scale);
match &mut out.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
if path.is_empty() {
return None;
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| prepared(c, width_scale)).map(Arc::new).collect();
if kids.is_empty() {
return None;
}
*children = kids;
}
_ => return None,
}
map_mask(&mut out, |art| prepared(art, width_scale));
Some(out)
}
/// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`.
fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<Node> {
let cy = b.center().y;
@@ -166,7 +400,21 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
let f = move |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
// A straight run needs no bending: move the art and keep its curves.
//
// Straightness is read off the track and the along map, not sampled. A bend anywhere in the
// span is two segment directions that differ, so this cannot miss one however short the bent
// part is; the five by five grid below could, since a wiggle narrower than its spacing passes
// between the samples (#3926 review). The grid now only checks the affine that structure has
// already said exists, so it can reject and never admit.
let (s_lo, s_hi) = along.span();
if along.is_linear()
&& t.is_straight(s_lo, s_hi)
&& let Some(a) = affine_fit(&f, b, flat_tol.max(1e-9))
{
return affine_node(art, a, cross.abs());
}
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
}
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
@@ -182,7 +430,7 @@ fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, wid
let art = widen_strokes(art, &factor);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
}
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
@@ -193,6 +441,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node {
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind {
*children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect();
}
map_mask(&mut out, |art| Some(widen_strokes(art, factor)));
out
}
@@ -262,7 +511,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
let width = light.max(heavy);
let cross = weight * width / 100.0;
let mut out = vec![];
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
let l = t.len();
if l <= 1e-9 {
continue;
@@ -304,7 +553,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
if out.full() {
break;
}
@@ -367,6 +616,9 @@ fn anchors_of(n: &Node) -> usize {
if let Some(c) = n.children() {
stack.extend(c.iter().map(Arc::as_ref));
}
if let Some(m) = &n.extras().mask {
stack.push(&m.art);
}
}
total
}
@@ -403,18 +655,175 @@ fn tile_on(t: &Track, tile: &(Node, Rect), s0: f64, s1: f64, cross: f64) -> Opti
bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross)
}
/// What a pattern brush draws at a corner: corner tile art (oriented, with its bounds), or what
/// [`CornerSource`] gives.
#[derive(Clone, Copy)]
enum Corner<'a> {
Art(&'a (Node, Rect)),
Source(CornerSource),
}
/// The pieces of the corner tile that `source` makes at corner `i` of `t` from `side` (oriented,
/// with its bounds) at scale `k`, and the length of path on each side of the corner they cover,
/// which is at most `room`.
///
/// At the corner, the pieces run straight along the directions the path arrives at and leaves
/// the corner in, and towards their far ends they move onto the path itself, so they meet the side
/// tiles where the path curves. The corner's diagonal is the line through the corner that halves
/// the turn, where the two offset edges of the band meet. [`CornerSource::None`] and a turn of
/// nearly 180° get no pieces.
fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k: f64, room: f64) -> (Vec<Node>, f64) {
let (art, b) = side;
let (a_dir, b_dir) = t.in_out(i);
let (Some(&p), Some(&sc)) = (t.verts.get(i), t.cum.get(i)) else { return (vec![], 0.0) };
let bt = a_dir + b_dir;
if source == CornerSource::None || a_dir.dot(bt) < 1e-3 {
return (vec![], 0.0);
}
let (tw, r) = (b.width() * k, b.height() * k / 2.0);
let (x0, cy) = (b.x0, b.center().y);
// Art point → (distance along from the left edge, offset across), in points.
let local = move |q: Point| ((q.x - x0) * k, (q.y - cy) * k);
// How far along `d` (from the corner) the diagonal is at offset `y` across `d`'s left normal.
let diag = move |d: Vec2, y: f64| -y * normal(d).dot(bt) / d.dot(bt);
// `u` along `d` from the corner and `y` across, moved from the straight line onto the path as
// `u` goes from `from` to `far` (signed: negative before the corner; `from` is the corner or
// a point between it and `far`).
let on_path = move |d: Vec2, u: f64, y: f64, from: f64, far: f64| {
let straight = p + d * u + normal(d) * y;
let span = far - from;
let w = if span.abs() > 1e-9 { ((u - from) / span).clamp(0.0, 1.0) } else { 0.0 };
if w > 0.0 { straight.lerp(t.map(sc + u, y), w) } else { straight }
};
// The most the diagonal is ahead of the corner, along either direction, within the band. Each
// source stretches the side tile along to `len` where the band is too tall for the tile to
// reach the diagonal, so the two sides always meet on it.
let ahead = r * normal(a_dir).dot(bt).abs() / a_dir.dot(bt);
// Before the corner, `u` in [-len, 0] maps onto [-len, diagonal]; after it, [0, len] onto
// [diagonal, len]. Both ends stay on the diagonal when `len` is shorter than `ahead`, and the
// points on the diagonal stay on the straight lines, where the two sides meet.
let into = move |u: f64, y: f64, len: f64| {
let reach = len.max(ahead);
let e = diag(a_dir, y).clamp(-reach, reach);
on_path(a_dir, -len + (u + len) * (e + len) / len.max(1e-9), y, e.min(0.0), -len)
};
let out_of = move |u: f64, y: f64, len: f64| {
let reach = len.max(ahead);
let e = diag(b_dir, y).clamp(-reach, reach);
on_path(b_dir, e + u * (len - e) / len.max(1e-9), y, e.max(0.0), len)
};
let flat_tol = tolerance(b.height().max(b.width()) * k) / k;
// The tile is squeezed along so that it covers at most `room` of the path on each side.
let room = room.max(0.0);
let len = tw.max(2.0 * ahead).min(match source {
CornerSource::AutoCentered => 2.0 * room,
CornerSource::AutoBetween => room,
_ => room + ahead,
});
if len <= 1e-9 {
return (vec![], 0.0);
}
let s = if tw > 1e-9 { len / tw } else { 1.0 };
let max_du = MAX_SEG / (2.0 * k * s.max(1.0));
let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, flat_tol * k, k);
// Art point → (distance along from the left edge, stretched to `len`, and offset across).
let along = move |q: Point| {
let (x, y) = local(q);
(x * s, y)
};
match source {
CornerSource::None => (vec![], 0.0),
CornerSource::AutoCentered => {
let half = len / 2.0;
let f = move |q: Point| {
let (x, y) = along(q);
let u = x - half;
if u < 0.0 { into(u, y, half) } else { out_of(u, y, half) }
};
(warp(&f).into_iter().collect(), half)
}
CornerSource::AutoBetween => {
let before = move |q: Point| {
let (x, y) = along(q);
into(x - len, y, len)
};
let after = move |q: Point| {
let (x, y) = along(q);
out_of(x, y, len)
};
(warp(&before).into_iter().chain(warp(&after)).collect(), len)
}
CornerSource::AutoSliced | CornerSource::AutoOverlap => {
// Each side's tile runs `ahead` past the corner.
let far = len - ahead;
let before = move |q: Point| {
let (x, y) = along(q);
on_path(a_dir, x - far, y, 0.0, -far)
};
let after = move |q: Point| {
let (x, y) = along(q);
on_path(b_dir, x - ahead, y, 0.0, far)
};
let (first, second) = (warp(&before), warp(&after));
if source == CornerSource::AutoOverlap {
return (first.into_iter().chain(second).collect(), far);
}
// Auto-Sliced masks each tile by the part of the outline of the box round its art
// (strokes included), placed as the tile is, on its own side of the diagonal. A tile
// squeezed along keeps its stroke width, so the box's margins at its ends grow by as
// much as the tile is squeezed.
let vb = art.visual_bounds().unwrap_or(*b);
let grow = 1.0 / s.min(1.0);
let (x0, x1) = (b.x0 - (b.x0 - vb.x0).max(0.0) * grow, b.x1 + (vb.x1 - b.x1).max(0.0) * grow);
let corners = [(x0, vb.y0), (x1, vb.y0), (x1, vb.y1), (x0, vb.y1), (x0, vb.y0)].map(|(x, y)| Point::new(x, y));
let outline = |f: &dyn Fn(Point) -> Point| -> Vec<Point> {
let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64);
corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect()
};
let pieces =
first.and_then(|n| masked(n, &outline(&before), p, bt)).into_iter().chain(second.and_then(|n| masked(n, &outline(&after), p, -bt)));
(pieces.collect(), far)
}
}
}
/// `n` in a clipping group whose mask is the part of polygon `quad` on the side of the line through
/// `p` that `towards` points away from. `None` when nothing of `quad` is on that side.
fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option<Node> {
let side = |q: Point| (q - p).dot(towards);
let mut kept = vec![];
for (j, &a) in quad.iter().enumerate() {
let b = quad.get(j + 1).or(quad.first()).copied().unwrap_or(a);
let (da, db) = (side(a), side(b));
if da <= 0.0 {
kept.push(a);
}
if (da <= 0.0) != (db <= 0.0) {
kept.push(a.lerp(b, da / (da - db)));
}
}
if kept.len() < 3 {
return None;
}
let mask = Node::path(NodeId(0), PathData::new(vec![SubPath::polyline(&kept, true)]), Appearance::default());
Some(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(n)], clip: true }))
}
pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across);
let Some(side) = orient(&pb.side) else { return vec![] };
let orient = |n: &Node, frame| oriented_tile(n, frame, pb.flip_along, pb.flip_across);
let Some(side) = orient(&pb.side, pb.side_frame) else { return vec![] };
let k = weight * pb.scale.max(1.0) / 100.0;
let outer = pb.outer_corner.as_ref().and_then(orient);
let inner = pb.inner_corner.as_ref().and_then(orient);
let start = pb.start.as_ref().and_then(orient);
let end = pb.end.as_ref().and_then(orient);
let outer = pb.outer_corner.as_ref().and_then(|n| orient(n, pb.outer_corner_frame));
let inner = pb.inner_corner.as_ref().and_then(|n| orient(n, pb.inner_corner_frame));
let start = pb.start.as_ref().and_then(|n| orient(n, pb.start_frame));
let end = pb.end.as_ref().and_then(|n| orient(n, pb.end_frame));
let tw = side.1.width() * k;
let gap = tw * pb.spacing.max(0.0) / 100.0;
// Each kind of corner takes its own art, else its source, else the other kind's art.
let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source));
let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source));
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
if out.full() {
break;
}
@@ -423,13 +832,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
continue;
}
let corners = t.corner_positions();
// Corner tiles, centred on the corner and turned to the bisector.
// Corner tiles, centred on the corner and turned to the bisector. The path on each side of
// a corner is shortened by `trims` (`None`: half the widest corner tile art).
let mut corner_half = 0.0_f64;
for &(i, _) in &corners {
let mut trims: Vec<Option<f64>> = Vec::with_capacity(corners.len());
// The path a tile made at corner `ci` may cover on each side: up to the ends of an open
// track, and half the way to the next corner.
let room = |ci: usize| {
let at = |j: usize| corners.get(j).map(|c| c.1);
let s = at(ci).unwrap_or(0.0);
let last = corners.len().saturating_sub(1);
let before = match (ci.checked_sub(1).and_then(at), t.closed) {
(Some(prev), _) => (s - prev) / 2.0,
(None, true) => (s - (at(last).unwrap_or(s) - l)) / 2.0,
(None, false) => s,
};
let after = match (at(ci + 1), t.closed) {
(Some(next), _) => (next - s) / 2.0,
(None, true) => (at(0).unwrap_or(s) + l - s) / 2.0,
(None, false) => l - s,
};
before.min(after)
};
for (ci, &(i, _)) in corners.iter().enumerate() {
if out.full() {
break;
}
let (a, b) = t.in_out(i);
// Turning towards the left normal puts the tile's +y side on the inside.
let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) };
let Some((art, bb)) = tile else { continue };
let tile = if a.cross(b) > 0.0 { inner_tile.or(outer.as_ref().map(Corner::Art)) } else { outer_tile.or(inner.as_ref().map(Corner::Art)) };
let (art, bb) = match tile {
Some(Corner::Art(tile)) => tile,
Some(Corner::Source(source)) => {
let (pieces, trim) = auto_corner(&t, i, source, &side, k, room(ci));
for n in pieces {
out.take(Some(n));
}
trims.push(Some(trim));
continue;
}
None => {
trims.push(Some(0.0));
continue;
}
};
trims.push(None);
corner_half = corner_half.max(bb.width() * k / 2.0);
let bis = a + b;
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
@@ -441,30 +888,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
n.transform(xf, true);
out.take(Some(n));
}
// Runs between corners (closed tracks wrap past the end).
let trim = |c: Option<usize>| c.and_then(|c| trims.get(c).copied()).map_or(0.0, |t| t.unwrap_or(corner_half));
// Runs between corners (closed tracks wrap past the end), with the corners they start and
// end at.
let mut bounds: Vec<f64> = corners.iter().map(|c| c.1).collect();
let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() {
vec![(0.0, l, false, false)]
let nc = bounds.len();
let runs: Vec<(f64, f64, Option<usize>, Option<usize>)> = if bounds.is_empty() {
vec![(0.0, l, None, None)]
} else if t.closed {
let first = bounds[0];
bounds.push(first + l);
bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect()
bounds.windows(2).enumerate().map(|(i, w)| (w[0], w[1], Some(i), Some((i + 1) % nc))).collect()
} else {
let mut v = vec![0.0];
v.extend(bounds);
v.push(l);
let n = v.len();
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect()
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i.checked_sub(1), (i < nc).then_some(i))).collect()
};
let nruns = runs.len();
let corner_tiles = inner.is_some() || outer.is_some();
for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() {
if ca && corner_tiles {
a += corner_half;
}
if cb && corner_tiles {
b -= corner_half;
if out.full() {
break;
}
a += trim(ca);
b -= trim(cb);
if !t.closed
&& ri == 0
&& let Some(st) = &start
@@ -532,3 +979,235 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
}
out.out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::track::tracks;
/// #3926 review: the five by five grid cannot promise a run is straight, only that it looks
/// straight where it was asked. A zigzag whose vertices land on the sample positions is
/// invisible to it: every sample sits on a y = 0 vertex whose blended tangent is flat, so all
/// 25 points agree with the affine exactly, while the art between them is thrown 4 pt off.
///
/// This is why the gate is `Track::is_straight` and the grid is only a second opinion.
#[test]
fn a_bend_shorter_than_the_sample_grid_is_still_a_bend() {
// Half-period 20 pt, amplitude 4 pt. The 11.3 degree turn is under `CORNER_DEG`, so the
// vertices are not corners and their tangents blend flat, which is what hides them.
let (d, a) = (20.0, 4.0);
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
for i in 1..=16 {
bp.line_to((d * i as f64, if i % 2 == 1 { a } else { 0.0 }));
}
let t = tracks(&bp, 0.01).remove(0);
assert_eq!(t.corner.iter().filter(|c| **c).count(), 0, "no vertex is a corner, so every tangent is blended");
// A span over four full periods, held clear of both ends so no sample hits the
// extrapolation branch: the five samples land on vertices 4, 6, 8, 10 and 12, all at y = 0.
let seglen = (d * d + a * a).sqrt();
let (s0, s1) = (4.0 * seglen, 12.0 * seglen);
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let along = AlongMap::linear(b.width(), s0, s1);
let (cy, x0, cross) = (b.center().y, b.x0, 1.0);
let f = |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
// The grid is satisfied, and it should not be.
let fit = affine_fit(&f, b, flat_tol.max(1e-9)).expect("the sampled check accepts this bend");
// How wrong it is: an art x landing on a peak of the zigzag, which no sample reaches.
let peak_x = x0 + (5.0 * seglen - s0) / (s1 - s0) * b.width();
let at_peak = Point::new(peak_x, cy);
let off = (f(at_peak) - fit * at_peak).hypot();
assert!(
off > 3.0 && off > flat_tol * 20.0,
"the affine the grid approved is {off:.3} pt out at the peak, against a {flat_tol:.4} pt tolerance"
);
// Reading the track instead refuses it, and still admits the straight run it is made of.
assert!(!t.is_straight(s0, s1), "a bend between the samples is still a bend");
let straight = tracks(
&{
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((320.0, 0.0));
bp
},
0.01,
)
.remove(0);
assert!(straight.is_straight(s0, s1), "and the same span of an actually straight track is straight");
// End to end through `bend`: the aliased bend is subdivided, the straight run keeps its
// curves. This is the gate itself, not the two predicates it is made of.
let anchors = |n: &Node| match &n.kind {
NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(),
_ => 0,
};
let circle = Node::path(
vectorcraft_doc::NodeId(0),
vectorcraft_geom::shapes::ellipse(b),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
assert_eq!(anchors(&circle), 4, "the art is a 4-anchor circle to begin with");
let bent = bend(&t, &circle, b, along, cross).expect("the zigzag still draws something");
assert!(anchors(&bent) > 8, "the aliased bend is subdivided, not affine-mapped: {} anchors", anchors(&bent));
let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something");
assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves");
}
/// A 4-anchor circle tile filling `b`.
fn circle_tile(b: Rect) -> Node {
Node::path(
NodeId(0),
vectorcraft_geom::shapes::ellipse(b),
Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
)
}
/// `per` points along each segment of `sp`, through `f`.
fn traced(sp: &SubPath, f: &dyn Fn(Point) -> Point, per: usize) -> Vec<Point> {
(0..sp.segment_count())
.flat_map(|i| {
let c = sp.segment(i);
(0..per).map(move |j| f(c.eval(j as f64 / per as f64)))
})
.collect()
}
/// How far apart two closed outlines get (Hausdorff distance, each taken as a polygon).
fn apart(a: &[Point], b: &[Point]) -> f64 {
let to = |q: Point, pts: &[Point]| {
pts.iter()
.zip(pts.iter().cycle().skip(1))
.map(|(&a, &b)| {
let ab = b - a;
let t = ((q - a).dot(ab) / ab.hypot2().max(1e-18)).clamp(0.0, 1.0);
q.distance(a + ab * t)
})
.fold(f64::MAX, f64::min)
};
let one_way = |a: &[Point], b: &[Point]| a.iter().map(|&q| to(q, b)).fold(0.0, f64::max);
one_way(a, b).max(one_way(b, a))
}
/// #3926: on a bend the tile is fitted with cubics instead of flattened into chords. A circle
/// tile bent round a circle stays a handful of smooth curves, closer to the bent art than the
/// chords were, in a small fraction of their anchors.
#[test]
fn a_circle_tile_bent_round_a_circle_stays_a_few_curves() {
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let t = tracks(&round, 0.05).remove(0);
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let along = AlongMap::linear(b.width(), 100.0, 140.0);
let tile = circle_tile(b);
let NodeKind::Path { path: art, .. } = &tile.kind else { panic!("the tile is a path") };
let art = &art.subpaths[0];
let bent = bend(&t, &tile, b, along, 1.0).expect("the tile is drawn");
let NodeKind::Path { path, .. } = &bent.kind else { panic!("a bent path is a path") };
assert_eq!(path.subpaths.len(), 1);
let sp = &path.subpaths[0];
assert!(sp.closed, "still a closed outline");
let n = sp.anchors.len();
assert!((4..=16).contains(&n), "a few anchors, the tile's own four among them: {n}");
assert!(sp.anchors.iter().all(|a| a.has_in() && a.has_out()), "every anchor keeps both handles");
assert!(sp.anchors.first().map(|a| a.p) != sp.anchors.last().map(|a| a.p), "closed without a duplicate anchor");
// The same map `bend` uses, at scale 1, and the tolerance it fits to.
let (cy, x0) = (b.center().y, b.x0);
let f = |p: Point| t.map(along.at(p.x - x0), p.y - cy);
let tol = tolerance(b.height().max(b.width()));
let exact = traced(art, &f, 1000);
let chords: Vec<Point> = densified(art, tol, MAX_SEG).expect("the tile flattens").into_iter().map(f).collect();
let curves = traced(sp, &|p| p, 200);
let (e_curves, e_chords) = (apart(&curves, &exact), apart(&chords, &exact));
// Checked at eight points a piece, so a little room between them.
assert!(e_curves <= fit_tol(tol) * 1.5, "the curves follow the bent art within {}: {e_curves:.4}", fit_tol(tol));
assert!(e_curves < e_chords, "closer than the chords were: {e_curves:.4} against {e_chords:.4}");
assert!(chords.len() >= 4 * n, "far fewer anchors than the {} chords took: {n}", chords.len());
}
/// Fitting is bounded however badly the map behaves: where it gives points that are not finite
/// the tile falls back to chords with those points left out (or to nothing), and where it never
/// fits (a map that jumps everywhere) the halving stops at its depth and anchor count. Either
/// way the call comes back promptly with finite geometry.
#[test]
fn a_map_that_misbehaves_still_returns_promptly_and_finite() {
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let tile = circle_tile(b);
let finite = |n: &Node| match &n.kind {
NodeKind::Path { path, .. } => {
path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())
}
_ => false,
};
let maps: [&dyn Fn(Point) -> Point; 4] = [
&|p| if p.x > 25.0 { Point::new(f64::NAN, p.y) } else { p },
&|p| if p.y > 10.0 { Point::new(f64::INFINITY, p.y) } else { p + Vec2::new(p.x.sin(), 0.0) },
&|p| Point::new(p.x, p.y + if (p.x * 1e3).sin() > 0.0 { 5.0 } else { 0.0 }),
&|_| Point::new(f64::NAN, f64::NAN),
];
for (i, f) in maps.iter().enumerate() {
let t0 = std::time::Instant::now();
let out = warp_node(&tile, f, 0.25, 1.5, 0.25, 1.0);
assert!(t0.elapsed() < std::time::Duration::from_secs(2), "map {i} took {:?}", t0.elapsed());
match (i, &out) {
(3, out) => assert!(out.is_none(), "a map that places nothing draws nothing"),
(_, Some(n)) => assert!(finite(n), "map {i} drew non-finite geometry"),
(_, None) => panic!("map {i} places most points, so something is drawn"),
}
}
}
/// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a
/// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put.
#[test]
fn a_linked_opacity_mask_bends_with_the_art() {
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let stroked = |id: u64| {
Node::path(
NodeId(id),
vectorcraft_geom::shapes::ellipse(b),
Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
2.0,
),
)
};
let masked = |linked: bool| {
let mut n = stroked(0);
let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true);
mask.linked = linked;
n.extras_mut().mask = Some(Box::new(mask));
n
};
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let bent_track = tracks(&round, 0.05).remove(0);
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((320.0, 0.0));
let straight_track = tracks(&line, 0.01).remove(0);
let along = AlongMap::linear(b.width(), 100.0, 140.0);
for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] {
let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn");
let mask = out.extras().mask.as_ref().expect("the mask is kept");
assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over");
assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's");
let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn");
let mask = out.extras().mask.as_ref().expect("the mask is kept");
assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was");
let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn");
let mask = out.extras().mask.as_ref().expect("the mask is kept");
assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art");
assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's");
}
assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art");
}
}
+1 -1
View File
@@ -622,7 +622,7 @@ impl<'d, 'a> Build<'d, 'a> {
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
let symbol = unique(label, &mut self.next_suffix, &self.symbol_names);
self.symbol_names.insert(symbol.clone());
self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) });
self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) });
symbol
});
self.symbols.insert(key, made.clone());
+12 -1
View File
@@ -66,7 +66,7 @@ pub(crate) fn number(s: &str) -> Option<f64> {
/// A whole number; some writers put integers as `1.0`.
pub(crate) fn integer(s: &str) -> Option<i64> {
let s = s.trim();
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64))
}
/// One object: its type (the value of its `0` group) and the groups up to the next object.
@@ -456,3 +456,14 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
}
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
}
#[cfg(test)]
mod tests {
use super::integer;
#[test]
fn integer_rejects_fractional_float_values() {
assert_eq!(integer("1.0"), Some(1));
assert_eq!(integer("1.9"), None);
}
}
+28
View File
@@ -1049,6 +1049,34 @@ fn drawings_the_export_writes_read_back() {
}
}
#[test]
fn a_lone_segment_reads_back_from_the_line_the_export_writes() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut b = kurbo::BezPath::new();
b.move_to((20.0, 30.0));
b.line_to((120.0, 70.0));
let id = doc.alloc_id();
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
doc.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
for version in crate::DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
let out = crate::export(&doc, &opts).unwrap();
assert!(std::str::from_utf8(&out.bytes).unwrap().contains("\nLINE\n"), "{version:?}");
let back = open(&out.bytes, &points());
let art = leaves(&back.document);
assert_eq!(art.len(), 1, "{version:?}");
let path = art[0].path_data().unwrap();
assert_eq!(path.subpaths.len(), 1, "{version:?}");
assert!(!path.subpaths[0].closed, "{version:?}");
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
let points = path_points(&art[0]);
assert_eq!(points.len(), 17, "{version:?}: one segment");
let ends = [shift * points[0], shift * points[16]];
assert!(ends[0].distance(Point::new(20.0, 30.0)) < 1e-6 && ends[1].distance(Point::new(120.0, 70.0)) < 1e-6, "{version:?}: {ends:?}");
}
}
#[test]
fn closed_control_point_splines_stay_closed_through_export_and_import() {
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
+5 -2
View File
@@ -233,7 +233,8 @@ impl RasterFormat {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Preserve {
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
/// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern)
/// become their filled outlines; brush
/// strokes become their brush art.
#[default]
Appearance,
@@ -277,7 +278,9 @@ pub struct DxfOptions {
pub colors: ColorDepth,
pub raster: RasterFormat,
pub preserve: Preserve,
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
/// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`]
/// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have
/// no lineweights) is written as the filled area it paints.
pub alter_paths: bool,
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
pub outline_text: bool,
+198 -10
View File
@@ -10,7 +10,7 @@ use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
use crate::aci::nearest_aci;
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
@@ -126,9 +126,104 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
return None;
}
// Verify that the curve progresses monotonically around the circle without reversing,
// both along each cubic segment's angular derivative and across sampled intervals,
// and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn.
let mut total_turn = 0.0;
let mut dir: Option<bool> = None;
let ang_threshold = radius * radius * 1e-9;
let mut seg_from = sub.start;
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
for step in 0..=32 {
let t = f64::from(step) / 32.0;
let p = cubic_point(seg_from, a, b, to, t);
let u = 1.0 - t;
let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x);
let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y);
let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx;
if cross.abs() > ang_threshold {
let is_ccw = cross > 0.0;
if let Some(expected_ccw) = dir {
if is_ccw != expected_ccw {
return None;
}
} else {
dir = Some(is_ccw);
}
}
}
seg_from = to;
}
for w in samples.windows(2) {
let (p0, p1) = (w[0], w[1]);
let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y);
let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y);
let cross = v0x * v1y - v0y * v1x;
let dot = v0x * v1x + v0y * v1y;
let d_theta = cross.atan2(dot);
if d_theta.abs() > 1e-9 {
let is_ccw = d_theta > 0.0;
if let Some(expected_ccw) = dir {
if is_ccw != expected_ccw {
return None;
}
} else {
dir = Some(is_ccw);
}
total_turn += d_theta;
}
}
if sub.closed {
if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 {
return None;
}
} else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 {
return None;
}
Some(CircularSub { centre, radius, samples })
}
/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle
/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two
/// anchors.
struct EllipticalSub {
centre: Point,
u: Vec2,
v: Vec2,
}
/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two
/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as
/// the unit circle, so the same tolerance and turn checks apply.
fn elliptical_subpath(sub: &Sub) -> Option<EllipticalSub> {
if !sub.closed || sub.segs.len() != 4 {
return None;
}
let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end());
let centre = a0.midpoint(a2);
let (u, v) = (a0 - centre, a1 - centre);
let det = u.cross(v);
let scale2 = u.hypot2().max(v.hypot2());
if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 {
return None;
}
let unit = |p: Point| {
let d = p - centre;
Point::new(d.cross(v) / det, u.cross(d) / det)
};
let mut segs = Vec::with_capacity(4);
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
segs.push(Seg::Cubic(unit(a), unit(b), unit(to)));
}
let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?;
if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 {
return None;
}
Some(EllipticalSub { centre, u, v })
}
fn angle_deg(p: Point, centre: Point) -> f64 {
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
}
@@ -178,6 +273,17 @@ const MAX_ENTITIES: u32 = 10_000_000;
const MAX_COORD: f64 = 1e9;
const MAX_POINTS: usize = 4_000_000;
/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of
/// odd length repeats twice in its linetype.
fn starts_with_pattern(d: &Dash) -> bool {
if !d.is_dashed() || !d.offset.is_finite() {
return true;
}
let period = d.pattern.iter().sum::<f64>() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 };
let phase = d.offset.rem_euclid(period);
phase.min(period - phase) <= period * 1e-9
}
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
// Far outside any drawing, a curve flattens to millions of points at this tolerance.
@@ -439,7 +545,7 @@ impl<'a> Scene<'a> {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
let mut art = (*sym.art).clone();
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
art.transform(*xf, false);
self.nest += 1;
self.node(&art, true);
@@ -557,8 +663,7 @@ impl<'a> Scene<'a> {
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
fn lineweight(&mut self, width: f64) -> i32 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
let hundredths = width * 2540.0 / 72.0 * scale;
let hundredths = self.hundredths(width);
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
if hundredths > f64::from(max) + 5.0 {
self.warn(WIDE_LINES);
@@ -566,6 +671,21 @@ impl<'a> Scene<'a> {
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
}
/// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with
/// the drawing when asked).
fn hundredths(&self, width: f64) -> f64 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
width * 2540.0 / 72.0 * scale
}
/// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines
/// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up
/// to 2.11 mm, to the hundredth.
fn line_keeps_width(&self, width: f64) -> bool {
let v = self.o.version;
!v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1]))
}
/// A linetype for dash pattern `d` (one per distinct pattern).
fn linetype(&mut self, d: &Dash) -> String {
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
@@ -618,7 +738,11 @@ impl<'a> Scene<'a> {
}
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
let plain = stroke::is_plain(st);
if self.o.alter_paths || (appearance && !plain) {
// A linetype has no phase: dashes that start part way into their pattern need outlines.
let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern);
// Alter Paths for Appearance outlines a stroke only when a line can't draw it.
let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width));
if altered || (appearance && !lined) {
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
let style = self.style(rgb, st.opacity, None, None);
self.area(&region.to_bezpath(), FillRule::NonZero, &style, true);
@@ -685,9 +809,9 @@ impl<'a> Scene<'a> {
self.e.int(98, 0);
}
/// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened),
/// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives
/// polylines as a width.
/// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for
/// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones.
/// `width`: the stroke weight, which DXF before 2000 gives polylines as a width.
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
let v = self.o.version;
let width = (!v.r2000() && width > 0.0).then_some(width * self.k);
@@ -700,11 +824,17 @@ impl<'a> Scene<'a> {
if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
pts.pop();
}
self.polyline(&pts, sub.closed, style, width);
// A LINE has no width, so a stroke before 2000 keeps its polyline width.
match (sub.closed, width, pts.as_slice()) {
(false, None, &[a, b]) => self.line(a, b, style),
_ => self.polyline(&pts, sub.closed, style, width),
}
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() && self.elliptical(&sub, style) {
// R13 and later write an ellipse as a native ELLIPSE.
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -723,6 +853,16 @@ impl<'a> Scene<'a> {
}
}
/// A line from `a` to `b` (document space).
fn line(&mut self, a: Point, b: Point, style: &EntityStyle) {
let (a, b) = (self.pt(a), self.pt(b));
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("LINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbLine");
self.e.xyz(10, a.x, a.y);
self.e.xyz(11, b.x, b.y);
}
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
/// vertex entity per point.
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
@@ -732,7 +872,8 @@ impl<'a> Scene<'a> {
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
let layer = self.layer.clone();
let h = self.handle();
let flags = i64::from(closed);
// Bit 128: a linetype runs on through the vertices instead of restarting at each one.
let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 };
if self.o.version.lwpolyline() {
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
self.e.int(90, pts.len() as i64);
@@ -832,6 +973,53 @@ impl<'a> Scene<'a> {
true
}
/// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center,
/// the end of its major axis relative to the center, and the minor to major ratio. Returns
/// false when the curve is not an ellipse, leaving the caller to write it as a spline.
fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
if !sub.closed || last.distance(sub.start) > 1e-9 {
return false;
}
let Some(ellipse) = elliptical_subpath(sub) else { return false };
let centre = self.to_dxf * ellipse.centre;
let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre;
let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre;
// The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T.
let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y);
let mean = (a + d) / 2.0;
let spread = ((a - d) / 2.0).hypot(b);
let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt());
let angle = 0.5 * (2.0 * b).atan2(a - d);
let axis = Vec2::new(major * angle.cos(), major * angle.sin());
let ratio = (minor / major).min(1.0);
// A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more.
if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 {
return false;
}
// The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and
// hypot(u.y, v.y) about the center.
let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v);
let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y));
for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] {
self.pt(p);
}
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse");
self.e.xyz(10, centre.x, centre.y);
self.e.xyz(11, axis.x, axis.y);
self.e.normal();
// The ratio and the end parameter are written in full: rounded to six decimals, a thin
// ellipse's minor axis would change size, and a whole turn would read back as an open curve.
self.e.raw(40, &ratio.to_string());
self.e.num(41, 0.0);
self.e.raw(42, &std::f64::consts::TAU.to_string());
true
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
+366 -8
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes};
use crate::*;
@@ -125,6 +125,71 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
assert_eq!(DxfVersion::from_id("2019"), None);
}
/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table
/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes
/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section:
/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C).
#[test]
fn r2000_and_later_files_have_the_structure_cad_apps_require() {
use std::collections::HashSet;
// Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in
// the first and the UCS type in the second).
let layout_codes: HashSet<i32> = [0, 5, 102, 330, 360, 100]
.into_iter()
.chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333])
.chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346])
.collect();
let d = shapes_doc();
for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) {
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
let p = pairs(&out.bytes);
for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] {
assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section");
}
let tables = section(&p, "TABLES");
let names = |k: &str| -> Vec<String> { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() };
for (k, want) in [
("LTYPE", "ByBlock"),
("LTYPE", "ByLayer"),
("LTYPE", "Continuous"),
("LAYER", "0"),
("STYLE", "Standard"),
("APPID", "ACAD"),
("DIMSTYLE", "Standard"),
("BLOCK_RECORD", "*Model_Space"),
("BLOCK_RECORD", "*Paper_Space"),
] {
assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}");
}
// Every object after the header has a handle below $HANDSEED, and every reference to
// another object (owner 330, soft/hard pointers 340/350/390) names one of them.
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
let handles: HashSet<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) {
let h = u32::from_str_radix(value, 16).unwrap();
assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}");
}
let objects = section(&p, "OBJECTS");
let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect();
assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1");
for l in &layouts {
let bad: Vec<i32> = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect();
assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}");
// The paper image origin is 148 (x) and 149 (y).
assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin");
// Flag 1024 marks the model space layout, and only it.
let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str());
let flags: i64 = get(l, 70).unwrap().parse().unwrap();
assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}");
}
// The plot style placeholder carries only the common object codes.
let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder");
assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}");
}
}
#[test]
fn r12_viewport_has_no_duplicate_group_codes() {
let d = shapes_doc();
@@ -208,10 +273,168 @@ fn circles_and_circular_arcs_are_native_dxf_entities() {
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
}
}
// An actual ellipse is not close enough to a circle and remains a spline.
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
/// The path `p` with `xf` applied.
fn transformed(p: &PathData, xf: Affine) -> PathData {
PathData::from_bezpath(&(xf * p.to_bezpath()))
}
fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() {
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
let d = doc_with(|d| {
let stroke = || Paint::solid(Color::BLACK);
vec![
// 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5.
path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0),
// A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3.
path(
d,
transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))),
Paint::None,
stroke(),
1.0,
),
// The first ellipse turned 30° about its center, filled: the outline is the ellipse too.
path(
d,
transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))),
Paint::solid(Color::BLACK),
Paint::None,
0.0,
),
]
});
for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}");
let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42));
let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]);
assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}");
assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn");
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]);
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]);
// Turned 30° counterclockwise on screen, so clockwise with DXF's y up.
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]);
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]);
assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}");
}
// R12 has no ELLIPSE: the curves are flattened polylines.
let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap());
assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}");
// A thin ellipse keeps its ratio in full (six decimals would write 0.000002).
let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
let e = entities(&export(&thin, &opts(&thin)).unwrap());
assert_eq!(kind(&e[0]), "ELLIPSE");
assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]);
}
#[test]
fn exported_ellipses_read_back_as_the_same_ellipse() {
let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0));
let d = doc_with(|d| {
vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["ELLIPSE"]);
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() };
let back = import(&out.bytes, &o).unwrap().document;
let mut paths = vec![];
back.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
paths.push(path.to_bezpath());
}
});
assert_eq!(paths.len(), 1);
assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed");
// Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered.
let bp = &paths[0];
let found = bp.bounding_box().center();
let inverse = xf.inverse();
let original = Point::new(60.0, 40.0);
for seg in bp.segments() {
for i in 0..=16 {
let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2();
let q = inverse * p;
let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0);
assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})");
}
}
}
#[test]
fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() {
// A rounded square: four cubics through the ellipse's anchors with longer handles.
let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8);
let mut squircle = vectorcraft_geom::BezPath::new();
squircle.move_to(Point::new(c.x + r, c.y));
squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y));
squircle.close_path();
// An egg: one half wider than the other.
let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath();
let mut els: Vec<kurbo::PathEl> = egg.elements().to_vec();
if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) {
a.x -= 10.0;
b.x -= 10.0;
}
let d = doc_with(|d| {
vec![
path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0),
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6).
path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]);
}
#[test]
fn a_lone_straight_segment_is_a_line() {
let segment = |closed: bool, extra: Option<Point>| {
let mut b = vectorcraft_geom::BezPath::new();
b.move_to(Point::new(20.0, 30.0));
b.line_to(Point::new(120.0, 70.0));
if let Some(p) = extra {
b.line_to(p);
}
if closed {
b.close_path();
}
PathData::from_bezpath(&b)
};
let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap());
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["LINE"], "{version:?}");
assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}");
assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom");
assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}");
}
// Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight
// before 2000 keeps its polyline width.
let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]);
let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap());
assert_eq!(r12(&filled), ["LINE"]);
assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]);
let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap());
assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1")));
// A closed segment and two segments stay polylines.
for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] {
let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]);
}
}
#[test]
@@ -246,6 +469,52 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
}
#[test]
fn a_closed_sliver_along_a_circle_is_not_a_circle() {
let d = doc_with(|d| {
let (cx, cy, r) = (60.0, 250.0, 200.0);
let at = |deg: f64| {
let rad = deg.to_radians();
Point::new(cx + r * rad.cos(), cy + r * rad.sin())
};
let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| {
let p0 = at(deg0);
let p3 = at(deg1);
let step = (deg1 - deg0).to_radians();
let k = 4.0 / 3.0 * (step / 4.0).tan();
let r0 = deg0.to_radians();
let r1 = deg1.to_radians();
let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k);
let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k);
sliver.curve_to(p1, p2, p3);
};
let mut sliver = vectorcraft_geom::BezPath::new();
sliver.move_to(at(-110.0));
along(&mut sliver, -110.0, -90.0);
along(&mut sliver, -90.0, -70.0);
along(&mut sliver, -70.0, -110.0);
sliver.close_path();
vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let modern = export(&d, &opts(&d)).unwrap();
assert_ne!(kinds(&modern), ["CIRCLE"]);
assert_eq!(kinds(&modern), ["SPLINE"]);
}
#[test]
fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() {
let d = doc_with(|d| {
let mut b = vectorcraft_geom::BezPath::new();
b.move_to(Point::new(-8.404028665134, 62.061475842818));
b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0));
vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let dxf = export(&d, &opts(&d)).unwrap();
assert_ne!(kinds(&dxf), ["ARC"]);
assert_eq!(kinds(&dxf), ["SPLINE"]);
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
@@ -407,14 +676,44 @@ fn dashes_become_linetypes_and_opacity_transparency() {
#[test]
fn alter_paths_writes_strokes_as_filled_outlines() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
// Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc.
let mut d = shapes_doc();
let c = Point::new(150.0, 95.0);
let k = 20.0 * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + 20.0, c.y));
arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0));
let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0);
let layer = d.layers[0].id;
d.insert(Some(layer), usize::MAX, arc).unwrap();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat());
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
// R12 polylines carry any width, so its strokes stay lines too.
let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(!kinds(&r12).iter().any(|k| k == "HATCH"));
assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2")));
// R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch.
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width.
let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]);
let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap();
assert_eq!(kinds(&out), rect_stroke);
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let lines = export(&wide, &opts(&wide)).unwrap();
assert_eq!(kinds(&lines), ["LWPOLYLINE"]);
assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm")));
// To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line.
for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] {
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]);
assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt");
}
// Preserve Appearance outlines only the strokes a line can't draw.
let mut d = shapes_doc();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
@@ -425,6 +724,65 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
// With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line.
let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
assert!(altered.warnings.is_empty(), "{:?}", altered.warnings);
}
#[test]
fn dashes_that_start_part_way_into_their_pattern_are_outlined() {
let dashed = |offset: f64| {
doc_with(|d| {
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false });
}
vec![n]
})
};
// A linetype starts at the start of its pattern: an offset of a whole period (the odd
// pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch.
for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] {
let d = dashed(offset);
let k = kinds(&export(&d, &opts(&d)).unwrap());
if outlined {
assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}");
} else {
assert_eq!(k, ["LWPOLYLINE"], "offset {offset}");
}
let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap());
assert_eq!(altered, k, "offset {offset}, Alter Paths");
}
// Maximize Editability keeps the line and its linetype.
let d = dashed(4.0);
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE"]);
assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1"));
}
#[test]
fn dashed_polylines_continue_their_pattern_around_corners() {
let rect = |dash: Option<Dash>| {
doc_with(|d| {
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = dash.clone();
}
vec![n]
})
};
let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string));
// Group 70 bit 128 makes the linetype run on through the vertices instead of restarting
// at each one; a solid polyline keeps only its closed bit.
let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }));
for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] {
assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129"));
}
let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap();
assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129")));
let solid = rect(None);
assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1"));
}
fn text_doc(text: &str, size: f64) -> Document {
@@ -628,7 +986,7 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let o = DxfOptions { crop: true, ..opts(&d) };
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
+15 -3
View File
@@ -564,7 +564,10 @@ impl Drawing {
c.handle(350, fixed::PLOT_NORMAL);
c.raw(100, "AcDbDictionaryWithDefault");
c.handle(340, fixed::PLOT_NORMAL);
c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder");
// A placeholder has no data and no subclass marker of its own.
c.raw(0, "ACDBPLACEHOLDER");
c.handle(5, fixed::PLOT_NORMAL);
c.handle(330, fixed::PLOT_STYLES);
self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD);
self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD);
}
@@ -618,14 +621,23 @@ impl Drawing {
c.num(141, 0.0);
c.num(142, 1.0);
c.num(143, 1.0);
c.int(70, 688);
// Plot flags; 1024 marks the model space layout.
c.int(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 });
c.int(72, 1);
c.int(73, 0);
c.int(74, 5);
c.raw(7, "");
c.int(75, 16);
if self.version.true_color() {
// Shade plot mode (as displayed), resolution level (normal) and custom DPI.
c.int(76, 0);
c.int(77, 2);
c.int(78, 300);
}
c.num(147, 1.0);
c.xy(148, 0.0, 0.0);
// The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158.
c.num(148, 0.0);
c.num(149, 0.0);
c.raw(100, "AcDbLayout");
c.raw(1, name);
c.int(70, 1);
+12 -3
View File
@@ -149,11 +149,20 @@ impl IccProfile {
/// Device values (RGB / CMYK / Gray, 0..1) → sRGB.
pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
let mut src = [0.0f32; 4];
let channels = self.channels();
src.get_mut(..channels)?.iter_mut().zip(v.iter()).for_each(|(dst, value)| *dst = *value);
self.to_srgb_into(src.get(..channels)?, intent)
}
/// Convert an exact-width device pixel using the cached transform and stack storage.
pub fn to_srgb_into(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
if v.len() != self.channels() {
return None;
}
let xf = self.to_xf(intent)?;
let mut src = v.to_vec();
src.resize(self.channels(), 0.0);
let mut dst = [0.0f32; 3];
xf.transform(&src, &mut dst).ok()?;
xf.transform(v, &mut dst).ok()?;
Some(dst.map(|x| x.clamp(0.0, 1.0)))
}
+45
View File
@@ -78,6 +78,14 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
const PERCEPTUAL_DEPTH: u32 = 10;
/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within
/// 1/65536 of a tint, finer than the 1/255 steps PDF writes.
const TINT_STEPS: u32 = 16;
/// The least OKLab lightness by which an ink at 100% must differ from paper white for
/// [`Gradient::ink_tints`] to tell its tints apart by lightness.
const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0;
/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for.
const MAX_INK_TINTS: usize = 4096;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
@@ -284,6 +292,43 @@ impl Gradient {
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
})
}
/// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as
/// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab
/// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings. Mixed
/// linearly between them, the tints follow a Perceptual gradient's curve. `None` when the
/// ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of paper white's (a white or nearly
/// white ink), so lightness does not tell its tints apart, and when the curve has more than
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_tints(&self, ink: &Color) -> Option<Vec<(f32, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN {
return None;
}
let curve: Vec<_> = self.expanded().take(MAX_INK_TINTS + 1).collect();
if curve.len() > MAX_INK_TINTS {
return None;
}
let tint = |c: &Color| {
let want = lightness(c);
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
if lightness(&ink.tinted(mid)) > want {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
};
Some(curve.into_iter().map(|(t, c, o, _)| (t, tint(&c), o)).collect())
}
/// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so
/// [`Self::ink_tints`] finds tints for it?
pub fn fits_ink_tints(&self) -> bool {
self.expanded().nth(MAX_INK_TINTS).is_none()
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
+31
View File
@@ -455,3 +455,34 @@ fn perceptual_gradients_draw_as_pixels() {
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}
/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
/// the curve has the lightness of the color mixed there, so the tints between the stops are not
/// a straight line.
#[test]
fn ink_tints_follow_the_perceptual_curve() {
use crate::libraries::srgb_to_oklab;
let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0);
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]);
g.method = GradientMethod::Perceptual;
assert!(g.fits_ink_tints());
let tints = g.ink_tints(&ink).expect("a magenta ink's tints differ in lightness");
assert!(tints.len() > 2, "the curve's stops: {tints:?}");
let (first, last) = (tints[0], tints[tints.len() - 1]);
assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}");
assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}");
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
for (t, tint, _) in &tints {
let want = lightness(&g.sample(*t).0);
assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}");
}
let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint");
assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}");
// A white ink's tints all have paper white's lightness: no tints by lightness.
assert_eq!(g.ink_tints(&Color::WHITE), None);
// More stops on the curve than MAX_INK_TINTS: no tints.
let n = MAX_INK_TINTS + 1;
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect();
assert_eq!(g.ink_tints(&ink), None);
assert!(!g.fits_ink_tints());
}
+9 -1
View File
@@ -69,7 +69,7 @@ impl fmt::Display for ColorKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Components are hundredths of the written units: exact with two decimals.
let num = |q: i32| {
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100);
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.unsigned_abs() / 100, q.unsigned_abs() % 100);
s.trim_end_matches('0').trim_end_matches('.').to_string()
};
let [a, b, c, k] = self.v.map(num);
@@ -461,6 +461,14 @@ impl Rng {
mod tests {
use super::*;
#[test]
fn key_display_survives_saturated_lab() {
for text in ["lab 50 -1e20 0", "lab 50 0 -1e20", "lab 50 1e20 0"] {
let k = ColorKey::parse(text).unwrap();
assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{text}");
}
}
fn close(a: Color, b: Color) -> bool {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
+1 -1
View File
@@ -1,6 +1,6 @@
use super::profile::{PressureProfile, WidthProfile};
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
use super::*;
use super::{BlendMode, Deserialize, Paint, Serialize};
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+10 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
/// in order along the path. Two points at the same position make a discontinuous point: the
@@ -53,6 +53,15 @@ impl WidthProfile {
// Two swells between narrow necks.
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
];
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
/// right factors at any of the points (between them the profile is straight), 1 for a profile
/// without points.
pub fn widest(&self) -> f64 {
if self.points.is_empty() {
return 1.0;
}
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
}
/// The built-in profile with this id.
pub fn preset(id: &str) -> Option<Self> {
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineCap {
+34 -3
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artboard {
@@ -35,8 +35,8 @@ pub struct Artboard {
pub video_ruler_pixel_aspect_ratio: f64,
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
/// it out.
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print draw
/// it behind the art ([`super::Document::with_artboard_fills`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
@@ -77,6 +77,37 @@ impl Artboard {
}
}
impl super::Document {
/// A copy for output with the background colors ([`Artboard::color`]) of the artboards at
/// `indices` drawn behind all the art: each a rectangle of its artboard filled with its color
/// (in the color's own model), on a layer of their own (Artboard Fill) below the
/// others. `None` when none of them has a color.
pub fn with_artboard_fills(&self, indices: impl IntoIterator<Item = usize>) -> Option<Self> {
let fills: Vec<(Rect, vectorcraft_color::Color)> = indices
.into_iter()
.filter_map(|i| {
let a = self.artboards.get(i)?;
Some((a.rect, a.color?))
})
.collect();
if fills.is_empty() {
return None;
}
let mut d = self.clone();
let mut layer = Node::layer(d.alloc_id(), "Artboard Fill", super::LayerColor::Preset(0));
let paths: Vec<Arc<Node>> = fills
.into_iter()
.map(|(r, c)| {
let look = super::Appearance::basic(vectorcraft_color::Paint::solid(c), vectorcraft_color::Paint::None, 0.0);
Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look))
})
.collect();
*layer.children_mut()? = paths;
d.layers.insert(0, Arc::new(layer));
Some(d)
}
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
pub(crate) rects: Vec<Rect>,
+270 -4
View File
@@ -196,7 +196,11 @@ pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
/// so dashes open out of a solid line; patterns of different lengths repeat to a common one.
fn lerp_dash(x: Option<&Dash>, y: Option<&Dash>, t: f64) -> Option<Dash> {
let solid_like = |d: &Dash| Dash {
pattern: d.pattern.chunks(2).flat_map(|c| [c.iter().sum::<f64>(), 0.0]).take(d.pattern.len().max(2)).collect(),
// An odd pattern repeats twice per period, so pair up the doubled one to keep every gap closed.
pattern: if d.pattern.len() % 2 == 1 { d.pattern.repeat(2) } else { d.pattern.clone() }
.chunks(2)
.flat_map(|c| [c.iter().sum::<f64>(), 0.0])
.collect(),
offset: d.offset,
align_corners: d.align_corners,
};
@@ -1059,13 +1063,146 @@ pub fn steps_knockout_shows(steps: &[Arc<Node>]) -> bool {
!steps.iter().all(|c| paints_opaquely(c))
}
/// Evaluate a blend: the keys and generated steps in paint order.
/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included.
/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps
/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps
/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes
/// proportionally fewer steps between each pair of keys ([`fit_steps`]).
pub const MAX_BLEND_STEPS: usize = 100_000;
/// The steps between each pair of keys, as the blend's spacing asks for them.
fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> Vec<usize> {
keys.windows(2)
.enumerate()
.map(|(i, w)| match w {
[a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))),
_ => 0,
})
.collect()
}
/// Most steps `spacing` makes between two keys, whatever their size and distance
/// ([`blend_step_count`]'s clamps).
fn most_steps(spacing: BlendSpacing) -> usize {
match spacing {
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
BlendSpacing::Distance(_) => 1000,
BlendSpacing::SmoothColor => 256,
}
}
/// `want` steps between each pair of keys, fewer where they would make more than
/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by
/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share
/// of its steps, rounded down, and none when the keys alone fill the budget.
fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec<usize> {
let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128;
let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add);
if demand <= room {
return want.to_vec();
}
// `demand` is more than `room`, so it isn't 0, and each share is below `n`.
want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect()
}
/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its
/// address while the count runs.
#[derive(Default)]
struct StepCount {
known: std::collections::HashMap<(usize, bool), usize>,
}
impl StepCount {
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
fn node(&mut self, n: &Node, copy: bool) -> usize {
let at = (std::ptr::from_ref(n) as usize, copy);
if let Some(&known) = self.known.get(&at) {
return known;
}
let count = match &n.kind {
NodeKind::Blend { children, spec } if copy => self.most(children, spec),
NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1,
// Each instance draws the source again.
NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)),
_ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy),
};
self.known.insert(at, count);
count
}
fn nodes(&mut self, nodes: &[Arc<Node>], copy: bool) -> usize {
nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add)
}
/// What one step between keys `a` and `b` makes: itself and the steps of the blends in it.
/// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key.
fn step(&mut self, a: &Node, b: &Node) -> usize {
let inner = match (&a.kind, &b.kind) {
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 })
if k1 == k2 && !(*k1 && ca.len() != cb.len()) =>
{
(0..ca.len().max(cb.len()))
.map(|i| match (ca.get(i), cb.get(i)) {
(Some(x), Some(y)) => self.step(x, y).saturating_sub(1),
(Some(x), None) | (None, Some(x)) => self.node(x, true),
(None, None) => 0,
})
.fold(0, usize::saturating_add)
}
_ => self.node(a, true).max(self.node(b, true)),
};
inner.saturating_add(1)
}
/// What each step between each pair of `keys` makes ([`Self::step`]).
fn steps(&mut self, keys: &[Arc<Node>]) -> Vec<usize> {
keys.windows(2)
.map(|w| match w {
[a, b] => self.step(a, b),
_ => 1,
})
.collect()
}
/// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes
/// in all, its keys' included.
fn blend(&mut self, keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec<usize>, usize) {
let inside = self.nodes(keys, false);
let each = self.steps(keys);
let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside);
let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add);
(kept, total)
}
/// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts
/// with [`most_steps`] between each pair, before rounding.
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
let inside = self.nodes(keys, true);
let each = self.steps(keys);
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
}
}
/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the
/// spacing asks for, fewer when they and the steps of the blends nested in them would pass
/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these).
pub fn blend_steps(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<usize> {
StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0
}
/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to
/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]).
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let k = keys.len();
if k < 2 {
return keys.iter().map(|n| (**n).clone()).collect();
}
let rail = Rail::new(keys, spec);
let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref());
let mut out = Vec::new();
for (i, a) in keys.iter().enumerate() {
let mut key = (**a).clone();
@@ -1074,8 +1211,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
}
out.push(key);
let Some(b) = keys.get(i + 1) else { break };
let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i));
let n = blend_step_count(a, b, spec.spacing, len);
let n = counts.get(i).copied().unwrap_or(0);
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
for j in 1..=n {
// Even position of the step, then where the easing puts its shape and its colours.
@@ -1291,6 +1427,12 @@ mod tests {
assert_eq!(p.at(0.5), (1.0, 1.0));
}
#[test]
fn odd_dash_blends_to_solid_with_closed_gaps() {
let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap();
assert!(d.pattern.iter().skip(1).step_by(2).all(|g| *g == 0.0), "{:?}", d.pattern);
}
#[test]
fn copied_steps_get_new_members() {
let rect = |x: f64| Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()));
@@ -1301,4 +1443,128 @@ mod tests {
mid.walk(&mut |n| ids.push(n.id));
assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}");
}
/// A blend of `a` and `b` spaced by `spacing`.
fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node {
let spec = BlendSpec { spacing, ..Default::default() };
Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) })
}
/// A blend's keys and options.
fn blend_parts(n: &Node) -> Option<(&Vec<Arc<Node>>, &BlendSpec)> {
match &n.kind {
NodeKind::Blend { children, spec } => Some((children, spec)),
_ => None,
}
}
/// A square of side `side` at (`x`, `y`).
fn square(x: f64, y: f64, side: f64) -> Node {
Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default())
}
/// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input).
fn nested(levels: u32, x: f64) -> Node {
if levels == 0 {
return square(x, 0.0, 4.0);
}
let w = 40.0 * f64::from(levels);
blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15))
}
/// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at
/// a time, the blends in its keys and steps evaluated again.
fn drawn_steps(n: &Node) -> usize {
match &n.kind {
NodeKind::Blend { children, spec } => {
let out = blend_expand(children, spec);
out.len() - children.len() + out.iter().map(drawn_steps).sum::<usize>()
}
_ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()),
}
}
/// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps,
/// and again the steps of the level below in each key and each step.
fn nested_steps(levels: u32) -> usize {
(17usize.pow(levels) - 1) * 15 / 16
}
/// #3579: blends whose keys are blends draw their steps again in every step above them. Five
/// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps
/// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each
/// level past that adds only what its keys draw.
#[test]
fn nested_blends_keep_to_the_step_budget() {
assert_eq!(nested_steps(5), 1_331_115);
let four = nested_steps(4);
assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS);
let five = nested(5, 0.0);
let (children, spec) = blend_parts(&five).unwrap();
assert_eq!(blend_steps(children, spec), vec![0]);
assert_eq!(drawn_steps(&five), 2 * four);
assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four);
// A blend whose keys leave room keeps the same share of its steps between each pair.
let mut three = nested(3, 0.0);
if let NodeKind::Blend { spec, .. } = &mut three.kind {
spec.spacing = BlendSpacing::Steps(1000);
}
let (children, spec) = blend_parts(&three).unwrap();
let inside = 2 * nested_steps(2);
let each = 1 + nested_steps(2);
let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each);
assert_eq!(blend_steps(children, spec), vec![kept]);
let drawn = drawn_steps(&three);
assert_eq!(drawn, inside + kept * each);
assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}");
}
/// Nested blends within the budget make every step their spacing asks for, as before.
#[test]
fn nested_blends_within_the_budget_draw_every_step() {
for levels in 1..=4 {
let n = nested(levels, 0.0);
let (children, spec) = blend_parts(&n).unwrap();
assert_eq!(blend_steps(children, spec), vec![15]);
assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}");
}
// The keys, then 15 steps, then the last key, each a blend whose keys moved with it.
let two = nested(2, 0.0);
let (children, spec) = blend_parts(&two).unwrap();
let out = blend_expand(children, spec);
assert_eq!(out.len(), 17);
assert_eq!(out[0], *children[0]);
assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. })));
assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0)));
}
/// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the
/// most its spacing can make. Here a tiny blend of one step is blended with a large blend of
/// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000
/// steps each, which neither key's own count shows.
#[test]
fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() {
let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0));
let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1));
assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1));
let n = blend_of(tiny, large, BlendSpacing::Steps(1000));
let (children, spec) = blend_parts(&n).unwrap();
let steps = blend_steps(children, spec);
assert!(steps[0] < 1000, "{steps:?}");
let drawn = drawn_steps(&n);
assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}");
}
/// A flat blend of many keys shares the budget evenly between its pairs.
#[test]
fn many_keys_share_the_step_budget() {
let keys: Vec<Arc<Node>> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect();
let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() };
let steps = blend_steps(&keys, &spec);
assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]);
assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::<usize>());
// Within the budget, every step stays.
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
}
}
+328
View File
@@ -0,0 +1,328 @@
//! A read document brought within the canvas: what lies beyond [`MAX_COORD`] is removed (#3575).
//! Edits refuse to leave anything there (`doc_sane` in the engine checks the artboards, the guides,
//! the ruler origin and the objects an edit touches), so a file holding something beyond it could be
//! opened but not edited: one object, artboard or guide out there made every move fail.
use std::sync::Arc;
use vectorcraft_geom::{Point, Rect};
use crate::{Document, MAX_COORD, Node, NodeId, NodeKind};
/// What [`Document::drop_beyond_canvas`] removed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BeyondCanvas {
/// Objects removed whole (a layer or group keeps its children within the canvas).
pub objects: usize,
pub artboards: usize,
/// Guides beyond the canvas, and the artboard guides of the artboards removed.
pub guides: usize,
/// The ruler origin was beyond the canvas and is back at the origin.
pub ruler_origin: bool,
}
impl BeyondCanvas {
/// The warning for what was removed, or `None` when nothing was.
pub fn note(&self) -> Option<String> {
let count = |n: usize, one: &str, many: &str| match n {
0 => None,
1 => Some(format!("1 {one}")),
n => Some(format!("{n} {many}")),
};
let parts: Vec<String> =
[count(self.objects, "object", "objects"), count(self.artboards, "artboard", "artboards"), count(self.guides, "guide", "guides")]
.into_iter()
.flatten()
.collect();
let limit = format!("more than {MAX_COORD} pt from the origin");
match (parts.is_empty(), self.ruler_origin) {
(true, false) => None,
(true, true) => Some(format!("the ruler origin lay beyond the canvas ({limit}) and was reset")),
(false, origin) => Some(format!(
"{} beyond the canvas ({limit}) {} removed{}",
parts.join(", "),
if self.objects + self.artboards + self.guides == 1 { "was" } else { "were" },
if origin { "; the ruler origin, beyond it too, was reset" } else { "" }
)),
}
}
}
/// Is every side of `r` finite and within [`MAX_COORD`] of the origin?
fn rect_in_range(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| in_range(*v))
}
fn in_range(v: f64) -> bool {
v.is_finite() && v.abs() <= MAX_COORD
}
/// Does `n` reach beyond the canvas? An object without geometry doesn't.
fn beyond(n: &Node) -> bool {
n.geometric_bounds().is_some_and(|b| !rect_in_range(b))
}
/// Whether `n` is a layer or group, and if so whether it clips: the containers whose objects are
/// looked at one by one. Anything else is one object, measured whole.
fn clipping(n: &Node) -> Option<bool> {
match n.kind {
NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => Some(clip),
_ => None,
}
}
/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by
/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its
/// clipping path.
fn reaches_beyond(n: &Node) -> bool {
match clipping(n) {
None => beyond(n),
Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))),
}
}
/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips
/// go together, so no other object becomes the clipping path.
fn clips_beyond(n: &Node) -> bool {
clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c))
}
/// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go
/// with them.
fn layers_in(n: &Node, layers: &mut Vec<NodeId>) {
if matches!(n.kind, NodeKind::Layer { .. }) {
layers.push(n.id);
}
if clipping(n).is_some() {
for c in n.children().into_iter().flatten() {
layers_in(c, layers);
}
}
}
/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its
/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything
/// else goes whole. Only containers holding such an object are copied. Recursion follows the
/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`.
fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut Vec<NodeId>) {
if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) {
return;
}
let Some(children) = Arc::make_mut(node).children_mut() else { return };
children.retain_mut(|c| {
if !reaches_beyond(c) {
return true;
}
if clipping(c).is_some() && !clips_beyond(c) {
drop_children_beyond(c, dropped, layers);
return true;
}
layers_in(c, layers);
*dropped += 1;
false
});
}
impl Document {
/// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects
/// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied
/// and stops clipping), artboards with their ruler origins and artboard guides (their ids
/// are retired, as deleting them does), guides with the layer guides of the layers removed,
/// and put a ruler origin beyond it back at the origin. Saved selections, assets and variable
/// bindings then forget the objects removed. Run after [`Document::limit_depth`]. A document
/// whose artboards were all removed has none left.
pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas {
let mut gone = BeyondCanvas::default();
let mut layers = vec![];
for layer in &mut self.layers {
if clips_beyond(layer) {
let layer = Arc::make_mut(layer);
if let NodeKind::Layer { children, clip, .. } = &mut layer.kind {
for c in children.iter() {
layers_in(c, &mut layers);
}
gone.objects += children.len();
children.clear();
*clip = false;
}
} else {
drop_children_beyond(layer, &mut gone.objects, &mut layers);
}
}
if gone.objects > 0 {
self.tidy_saved_selections();
self.prune_assets();
self.prune_variable_bindings();
}
let removed: Vec<u32> = self.artboards.iter().filter(|a| !rect_in_range(a.rect)).map(|a| a.id).collect();
self.artboards.retain(|a| rect_in_range(a.rect));
for id in &removed {
self.artboard_ruler_origins.remove(id);
self.retire_artboard_id(*id);
}
gone.artboards = removed.len();
let guides = self.guides.len();
self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l)));
gone.guides = guides - self.guides.len();
if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) {
self.ruler_origin = Point::ZERO;
gone.ruler_origin = true;
}
gone
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, Artboard, Guide};
use vectorcraft_geom::shapes;
fn rect(id: u64, y: f64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, y, 10.0, y + 10.0)), Appearance::default_art()))
}
fn doc(children: Vec<Arc<Node>>) -> Document {
let mut d = Document::new(100.0, 100.0);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.extend(children);
}
d
}
fn ids(d: &Document) -> Vec<u64> {
let mut out = vec![];
fn walk(n: &Node, out: &mut Vec<u64>) {
out.push(n.id.0);
for c in n.children().into_iter().flatten() {
walk(c, out);
}
}
for l in &d.layers {
walk(l, &mut out);
}
out
}
#[test]
fn a_document_within_the_canvas_is_left_alone() {
let mut d = doc(vec![rect(10, 0.0), rect(11, MAX_COORD - 10.0), rect(12, -MAX_COORD)]);
let before = d.clone();
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
assert_eq!(d, before);
assert_eq!(BeyondCanvas::default().note(), None);
}
#[test]
fn objects_beyond_the_canvas_are_removed_and_groups_keep_the_rest() {
let group = Arc::new(Node::group(NodeId(20), vec![rect(21, 0.0), rect(22, 1e17)]));
let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, f64::NAN), rect(32, 0.0)], clip: true }));
let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), group, clip, rect(12, -MAX_COORD - 1.0)]);
let layer = d.layers[0].id.0;
let gone = d.drop_beyond_canvas();
assert_eq!(gone, BeyondCanvas { objects: 4, ..Default::default() });
assert_eq!(ids(&d), [layer, 10, 20, 21], "the group keeps its object within the canvas; the clip group goes with its clipping path");
assert!(d.layers.iter().all(|l| !beyond(l)));
assert_eq!(gone.note().unwrap(), format!("4 objects beyond the canvas (more than {MAX_COORD} pt from the origin) were removed"));
}
#[test]
fn artboards_guides_and_the_ruler_origin_beyond_the_canvas_are_removed_or_reset() {
let mut d = doc(vec![rect(10, 0.0)]);
let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() };
d.artboards.push(far);
let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) };
d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))];
d.ruler_origin = Point::new(1e17, 0.0);
let gone = d.drop_beyond_canvas();
assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true });
assert_eq!(d.artboards.len(), 1);
assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it");
assert_eq!(d.ruler_origin, Point::ZERO);
assert_eq!(
gone.note().unwrap(),
format!(
"1 artboard, 3 guides beyond the canvas (more than {MAX_COORD} pt from the origin) were removed; the ruler origin, beyond it too, was reset"
)
);
}
/// A sublayer like the document's first layer, holding `children`.
fn sublayer(d: &Document, id: u64, children: Vec<Arc<Node>>, clip: bool) -> Arc<Node> {
let mut l = (*d.layers[0]).clone();
l.id = NodeId(id);
if let NodeKind::Layer { children: c, clip: k, .. } = &mut l.kind {
*c = children;
*k = clip;
}
Arc::new(l)
}
#[test]
fn objects_hidden_or_clipped_inside_a_container_are_looked_at_one_by_one() {
// A layer's bounds leave out its hidden objects, and a clip group's are its clipping path's.
let mut hidden = (*rect(41, 1e17)).clone();
hidden.visible = false;
let probe = Document::new(1.0, 1.0);
let sub = sublayer(&probe, 40, vec![Arc::new(hidden), rect(42, 0.0)], false);
let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, 0.0), rect(32, 1e17), rect(33, 5.0)], clip: true }));
let mut d = doc(vec![sub, clip]);
let layer = d.layers[0].id.0;
assert_eq!(d.drop_beyond_canvas().objects, 2);
assert_eq!(ids(&d), [layer, 40, 42, 30, 31, 33], "the far hidden object and the far clipped one go; the clipping path stays first");
}
#[test]
fn a_top_level_layer_clipping_by_a_path_beyond_the_canvas_is_emptied() {
let mut d = doc(vec![rect(10, 1e17), rect(11, 0.0), rect(12, 5.0)]);
if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
*clip = true;
}
assert_eq!(d.drop_beyond_canvas().objects, 3, "the clipping path and what it clipped go together");
assert!(
matches!(&d.layers[0].kind, NodeKind::Layer { children, clip: false, .. } if children.is_empty()),
"no other object becomes the clipping path"
);
// A clipping sublayer goes whole, as a clip group does.
let mut d = doc(vec![]);
let sub = sublayer(&d, 40, vec![rect(41, 1e17), rect(42, 0.0)], true);
let mut d2 = doc(vec![sub, rect(10, 0.0)]);
let layer = d2.layers[0].id.0;
assert_eq!(d2.drop_beyond_canvas().objects, 1);
assert_eq!(ids(&d2), [layer, 10]);
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
}
#[test]
fn a_removed_artboards_ruler_origin_goes_and_its_id_is_not_given_again() {
let mut d = doc(vec![]);
let kept = d.artboards[0].id;
d.artboards.push(Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() });
d.artboard_ruler_origins.insert(kept, vectorcraft_geom::Vec2::new(1.0, 2.0));
d.artboard_ruler_origins.insert(9, vectorcraft_geom::Vec2::new(3.0, 4.0));
assert_eq!(d.drop_beyond_canvas().artboards, 1);
assert_eq!(d.artboard_ruler_origins.keys().copied().collect::<Vec<_>>(), [kept]);
assert!(d.next_artboard_id() > 9, "a new artboard doesn't take the removed one's id");
}
#[test]
fn what_names_a_removed_object_forgets_it() {
let probe = Document::new(1.0, 1.0);
let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true);
let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]);
d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }];
d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into();
d.assets = vec![
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] },
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] },
];
d.variables.bindings.insert(NodeId(11), ["v".to_string()].into());
d.variables.bindings.insert(NodeId(10), ["w".to_string()].into());
let gone = d.drop_beyond_canvas();
assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide");
assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays");
assert_eq!(d.saved_selections[0].objects, [NodeId(10)]);
assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::<Vec<_>>(), [(2, vec![NodeId(10)])]);
assert_eq!(d.variables.bindings.keys().copied().collect::<Vec<_>>(), [NodeId(10)]);
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
}
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
return None;
}
+76
View File
@@ -0,0 +1,76 @@
//! Compound shapes (Pathfinder panel › Make Compound Shape): a group whose members each have a
//! shape mode. It draws as the one area the modes make, from the backmost member up, painted with
//! the group's own appearance; the members stay editable. Evaluated in `vectorcraft-effects`.
use serde::{Deserialize, Deserializer, Serialize};
use crate::{Node, NodeKind};
/// How a compound shape's member combines with the area of the members below it. The backmost
/// member's mode does not matter: it starts the area.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ShapeMode {
/// Adds the member's area.
#[default]
Add,
/// Takes the member's area away.
Subtract,
/// Keeps only what the member's area overlaps.
Intersect,
/// Keeps where exactly one of the area and the member's area is.
Exclude,
}
impl ShapeMode {
/// Every mode, in the Pathfinder panel's order.
pub const ALL: [ShapeMode; 4] = [ShapeMode::Add, ShapeMode::Subtract, ShapeMode::Intersect, ShapeMode::Exclude];
/// The mode's name in commands and files.
pub fn name(self) -> &'static str {
match self {
ShapeMode::Add => "add",
ShapeMode::Subtract => "subtract",
ShapeMode::Intersect => "intersect",
ShapeMode::Exclude => "exclude",
}
}
/// The mode named `s` ([`ShapeMode::name`]).
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.name() == s)
}
}
/// Files are untrusted: a mode that isn't one of the names reads as Add.
impl<'de> Deserialize<'de> for ShapeMode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_str().and_then(ShapeMode::parse).unwrap_or_default())
}
}
impl Node {
/// Is this a compound shape? Only a group that doesn't clip can be one: the mark on anything
/// else (from a file) is ignored.
pub fn is_compound_shape(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().compound_shape
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_read_back_and_unknown_ones_are_add() {
for m in ShapeMode::ALL {
let v = serde_json::to_value(m).unwrap();
assert_eq!(v, serde_json::json!(m.name()));
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), m);
}
for v in [serde_json::json!("divide"), serde_json::json!(3), serde_json::json!(null), serde_json::json!({"a": [1]})] {
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), ShapeMode::Add);
}
}
}
+3 -3
View File
@@ -485,7 +485,7 @@ mod tests {
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
@@ -504,7 +504,7 @@ mod tests {
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
d.symbols.push(crate::Symbol { dynamic: false, name: format!("s{i}"), art });
}
d
}
@@ -573,7 +573,7 @@ mod tests {
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
+4 -1
View File
@@ -3,7 +3,10 @@ use super::graphic_style::default_graphic_styles;
use super::guides::{GridPrefs, RulerMode};
use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID};
use super::units::{ColorMode, Unit};
use super::*;
use super::{
Arc, BTreeMap, ColorProfiles, DocMetadata, DocSetup, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, PatternDef, Places, Point,
RasterEffectsSettings, Rect, Vec2,
};
impl Document {
/// Is everything but the layers the same in `other`, as the next version of the document after
+4 -1
View File
@@ -1,7 +1,10 @@
use super::graphic_style::GraphicStyle;
use super::limits::MAX_NEXT_ID;
use super::records::{ImageBlob, SavedSelection, SavedView};
use super::*;
use super::{
Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice,
SymbolRegistration,
};
impl Document {
/// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's.
+1 -1
View File
@@ -1,6 +1,6 @@
use super::document_ext::free_id;
use super::guides::Guide;
use super::*;
use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds};
impl Document {
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Appearance, Deserialize, Knockout, Node, Rect, Serialize, skip};
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, NodeId, Serialize, Vec2};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
+82 -14
View File
@@ -110,6 +110,72 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
hit_test_skipping(doc, p, opt, &|_| false, false)
}
/// [`hit_test_visible`] passing over the nodes `skip` names (and their contents): what lies below
/// them at `p`. A skipped clipping path still clips.
pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
hit_test_skipping(doc, p, opt, skip, false)
}
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
/// art instead of starting a marquee: an image in a mask, picked in the Layers panel, can be
/// dragged by its hidden part, whose frame shows while it is selected. An object that a click
/// can't hit anyway doesn't count: hidden, locked, a template, a guide while guides are locked
/// or hidden, in a Shaper group's part a click can't reach, outside the isolated container
/// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its
/// outline, as a click there hits it.
pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option<NodeId> {
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
selected.iter().rev().copied().find(|&id| {
let Some(chain) = doc.ancestry(id) else { return false };
let nodes: Vec<&Node> = chain.iter().filter_map(|a| doc.node(*a)).collect();
if nodes.len() != chain.len()
|| nodes.iter().any(|n| !n.visible || n.locked || n.is_template())
|| opt.scope.is_some_and(|s| !chain.contains(&s))
|| locks.holds_id(id)
{
return false;
}
// How a click reaches it, if one does.
let Some(opt) = path_options(&nodes, &chain, opt) else { return false };
// Hidden by a mask: one of the containers around it clips, and not to `p`.
let masked = nodes
.iter()
.rev()
.skip(1)
.any(|n| n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p)));
// A click may reach it there ([`may_hit`]: not a guide while guides are locked or
// hidden, nor a Shaper group's hidden part), and its own art is what a click would hit
// in it: a group's hidden, locked or masked contents don't count.
let [.., parent, n] = nodes.as_slice() else { return false };
let reachable =
parent.children().and_then(|c| c.iter().position(|c| c.id == id)).is_some_and(|i| may_hit(parent, i, n, p, opt, &symbols, true));
masked && reachable && object_hit(n, p, opt, &symbols, true)
})
}
/// The options a click goes down `nodes` (an object's ancestry, `chain`, the layer first) with, as
/// [`hit_test`] goes: from the isolated container (`opt.scope`), else from the layer, in outline
/// below a layer whose Preview is off. `None` when a Shaper group's part it can't reach lies on
/// the way ([`shaper_hides`]).
fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option<HitOptions> {
let scope_at = opt.scope.and_then(|s| chain.iter().position(|a| *a == s));
let child_index = |parent: &Node, child: &Node| parent.children().and_then(|c| c.iter().position(|c| c.id == child.id));
let hidden = nodes.windows(2).skip(scope_at.unwrap_or(0)).any(|w| match w {
[parent, child] => child_index(parent, child).is_some_and(|i| shaper_hides(parent, i, opt.scope)),
_ => false,
});
if hidden {
return None;
}
// The isolated container's own Preview doesn't count: a click starts inside it.
let below = scope_at.map_or(0, |i| i + 1);
let outline = nodes.iter().skip(below).any(|n| matches!(n.kind, NodeKind::Layer { preview: false, .. }));
Some(if outline { HitOptions { outline: true, ..opt } } else { opt })
}
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
@@ -277,7 +343,14 @@ fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
/// Would a click at `p` hit the locked object `n` if it were not locked?
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
object_hit(n, p, opt, symbols, false)
}
/// Would a click at `p` hit the object `n`, wherever it is? A container is hit by its contents as
/// [`hit_test`] tries them (its own clipping mask, and with `editable_only` none of its hidden,
/// locked or template contents).
fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), editable_only).is_some();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
@@ -471,20 +544,15 @@ fn hit_children(
}
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
/// come first (from the top among them too).
/// from the top, except that in an Intertwine group the members are tried in the order they are
/// drawn at `p`, from the top ([`crate::intertwine::tier_at`]).
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
} else {
vec![]
};
let rest = (0..children.len()).rev().filter({
let over = over.clone();
move |i| !over.contains(i)
});
over.into_iter().chain(rest)
let mut order: Vec<usize> = (0..children.len()).rev().collect();
if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let tier = |i: usize| children.get(i).map_or(0, |c| crate::intertwine::tier_at(c, p));
order.sort_by_cached_key(|i| std::cmp::Reverse((tier(*i), *i)));
}
order.into_iter()
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
+143 -1
View File
@@ -77,6 +77,148 @@ fn half_clipped(d: &mut Document) -> (Node, Node) {
(mask, art)
}
/// The art a mask hides counts only for a selected object: there a click hits nothing, and the
/// selection tools grab the selection by that art. Not where the art shows (the click hits it),
/// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked,
/// hidden, outside the isolated container). A layer's clipping mask hides art as a clip group's
/// does.
#[test]
fn a_selected_objects_art_hidden_by_a_mask_is_found_under_the_pointer() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, shown, beyond) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0), Point::new(250.0, 50.0));
let opt = HitOptions::default();
assert!(hit_test(&d, hidden, opt).is_none());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
assert_eq!(selected_hidden_at(&d, hidden, opt, &[]), None);
assert_eq!(selected_hidden_at(&d, shown, opt, &[art_id]), None);
assert_eq!(selected_hidden_at(&d, beyond, opt, &[art_id]), None);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None);
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(g), ..opt }, &[art_id]), Some(art_id));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(art_id), ..opt }, &[art_id]), Some(art_id));
let other = d.add_layer(Some("Other"));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(other), ..opt }, &[art_id]), None);
for change in [|n: &mut Node| n.locked = true, |n: &mut Node| n.visible = false] {
let mut d = d.clone();
change(d.node_mut(art_id).unwrap());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), None);
}
// A layer's clipping mask.
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let art_id = art.id;
d.insert(Some(l), 0, mask).unwrap();
d.insert(Some(l), 1, art).unwrap();
d.node_mut(l).unwrap().set_clips(true);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
}
/// A selected group's own art is what a click would hit in it: not a hidden or locked child, nor
/// art its own clipping mask hides, all under the pointer where an outer mask hides them.
#[test]
fn a_selected_groups_hidden_art_counts_only_where_a_click_would_hit_it() {
// An outer clip group masking to the left half (0..100); the selected group inside it holds
// `child` (100..200 × 0..100, under the pointer at (150, 50)) and `shown` (inside the mask).
let build = |change: &dyn Fn(&mut Node, &mut Node)| {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let outer_mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
let (gid, cid) = (d.alloc_id(), d.alloc_id());
let mut group = Node::group(gid, vec![Arc::new(shown)]);
change(&mut group, &mut child);
if let NodeKind::Group { children, .. } = &mut group.kind {
children.push(Arc::new(child));
}
let mut clip = Node::group(cid, vec![Arc::new(outer_mask), Arc::new(group)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
(d, gid)
};
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
let (d, g) = build(&|_, _| {});
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), Some(g));
let (d, g) = build(&|_, child| child.visible = false);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a hidden child");
let (d, g) = build(&|_, child| child.locked = true);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a locked child");
// The selected group is a clip group too, whose own mask (its first child, 10..40) leaves
// `child` out.
let (d, g) = build(&|group, _| group.set_clips(true));
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "its own mask");
}
/// A selected guide path where a mask hides it counts only while guides can be clicked: not
/// while they are locked or hidden (`skip_guides`), as a click on it then hits nothing.
#[test]
fn a_selected_guide_hidden_by_a_mask_counts_only_while_guides_can_be_clicked() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, mut art) = half_clipped(&mut d);
if let NodeKind::Path { guide, .. } = &mut art.kind {
*guide = true;
}
let (g, guide_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[guide_id]), Some(guide_id));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { skip_guides: true, ..opt }, &[guide_id]), None);
}
/// A selected path in a Shaper group's original shapes, where an outer mask hides it, counts only
/// while those shapes can be clicked (Construction mode, with them isolated), however deep it
/// lies in them.
#[test]
fn a_selected_path_in_a_shaper_groups_hidden_shapes_counts_only_in_construction_mode() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// The outer clip group masks to 0..100; the path, 100..200, lies in the Shaper group's
// original shapes (its first child), under (150, 50).
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let path = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
let path_id = path.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(path)]);
sources.name = Some(crate::shaper::SOURCES.into());
let sources_id = sources.id;
let face = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
let mut shaper = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
shaper.extras_mut().shaper = Some(Box::default());
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(shaper)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[path_id]), None);
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(sources_id), ..opt }, &[path_id]), Some(path_id));
}
/// On a layer whose Preview is off, a click hits art in outline: a selected object's hidden art
/// counts on its outline, not inside its fill.
#[test]
fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(l).unwrap().kind {
*preview = false;
}
let opt = HitOptions::default();
assert_eq!(selected_hidden_at(&d, Point::new(150.0, 50.0), opt, &[art_id]), None, "inside the fill");
assert_eq!(selected_hidden_at(&d, Point::new(200.0, 50.0), opt, &[art_id]), Some(art_id), "on the outline");
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
@@ -699,7 +841,7 @@ fn objects_at_matches_repeated_hits_on_random_documents() {
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
+6 -3
View File
@@ -109,9 +109,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
}
}
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
/// dropped.
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no
/// link: changes to it are dropped.
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
for it in &mut n.appearance.items {
match it {
@@ -119,6 +119,9 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
}
}
if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) {
visit_paint(p, f);
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
+59 -20
View File
@@ -1,36 +1,51 @@
//! Object › Intertwine: objects that pass over and under each other.
//!
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
//! stacking order. A member can be drawn over the other members in part of the group: that area
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
//! over the others in one place, the upper one in the stacking order ends on top.
//! stacking order. A member can be drawn over other members in part of the group, at a tier: the
//! area of tier 1 is the member's [`crate::node::NodeExtras::intertwine`], and the areas of tiers 2
//! and up are its [`crate::node::NodeExtras::intertwine_tiers`], in document coordinates. In each
//! place the members are drawn by their tier there (0 outside their areas), and members of one
//! tier in their stacking order, so any order of the members can be set for each overlap area.
//! The group draws (and exports, and is hit) as its live expansion ([`expand`]): the members,
//! then, tier by tier, a copy of each member with an area of that tier clipped to it.
//!
//! The members stay editable, and Release gives them back as they were (the areas let go).
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
use std::sync::Arc;
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{FillRule, PathData, Point};
use crate::{Node, NodeId, NodeKind};
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
pub fn over_area(n: &Node) -> Option<&PathData> {
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
/// The areas where `n` is drawn over other members of its Intertwine group, each with its tier
/// (1 and up); empty ones are left out.
pub fn tier_areas(n: &Node) -> impl Iterator<Item = (usize, &PathData)> {
let e = n.extras();
e.intertwine
.as_deref()
.map(|a| (1, a))
.into_iter()
.chain(e.intertwine_tiers.iter().enumerate().map(|(k, a)| (k + 2, a)))
.filter(|(_, a)| !a.is_empty())
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
/// member with an area drawn over the others, as a clip group of that area around a copy of it
/// (ids 0: generated art).
/// The tier `n` is drawn at at `p`: the highest tier whose area holds `p` (0: none does).
pub fn tier_at(n: &Node, p: Point) -> usize {
tier_areas(n).filter(|(_, a)| fill_contains(&a.to_bezpath(), FillRule::NonZero, p)).map(|(t, _)| t).max().unwrap_or(0)
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then, for
/// each tier from 1 up, each member with an area of that tier, as a clip group of that area
/// around a copy of it (ids 0: generated art).
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
for c in children {
let Some(area) = over_area(c) else { continue };
if !c.visible {
continue;
}
let mut over: Vec<(usize, usize, &PathData)> =
children.iter().enumerate().filter(|(_, c)| c.visible).flat_map(|(i, c)| tier_areas(c).map(move |(t, a)| (t, i, a))).collect();
over.sort_by_key(|(t, i, _)| (*t, *i));
for (_, i, area) in over {
let Some(c) = children.get(i) else { continue };
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = crate::Appearance::default().into();
let mut copy = plain(c);
@@ -40,11 +55,13 @@ pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
out
}
/// `n` without its Intertwine area (the expansion is plain art).
/// `n` without its Intertwine areas (the expansion is plain art).
fn plain(n: &Node) -> Node {
let mut n = n.clone();
if n.extras().intertwine.is_some() {
n.extras_mut().intertwine = None;
if n.extras().intertwine.is_some() || !n.extras().intertwine_tiers.is_empty() {
let mut e = n.extras_mut();
e.intertwine = None;
e.intertwine_tiers = Vec::new();
}
n
}
@@ -88,6 +105,28 @@ mod tests {
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
}
#[test]
fn copies_are_drawn_tier_by_tier() {
let over = || shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0));
// The lower member at tier 2, the upper one at tier 1: the lower one ends on top.
let mut a = (*square(1, 0.0)).clone();
a.extras_mut().intertwine_tiers = vec![over()];
let mut b = (*square(2, 5.0)).clone();
b.extras_mut().intertwine = Some(Box::new(over()));
let kids = vec![Arc::new(a), Arc::new(b)];
assert_eq!((tier_at(&kids[0], Point::new(7.0, 5.0)), tier_at(&kids[1], Point::new(7.0, 5.0))), (2, 1));
assert_eq!(tier_at(&kids[0], Point::new(2.0, 5.0)), 0);
let out = expand(&kids);
assert_eq!(out.len(), 4, "both members, then the copy of tier 1, then the one of tier 2");
assert!(out.iter().all(|n| n.extras().intertwine.is_none() && n.extras().intertwine_tiers.is_empty()), "the expansion is plain art");
let copied = |n: &Node| match &n.kind {
NodeKind::Group { children, clip: true } => children[1].path_data().cloned(),
k => panic!("a clip group: {k:?}"),
};
assert_eq!(copied(&out[2]).as_ref(), kids[1].path_data());
assert_eq!(copied(&out[3]).as_ref(), kids[0].path_data());
}
#[test]
fn a_hidden_member_is_not_drawn_over_the_others() {
let mut a = (*square(1, 0.0)).clone();
+9 -2
View File
@@ -11,6 +11,7 @@ pub mod blend;
pub mod cancel;
pub mod clipnest;
pub mod cmyk;
pub mod compound_shape;
pub mod corners;
pub mod depth;
pub mod dimension;
@@ -23,11 +24,13 @@ pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_nine_slice;
pub mod symbol_overrides;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
@@ -42,7 +45,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub use pixels::{GraySource, MAX_RASTER_ALLOC};
pub mod placed_document;
pub mod plugin_data;
pub mod profiles;
@@ -76,6 +79,7 @@ pub use appearance::{
StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
pub use corners::LiveCorners;
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
pub use graph::{
@@ -105,7 +109,8 @@ pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
@@ -117,6 +122,7 @@ use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
mod artboard;
mod canvas_range;
mod document;
mod document_ext;
mod document_guides;
@@ -127,6 +133,7 @@ mod records;
mod units;
pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
pub use canvas_range::BeyondCanvas;
#[cfg(test)]
use document_ext::free_id;
pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle};
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::NodeId;
/// Coordinates beyond this many points exceed the supported document range.
pub const MAX_COORD: f64 = 4.0e6;
+65 -1
View File
@@ -126,6 +126,40 @@ impl ImageBlob {
self
}
/// This blob after its pixels are decoded once, within [`crate::MAX_RASTER_ALLOC`], to check
/// that they can be read; with `proxy`, it also gets the preview [`Self::with_proxy`] makes,
/// made from that decode. `None` when the pixels can't be read, as with a file cut short that
/// has a header but not all its pixels. An image whose pixels would take more than the
/// allowance as RGBA is not decoded, and one whose decode needs more than the allowance stops
/// decoding; either is returned as it is.
pub fn checked_with_proxy(self, proxy: bool) -> Option<Self> {
self.checked_with_pixels(proxy).map(|(blob, _)| blob)
}
/// [`Self::checked_with_proxy`], with the pixels it decoded: `None` beside the blob when it
/// decoded none (an image past the allowance).
pub fn checked_with_pixels(mut self, proxy: bool) -> Option<(Self, Option<image::DynamicImage>)> {
let max = crate::MAX_RASTER_ALLOC;
let reader = || image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok();
let (w, h) = reader()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) > max / 4 {
return Some((self, None));
}
let mut r = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
r.limits(limits);
let img = match r.decode() {
Ok(img) => img,
Err(image::ImageError::Limits(_)) => return Some((self, None)),
Err(_) => return None,
};
if proxy && self.proxy.is_none() && w.max(h) > PROXY_SIZE {
self.proxy = proxy_of(&img).map(Arc::new);
}
Some((self, Some(img)))
}
/// Are the bytes the preview (the linked file hasn't been read)?
pub fn is_proxy(&self) -> bool {
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
@@ -149,7 +183,12 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
reader.limits(limits);
let small = reader.decode().ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
proxy_of(&reader.decode().ok()?)
}
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
let mut png = Vec::new();
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
@@ -442,6 +481,31 @@ mod tests {
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
}
/// A checked image's pixels are decoded once: the preview comes from that decode, and an image
/// whose header reads but whose pixels don't is refused.
#[test]
fn a_checked_image_is_refused_when_its_pixels_cant_be_read() {
let full = png(600, 300);
let big = ImageBlob::new("image/png", full.clone()).checked_with_proxy(true).unwrap();
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
assert_eq!((p.width(), p.height()), (256, 128));
assert!(ImageBlob::new("image/png", full.clone()).checked_with_proxy(false).unwrap().proxy.is_none(), "no preview asked for");
assert!(ImageBlob::new("image/png", png(40, 20)).checked_with_proxy(true).unwrap().proxy.is_none(), "small images keep their pixels");
let cut = full[..full.len() / 2].to_vec();
assert_eq!(image::ImageReader::new(Cursor::new(&cut)).with_guessed_format().unwrap().into_dimensions().unwrap(), (600, 300));
assert!(ImageBlob::new("image/png", cut).checked_with_proxy(true).is_none(), "cut to half its length");
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).checked_with_proxy(false).is_none(), "not an image");
}
/// A checked image hands on the pixels it decoded, so they need not be decoded again.
#[test]
fn a_checked_image_hands_on_its_pixels() {
let full = png(600, 300);
let (blob, pixels) = ImageBlob::new("image/png", full.clone()).checked_with_pixels(true).unwrap();
assert!(blob.proxy.is_some(), "the preview is made as checked_with_proxy makes it");
assert_eq!(pixels.map(|p| p.to_rgba8()), Some(image::load_from_memory(&full).unwrap().to_rgba8()));
}
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
+128 -2
View File
@@ -63,7 +63,7 @@ pub use expand::{
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
#[cfg(test)]
@@ -181,6 +181,44 @@ mod tests {
assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}");
}
/// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the
/// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory
/// does, and each patch stays at least one quad.
#[test]
fn a_large_mesh_tessellates_within_the_quad_budget() {
let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
// 3,600 patches at 32 x 32 would be 3.7 million quads.
let m = mesh(60);
let quads = m.quads(32);
assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len());
assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len());
// A small mesh keeps the subdivision asked for.
assert_eq!(mesh(3).quads(8).len(), 9 * 64);
// The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch.
let big = mesh(150).quads(64);
assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len());
// More patches than the budget has quads is malformed, as a point count that doesn't fit is.
let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] };
let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] };
assert!(!huge.is_valid());
assert!(huge.quads(1).is_empty());
}
/// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`]
/// gives way to: each patch's pieces are its own quads', so together they cover the mesh once.
#[test]
fn a_large_meshs_pieces_follow_the_quad_budget() {
let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
let area = |pts: &[Point]| {
let n = pts.len();
(0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::<f64>().abs() / 2.0
};
let pieces = m.pieces(32);
assert!(pieces.len() >= 3600, "{} pieces", pieces.len());
let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum();
assert!((total - 90_000.0).abs() < 90.0, "covered {total}");
}
/// A file's rows and columns can be anything: a point count too large to compute is no match
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
#[test]
@@ -195,17 +233,86 @@ mod tests {
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
}
/// How far the border of `m` strays from the ellipse in `r`, in points (measured across the
/// ellipse's short radius), checked at 20 places along every border edge.
fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 {
let (rows, cols) = (m.rows as usize, m.cols as usize);
let edges = (0..cols).flat_map(|c| [m.edge_u(0, c), m.edge_u(rows, c)]).chain((0..rows).flat_map(|k| [m.edge_v(k, 0), m.edge_v(k, cols)]));
let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0);
edges
.flat_map(|e| (0..=20).map(move |i| e.eval(f64::from(i) / 20.0)))
.map(|p| ((p.x - c.x) / a).hypot((p.y - c.y) / b) - 1.0)
.fold(0.0f64, |w, d| w.max(d.abs() * a.min(b)))
}
/// #3187: a gradient mesh made from an ellipse keeps the ellipse's shape: a Mesh tool click
/// (a 1×1 mesh with lines through the point) and Create Gradient Mesh's 4×4 grid both lay
/// their border on the outline, within about twice the 0.35 pt the outline is flattened to.
/// The 1×1 mesh was up to 7 pt off this 400 × 300 pt ellipse.
#[test]
fn a_mesh_from_an_ellipse_keeps_its_outline() {
const OFF: f64 = 0.75;
let r = Rect::new(100.0, 100.0, 500.0, 400.0);
let e = shapes::ellipse(r);
for (rows, cols) in [(1, 1), (4, 4), (2, 5)] {
let m = GradientMesh::for_path(&e, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let off = mesh_border_off_ellipse(&m, r);
assert!(off < OFF, "{rows}×{cols} mesh: border {off} pt off the ellipse");
}
let made = GradientMesh::for_path(&e, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let mut m = made.clone();
// Off centre, so each line splits its edges unevenly.
let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap();
let off = mesh_border_off_ellipse(&m, r);
assert!(off < OFF, "after a Mesh tool click: {off} pt");
// Deleting the lines again (Alt-click) gives the border back as it was.
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
for (k, (now, was)) in m.points.iter().zip(&made.points).enumerate() {
let moved = now.handles.iter().zip(&was.handles).map(|(a, b)| (*a - *b).hypot()).fold(0.0, f64::max);
assert!(now.p.distance(was.p) < 1e-6 && moved < 1e-3, "point {k}: {now:?}, made as {was:?}");
}
// With a handle at the new border point shortened since, the two edges no longer make one
// curve: the merged edge is straight rather than grown past its ends.
let mut m = made.clone();
let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap();
let top = m.idx(0, 1);
m.points[top].handles[H_LEFT] *= 1e-4;
assert!(m.remove_point_lines(i));
let (a, b) = (m.points[0].p, m.points[1].p);
assert!((m.points[0].handles[H_RIGHT] - (b - a) / 3.0).hypot() < 1e-9, "{:?}", m.points[0].handles);
// A rectangle's mesh keeps straight edges with handles a third of the way along.
let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 60.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let e = m.edge_u(0, 0);
assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}");
}
#[test]
fn mesh_insert_and_remove_lines() {
let mut m =
GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap();
let i = m.add_lines_at(Point::new(30.0, 60.0), 0.0).unwrap();
assert_eq!((m.rows, m.cols), (2, 2));
assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p);
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
}
/// A point outside the mesh within the tolerance locates the nearest point of its edge.
#[test]
fn mesh_locate_within_reaches_the_edge() {
let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let p = Point::new(102.0, 30.0);
assert_eq!(m.locate(p), None);
assert_eq!(m.locate_within(p, 1.0), None);
let (r, c, u, v) = m.locate_within(p, 3.0).unwrap();
assert_eq!((r, c), (0, 1));
assert_eq!(u, 1.0, "on the right edge");
let q = m.eval(r, c, u, v);
assert!(q.distance(Point::new(100.0, 30.0)) < 1e-6, "{q:?}");
assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0)));
}
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
#[test]
fn a_cmyk_mesh_mixes_its_inks() {
@@ -221,6 +328,25 @@ mod tests {
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
}
/// #3232: the Appearance highlight lightens each point towards white in its own model, so a
/// CMYK mesh stays CMYK (K shrinks with the other inks) and a grey one stays grey.
#[test]
fn mesh_highlight_keeps_each_points_model() {
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let ink = Color::Cmyk { c: 0.0, m: 0.4, y: 0.6, k: 0.1 };
let m = GradientMesh::for_path(&square, 2, 2, ink, MeshAppearance::ToCenter, 50.0).unwrap();
assert_eq!(m.points[0].color, ink, "corners stay");
let centre = m.points[m.idx(1, 1)].color;
let Color::Cmyk { c, m: mg, y, k } = centre else { panic!("{centre:?}") };
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-6 && (y - 0.3).abs() < 1e-6 && (k - 0.05).abs() < 1e-6, "{c} {mg} {y} {k}");
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Cmyk { .. }), "patches mix in CMYK");
let m = GradientMesh::for_path(&square, 2, 2, Color::gray(0.8), MeshAppearance::ToEdge, 100.0).unwrap();
assert_eq!((m.points[0].color, m.points[m.idx(1, 1)].color), (Color::gray(0.0), Color::gray(0.8)));
// RGB lightens in RGB as before.
let m = GradientMesh::for_path(&square, 2, 2, Color::rgb(1.0, 0.0, 0.0), MeshAppearance::ToCenter, 100.0).unwrap();
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
}
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
/// deletes nothing (it indexed past the points, a panic).
#[test]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, PathData, Serialize};
// =====================================================================================
// Data
+1 -1
View File
@@ -1,6 +1,6 @@
use super::expand::{Hooks, Outliner, Warper, map_node};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
use super::*;
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+23 -8
View File
@@ -1,7 +1,10 @@
use super::envelope::{EnvelopeOptions, expand_envelope};
use super::envelope::{EnvelopeOptions, PreserveShape, expand_envelope};
use super::mesh::GradientMesh;
use super::warp::map_nonlinear;
use super::*;
use super::{
Affine, Appearance, AppearanceItem, Arc, Color, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2,
blend_expand,
};
/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines.
pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option<Node>>;
@@ -154,8 +157,10 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its
/// edges (at most half a point) so neighbours overlap and no seams show.
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
/// seams show.
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
@@ -186,11 +191,20 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
if len > 1e-12 { *p + v * (grow / len) } else { *p }
})
.collect();
let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default());
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = !w.options.anti_alias;
if w.options.preserve_shape == PreserveShape::Transparency {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
continue;
}
let mut clip = Node::path(n.id, shape, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
}
}
@@ -279,9 +293,10 @@ pub fn is_live(n: &Node) -> bool {
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined
/// where the colour hardly changes ([`GradientMesh::pieces`]).
pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> {
m.quads(n)
m.pieces(n)
.into_iter()
.map(|q| {
let mut ap = Appearance::default();
+299 -19
View File
@@ -1,6 +1,9 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::*;
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshPoint {
@@ -32,6 +35,15 @@ pub struct MeshQuad {
pub corners: [(Color, f32); 4],
}
/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through
/// tessellation points, its colour and opacity.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPiece {
pub pts: Vec<Point>,
pub color: Color,
pub opacity: f32,
}
/// Create Gradient Mesh → Appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MeshAppearance {
@@ -154,6 +166,76 @@ impl Coons {
// Gradient meshes
// =====================================================================================
/// The handles, as offsets from `ends`, of the cubic from `ends.0` to `ends.1` that comes closest
/// (least squares) to `side` between `span.0` and `span.1`; `None` when they can't be computed.
/// Each point of the side is matched to the curve's nearest point, refined over a few rounds.
fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(Vec2, Vec2)> {
const N: usize = 32;
const ROUNDS: usize = 6;
let (p0, p3) = (ends.0, ends.1);
let qs: Vec<Point> = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect();
let mut ts: Vec<f64> = (1..N).map(|i| i as f64 / N as f64).collect();
let mut fit = None;
for _ in 0..ROUNDS {
let (mut c11, mut c12, mut c22) = (0.0, 0.0, 0.0);
let (mut x1, mut x2) = (Vec2::ZERO, Vec2::ZERO);
for (q, &t) in qs.iter().zip(&ts) {
let s = 1.0 - t;
let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t);
let r = q.to_vec2() - b0 * p0.to_vec2() - b3 * p3.to_vec2();
c11 += b1 * b1;
c12 += b1 * b2;
c22 += b2 * b2;
x1 += b1 * r;
x2 += b2 * r;
}
let det = c11 * c22 - c12 * c12;
if det.abs() < 1e-12 {
break;
}
let (p1, p2) = (((c22 * x1 - c12 * x2) / det).to_point(), ((c11 * x2 - c12 * x1) / det).to_point());
if !(p1.is_finite() && p2.is_finite()) {
break;
}
fit = Some((p1 - p0, p2 - p3));
// One Newton step towards the nearest point of the curve for each point of the side.
let c = CubicBez::new(p0, p1, p2, p3);
let (a, b, k) = (p1 - p0, p2 - p1, p3 - p2);
for (q, t) in qs.iter().zip(ts.iter_mut()) {
let s = 1.0 - *t;
let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k);
let acc = 6.0 * (s * (b - a) + *t * (k - b));
let e = c.eval(*t) - *q;
let den = v.hypot2() + e.dot(acc);
if den.abs() > 1e-12 {
*t = (*t - e.dot(v) / den).clamp(0.0, 1.0);
}
}
}
fit
}
/// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles
/// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer
/// ends: each outer handle grown back by the share of the curve its edge had, which undoes the
/// split [`GradientMesh::insert_row`] and [`GradientMesh::insert_col`] make, so deleting a mesh
/// line keeps the curve of the border it crossed (#3187). The share is the split parameter, read
/// from the lengths of the middle handles. When the edges no longer fit one cubic (a point or a
/// handle was moved since the split), the cubic grown back would not pass through the middle
/// point, and the merged edge is straight.
fn merged_handles(a: (Point, Vec2), mid: (Point, Vec2, Vec2), b: (Point, Vec2)) -> (Vec2, Vec2) {
let straight = ((b.0 - a.0) / 3.0, (a.0 - b.0) / 3.0);
let (la, lb) = (mid.1.hypot(), mid.2.hypot());
let t = la / (la + lb);
if !(0.01..=0.99).contains(&t) {
return straight;
}
let (ha, hb) = (a.1 / t, b.1 / (1.0 - t));
let through = CubicBez::new(a.0, a.0 + ha, b.0 + hb, b.0).eval(t);
let tol = 1e-3 * (a.0.distance(mid.0) + mid.0.distance(b.0)).max(1e-9);
if ha.is_finite() && hb.is_finite() && through.distance(mid.0) <= tol { (ha, hb) } else { straight }
}
/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps.
fn even_steps(n: u32) -> Vec<f64> {
let n = n.clamp(1, 200);
@@ -167,11 +249,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option<usize> {
}
impl GradientMesh {
/// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can
/// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches
/// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh
/// with more patches than that is malformed, like a point count that doesn't fit (#3592).
pub const MAX_QUADS: usize = 1 << 20;
pub fn idx(&self, r: usize, c: usize) -> usize {
r * (self.cols as usize + 1) + c
}
pub fn is_valid(&self) -> bool {
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len())
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS
}
/// The number of patches (rows times columns), saturating.
fn patches(&self) -> usize {
(self.rows as usize).saturating_mul(self.cols as usize)
}
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
@@ -260,11 +353,54 @@ impl GradientMesh {
/// colour and opacity `color_at` gives at its position (a gradient fill's, say).
pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<Self> {
let coons = Coons::from_path(path)?;
Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)))
let (us, vs) = (even_steps(cols), even_steps(rows));
let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p));
m.fit_border(&coons, &us, &vs);
Some(m)
}
/// Give each edge on the mesh's border the handles that make it follow the outline it stands
/// for, in place of the handles [`Self::from_grid`] estimates from the surface's derivatives
/// (#3187). Where two border edges meet along a side, their handles are made collinear, so the
/// border stays smooth there; the four corners keep the handles their own edges were fitted with.
fn fit_border(&mut self, coons: &Coons, us: &[f64], vs: &[f64]) {
let (rows, cols) = (self.rows as usize, self.cols as usize);
if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 {
return;
}
let edges = [(0, &coons.top), (rows, &coons.bottom)]
.map(|(r, side)| (r, side, true))
.into_iter()
.chain([(0, &coons.left), (cols, &coons.right)].map(|(c, side)| (c, side, false)));
for (line, side, along_u) in edges {
let (n, steps) = if along_u { (cols, us) } else { (rows, vs) };
for k in 0..n {
let (a, b, out, back) = if along_u {
(self.idx(line, k), self.idx(line, k + 1), H_RIGHT, H_LEFT)
} else {
(self.idx(k, line), self.idx(k + 1, line), H_DOWN, H_UP)
};
if let Some((h1, h2)) = fit_edge(side, (steps[k], steps[k + 1]), (self.points[a].p, self.points[b].p)) {
self.points[a].handles[out] = h1;
self.points[b].handles[back] = h2;
}
}
for k in 1..n {
let (i, out, back) = if along_u { (self.idx(line, k), H_RIGHT, H_LEFT) } else { (self.idx(k, line), H_DOWN, H_UP) };
let h = &mut self.points[i].handles;
let (fwd, bwd) = (h[out], h[back]);
let dir = (fwd.normalize() - bwd.normalize()).normalize();
if fwd.hypot2() > 0.0 && bwd.hypot2() > 0.0 && dir.is_finite() {
h[out] = dir * fwd.hypot();
h[back] = -dir * bwd.hypot();
}
}
}
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
/// the edges, by at most `highlight` %.
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
let (rows, cols) = (self.rows as usize, self.cols as usize);
@@ -279,7 +415,8 @@ impl GradientMesh {
};
if w > 0.0 {
let i = self.idx(r, c);
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
let color = self.points[i].color;
self.points[i].color = lerp_color(&color, &Color::WHITE.in_model(color.model()), w);
}
}
}
@@ -342,13 +479,20 @@ impl GradientMesh {
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
/// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser
/// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least).
fn subdivision(&self, n: usize) -> usize {
n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1)
}
/// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre
/// and its corners.
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
if !self.is_valid() {
return vec![];
}
let n = n.clamp(1, 64);
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
let n = self.subdivision(n);
let mut out = Vec::with_capacity(self.patches() * n * n);
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
for r in 0..self.rows as usize {
@@ -378,6 +522,105 @@ impl GradientMesh {
out
}
/// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG,
/// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads
/// joined into square blocks as large as keeps the colour across each block within about one
/// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps
/// all its quads. A piece's outline runs through its quads' outer corners, so it covers what
/// they covered, and it takes the colour at its centre.
pub fn pieces(&self, n: usize) -> Vec<MeshPiece> {
let n = self.subdivision(n);
let quads = self.quads(n);
let cols = (self.cols as usize).max(1);
let mut out = Vec::with_capacity(quads.len());
for (k, patch) in quads.chunks_exact(n * n).enumerate() {
let (r, c) = (k / cols, k % cols);
let side = self.block_side(r, c, n, patch);
if side == 1 {
out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity }));
continue;
}
// Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of.
let at = |i: usize, j: usize| -> Option<Point> {
let (qi, qj) = (i.min(n - 1), j.min(n - 1));
let q = patch.get(qj * n + qi)?;
q.pts
.get(match (i > qi, j > qj) {
(false, false) => 0,
(true, false) => 1,
(true, true) => 2,
(false, true) => 3,
})
.copied()
};
for j0 in (0..n).step_by(side) {
for i0 in (0..n).step_by(side) {
let (i1, j1) = (i0 + side, j0 + side);
// Around the block, in the quads' order: along its top, down its right side,
// back along its bottom and up its left side.
let ring = (i0..i1)
.map(|i| (i, j0))
.chain((j0..j1).map(|j| (i1, j)))
.chain((i0 + 1..=i1).rev().map(|i| (i, j1)))
.chain((j0 + 1..=j1).rev().map(|j| (i0, j)));
let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::<Option<Vec<Point>>>() else { continue };
let half = side as f64 / 2.0;
let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64);
out.push(MeshPiece { pts, color, opacity });
}
}
}
out
}
/// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into
/// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one
/// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline
/// of a block across the fold wouldn't fill it as its quads do.
fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize {
let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc));
let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else {
return 1;
};
// Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too.
let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. }));
let channels = |p: &MeshPoint| -> Vec<f32> {
let mut v = p.color.to_rgb().to_vec();
if inks && let Color::Cmyk { c, m, y, k } = p.color {
v.extend([c, m, y, k]);
}
v.push(p.opacity);
v
};
let [a, b, cc, d] = [p00, p01, p10, p11].map(channels);
// A bilinear patch changes across a block of side h (0..1 of the patch) by at most
// h × (du + dv) in each channel, du and dv its largest change along each parameter.
let mut steps = 0.0f32;
for k in 0..a.len() {
let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default();
let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs());
let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs());
steps = steps.max((du + dv) * 255.0);
}
if !steps.is_finite() {
return 1;
}
let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1);
if side > 1 {
// Every quad turning the same way: the patch doesn't fold, so a block's outline
// encloses just what its quads cover.
let turn = |q: &MeshQuad| {
let [a, b, c, d] = q.pts;
(b - a).cross(c - a) + (c - a).cross(d - a)
};
let first = quads.first().map(turn).unwrap_or_default();
if !quads.iter().all(|q| turn(q) * first > 0.0) {
return 1;
}
}
side
}
/// The mesh's outer boundary as a closed path.
pub fn outline(&self) -> PathData {
if !self.is_valid() {
@@ -461,12 +704,21 @@ impl GradientMesh {
/// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh).
pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> {
self.locate_within(p, 0.0)
}
/// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the
/// patch and local (u, v) of the nearest point of the mesh's edge.
pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> {
if !self.is_valid() {
return None;
}
let bp = self.outline().to_bezpath();
if bp.winding(p) == 0 {
return None;
if tol.is_nan() || tol <= 0.0 {
return None;
}
return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v));
}
const N: usize = 16;
let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0);
@@ -503,6 +755,30 @@ impl GradientMesh {
Some((r, c, u, v))
}
/// The point of the mesh's outer edge nearest `p`: its squared distance from `p`, and its
/// boundary patch and local (u, v).
fn nearest_edge(&self, p: Point) -> Option<(f64, usize, usize, f64, f64)> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let (rows, cols) = (self.rows as usize, self.cols as usize);
let mut best: Option<(f64, usize, usize, f64, f64)> = None;
let mut take = |e: CubicBez, at: (usize, usize, Option<f64>, Option<f64>)| {
let n = e.nearest(p, 1e-9);
if best.is_none_or(|b| n.distance_sq < b.0) {
let (r, c, u, v) = at;
best = Some((n.distance_sq, r, c, u.unwrap_or(n.t), v.unwrap_or(n.t)));
}
};
for c in 0..cols {
take(self.edge_u(0, c), (0, c, None, Some(0.0)));
take(self.edge_u(rows, c), (rows - 1, c, None, Some(1.0)));
}
for r in 0..rows {
take(self.edge_v(r, 0), (r, 0, Some(0.0), None));
take(self.edge_v(r, cols), (r, cols - 1, Some(1.0), None));
}
best
}
/// Index of the mesh point within `tol` of `p`.
pub fn point_near(&self, p: Point, tol: f64) -> Option<usize> {
self.points
@@ -590,9 +866,10 @@ impl GradientMesh {
self.cols += 1;
}
/// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index.
pub fn add_lines_at(&mut self, p: Point) -> Option<usize> {
let (r, c, u, v) = self.locate(p)?;
/// Mesh tool click: add a row and a column through `p` (inside the mesh, or outside within
/// `tol` of its edge, through the nearest point of the edge). Returns the new point index.
pub fn add_lines_at(&mut self, p: Point, tol: f64) -> Option<usize> {
let (r, c, u, v) = self.locate_within(p, tol)?;
let mut rr = r;
let mut cc = c;
let row_new = v > 0.02 && v < 0.98;
@@ -627,10 +904,11 @@ impl GradientMesh {
let above = self.idx(r - 1, cc);
let below = self.idx(r + 1, cc);
let mid = self.idx(r, cc);
let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p);
let _ = pm;
self.points[above].handles[H_DOWN] = (pb - pa) / 3.0;
self.points[below].handles[H_UP] = (pa - pb) / 3.0;
let (pa, pb, m) = (self.points[above].p, self.points[below].p, &self.points[mid]);
let hm = (m.p, m.handles[H_UP], m.handles[H_DOWN]);
let (down, up) = merged_handles((pa, self.points[above].handles[H_DOWN]), hm, (pb, self.points[below].handles[H_UP]));
self.points[above].handles[H_DOWN] = down;
self.points[below].handles[H_UP] = up;
}
self.points.drain(r * w..(r + 1) * w);
self.rows -= 1;
@@ -641,9 +919,11 @@ impl GradientMesh {
let rows = self.rows as usize;
for rr in 0..=rows {
let (l, rt) = (rr * w + c - 1, rr * w + c + 1);
let (pl, pr) = (self.points[l].p, self.points[rt].p);
self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0;
self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0;
let (pl, pr, m) = (self.points[l].p, self.points[rt].p, &self.points[rr * w + c]);
let hm = (m.p, m.handles[H_LEFT], m.handles[H_RIGHT]);
let (right, left) = merged_handles((pl, self.points[l].handles[H_RIGHT]), hm, (pr, self.points[rt].handles[H_LEFT]));
self.points[l].handles[H_RIGHT] = right;
self.points[rt].handles[H_LEFT] = left;
}
let mut k = 0;
self.points.retain(|_| {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Anchor, BezPath, CubicBez, PI, ParamCurve, PathData, Point, SubPath};
// =====================================================================================
// Warp maps (shared with vectorcraft-effects' Warp effects)
+5 -1
View File
@@ -250,9 +250,13 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
if e.intertwine_tiers.capacity() > 0 {
b += ALLOC + e.intertwine_tiers.capacity() * size_of::<PathData>() + e.intertwine_tiers.iter().map(path_bytes).sum::<usize>();
}
b += e.symbol_overrides.capacity() * size_of::<crate::SymbolOverride>();
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
+2 -1
View File
@@ -28,10 +28,11 @@ pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttribut
pub use clip::{ClipPaint, Uniter};
pub use extras::{Extras, ExtrasMut, NodeExtras};
pub use ids::{LAYER_COLORS, LayerColor, NodeId};
pub use kind::{ImageObject, NodeKind};
pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget};
pub use live_shape::{LiveShape, Scaling};
pub use opacity::{Knockout, OpacityMask, whiten};
use wire::NodeWire;
pub use wire::{MAX_READ_NODES, with_node_budget};
fn yes() -> bool {
true
+1 -1
View File
@@ -1,6 +1,6 @@
use super::kind::NodeKind;
use super::live_shape::LiveShape;
use super::*;
use super::{Deserialize, Node, Serialize};
/// Window → Attributes: an object's settings that don't change how it prints.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::kind::NodeKind;
use super::*;
use super::{BezPath, FillRule, Node, Outliner, Rect, shapes};
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
+27 -1
View File
@@ -2,7 +2,7 @@ use super::attributes::ObjectAttributes;
use super::kind::NodeKind;
use super::live_shape::LiveShape;
use super::opacity::OpacityMask;
use super::*;
use super::{Node, PathData};
/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without
/// them stay small ([`Node::extras`]).
@@ -23,6 +23,9 @@ pub struct NodeExtras {
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// An image drawn without smoothing: each pixel of the picture takes the color of the image
/// pixel nearest to it (an envelope's distorted image with Envelope Options › Anti-Alias off).
pub pixelated: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
@@ -41,11 +44,28 @@ pub struct NodeExtras {
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
/// filled non-zero) where this object is drawn over the group's other members.
pub intertwine: Option<Box<PathData>>,
/// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates,
/// filled non-zero) where this object is drawn over members that are themselves drawn over
/// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1
/// (see [`crate::intertwine`]).
pub intertwine_tiers: Vec<PathData>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
/// A grayscale or 1-bit image's fill color (`paint.setFill` on the image): a solid paint that
/// colorizes it. Each pixel is drawn in this color, as opaque as the pixel is dark, so white
/// pixels are transparent. `None`: the image shows its own pixels.
pub colorize: Option<Box<vectorcraft_color::Paint>>,
/// Pathfinder panel › Make Compound Shape: this group is a compound shape
/// ([`Node::is_compound_shape`], [`crate::compound_shape`]).
pub compound_shape: bool,
/// As a member of a compound shape, how this object combines with the members below it.
pub shape_mode: crate::ShapeMode,
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
/// art (see [`crate::symbol_overrides`]); empty for none.
pub symbol_overrides: Vec<crate::SymbolOverride>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -56,6 +76,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
graph: None,
series_index: None,
type_outlines: false,
pixelated: false,
shaper: None,
graphic_style: None,
attrs: None,
@@ -63,8 +84,13 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
perspective: None,
dimension: None,
intertwine: None,
intertwine_tiers: Vec::new(),
live_paint_gaps: None,
objects_on_path: None,
colorize: None,
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// Stable per-document object id. Never reused.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+147 -3
View File
@@ -1,6 +1,6 @@
use super::ids::LayerColor;
use super::live_shape::LiveShape;
use super::*;
use super::{Affine, Arc, BlendSpec, Deserialize, EnvelopeKind, FillRule, GradientMesh, Node, PathData, RepeatSpec, Serialize, TextObject, yes};
/// Embedded or linked raster image.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -129,6 +129,8 @@ pub enum NodeKind {
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
/// fields before `type` are buffered, and only those.
pub use kind_de::{MAX_BUFFERED_VALUES, with_buffered_value_budget};
mod kind_de {
use std::fmt;
use std::sync::Arc;
@@ -237,12 +239,150 @@ mod kind_de {
let tag: String = map.next_value()?;
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
}
before.push((key, map.next_value::<serde_json::Value>()?));
before.push((key, map.next_value::<Counted>()?.0));
}
Err(de::Error::missing_field("type"))
}
}
/// Most JSON values (each number, string, array, object…) the fields read before an object's
/// `type` may hold together while [`with_buffered_value_budget`] runs: 32M (about 1 GB as
/// `serde_json::Value`s, some 32 bytes each), 4M in the web build. VectorCraft writes `type`
/// first, so its files buffer nothing; a reordered file buffers what comes before it, and
/// a crafted one could fill memory with values two bytes each in the file before any object in
/// them is counted (#3587).
pub const MAX_BUFFERED_VALUES: usize = if cfg!(target_arch = "wasm32") { 4 << 20 } else { 32 << 20 };
std::thread_local! {
/// Values the fields buffered on this thread may still hold, and the budget they come from.
static VALUES_LEFT: std::cell::Cell<Option<(usize, usize)>> = const { std::cell::Cell::new(None) };
/// Buffered fields being read back: what they hold was counted when it was buffered.
static REPLAYING: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Run `f` (which reads objects with serde) letting the fields it buffers before an object's
/// `type` hold at most `max` values together; buffering more fails.
pub fn with_buffered_value_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
struct Restore(Option<(usize, usize)>);
impl Drop for Restore {
fn drop(&mut self) {
VALUES_LEFT.set(self.0);
}
}
let _restore = Restore(VALUES_LEFT.replace(Some((max, max))));
f()
}
/// While buffered fields are read back ([`REPLAYING`]).
struct Replaying;
impl Replaying {
fn enter() -> Self {
REPLAYING.set(REPLAYING.get().saturating_add(1));
Replaying
}
}
impl Drop for Replaying {
fn drop(&mut self) {
REPLAYING.set(REPLAYING.get().saturating_sub(1));
}
}
/// Count one buffered value against [`with_buffered_value_budget`].
fn count<E: de::Error>() -> Result<(), E> {
if REPLAYING.get() > 0 {
return Ok(());
}
if let Some((left, max)) = VALUES_LEFT.get() {
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("an object has more than {max} values before its type")))?;
VALUES_LEFT.set(Some((left, max)));
}
Ok(())
}
/// A field read before `type`, as a `serde_json::Value` counted value by value as it is read,
/// so the budget stops a large one before it is all in memory.
struct Counted(serde_json::Value);
impl<'de> Deserialize<'de> for Counted {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(CountedVisitor)
}
}
struct CountedVisitor;
impl<'de> Visitor<'de> for CountedVisitor {
type Value = Counted;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a JSON value")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::Bool(v)))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Counted, E> {
count()?;
Ok(Counted(v.into()))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Counted, E> {
count()?;
Ok(Counted(v.into()))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Number::from_f64(v).map_or(serde_json::Value::Null, serde_json::Value::Number)))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::String(v.to_owned())))
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::String(v)))
}
fn visit_none<E: de::Error>(self) -> Result<Counted, E> {
self.visit_unit()
}
fn visit_unit<E: de::Error>(self) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::Null))
}
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Counted, D::Error> {
Counted::deserialize(d)
}
fn visit_seq<S: de::SeqAccess<'de>>(self, mut seq: S) -> Result<Counted, S::Error> {
count()?;
let mut out = Vec::new();
while let Some(Counted(v)) = seq.next_element()? {
out.push(v);
}
Ok(Counted(serde_json::Value::Array(out)))
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Counted, M::Error> {
count()?;
let mut out = serde_json::Map::new();
while let Some(key) = map.next_key::<String>()? {
let Counted(v) = map.next_value()?;
out.insert(key, v);
}
Ok(Counted(serde_json::Value::Object(out)))
}
}
/// The fields read before `type` (buffered), then the rest of the object.
struct Buffered<A> {
before: std::vec::IntoIter<(String, serde_json::Value)>,
@@ -265,7 +405,11 @@ mod kind_de {
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
match self.value.take() {
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
Some(value) => {
// Counted when it was read: the objects it holds buffer it again as they read it.
let _replaying = Replaying::enter();
seed.deserialize(value).map_err(de::Error::custom)
}
None => self.map.next_value_seed(seed),
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Affine, CornerKind, Deserialize, PathData, Point, Rect, Serialize, cut_corners, shapes};
/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these
/// while the shape stays live; editing anchors directly converts it to a plain path.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::kind::NodeKind;
use super::*;
use super::{Arc, Color, Deserialize, Node, Paint, Serialize, yes};
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+27 -7
View File
@@ -3,7 +3,7 @@ use super::ids::{LayerColor, NodeId};
use super::kind::NodeKind;
use super::live_shape::{Scaling, moves_only};
use super::opacity::Knockout;
use super::*;
use super::{Affine, Appearance, Arc, BlendMode, EnvelopeKind, FillRule, Node, PathData, Point, Rect, RepeatSpec, SYMBOL_SQUARE, Shape, SymbolBoxes};
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
@@ -127,6 +127,7 @@ impl Node {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } if self.extras().compound_shape => "Compound Shape",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -495,8 +496,13 @@ impl Node {
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
}
// The area where an Intertwine member is drawn over the others moves with it.
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
area.transform(a);
if let Some(e) = self.extras_if_any() {
if let Some(area) = e.intertwine.as_mut() {
area.transform(a);
}
for area in &mut e.intertwine_tiers {
area.transform(a);
}
}
}
@@ -545,9 +551,16 @@ impl Node {
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
/// geometric bounds. Strokes fit the box grown by half their weight.
/// geometric bounds. Strokes fit the box grown by half their weight. A colorized image shows
/// its colorize paint as its fill.
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& !stroke
&& let Some(c) = self.extras().colorize.as_deref()
{
return Some((c, Affine::IDENTITY, self.geometric_bounds().unwrap_or(Rect::ZERO)));
}
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
@@ -675,9 +688,15 @@ impl Node {
impl Node {
/// Whether this object shows any transparency, itself or anything inside it: object, fill or
/// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient
/// stops. Hidden objects show none.
/// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient
/// stops or a colorized image. Hidden objects show none.
pub fn shows_transparency(&self) -> bool {
self.shows_transparency_with(&|_| true)
}
/// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize`
/// accepts it, for files that write some colorized images in their colorize paint (EPS).
pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool {
use vectorcraft_color::Paint;
let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0));
let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible);
@@ -685,13 +704,14 @@ impl Node {
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|| (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self))
|| effects(&self.appearance.effects)
|| self
.appearance
.items
.iter()
.any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize))))
}
}
+85
View File
@@ -19,6 +19,8 @@ fn extras_are_written_flat_in_order() {
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
e.live_paint_gaps = Some(Box::default());
e.compound_shape = true;
e.shape_mode = crate::ShapeMode::Exclude;
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -61,8 +63,11 @@ fn extras_are_written_flat_in_order() {
"bbox_angle",
"perspective",
"live_paint_gaps",
"compound_shape",
"shape_mode",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
@@ -334,6 +339,35 @@ fn transform_group_recurses() {
assert_eq!(child.appearance.stroke_width(), 2.0);
}
/// A symbol instance in perspective keeps its picture moved by a transform, and loses its
/// projection when no invertible map of finite numbers draws the moved picture.
#[test]
fn a_projection_follows_the_transform_or_is_removed() {
use vectorcraft_geom::{Homography, Point};
let instance = |h: Homography| {
let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
n.extras_mut().perspective =
Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) }));
n
};
let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection);
// A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down.
let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
let mut n = instance(big);
n.transform(Affine::scale(10.0), false);
let p = Point::new(3.0, 7.0);
assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9);
// A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0).
let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let mut n = instance(far);
n.transform(Affine::scale(1e150), false);
assert_eq!(projection(&n), None);
// A transform that is not invertible leaves the projection as it is.
let mut n = instance(far);
n.transform(Affine::scale(0.0), false);
assert_eq!(projection(&n), Some(far.to_array()));
}
#[test]
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
@@ -433,3 +467,54 @@ fn bad_kinds_are_errors() {
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
}
}
/// #3587: [`with_node_budget`] counts every object read, nested ones included, refuses the one past
/// it with a message naming the budget, and is gone once its reading is done.
#[test]
fn objects_are_read_within_the_node_budget() {
let group = Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![])), Arc::new(Node::group(NodeId(3), vec![]))]);
let json = serde_json::to_string(&group).unwrap();
let read = || serde_json::from_str::<Node>(&json);
assert_eq!(with_node_budget(3, read).unwrap(), group, "three objects fit a budget of three");
let why = with_node_budget(2, read).unwrap_err().to_string();
assert!(why.contains("more than 2 objects"), "{why}");
assert_eq!(read().unwrap(), group, "no budget outside a read");
// A budget left by a read that failed doesn't carry over to the next one.
assert!(with_node_budget(1, read).is_err());
assert!(with_node_budget(3, read).is_ok());
}
/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts
/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused.
#[test]
fn intertwine_tiers_are_read_within_the_node_budget() {
let mut n = Node::group(NodeId(1), vec![]);
let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
n.extras_mut().intertwine_tiers = vec![PathData::default(), area];
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains("\"intertwine_tiers\""), "{json}");
let read = || serde_json::from_str::<Node>(&json);
assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three");
let why = with_node_budget(2, read).unwrap_err().to_string();
assert!(why.contains("more than 2 objects"), "{why}");
let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap();
assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}");
}
/// #3587: fields read before an object's `type` are buffered value by value within
/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers
/// its own fields again without counting them twice. VectorCraft writes `type` first, which
/// buffers nothing.
#[test]
fn fields_before_the_type_are_buffered_within_the_value_budget() {
let reordered = r#"{"id":1,"kind":{"children":[{"id":2,"kind":{"children":[],"type":"group"}}],"type":"group"}}"#;
let read = |json: &str| serde_json::from_str::<Node>(json);
// The children's array, the inner object, its id, its kind, the kind's empty children and
// its type: six values.
assert!(with_buffered_value_budget(6, || read(reordered)).is_ok());
let why = with_buffered_value_budget(5, || read(reordered)).unwrap_err().to_string();
assert!(why.contains("more than 5 values before its type"), "{why}");
let written = serde_json::to_string(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![]))])).unwrap();
assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}");
assert!(read(reordered).is_ok(), "no budget outside a read");
}
+103 -6
View File
@@ -3,7 +3,13 @@ use super::extras::{Extras, NO_EXTRAS, NodeExtras};
use super::ids::NodeId;
use super::kind::NodeKind;
use super::opacity::{Knockout, OpacityMask};
use super::*;
use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes};
/// Field `name` of a [`Node`] written as `v`, or skipped. A function of its own, so the long field
/// list stays one straight run of calls.
fn write_field<S: serde::ser::SerializeStruct, T: Serialize + ?Sized>(st: &mut S, skip: bool, name: &'static str, v: &T) -> Result<(), S::Error> {
if skip { st.skip_field(name) } else { st.serialize_field(name, v) }
}
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
@@ -30,6 +36,7 @@ impl Serialize for Node {
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
is_default(&e.pixelated),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
@@ -39,18 +46,19 @@ impl Serialize for Node {
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
e.intertwine_tiers.is_empty(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
e.colorize.is_none(),
is_default(&e.compound_shape),
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
macro_rules! field {
($name:literal, $v:expr) => {
if skip.next().unwrap_or(false) {
st.skip_field($name)?;
} else {
st.serialize_field($name, $v)?;
}
write_field(&mut st, skip.next().unwrap_or(false), $name, $v)?;
};
}
field!("id", &self.id);
@@ -69,6 +77,7 @@ impl Serialize for Node {
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("pixelated", &e.pixelated);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
@@ -78,17 +87,87 @@ impl Serialize for Node {
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("intertwine_tiers", &e.intertwine_tiers);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
field!("colorize", &e.colorize);
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
st.end()
}
}
/// Most objects [`with_node_budget`] lets a document read: 4M (about 1 GB of the smallest
/// objects), 512K in the web build. Paths have their own anchor budget
/// ([`vectorcraft_geom::path::MAX_READ_ANCHORS`]); this one bounds the objects themselves.
pub const MAX_READ_NODES: usize = if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 };
std::thread_local! {
/// Objects the document being read on this thread may still have, and the budget they come
/// from ([`with_node_budget`]).
static NODES_LEFT: std::cell::Cell<Option<(usize, usize)>> = const { std::cell::Cell::new(None) };
}
/// Run `f` (which reads nodes with serde) letting it read at most `max` objects together; reading
/// more fails. A compressed file of repeated empty objects stays small while they take gigabytes
/// once read, about 270 bytes each against some 50 in the file (#3587).
pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
struct Restore(Option<(usize, usize)>);
impl Drop for Restore {
fn drop(&mut self) {
NODES_LEFT.set(self.0);
}
}
let _restore = Restore(NODES_LEFT.replace(Some((max, max))));
f()
}
/// A node's id, counting the node against [`with_node_budget`]. Every node has exactly one id, so
/// each counts once, and the id usually comes first, before the node's children are read.
fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<NodeId, D::Error> {
let id = NodeId::deserialize(d)?;
count_one()?;
Ok(id)
}
/// Count one object against [`with_node_budget`].
fn count_one<E: serde::de::Error>() -> Result<(), E> {
if let Some((left, max)) = NODES_LEFT.get() {
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?;
NODES_LEFT.set(Some((left, max)));
}
Ok(())
}
/// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol
/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed
/// file of repeated empty items stays small while they take memory once read.
fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Vec<T>, D::Error> {
struct Items<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items<T> {
type Value = Vec<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, A::Error> {
let mut out = Vec::new();
while let Some(item) = seq.next_element::<T>()? {
count_one()?;
out.push(item);
}
Ok(out)
}
}
d.deserialize_seq(Items(std::marker::PhantomData))
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
/// (and its extras, allocated only when one is set) without a copy of what they own.
#[derive(Deserialize)]
#[serde(rename = "Node")]
pub(super) struct NodeWire {
#[serde(deserialize_with = "counted_id")]
id: NodeId,
#[serde(default)]
name: Option<String>,
@@ -121,6 +200,8 @@ pub(super) struct NodeWire {
#[serde(default)]
type_outlines: bool,
#[serde(default)]
pixelated: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
@@ -137,10 +218,20 @@ pub(super) struct NodeWire {
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default, deserialize_with = "counted_list")]
intertwine_tiers: Vec<PathData>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
#[serde(default)]
colorize: Option<Box<vectorcraft_color::Paint>>,
#[serde(default)]
compound_shape: bool,
#[serde(default)]
shape_mode: crate::ShapeMode,
#[serde(default, deserialize_with = "counted_list")]
symbol_overrides: Vec<crate::SymbolOverride>,
}
impl From<NodeWire> for Node {
@@ -152,6 +243,7 @@ impl From<NodeWire> for Node {
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
pixelated: w.pixelated,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
@@ -159,8 +251,13 @@ impl From<NodeWire> for Node {
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
intertwine_tiers: w.intertwine_tiers,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
colorize: w.colorize,
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
};
Node {
id: w.id,
+1 -1
View File
@@ -337,7 +337,7 @@ mod tests {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
+1 -1
View File
@@ -200,7 +200,7 @@ mod tests {
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
+5 -3
View File
@@ -58,12 +58,14 @@ impl Node {
}
}
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An
/// invertible `a` that the projection cannot follow removes it, and the art is drawn flat.
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
&& let Some(h) = p.homography()
&& a.determinant().abs() >= 1e-15
{
p.projection = Some(h.to_array());
p.projection = h.conjugated(a).map(|h| h.to_array());
}
}
}

Some files were not shown because too many files have changed in this diff Show More