Merge remote-tracking branch 'upstream/main' into fix/3260-halftone-radius-pixels
@@ -13,27 +13,27 @@ on:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
# A newer push to a PR branch cancels its older run.
|
||||
concurrency:
|
||||
group: print-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
# Desktop printing on Windows, through the suite's shared gate. Pull requests restore the cache
|
||||
# but never save one; a manual run on main saves the cache they restore.
|
||||
windows:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: desktop-print
|
||||
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
|
||||
- run: cargo test --locked -p vectorcraft
|
||||
- name: Check pending submission and queue refusal feedback
|
||||
shell: bash
|
||||
# A filter that matches nothing still passes, so fail unless some test ran.
|
||||
run: |
|
||||
cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee printps.log
|
||||
grep -Eq 'test result: ok\. [1-9][0-9]* passed' printps.log || { echo "::error::tests::printps matched no tests"; exit 1; }
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
os: '["windows-2022"]'
|
||||
timeout-minutes: 90
|
||||
# As before: printing builds and tests without craft-fonts, nextest or the wasm target.
|
||||
craft-fonts-ref: ""
|
||||
tools: ""
|
||||
targets: ""
|
||||
# A filter that matches nothing still passes, so the printps run fails unless some test ran.
|
||||
command: |
|
||||
cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
|
||||
cargo test --locked -p vectorcraft
|
||||
log="$RUNNER_TEMP/printps.log"
|
||||
cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee "$log"
|
||||
grep -Eq 'test result: ok\. [1-9][0-9]* passed' "$log" || { echo "::error::tests::printps matched no tests"; exit 1; }
|
||||
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
needs: git-sources
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
# 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/release.yml@main
|
||||
with:
|
||||
|
||||
@@ -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,20 +72,17 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -224,13 +224,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-swap.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-swatch-kinds.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-symbol-sprayer.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-touch-type.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-touch-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-transform-panel.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-transparency.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-twirl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-area.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-vertical-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-type-vertical-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-width-profile.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-width.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-wrinkle.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -345,6 +348,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
|
||||
|
||||
@@ -352,20 +358,20 @@ The shared PDF backend keeps the existing standard-font and CMap features. These
|
||||
|
||||
| Crate / file | Author | Source | Licence | SHA-256 | Use |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitDingbats.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitDingbats.pfb | BSD-3-Clause | `845c752392b6c914fb989c75a08b7792b88f542d2499042ef2889f8c814a16ed` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixed.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixed.pfb | BSD-3-Clause | `b6c8fe53f134b8b6d4578cd2d544df4cee9624c4efa8d51a560fb40ea296101b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixedBold.pfb | BSD-3-Clause | `f1b7159702973f54fc86254ea38bcf3712b2a736eb3a0995751e9f4bb45ad603` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixedBoldItalic.pfb | BSD-3-Clause | `8a000945843bd31add06aee63bf9fd41b7578b4f3242a4b7cd46349ad9d24d4d` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitFixedItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitFixedItalic.pfb | BSD-3-Clause | `5007faf8320fa1fcc08b8894b356ad976f60b992ba29ee5272578bbdebaf3876` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSans.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSans.pfb | BSD-3-Clause | `a02339e95d20db14cbdfc1c6baf359cd36a091d5434925c3a4040f60e1a1af9e` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSansBold.pfb | BSD-3-Clause | `7bdfa36de1d5757dcfaf8b8a4be95c6e41c40fa70dbed9d78bf979a7b9959054` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSansBoldItalic.pfb | BSD-3-Clause | `555ce45924338e2e4b32821910221ce85da691fd59a9723c4c971a7c5627fc8b` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSansItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSansItalic.pfb | BSD-3-Clause | `7751cbefeda351708dae8c4a88db043411bc39ab2ae2097685da845d4d16f307` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerif.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerif.pfb | BSD-3-Clause | `4f57d2b9d884af8f907bf22df6019b52d86cbf6214fdbd02b1ae05472a543f35` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBold.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerifBold.pfb | BSD-3-Clause | `0bdf4b04e964139818d51eda03d566ba999fc3ba2421b1c6c51f9dc969022e80` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifBoldItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerifBoldItalic.pfb | BSD-3-Clause | `a406cac82583bf98175cb62c87ed5e95c45fbb34eece63a10b0a13e793cb2e10` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSerifItalic.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSerifItalic.pfb | BSD-3-Clause | `610ae0687198045c4db5d1a6650fd5e92536631706382eb8b72e38df578d0ae9` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/FoxitSymbol.pfb` | Foxit Software Inc. / PDFium Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/FoxitSymbol.pfb | BSD-3-Clause | `47967d055530e7357088a08403115425643ec2cdfd6201ba8af0fbd7116c1539` | Renders non-embedded standard-14 fonts (pinned hayro-interpret) |
|
||||
| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc | CC0-1.0 | `73e1ba37d2bad5bab2a964f40a9eed96209666efc067c3322626214bbef234a0` | Default CMYK colour conversion (pinned hayro-interpret) |
|
||||
| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-cmap/assets/cmaps.brotli | BSD-3-Clause | `ddf37af521d904d5646c2fc37b2b7e912dc1b56f0d210190b61f598154758e46` | Character-code mapping tables required to read CJK PDFs. Non-visual dependency data; existing embedding unchanged |
|
||||
| `hayro-interpret 0.7.0 / src/font/generated/metrics.rs` | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/hayro-interpret/src/font/generated/metrics.rs | MIT OR Apache-2.0 | `2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41` | Glyph widths and encodings for non-embedded standard fonts. Non-visual dependency data; existing embedding unchanged |
|
||||
|
||||
@@ -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.
|
||||
@@ -47,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
|
||||
@@ -60,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.
|
||||
|
||||
@@ -217,7 +217,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -959,17 +970,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -992,7 +1003,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1002,7 +1013,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
@@ -1013,9 +1024,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree 0.21.2",
|
||||
"tempfile",
|
||||
@@ -1026,7 +1038,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1047,7 +1059,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1070,10 +1082,21 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1563,7 +1586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1888,7 +1911,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 +1963,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"
|
||||
@@ -2173,7 +2206,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
@@ -2182,7 +2215,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -2200,7 +2233,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
@@ -2218,12 +2251,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-postscript"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -2233,7 +2266,7 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"stringprep",
|
||||
"zune-jpeg",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2609,14 +2642,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]]
|
||||
@@ -2831,13 +2864,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f8f8829cc7be65d466d687dae381d1ca60e31242#f8f8829cc7be65d466d687dae381d1ca60e31242"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"gif 0.14.2",
|
||||
"image-webp",
|
||||
"imagesize 0.14.0",
|
||||
"indexmap",
|
||||
@@ -2852,7 +2885,7 @@ dependencies = [
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3618,7 +3651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3900,7 +3933,7 @@ dependencies = [
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4140,7 +4173,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4380,12 +4413,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]]
|
||||
@@ -4489,15 +4525,15 @@ 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",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4556,7 +4592,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5097,9 +5133,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",
|
||||
]
|
||||
@@ -5142,7 +5178,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"thiserror 2.0.21",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
@@ -5415,10 +5451,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.59.0",
|
||||
"rustix 1.1.6",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5490,7 +5526,7 @@ dependencies = [
|
||||
"half",
|
||||
"quick-error 2.0.1",
|
||||
"weezl 0.1.12",
|
||||
"zune-jpeg",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6037,7 +6073,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -6065,6 +6101,7 @@ dependencies = [
|
||||
"smithay-clipboard",
|
||||
"socket2",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-backend",
|
||||
@@ -6081,7 +6118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -6092,7 +6129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -6104,7 +6141,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6120,7 +6157,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -6142,7 +6179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -6154,7 +6191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
@@ -6168,12 +6205,12 @@ dependencies = [
|
||||
"tiff",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-geom",
|
||||
"zune-jpeg",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6189,17 +6226,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"cfb",
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"hayro-jpeg2000",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"roxmltree 0.21.2",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6228,7 +6268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -6252,11 +6292,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-vectorcraft-format",
|
||||
"flate2",
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6267,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -6279,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6298,7 +6339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6312,7 +6353,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -6324,7 +6365,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
@@ -6356,7 +6397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6370,10 +6411,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
"gif 0.14.2",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"log",
|
||||
@@ -6382,6 +6423,7 @@ dependencies = [
|
||||
"psd",
|
||||
"serde_json",
|
||||
"tiff",
|
||||
"unicode-general-category",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -6399,14 +6441,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -6416,6 +6458,7 @@ dependencies = [
|
||||
"resvg",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simplecss",
|
||||
"skrifa 0.47.0",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
@@ -6431,7 +6474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -6449,7 +6492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -6470,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -6478,12 +6521,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
@@ -6494,7 +6538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6506,7 +6550,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-keymap",
|
||||
@@ -6517,7 +6561,7 @@ dependencies = [
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
@@ -6544,7 +6588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -6711,12 +6755,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
version = "0.263.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
checksum = "0b883a26aea02eaabe2d2c3fba7eb039ef5cdcb5d5201ae5551e73d5e251a81f"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser 0.262.0",
|
||||
"wasmparser 0.263.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6771,9 +6815,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
version = "0.263.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
checksum = "4f5fa0a054ae153606df820eb0a84213e28cda41bbe04ba2628e71b6ba100fcb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"indexmap",
|
||||
@@ -6782,9 +6826,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wast"
|
||||
version = "262.0.0"
|
||||
version = "263.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23"
|
||||
checksum = "69d90bcf581b501aa1c358bc5dc0faa979cd375796908860ebcb6d1d70b907c7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"leb128fmt",
|
||||
@@ -6795,9 +6839,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wat"
|
||||
version = "1.262.0"
|
||||
version = "1.263.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1"
|
||||
checksum = "bbc9c5aca783b3f1838be89f3f9937c528b90ec5faa6fb62d809726c16a7d8f7"
|
||||
dependencies = [
|
||||
"wast",
|
||||
]
|
||||
@@ -6810,7 +6854,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
@@ -6823,7 +6867,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",
|
||||
]
|
||||
@@ -6845,7 +6889,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",
|
||||
]
|
||||
@@ -7183,7 +7227,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7520,7 +7564,7 @@ dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
@@ -7579,7 +7623,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
@@ -7621,7 +7665,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7675,7 +7719,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -7725,7 +7769,7 @@ dependencies = [
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.5",
|
||||
"rustix 1.1.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
@@ -7948,19 +7992,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]]
|
||||
|
||||
@@ -4,23 +4,25 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.0"
|
||||
version = "0.21.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
# 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-window = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242", features = ["egui"] }
|
||||
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" }
|
||||
# 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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
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" }
|
||||
@@ -145,12 +147,12 @@ lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
[patch.crates-io]
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "f8f8829cc7be65d466d687dae381d1ca60e31242" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -72,10 +72,10 @@ Shared PDF backend
|
||||
|
||||
The patched hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0, hayro-cmap 0.1.0
|
||||
and hayro-postscript 0.1.0 are fetched from https://github.com/storytold/craft-libs at revision
|
||||
92d392cff132eaf53e13afff77f38b5c11b73aa0. Their upstream project is
|
||||
f8f8829cc7be65d466d687dae381d1ca60e31242. Their upstream project is
|
||||
https://github.com/LaurenzV/hayro, licensed MIT OR Apache-2.0. The snapshot retains each
|
||||
crate's upstream licence files. The patch ledger is at
|
||||
https://github.com/storytold/craft-libs/blob/92d392cff132eaf53e13afff77f38b5c11b73aa0/vendor/README.craft-reader.md.
|
||||
https://github.com/storytold/craft-libs/blob/f8f8829cc7be65d466d687dae381d1ca60e31242/vendor/README.craft-reader.md.
|
||||
|
||||
Assets already bundled through Hayro's default features retain their licences:
|
||||
- Foxit standard-14 substitutes: Copyright 2014 PDFium Authors. All rights reserved.
|
||||
|
||||
@@ -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
|
||||
@@ -328,12 +328,21 @@ stand on its own.
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | **Vector illustration · you are here** | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
@@ -34,8 +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.
|
||||
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.
|
||||
|
||||
@@ -49,6 +49,10 @@ mod prefs_dir;
|
||||
#[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,6 +165,9 @@ 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(windows)]
|
||||
system_fonts::install();
|
||||
|
||||
@@ -116,7 +116,8 @@ 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, pen: None };
|
||||
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 +216,14 @@ 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,
|
||||
pen: None,
|
||||
};
|
||||
s.select_tool("selection", view).map_err(err)?;
|
||||
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
|
||||
let mut dx = 0.0;
|
||||
|
||||
@@ -23,7 +23,7 @@ use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
|
||||
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
|
||||
use vectorcraft_ui_egui::touch::{self, Source};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast as _;
|
||||
@@ -95,7 +95,7 @@ impl Pointers {
|
||||
|
||||
/// A pen moved or pressed with `pressure`, with the end it last pressed with.
|
||||
fn pen_sample(&self, pressure: f32) {
|
||||
self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser }));
|
||||
self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser, pose: PenPose::default() }));
|
||||
}
|
||||
|
||||
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
|
||||
|
||||
@@ -54,7 +54,12 @@ pollster = "1"
|
||||
# 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.
|
||||
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
|
||||
@@ -86,8 +91,8 @@ 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.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#[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};
|
||||
@@ -66,6 +68,9 @@ impl eframe::App for App {
|
||||
// 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).
|
||||
@@ -105,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);
|
||||
}
|
||||
@@ -147,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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ use std::io::{Cursor, Read};
|
||||
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;
|
||||
use vectorcraft_ui_egui::{ClipboardRead, 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).
|
||||
@@ -157,6 +157,10 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
None if vectorcraft_engine::file_access::current().is_none() => {}
|
||||
None => return None,
|
||||
}
|
||||
// A device or pipe may never end: only a regular file is read whole.
|
||||
if !std::fs::metadata(&path).ok()?.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut head = Vec::with_capacity(FILE_HEAD);
|
||||
std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
|
||||
let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?;
|
||||
@@ -172,7 +176,8 @@ mod win {
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
|
||||
use super::{
|
||||
BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
|
||||
BITMAP, ClipboardRead, 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
|
||||
@@ -249,10 +254,16 @@ mod win {
|
||||
// A clipboard manager may have copied our data again as its own.
|
||||
let Some(ours) = self.text.as_deref() else { return false };
|
||||
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
|
||||
read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())
|
||||
read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes())
|
||||
}
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
self.read_deferred(mimes)?.flavour()
|
||||
}
|
||||
|
||||
/// A device-independent bitmap is copied off the clipboard here and decoded and encoded
|
||||
/// as PNG by the work that comes back.
|
||||
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
|
||||
let mut files = vec![];
|
||||
{
|
||||
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
|
||||
@@ -260,7 +271,7 @@ mod win {
|
||||
let _ = raw::get_file_list_path(&mut files);
|
||||
}
|
||||
// The files are read with the clipboard closed again, not keeping other apps waiting.
|
||||
copied_file(files, mimes).or_else(|| {
|
||||
copied_file(files, mimes).map(ClipboardRead::Now).or_else(|| {
|
||||
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
|
||||
mimes.iter().find_map(|m| read_one(m))
|
||||
})
|
||||
@@ -284,16 +295,28 @@ mod win {
|
||||
})
|
||||
}
|
||||
|
||||
/// One flavour, while the clipboard is open.
|
||||
fn read_one(mime: &'static str) -> Option<Flavour> {
|
||||
/// A device-independent bitmap as a PNG flavour.
|
||||
fn dib_png(dib: &[u8]) -> Option<Flavour> {
|
||||
png_flavour(&dib_image(dib)?)
|
||||
}
|
||||
|
||||
/// Copy both bitmap formats while the clipboard is open, then decode them in preference order.
|
||||
pub(super) fn read_dibs(mut get: impl FnMut(u32) -> Option<Vec<u8>>) -> Option<ClipboardRead> {
|
||||
let dibs: Vec<_> = DIBS.into_iter().filter_map(&mut get).collect();
|
||||
(!dibs.is_empty()).then_some(ClipboardRead::Later(Box::new(move || dibs.iter().find_map(|dib| dib_png(dib)))))
|
||||
}
|
||||
|
||||
/// One flavour, while the clipboard is open. A device-independent bitmap is copied here, and
|
||||
/// the work that comes back decodes each captured format in preference order.
|
||||
fn read_one(mime: &'static str) -> Option<ClipboardRead> {
|
||||
let mut data = vec![];
|
||||
let mime = match mime {
|
||||
BITMAP if read_registered(PNG, &mut data) => PNG,
|
||||
BITMAP => {
|
||||
return DIBS.into_iter().find_map(|id| {
|
||||
data.clear();
|
||||
raw::get_vec(id, &mut data).ok()?;
|
||||
png_flavour(&dib_image(&data)?)
|
||||
return read_dibs(|id| {
|
||||
let mut dib = vec![];
|
||||
raw::get_vec(id, &mut dib).ok()?;
|
||||
Some(dib)
|
||||
});
|
||||
}
|
||||
TEXT => {
|
||||
@@ -309,7 +332,7 @@ mod win {
|
||||
{
|
||||
data.truncate(end);
|
||||
}
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
(!data.is_empty()).then_some(ClipboardRead::Now(Flavour { mime, data }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +345,7 @@ mod portable {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
|
||||
use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
|
||||
|
||||
/// What our last write put on the clipboard.
|
||||
enum Ours {
|
||||
@@ -339,6 +362,7 @@ mod portable {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -579,22 +603,27 @@ mod portable {
|
||||
}
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
self.read_deferred(mimes)?.flavour()
|
||||
}
|
||||
|
||||
/// arboard's image is read here and encoded as PNG by the work that comes back.
|
||||
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
|
||||
let cb = self.cb(Asker::User).ok()?;
|
||||
if let Some(f) = copied_file(cb.file_list(), mimes) {
|
||||
return Some(f);
|
||||
return Some(ClipboardRead::Now(f));
|
||||
}
|
||||
mimes.iter().find_map(|m| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(f) = super::mac::read(m) {
|
||||
return Some(f);
|
||||
return Some(ClipboardRead::Now(f));
|
||||
}
|
||||
match *m {
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
|
||||
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })),
|
||||
BITMAP => {
|
||||
let img = cb.get_image().ok()?;
|
||||
let rgba =
|
||||
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
|
||||
png_flavour(&rgba)
|
||||
let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
|
||||
let bytes = img.bytes.into_owned();
|
||||
Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -1056,6 +1085,28 @@ mod tests {
|
||||
img.pixels().map(|p| p.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn dib_fallback_captures_both_formats_before_decoding() {
|
||||
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
|
||||
|
||||
let valid = dib(40, 1, 1, 32, 0, &[], &bgra(&[RED]));
|
||||
let mut captured = Vec::new();
|
||||
let read = win::read_dibs(|id| {
|
||||
captured.push(id);
|
||||
match id {
|
||||
CF_DIBV5 => Some(vec![40, 0, 0]),
|
||||
CF_DIB => Some(valid.clone()),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(captured, [CF_DIBV5, CF_DIB]);
|
||||
let flavour = read.flavour().unwrap();
|
||||
assert_eq!(flavour.mime, PNG);
|
||||
assert_eq!(pixels(&decode_png(&flavour.data).unwrap()), [RED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_screenshot_dib_pastes_opaque_and_upright() {
|
||||
// CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero.
|
||||
|
||||
@@ -182,11 +182,14 @@ fn any_hardware(kinds: &[DeviceType]) -> bool {
|
||||
kinds.iter().any(|k| *k != DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/// 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.
|
||||
|
||||
@@ -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(), &le), 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> {
|
||||
|
||||
@@ -151,6 +151,22 @@ fn reveal_command(path: &str) -> std::process::Command {
|
||||
c
|
||||
}
|
||||
|
||||
/// Read a whole file. A device or pipe may never end: only a regular file is read.
|
||||
fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn read_file_refuses_what_is_not_a_regular_file() {
|
||||
assert!(read_file(&std::env::temp_dir().to_string_lossy()).is_err());
|
||||
#[cfg(unix)]
|
||||
assert!(read_file("/dev/zero").is_err());
|
||||
}
|
||||
|
||||
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
|
||||
@@ -277,7 +293,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
// Windows reads it from the pen's pointer messages (`windows_stylus`).
|
||||
#[cfg(windows)]
|
||||
stylus: Some(windows_stylus::start()),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
read: Some(Box::new(read_file)),
|
||||
write: Some(Box::new(write_file)),
|
||||
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
|
||||
// Background Save and Export write from a worker thread.
|
||||
@@ -300,6 +316,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
reveal: Some(Box::new(reveal)),
|
||||
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
|
||||
open_file: Some(Box::new(open_file)),
|
||||
// Edit Original of a raster image with Use System Defaults for Edit Original off (#5236).
|
||||
open_in_photocraft: Some(Box::new(open_in_photocraft)),
|
||||
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
|
||||
// Sample Color from Screen: the system's color sampler.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -334,3 +352,281 @@ fn open_file(path: &str) -> Result<(), String> {
|
||||
c.arg(path);
|
||||
c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Edit Original with Preferences › File Handling › Use System Defaults for Edit Original off
|
||||
/// (#5236): open the raster image `path` in PhotoCraft, found only where it installs
|
||||
/// ([`find_photocraft`]); `Ok(false)` when it isn't installed. PhotoCraft gets the path as its only
|
||||
/// argument and is never started through a shell.
|
||||
fn open_in_photocraft(path: &str) -> Result<bool, String> {
|
||||
explorer_safe(path)?;
|
||||
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from);
|
||||
let Some(app) = find_photocraft(home.as_deref(), std::env::var_os("PATH").as_deref()) else { return Ok(false) };
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut c = {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.arg("-a").arg(&app).arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let mut c = {
|
||||
let mut c = std::process::Command::new(&app);
|
||||
c.arg(path);
|
||||
c
|
||||
};
|
||||
// macOS: `open` returns once Launch Services has the request, so a short wait reports an app
|
||||
// it can't open (a folder named `.app` that isn't a bundle). The wait is capped so a stalled
|
||||
// `open` never holds the window: past it, the launch is left to run. Elsewhere the editor
|
||||
// itself runs: waiting would last until it closes.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
const WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
let fail = |why: String| format!("can't open {path} in {}: {why}", app.display());
|
||||
let mut child = c.stderr(std::process::Stdio::piped()).spawn().map_err(|e| fail(e.to_string()))?;
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => return Ok(true),
|
||||
Ok(Some(status)) => {
|
||||
let mut why = String::new();
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
// Best effort: without its message the exit status says why.
|
||||
let _ = std::io::Read::read_to_string(&mut err, &mut why);
|
||||
}
|
||||
let why = why.trim().to_string();
|
||||
return Err(fail(if why.is_empty() { status.to_string() } else { why }));
|
||||
}
|
||||
Ok(None) if started.elapsed() < WAIT => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
Ok(None) => {
|
||||
// Reaped off the UI thread once it ends.
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
return Ok(true);
|
||||
}
|
||||
Err(e) => return Err(fail(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
c.spawn().map(|_| true).map_err(|e| format!("can't open {path} in {}: {e}", app.display()))
|
||||
}
|
||||
|
||||
/// PhotoCraft where it is installed: the first of [`photocraft_candidates`] that is an application.
|
||||
pub(crate) fn find_photocraft(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
|
||||
photocraft_candidates(home, path_var).into_iter().find(|p| check_app(p).is_ok())
|
||||
}
|
||||
|
||||
/// Where PhotoCraft may be installed: the Applications folders on macOS; on Windows where its
|
||||
/// installer puts it ([`windows_candidates`]); else a `photocraft` program on the `PATH`.
|
||||
pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Vec<std::path::PathBuf> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let mut v = vec![std::path::PathBuf::from("/Applications/PhotoCraft.app")];
|
||||
v.extend(home.map(|h| h.join("Applications/PhotoCraft.app")));
|
||||
return v;
|
||||
}
|
||||
let name = if cfg!(windows) { "photocraft.exe" } else { "photocraft" };
|
||||
let on_path = path_var.map(|p| std::env::split_paths(p).map(|dir| dir.join(name)).collect()).unwrap_or_default();
|
||||
if cfg!(windows) {
|
||||
let folders: Vec<std::path::PathBuf> = [
|
||||
std::env::var_os("LOCALAPPDATA").map(|d| std::path::PathBuf::from(d).join("Programs")),
|
||||
std::env::var_os("ProgramW6432").map(std::path::PathBuf::from),
|
||||
std::env::var_os("ProgramFiles").map(std::path::PathBuf::from),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
return windows_candidates(registered_photocraft(), &folders, on_path);
|
||||
}
|
||||
on_path
|
||||
}
|
||||
|
||||
/// Windows: PhotoCraft's installer puts it in `ArtCraft\PhotoCraft` under a folder of
|
||||
/// `program_folders` (for the current user `%LOCALAPPDATA%\Programs`, for everyone Program Files)
|
||||
/// and registers its path under App Paths, which also covers a folder chosen in setup. So: the
|
||||
/// `registered` paths first, then those folders, then `on_path`.
|
||||
pub(crate) fn windows_candidates(
|
||||
registered: Vec<std::path::PathBuf>,
|
||||
program_folders: &[std::path::PathBuf],
|
||||
on_path: Vec<std::path::PathBuf>,
|
||||
) -> Vec<std::path::PathBuf> {
|
||||
let mut v = registered;
|
||||
for f in program_folders {
|
||||
let p = f.join("ArtCraft").join("PhotoCraft").join("photocraft.exe");
|
||||
if !v.contains(&p) {
|
||||
v.push(p);
|
||||
}
|
||||
}
|
||||
v.extend(on_path);
|
||||
v
|
||||
}
|
||||
|
||||
/// The paths PhotoCraft's installer registers under App Paths, for the current user and then for
|
||||
/// everyone (`reg query`, so no extra dependency; a hidden console window).
|
||||
#[cfg(windows)]
|
||||
fn registered_photocraft() -> Vec<std::path::PathBuf> {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
// reg.exe by its full path, so nothing named `reg` in the app's folder or on PATH runs instead.
|
||||
let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into());
|
||||
["HKCU", "HKLM"]
|
||||
.into_iter()
|
||||
.filter_map(|hive| {
|
||||
let key = format!(r"{hive}\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe");
|
||||
let out = std::process::Command::new(root.join("System32").join("reg.exe"))
|
||||
.args(["query", &key, "/ve"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.ok()?;
|
||||
registered_path(&String::from_utf8_lossy(&out.stdout))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn registered_photocraft() -> Vec<std::path::PathBuf> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The path in `reg query … /ve` output like
|
||||
/// ` (Default) REG_SZ C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`.
|
||||
#[cfg_attr(not(any(windows, test)), allow(dead_code))]
|
||||
pub(crate) fn registered_path(text: &str) -> Option<std::path::PathBuf> {
|
||||
text.lines().find_map(|l| l.split_once("REG_SZ")).map(|(_, v)| v.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from)
|
||||
}
|
||||
|
||||
/// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on
|
||||
/// Windows, an executable file elsewhere? Anything else (a document, a folder, a script on
|
||||
/// Windows) found where PhotoCraft installs is not taken for it.
|
||||
pub(crate) fn check_app(app: &std::path::Path) -> Result<(), String> {
|
||||
let ext = app.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let ok = if cfg!(target_os = "macos") {
|
||||
ext == "app" && app.is_dir()
|
||||
} else if cfg!(windows) {
|
||||
ext == "exe" && app.is_file()
|
||||
} else {
|
||||
executable_file(app)
|
||||
};
|
||||
if ok { Ok(()) } else { Err(format!("{} isn't an application Edit Original can open files in", app.display())) }
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn executable_file(p: &std::path::Path) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn executable_file(p: &std::path::Path) -> bool {
|
||||
p.is_file()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod open_with_tests {
|
||||
use super::{check_app, find_photocraft, photocraft_candidates, registered_path, windows_candidates};
|
||||
|
||||
/// A fresh, empty folder under the system's temporary folder.
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vc-open-with-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// #5236: only an application counts as PhotoCraft, never a document, a folder or a missing
|
||||
/// path.
|
||||
#[test]
|
||||
fn only_applications_are_started() {
|
||||
let d = scratch("check");
|
||||
let doc = d.join("notes.txt");
|
||||
std::fs::write(&doc, "hi").unwrap();
|
||||
assert!(check_app(&doc).is_err(), "a document");
|
||||
assert!(check_app(&d).is_err(), "a folder");
|
||||
assert!(check_app(&d.join("Missing.app")).is_err(), "a missing path");
|
||||
if cfg!(target_os = "macos") {
|
||||
let app = d.join("Editor.app");
|
||||
std::fs::create_dir_all(&app).unwrap();
|
||||
assert!(check_app(&app).is_ok(), "an .app bundle");
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let tool = d.join("editor");
|
||||
std::fs::write(&tool, "#!/bin/sh\n").unwrap();
|
||||
assert!(check_app(&tool).is_err(), "not executable yet");
|
||||
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(check_app(&tool).is_ok(), "an executable file");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// PhotoCraft is looked for only where it installs.
|
||||
#[test]
|
||||
fn photocraft_is_found_where_it_installs() {
|
||||
let d = scratch("find");
|
||||
if cfg!(target_os = "macos") {
|
||||
let candidates = photocraft_candidates(Some(&d), None);
|
||||
assert_eq!(candidates.first().map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/Applications/PhotoCraft.app"));
|
||||
assert!(candidates.contains(&d.join("Applications/PhotoCraft.app")), "{candidates:?}");
|
||||
std::fs::create_dir_all(d.join("Applications/PhotoCraft.app")).unwrap();
|
||||
assert!(find_photocraft(Some(&d), None).unwrap().ends_with("PhotoCraft.app"));
|
||||
} else {
|
||||
let bin = d.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let path_var = std::env::join_paths([&bin]).unwrap();
|
||||
// On Windows an installed PhotoCraft (registered, or in a program folder) comes first, so
|
||||
// there only the `PATH` candidate is checked.
|
||||
if !cfg!(windows) {
|
||||
assert!(find_photocraft(None, Some(&path_var)).is_none(), "not installed");
|
||||
}
|
||||
let exe = bin.join(if cfg!(windows) { "photocraft.exe" } else { "photocraft" });
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
if cfg!(windows) {
|
||||
assert_eq!(photocraft_candidates(None, Some(&path_var)).last(), Some(&exe));
|
||||
assert!(check_app(&exe).is_ok());
|
||||
} else {
|
||||
assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe));
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// #5327 review: on Windows PhotoCraft is looked for where its installer puts it: the path it
|
||||
/// registers, then `ArtCraft\PhotoCraft` in the per-user and per-machine program folders, then
|
||||
/// the `PATH`.
|
||||
#[test]
|
||||
fn windows_looks_where_the_photocraft_installer_puts_it() {
|
||||
use std::path::PathBuf;
|
||||
let registered = vec![PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe")];
|
||||
let folders = [PathBuf::from("C:/Users/me/AppData/Local/Programs"), PathBuf::from("C:/Program Files"), PathBuf::from("C:/Program Files")];
|
||||
let on_path = vec![PathBuf::from("C:/bin/photocraft.exe")];
|
||||
let exe = |f: &str| PathBuf::from(f).join("ArtCraft").join("PhotoCraft").join("photocraft.exe");
|
||||
assert_eq!(
|
||||
windows_candidates(registered, &folders, on_path),
|
||||
[
|
||||
PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe"),
|
||||
exe("C:/Users/me/AppData/Local/Programs"),
|
||||
exe("C:/Program Files"),
|
||||
PathBuf::from("C:/bin/photocraft.exe"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The registered path is read from `reg query … /ve` output, spaces and quotes included;
|
||||
/// no value, no path.
|
||||
#[test]
|
||||
fn the_registered_path_is_read_from_reg_query() {
|
||||
let out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\ArtCraft\\PhotoCraft\\photocraft.exe\r\n";
|
||||
assert_eq!(registered_path(out), Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe")));
|
||||
assert_eq!(
|
||||
registered_path(" (Standard) REG_SZ \"C:\\A B\\photocraft.exe\""),
|
||||
Some(std::path::PathBuf::from(r"C:\A B\photocraft.exe"))
|
||||
);
|
||||
assert_eq!(registered_path("ERROR: The system was unable to find the specified registry key or value."), None);
|
||||
assert_eq!(registered_path(" (Default) REG_SZ "), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
|
||||
//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
|
||||
//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
|
||||
//! macOS: a drawing tablet's pen pressure (#852), tilt and barrel rotation (#764). winit 0.30
|
||||
//! hands a pen to the window there as a mouse, without them, so the app reads them from AppKit
|
||||
//! itself and writes them into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
|
||||
//! reads, as on X11 ([`crate::tablet`]).
|
||||
//!
|
||||
//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
|
||||
//! each event it is about to dispatch" in this app:
|
||||
@@ -9,9 +10,14 @@
|
||||
//! it on unchanged:
|
||||
//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
|
||||
//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
|
||||
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
|
||||
//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
|
||||
//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
|
||||
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. Its `tilt`
|
||||
//! (−1..1 on each axis, ±[`TILT_DEGREES`] at the ends) and its `rotation` go into the feed as the
|
||||
//! pen's pose, kept for that move too. AppKit gives a rotation of 0 for a pen that does not read
|
||||
//! one and a tilt of 0 for an upright pen, so one event cannot tell a pen without tilt or rotation
|
||||
//! from one held upright and unturned. Each reads as none until the pen reports a value other
|
||||
//! than 0 after it comes near ([`Pen`]).
|
||||
//! A mouse event of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
|
||||
//! - An `NSEventTypeTabletPoint` event carries the same pressure, tilt and rotation without a move.
|
||||
//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
|
||||
//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
|
||||
//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
|
||||
@@ -20,12 +26,14 @@
|
||||
//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
|
||||
//! merges the drags that queue up between two reads into one: every report of the pen arrives.
|
||||
//!
|
||||
//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
|
||||
//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
|
||||
//! reads them.
|
||||
//! The event mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`). The tilt
|
||||
//! and rotation follow Apple's `NSEvent` documentation, as [`RawEvent::pose`] describes. The mapping
|
||||
//! works on plain values and is tested on every platform; the monitor reads them.
|
||||
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
|
||||
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
|
||||
|
||||
use crate::tablet::TILT_DEGREES;
|
||||
|
||||
/// `NSEventType` values (AppKit headers).
|
||||
mod event_type {
|
||||
@@ -56,6 +64,10 @@ struct RawEvent {
|
||||
subtype: i16,
|
||||
/// `pressure` (tablet point events and mouse events of that subtype only).
|
||||
pressure: f32,
|
||||
/// `tilt` (x, y), each −1..1 (the same events as `pressure`).
|
||||
tilt: (f64, f64),
|
||||
/// `rotation`, in degrees (the same events as `pressure`).
|
||||
rotation: f32,
|
||||
/// `pointingDeviceType` (proximity events only).
|
||||
device: usize,
|
||||
/// `isEnteringProximity` (proximity events only).
|
||||
@@ -74,6 +86,40 @@ impl RawEvent {
|
||||
fn point(&self) -> bool {
|
||||
self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
|
||||
}
|
||||
|
||||
/// The pen's tilt and rotation in [`PenPose`]'s units.
|
||||
///
|
||||
/// AppKit's `tilt` is −1.0..1.0 on each axis and 0.0 on both with the pen perpendicular to the
|
||||
/// tablet. A negative x is a tilt to the left and a positive x a tilt to the right; a negative y
|
||||
/// is a tilt to the top and a positive y a tilt to the bottom
|
||||
/// (<https://developer.apple.com/documentation/appkit/nsevent/tilt>). [`PenPose`]'s x is positive
|
||||
/// to the right and its y positive down the page, the same signs, so each axis keeps its sign.
|
||||
/// Apple gives the ends of the range as maximum tilt and does not give them in degrees
|
||||
/// (<https://developer.apple.com/documentation/coregraphics/cgeventfield/tableteventtiltx>), so
|
||||
/// ±1.0 is scaled to ±[`TILT_DEGREES`], the degrees the X11 reader ([`crate::tablet`]) gives the
|
||||
/// ends of a tilt range that is not in degrees. A value past ±1.0 is clamped.
|
||||
///
|
||||
/// AppKit's `rotation` is in degrees, and a device without rotation reports 0.0
|
||||
/// (<https://developer.apple.com/documentation/appkit/nsevent/rotation>). Apple's tablet pages give
|
||||
/// no direction for rotation; the Cocoa Event Handling Guide gives the `rotation` accessor's
|
||||
/// degrees as counterclockwise for the rotate gesture
|
||||
/// (<https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/HandlingTouchEvents/HandlingTouchEvents.html>),
|
||||
/// and the pen's rotation is taken the same way. [`PenPose`]'s rotation runs clockwise, so the
|
||||
/// angle is negated and brought into 0..360.
|
||||
///
|
||||
/// The tilt is left out unless `tilts`, and the rotation unless `turns` ([`Pen`]).
|
||||
fn pose(&self, tilts: bool, turns: bool) -> PenPose {
|
||||
let degrees = |v: f64| (v.clamp(-1.0, 1.0) * TILT_DEGREES) as f32;
|
||||
// `0.0 - r` rather than `-r`: a rotation of 0 gives 0, not −0.
|
||||
let rotation = turns.then(|| (0.0 - self.rotation).rem_euclid(360.0));
|
||||
PenPose { tilt: tilts.then(|| [degrees(self.tilt.0), degrees(self.tilt.1)]), rotation }
|
||||
}
|
||||
|
||||
/// Whether the event reports a tilt other than 0, and a rotation other than 0.
|
||||
fn reads(&self) -> (bool, bool) {
|
||||
let read = |v: f64| v.is_finite() && v != 0.0;
|
||||
(read(self.tilt.0) || read(self.tilt.1), read(f64::from(self.rotation)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
|
||||
@@ -86,7 +132,7 @@ fn event_mask() -> u64 {
|
||||
enum Update {
|
||||
/// Nothing: an event the monitor doesn't map.
|
||||
Keep,
|
||||
/// The pressure and end of a pen, or `None` for a mouse.
|
||||
/// The sample of a pen, or `None` for a mouse.
|
||||
Set(Option<PenSample>),
|
||||
/// A pen's sample that comes with a pointer move.
|
||||
Move(PenSample),
|
||||
@@ -102,10 +148,14 @@ impl Update {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which end of the pen is near the tablet.
|
||||
/// Which end of the pen is near the tablet, and whether it has reported a tilt or a rotation other
|
||||
/// than 0 since it came near: until it has, its tilt or rotation reads as none, since AppKit gives
|
||||
/// 0 for a pen without one and for one held upright or unturned.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Pen {
|
||||
eraser: bool,
|
||||
tilts: bool,
|
||||
turns: bool,
|
||||
}
|
||||
|
||||
impl Pen {
|
||||
@@ -113,10 +163,16 @@ impl Pen {
|
||||
if e.proximity() {
|
||||
// Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
|
||||
self.eraser = e.entering && e.device == device::ERASER;
|
||||
(self.tilts, self.turns) = (false, false);
|
||||
let pen = e.entering && e.device != device::CURSOR;
|
||||
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
|
||||
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser, ..PenSample::default() }));
|
||||
}
|
||||
let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
|
||||
if e.point() {
|
||||
let (tilts, turns) = e.reads();
|
||||
self.tilts |= tilts;
|
||||
self.turns |= turns;
|
||||
}
|
||||
let sample = PenSample { pressure: e.pressure, eraser: self.eraser, pose: e.pose(self.tilts, self.turns) };
|
||||
match (e.point(), e.mouse()) {
|
||||
(true, true) => Update::Move(sample),
|
||||
(true, false) => Update::Set(Some(sample)),
|
||||
@@ -187,6 +243,9 @@ mod monitor {
|
||||
}
|
||||
if raw.point() {
|
||||
raw.pressure = e.pressure();
|
||||
let tilt = e.tilt();
|
||||
raw.tilt = (tilt.x, tilt.y);
|
||||
raw.rotation = e.rotation();
|
||||
}
|
||||
if raw.proximity() {
|
||||
raw.device = e.pointingDeviceType().0;
|
||||
@@ -211,8 +270,14 @@ mod tests {
|
||||
RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
|
||||
}
|
||||
|
||||
/// A sample of the tip of a pen that has reported no tilt or rotation.
|
||||
fn tip(pressure: f32) -> PenSample {
|
||||
PenSample { pressure, eraser: false }
|
||||
PenSample { pressure, eraser: false, pose: PenPose::default() }
|
||||
}
|
||||
|
||||
/// A sample of the pen near the tablet but not yet read: no pose.
|
||||
fn near(eraser: bool) -> PenSample {
|
||||
PenSample { pressure: 0.0, eraser, pose: PenPose::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,16 +305,45 @@ mod tests {
|
||||
#[test]
|
||||
fn proximity_says_which_end_is_near() {
|
||||
let mut p = Pen::default();
|
||||
let eraser = |pressure| PenSample { pressure, eraser: true };
|
||||
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
|
||||
let eraser = |pressure| PenSample { pressure, eraser: true, pose: PenPose::default() };
|
||||
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(near(true))));
|
||||
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
|
||||
assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
|
||||
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
|
||||
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
|
||||
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
|
||||
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(near(false))), "the tip");
|
||||
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(near(false))), "an unknown tool draws as the tip");
|
||||
assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
|
||||
let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
|
||||
assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
|
||||
assert_eq!(p.handle(&e), Update::Set(Some(near(true))), "as a mouse event's subtype");
|
||||
}
|
||||
|
||||
/// #764: AppKit's tilt (−1..1, x positive to the right, y positive to the bottom) reaches the
|
||||
/// feed in degrees with the same signs, and its rotation, taken as counterclockwise, reaches it
|
||||
/// clockwise.
|
||||
#[test]
|
||||
fn a_pens_tilt_and_rotation_go_into_the_feed() {
|
||||
let mut p = Pen::default();
|
||||
let sample = |pressure, pose| PenSample { pressure, eraser: false, pose };
|
||||
assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)), "no tilt or rotation reported yet");
|
||||
let leaning = RawEvent { tilt: (0.5, -0.25), rotation: 30.0, ..pen(DRAG, 0.5) };
|
||||
let pose = PenPose { tilt: Some([30.0, -15.0]), rotation: Some(330.0) };
|
||||
assert_eq!(p.handle(&leaning), Update::Move(sample(0.5, pose)));
|
||||
let past_the_ends = RawEvent { tilt: (-4.0, 1.5), rotation: 270.0, ..pen(event_type::TABLET_POINT, 0.8) };
|
||||
let pose = PenPose { tilt: Some([-60.0, 60.0]), rotation: Some(90.0) };
|
||||
assert_eq!(p.handle(&past_the_ends), Update::Set(Some(sample(0.8, pose))), "clamped");
|
||||
let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
|
||||
let Update::Move(upright) = p.handle(&pen(DRAG, 0.6)) else { panic!("a pen's drag is a move") };
|
||||
assert_eq!(upright, sample(0.6, pose), "once reported, 0 is upright and unturned");
|
||||
assert!(upright.pose.rotation.is_some_and(f32::is_sign_positive), "{upright:?}");
|
||||
let feed = StylusFeed::default();
|
||||
p.handle(&RawEvent { tilt: (f64::NAN, 0.0), rotation: -90.0, ..pen(DRAG, 0.3) }).apply(&feed);
|
||||
assert_eq!(feed.pose(), Some(PenPose { tilt: None, rotation: Some(90.0) }), "a tilt that is not a number is left out");
|
||||
p.handle(&proximity(1, true));
|
||||
let tilted = RawEvent { tilt: (0.0, 0.5), ..pen(event_type::TABLET_POINT, 0.8) };
|
||||
let pose = PenPose { tilt: Some([0.0, 30.0]), rotation: None };
|
||||
assert_eq!(p.handle(&tilted), Update::Set(Some(sample(0.8, pose))), "no rotation reported since the pen came near");
|
||||
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
|
||||
assert_eq!(feed.pose(), None, "a mouse moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -259,7 +353,8 @@ mod tests {
|
||||
for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
|
||||
p.handle(&e).apply(&feed);
|
||||
}
|
||||
assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
|
||||
let pressures: Vec<f32> = feed.take_moves().into_iter().map(|(p, _)| p).collect();
|
||||
assert_eq!((pressures, feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
|
||||
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
|
||||
assert_eq!(feed.held(), None, "a mouse moved");
|
||||
}
|
||||
|
||||
@@ -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`).
|
||||
@@ -16,6 +19,7 @@
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
mod automation_args;
|
||||
mod caps_lock;
|
||||
mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
mod compositor_gpu;
|
||||
@@ -45,11 +49,15 @@ mod open_documents;
|
||||
mod portal_save;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[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;
|
||||
|
||||
@@ -87,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.
|
||||
@@ -118,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:
|
||||
@@ -142,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) }) {
|
||||
@@ -154,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;
|
||||
@@ -183,7 +196,19 @@ 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(windows)]
|
||||
system_fonts::install();
|
||||
@@ -265,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)");
|
||||
}
|
||||
@@ -321,14 +346,15 @@ fn main() -> std::process::ExitCode {
|
||||
// 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
|
||||
});
|
||||
}
|
||||
@@ -452,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`).
|
||||
@@ -461,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,
|
||||
@@ -473,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"))]
|
||||
|
||||
@@ -96,6 +96,12 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Sav
|
||||
.map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
let uri = uris.first().ok_or("no file in the portal's answer")?;
|
||||
let path = file_uri_path(uri).ok_or_else(|| format!("not a local file: {uri}"))?;
|
||||
// A name that isn't UTF-8 can't be written as picked: replacing its bytes would save over (and
|
||||
// ask to replace) another file. The dialog ends without a file instead.
|
||||
let Ok(path) = String::from_utf8(path) else {
|
||||
log::warn!("the portal's save dialog picked a file name that isn't UTF-8: nothing is saved");
|
||||
return Ok(None);
|
||||
};
|
||||
let filter = results.get("current_filter").and_then(|v| {
|
||||
let (label, _globs): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?;
|
||||
Some(label)
|
||||
@@ -103,9 +109,9 @@ fn answer(code: u32, results: &HashMap<String, OwnedValue>) -> Result<Option<Sav
|
||||
Ok(Some(Saved { path, filter }))
|
||||
}
|
||||
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg`; `None` for a file elsewhere (`file://host/`)
|
||||
/// or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<String> {
|
||||
/// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere
|
||||
/// (`file://host/`) or another scheme.
|
||||
fn file_uri_path(uri: &str) -> Option<Vec<u8>> {
|
||||
let rest = uri.strip_prefix("file://")?;
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
if !rest.starts_with('/') {
|
||||
@@ -126,8 +132,7 @@ fn file_uri_path(uri: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `show_dialog` makes rfd's path a string with `to_string_lossy` too.
|
||||
Some(String::from_utf8_lossy(&out).into_owned())
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `saved` as the answer of the save dialog for `request`: the path, then the first extension of
|
||||
@@ -406,6 +411,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn file_uris_become_local_paths() {
|
||||
let file_uri_path = |uri: &str| super::file_uri_path(uri).map(|b| String::from_utf8(b).unwrap());
|
||||
assert_eq!(file_uri_path("file:///home/me/Untitled-1.vectorcraft").as_deref(), Some("/home/me/Untitled-1.vectorcraft"));
|
||||
assert_eq!(file_uri_path("file:///home/me/A%20b%C3%A9.svg").as_deref(), Some("/home/me/A bé.svg"));
|
||||
assert_eq!(file_uri_path("file://localhost/tmp/x.pdf").as_deref(), Some("/tmp/x.pdf"));
|
||||
@@ -413,6 +419,9 @@ mod tests {
|
||||
assert_eq!(file_uri_path("file:///tmp/100%+1%2").as_deref(), Some("/tmp/100%+1%2"));
|
||||
assert_eq!(file_uri_path("file://host/x.svg"), None);
|
||||
assert_eq!(file_uri_path("sftp://host/x.svg"), None);
|
||||
// Not UTF-8: the bytes as they are, which `answer` refuses rather than changing them.
|
||||
assert_eq!(super::file_uri_path("file:///tmp/a%FF.svg"), Some(b"/tmp/a\xff.svg".to_vec()));
|
||||
assert!(matches!(answer(0, &results("file:///tmp/a%FF.svg", None)), Ok(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -62,7 +62,12 @@ pub(crate) fn keep_prefs_copy(path: &std::path::Path, bytes: &[u8], stem: &str)
|
||||
use std::io::Write as _;
|
||||
for n in 1..100u32 {
|
||||
let name = if n == 1 { format!("{stem}.json") } else { format!("{stem}-{n}.json") };
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
// The preferences hold recent document paths: the copy is owner-only, whatever the umask.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
match options.open(path.with_file_name(name)) {
|
||||
Ok(mut file) => {
|
||||
if file.write_all(bytes).is_ok() {
|
||||
return;
|
||||
@@ -171,6 +176,11 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
|
||||
let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else {
|
||||
return mine.clone();
|
||||
};
|
||||
// The current Actions set (`action_set`, an index) is this window's when this window chose
|
||||
// another one or the other window did not; otherwise it is the other window's.
|
||||
let use_mine = chose_another_action_set(base, mine) || !chose_another_action_set(base, &out);
|
||||
// The list the saved current set was chosen from.
|
||||
let chosen_from = if use_mine { mine.get("action_sets").cloned() } else { out.get("action_sets").cloned() };
|
||||
let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect();
|
||||
for key in keys {
|
||||
if PASSING.contains(&key.as_str()) {
|
||||
@@ -180,7 +190,7 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
|
||||
continue;
|
||||
}
|
||||
let (was, now) = (base.get(key), mine.get(key));
|
||||
if was == now {
|
||||
if was == now || key == "action_set" {
|
||||
continue;
|
||||
}
|
||||
if key == "engine_prefs"
|
||||
@@ -209,9 +219,95 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
|
||||
}
|
||||
}
|
||||
}
|
||||
if use_mine {
|
||||
match mine.get("action_set") {
|
||||
Some(v) => {
|
||||
out.insert("action_set".to_string(), v.clone());
|
||||
}
|
||||
None => {
|
||||
out.remove("action_set");
|
||||
}
|
||||
}
|
||||
}
|
||||
keep_current_action_set(&mut out, chosen_from.as_ref());
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// Point the saved current Actions set (`action_set`) at the same set in the merged
|
||||
/// `action_sets` when the merge took the index from one window and the list from another
|
||||
/// ([`find_action_set`]). With no such set, no set is current.
|
||||
fn keep_current_action_set(out: &mut serde_json::Map<String, serde_json::Value>, chosen_from: Option<&serde_json::Value>) {
|
||||
use serde_json::Value;
|
||||
let Some(i) = out.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()) else { return };
|
||||
let merged = out.get("action_sets");
|
||||
if merged == chosen_from {
|
||||
return;
|
||||
}
|
||||
match find_action_set(chosen_from, i, merged) {
|
||||
Some(j) => {
|
||||
out.insert("action_set".to_string(), Value::from(j));
|
||||
}
|
||||
None => {
|
||||
out.remove("action_set");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Was another Actions set made current from `base` to `now`? Recording into the current set,
|
||||
/// renaming it, deleting it, or deleting a set before it changes the list or the index without
|
||||
/// choosing another set.
|
||||
fn chose_another_action_set(base: &serde_json::Map<String, serde_json::Value>, now: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
use serde_json::Value;
|
||||
let index = |v: &serde_json::Map<String, Value>| v.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
|
||||
let (was, sets) = (base.get("action_sets"), now.get("action_sets"));
|
||||
let (Some(i), Some(k)) = (index(base), index(now)) else {
|
||||
return match (index(base), index(now)) {
|
||||
(None, None) => false,
|
||||
// No set is current: another set was chosen only if the current one is still there.
|
||||
(Some(i), None) => find_action_set(was, i, sets).is_some(),
|
||||
_ => true,
|
||||
};
|
||||
};
|
||||
if was == sets {
|
||||
return i != k;
|
||||
}
|
||||
if let Some(j) = find_action_set(was, i, sets) {
|
||||
return j != k;
|
||||
}
|
||||
// The set at the same place changed while every other set stayed as it was: it was edited.
|
||||
let edited_in_place = was
|
||||
.and_then(Value::as_array)
|
||||
.zip(sets.and_then(Value::as_array))
|
||||
.is_some_and(|(was, sets)| was.len() == sets.len() && was.iter().zip(sets).enumerate().all(|(j, (a, b))| j == i || a == b));
|
||||
!(i == k && edited_in_place)
|
||||
}
|
||||
|
||||
/// Where the Actions set at index `i` of `old` is in `new` (two `action_sets` lists): found by
|
||||
/// its contents when no other set in either list has the same contents, otherwise by its name when
|
||||
/// no other set in either list has it.
|
||||
fn find_action_set(old: Option<&serde_json::Value>, i: usize, new: Option<&serde_json::Value>) -> Option<usize> {
|
||||
use serde_json::Value;
|
||||
let (old, new) = (old?.as_array()?, new?.as_array()?);
|
||||
let set = old.get(i)?;
|
||||
let only = |list: &[Value], same: &dyn Fn(&Value) -> bool| {
|
||||
let mut found = list.iter().enumerate().filter(|(_, s)| same(s));
|
||||
match (found.next(), found.next()) {
|
||||
(Some((j, _)), None) => Some(j),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let same_contents = |s: &Value| s == set;
|
||||
if only(old, &same_contents).is_some()
|
||||
&& let Some(j) = only(new, &same_contents)
|
||||
{
|
||||
return Some(j);
|
||||
}
|
||||
let name = set.get("name")?;
|
||||
let same_name = |s: &Value| s.get("name") == Some(name);
|
||||
only(old, &same_name)?;
|
||||
only(new, &same_name)
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -221,6 +317,8 @@ pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
|
||||
/// [`prefs_bytes`] as JSON.
|
||||
fn prefs_value(app: &VectorcraftApp) -> serde_json::Result<serde_json::Value> {
|
||||
let mut ui = app.ui.clone();
|
||||
// An open dialog is never restored, and its fields can hold passwords (PDF import and save).
|
||||
ui.dialog = None;
|
||||
ui.engine_prefs = app.session.prefs.to_json();
|
||||
serde_json::to_value(&ui)
|
||||
}
|
||||
@@ -233,6 +331,19 @@ mod tests {
|
||||
|
||||
/// 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.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_kept_preferences_copy_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = std::env::temp_dir().join(format!("vc-prefs-copy-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
super::keep_prefs_copy(&dir.join("ui.json"), b"{}", "ui.unreadable");
|
||||
let mode = std::fs::metadata(dir.join("ui.unreadable.json")).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[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}});
|
||||
@@ -274,6 +385,112 @@ mod tests {
|
||||
assert!(!same_lasting(&base, &mine));
|
||||
}
|
||||
|
||||
/// One window deleted the first Actions set and saved; this one made the second set current.
|
||||
/// The saved index follows that set to its place in the merged list.
|
||||
#[test]
|
||||
fn the_current_action_set_follows_its_set_in_the_merged_list() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
|
||||
let on_disk = json!({"action_sets": [set("B"), set("C")]});
|
||||
let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 0}));
|
||||
// The other window also recorded into B: B is found by its name.
|
||||
let on_disk = json!({"action_sets": [{"name": "B", "actions": [{"name": "Action 1", "steps": []}]}, set("C")]});
|
||||
let merged = merge_changes(&base, &mine, on_disk);
|
||||
assert_eq!(merged["action_set"], json!(0));
|
||||
// The other window deleted B: no set is current.
|
||||
let on_disk = json!({"action_sets": [set("A"), set("C")]});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]}));
|
||||
}
|
||||
|
||||
/// This window deleted the first set while the index saved by the other window still stands.
|
||||
#[test]
|
||||
fn the_other_windows_current_action_set_follows_its_set() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")]});
|
||||
let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
|
||||
let mine = json!({"action_sets": [set("B"), set("C")]});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
|
||||
}
|
||||
|
||||
/// The other window replaced or renamed the set this one made current (the saved sets do not
|
||||
/// tell the two apart): no set is current.
|
||||
#[test]
|
||||
fn a_set_in_the_place_of_the_current_set_is_not_made_current() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")]});
|
||||
let on_disk = json!({"action_sets": [set("A"), set("D"), set("C")]});
|
||||
let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None);
|
||||
let on_disk = json!({"action_sets": [set("A"), set("C"), set("D")]});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None);
|
||||
}
|
||||
|
||||
/// This window deleted a set and made the set now at the old index current: its choice is
|
||||
/// saved, though the index is the same number.
|
||||
#[test]
|
||||
fn a_new_current_set_at_the_same_index_is_saved() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
|
||||
let on_disk = json!({"action_sets": [{"name": "A", "actions": [{"name": "Action 1", "steps": []}]}, set("B"), set("C")], "action_set": 1});
|
||||
let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
|
||||
}
|
||||
|
||||
/// Recording into the current set does not choose it again: the other window's choice stays.
|
||||
#[test]
|
||||
fn recording_into_the_current_set_keeps_the_other_windows_choice() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let recorded = json!({"name": "A", "actions": [{"name": "Action 1", "steps": []}]});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
|
||||
let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
|
||||
let mine = json!({"action_sets": [recorded.clone(), set("B"), set("C")], "action_set": 0});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [recorded, set("B"), set("C")], "action_set": 2}));
|
||||
}
|
||||
|
||||
/// Renaming the current set does not choose it again: the other window's choice stays. With no
|
||||
/// other choice, the renamed set stays current.
|
||||
#[test]
|
||||
fn renaming_the_current_set_keeps_the_other_windows_choice() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
|
||||
let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
|
||||
let mine = json!({"action_sets": [set("A2"), set("B"), set("C")], "action_set": 0});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk)["action_set"], json!(1));
|
||||
assert_eq!(merge_changes(&base, &mine, base.clone())["action_set"], json!(0));
|
||||
}
|
||||
|
||||
/// Deleting the current set does not override the other window's choice.
|
||||
#[test]
|
||||
fn deleting_the_current_set_keeps_the_other_windows_choice() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B")], "action_set": 0});
|
||||
let on_disk = json!({"action_sets": [set("A"), set("B")], "action_set": 1});
|
||||
let mine = json!({"action_sets": [set("B")]});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B")], "action_set": 0}));
|
||||
assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B")]}));
|
||||
}
|
||||
|
||||
/// Deleting a set before the current one moves its index without choosing it again.
|
||||
#[test]
|
||||
fn deleting_a_set_before_the_current_one_keeps_it_current() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
|
||||
let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1});
|
||||
assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
|
||||
}
|
||||
|
||||
/// Two sets with the same contents: when the other window deleted the one this window made
|
||||
/// current, the one left is not made current in its place.
|
||||
#[test]
|
||||
fn a_set_with_the_same_contents_does_not_take_the_place_of_the_current_set() {
|
||||
let set = |name: &str| json!({"name": name, "actions": []});
|
||||
let base = json!({"action_sets": [set("A"), set("A"), set("C")]});
|
||||
let on_disk = json!({"action_sets": [set("A"), set("C")]});
|
||||
let mine = json!({"action_sets": [set("A"), set("A"), set("C")], "action_set": 1});
|
||||
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_an_object_is_replaced() {
|
||||
let base = json!({"workspace": "Essentials"});
|
||||
|
||||
@@ -149,6 +149,15 @@ fn copy_dir(from: &Path, to: &Path) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make folder `dir`, owner-only on Unix.
|
||||
fn private_dir(dir: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
|
||||
builder.create(dir)
|
||||
}
|
||||
|
||||
fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
let entries = match std::fs::read_dir(from) {
|
||||
Ok(entries) => entries,
|
||||
@@ -165,7 +174,12 @@ fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
// A symbolic link is copied as what it points to, or skipped when that is a folder.
|
||||
let copied = match entry.file_type() {
|
||||
Ok(kind) if kind.is_dir() => std::fs::create_dir_all(&dst).map(|()| copy_dir_into(&src, &dst, skip)),
|
||||
// The copy keeps the folder's permissions (a private recovery folder stays private):
|
||||
// owner-only while it fills, then the source's.
|
||||
Ok(kind) if kind.is_dir() => private_dir(&dst).and_then(|()| {
|
||||
copy_dir_into(&src, &dst, skip);
|
||||
std::fs::set_permissions(&dst, std::fs::metadata(&src)?.permissions())
|
||||
}),
|
||||
Ok(_) => std::fs::copy(&src, &dst).map(|_| ()),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
@@ -387,4 +401,19 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
let _ = std::fs::remove_dir_all(&to);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_copied_folder_keeps_its_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let from = temp("copy-mode-from");
|
||||
let to = temp("copy-mode-to");
|
||||
std::fs::create_dir_all(from.join("recovery")).unwrap();
|
||||
std::fs::write(from.join("recovery/copy.vectorcraft"), b"unsaved").unwrap();
|
||||
std::fs::set_permissions(from.join("recovery"), std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
copy_dir(&from, &to).unwrap();
|
||||
assert_eq!(std::fs::metadata(to.join("recovery")).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
let _ = std::fs::remove_dir_all(&from);
|
||||
let _ = std::fs::remove_dir_all(&to);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,24 @@
|
||||
//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a
|
||||
//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from
|
||||
//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
|
||||
//! reads.
|
||||
//! Linux: a drawing tablet's pen pressure, tilt and barrel rotation under X11, XWayland included
|
||||
//! (#764). winit 0.30 hands a pen to the window there as a mouse, without any of them, so the app
|
||||
//! reads them from XInput2 itself and writes them into the UI's [`StylusFeed`]
|
||||
//! (`Services::stylus`), which the canvas reads.
|
||||
//!
|
||||
//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without
|
||||
//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw
|
||||
//! events reach every client that selects them, also while winit holds the pointer grab of a
|
||||
//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen
|
||||
//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label),
|
||||
//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the
|
||||
//! valuator's range to 0..1, goes into the feed. Any other pointer
|
||||
//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so
|
||||
//! the last pressure of each pen is kept.
|
||||
//! unless its XInput type is a touchpad's or a touchscreen's. Its valuators go into the feed:
|
||||
//!
|
||||
//! - "Abs Pressure", scaled from the valuator's range to 0..1;
|
||||
//! - "Abs Tilt X" and "Abs Tilt Y" (a pen that has both), in degrees as they are when the range
|
||||
//! fits in ±90, otherwise scaled to ±[`TILT_DEGREES`];
|
||||
//! - "Abs Rotary Z" (the barrel rotation of an Art Pen), scaled to a turn with the middle of the
|
||||
//! range at 0°.
|
||||
//!
|
||||
//! Any other pointer device moving sets the feed to `None`. Raw events carry only the valuators
|
||||
//! that changed, so the last value of each axis of each pen is kept; an axis the pen has that no
|
||||
//! event has carried yet reads 0 (upright, unturned), and an axis it lacks reads nothing.
|
||||
//!
|
||||
//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains
|
||||
//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's
|
||||
@@ -22,13 +29,18 @@
|
||||
//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland
|
||||
//! that receives none of the window's input.
|
||||
//!
|
||||
//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with
|
||||
//! the pressure axis only.
|
||||
//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`).
|
||||
//! PhotoCraft maps the rotation to 0..360° and reads an axis a pen lacks as 0; here the middle of
|
||||
//! the rotation's range is 0°, and an axis a pen lacks reads nothing.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
|
||||
|
||||
/// The tilt of a valuator whose range does not fit in ±90 (so does not give degrees), at the ends
|
||||
/// of its range.
|
||||
pub(crate) const TILT_DEGREES: f64 = 60.0;
|
||||
|
||||
/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer
|
||||
/// than 16).
|
||||
@@ -41,33 +53,76 @@ const MAX_DEVICES: usize = 256;
|
||||
/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens.
|
||||
const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"];
|
||||
|
||||
/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range.
|
||||
/// Which pen axis a valuator carries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AxisKind {
|
||||
Pressure,
|
||||
TiltX,
|
||||
TiltY,
|
||||
Rotation,
|
||||
}
|
||||
|
||||
impl AxisKind {
|
||||
/// The kind of the valuator labeled `label`, if it is one of a pen's.
|
||||
fn from_label(label: &str) -> Option<Self> {
|
||||
match label {
|
||||
"Abs Pressure" => Some(Self::Pressure),
|
||||
"Abs Tilt X" => Some(Self::TiltX),
|
||||
"Abs Tilt Y" => Some(Self::TiltY),
|
||||
"Abs Rotary Z" => Some(Self::Rotation),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where [`Pens`] keeps the last value of this axis.
|
||||
fn slot(self) -> usize {
|
||||
match self {
|
||||
Self::Pressure => 0,
|
||||
Self::TiltX => 1,
|
||||
Self::TiltY => 2,
|
||||
Self::Rotation => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A valuator of a pen: what it carries, its number in a raw event's valuator mask, and its range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Axis {
|
||||
kind: AxisKind,
|
||||
number: u16,
|
||||
min: f64,
|
||||
max: f64,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
/// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a
|
||||
/// mouse's, 1.
|
||||
fn pressure(&self, v: f64) -> f32 {
|
||||
/// A raw value of this valuator as a pressure of 0..1, a tilt in degrees (−90..90) or a
|
||||
/// rotation in degrees (−180..180, the middle of the range at 0). A value that is not a number
|
||||
/// is a mouse's pressure, 1, or a tilt or rotation of 0.
|
||||
fn map(&self, v: f64) -> f32 {
|
||||
if !v.is_finite() {
|
||||
return 1.0;
|
||||
return if self.kind == AxisKind::Pressure { 1.0 } else { 0.0 };
|
||||
}
|
||||
let span = self.max - self.min;
|
||||
let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v };
|
||||
unit.clamp(0.0, 1.0) as f32
|
||||
let unit = unit.clamp(0.0, 1.0);
|
||||
match self.kind {
|
||||
AxisKind::Pressure => unit as f32,
|
||||
AxisKind::TiltX | AxisKind::TiltY if self.min >= -90.0 && self.max <= 90.0 && span > 0.0 => v.clamp(-90.0, 90.0) as f32,
|
||||
AxisKind::TiltX | AxisKind::TiltY => ((unit * 2.0 - 1.0) * TILT_DEGREES) as f32,
|
||||
AxisKind::Rotation => ((unit - 0.5) * 360.0) as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pointer device the X server lists.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Device {
|
||||
id: u16,
|
||||
/// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.
|
||||
pressure: Option<Axis>,
|
||||
/// The name the X server lists, so that another device under the same id compares unequal.
|
||||
name: String,
|
||||
/// The pen's valuators: a pen has a pressure valuator, a mouse, a touchpad or a touchscreen
|
||||
/// has none (and keeps none).
|
||||
axes: Vec<Axis>,
|
||||
/// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.
|
||||
eraser: bool,
|
||||
}
|
||||
@@ -77,14 +132,31 @@ impl Device {
|
||||
/// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).
|
||||
fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {
|
||||
let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));
|
||||
let pressure = valuators
|
||||
.into_iter()
|
||||
.filter(|(number, ..)| *number < MAX_AXES)
|
||||
.find(|(_, label, ..)| label == "Abs Pressure")
|
||||
.map(|(number, _, min, max)| Axis { number, min, max })
|
||||
.filter(|_| !finger);
|
||||
let mut axes: Vec<Axis> = Vec::new();
|
||||
for (number, label, min, max) in valuators {
|
||||
// The first valuator of each kind.
|
||||
if let Some(kind) = AxisKind::from_label(&label)
|
||||
&& number < MAX_AXES
|
||||
&& !axes.iter().any(|a| a.kind == kind)
|
||||
{
|
||||
axes.push(Axis { kind, number, min, max });
|
||||
}
|
||||
}
|
||||
if finger || !axes.iter().any(|a| a.kind == AxisKind::Pressure) {
|
||||
axes.clear();
|
||||
}
|
||||
let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));
|
||||
Self { id, pressure, eraser }
|
||||
Self { id, name: name.to_string(), axes, eraser }
|
||||
}
|
||||
|
||||
/// The valuator of `kind`, if the device is a pen that has one.
|
||||
fn axis(&self, kind: AxisKind) -> Option<&Axis> {
|
||||
self.axes.iter().find(|a| a.kind == kind)
|
||||
}
|
||||
|
||||
/// A pen: a device with a pressure valuator.
|
||||
fn is_pen(&self) -> bool {
|
||||
!self.axes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,36 +165,46 @@ impl Device {
|
||||
enum Update {
|
||||
/// Nothing: a device the last listing didn't have (a master, or one added since).
|
||||
Keep,
|
||||
/// The pressure and end of a pen, or `None` for another pointer.
|
||||
/// The pressure, end, tilt and rotation of a pen, or `None` for another pointer.
|
||||
Set(Option<PenSample>),
|
||||
}
|
||||
|
||||
/// The pointer devices and the last pressure of each pen.
|
||||
/// The pointer devices and the last pressure, tilt and rotation of each pen.
|
||||
#[derive(Debug, Default)]
|
||||
struct Pens {
|
||||
devices: HashMap<u16, Device>,
|
||||
last: HashMap<u16, f32>,
|
||||
last: HashMap<u16, [Option<f32>; 4]>,
|
||||
}
|
||||
|
||||
impl Pens {
|
||||
/// Replace the device list (at the start, and when devices come and go or change).
|
||||
/// A device keeps its last values only while it is listed unchanged: another device under the
|
||||
/// same id starts without them.
|
||||
fn set_devices(&mut self, devices: impl IntoIterator<Item = Device>) {
|
||||
self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
|
||||
self.last.retain(|id, _| self.devices.contains_key(id));
|
||||
let devices: HashMap<u16, Device> = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
|
||||
self.last.retain(|id, _| devices.get(id).is_some_and(|d| self.devices.get(id) == Some(d)));
|
||||
self.devices = devices;
|
||||
}
|
||||
|
||||
/// A raw motion or button event of device `id`, with the valuators that changed.
|
||||
fn handle(&mut self, id: u16, valuators: impl IntoIterator<Item = (u16, f64)>) -> Update {
|
||||
let Some(device) = self.devices.get(&id) else { return Update::Keep };
|
||||
let Some(axis) = device.pressure else { return Update::Set(None) };
|
||||
let eraser = device.eraser;
|
||||
let last = self.last.entry(id).or_insert(0.0);
|
||||
if !device.is_pen() {
|
||||
return Update::Set(None);
|
||||
}
|
||||
let last = self.last.entry(id).or_default();
|
||||
for (number, v) in valuators {
|
||||
if number == axis.number {
|
||||
*last = axis.pressure(v);
|
||||
for axis in device.axes.iter().filter(|a| a.number == number) {
|
||||
if let Some(slot) = last.get_mut(axis.kind.slot()) {
|
||||
*slot = Some(axis.map(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
Update::Set(Some(PenSample { pressure: *last, eraser }))
|
||||
// An axis the pen has reads 0 until an event carries it; one it lacks reads nothing.
|
||||
let value = |kind: AxisKind| device.axis(kind).map(|_| last.get(kind.slot()).copied().flatten().unwrap_or(0.0));
|
||||
let tilt = value(AxisKind::TiltX).zip(value(AxisKind::TiltY)).map(|(x, y)| [x, y]);
|
||||
let pose = PenPose { tilt, rotation: value(AxisKind::Rotation) };
|
||||
Update::Set(Some(PenSample { pressure: value(AxisKind::Pressure).unwrap_or(0.0), eraser: device.eraser, pose }))
|
||||
}
|
||||
|
||||
/// A raw event as the X server sends it: its valuator mask and the values of the valuators
|
||||
@@ -149,8 +231,8 @@ fn fp3232(integral: i32, frac: u32) -> f64 {
|
||||
f64::from(integral) + f64::from(frac) / 4_294_967_296.0
|
||||
}
|
||||
|
||||
/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None`
|
||||
/// (logged) when the X server can't be reached or has no XInput 2.1.
|
||||
/// Start reading pen pressure, tilt and rotation for a window on X11: the feed the reader writes
|
||||
/// into, or `None` (logged) when the X server can't be reached or has no XInput 2.1.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn start() -> Option<StylusFeed> {
|
||||
let feed = StylusFeed::default();
|
||||
@@ -224,7 +306,7 @@ mod x11 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The slave pointer devices with their names and pressure valuators (none on any error).
|
||||
/// The slave pointer devices with their names and pen valuators (none on any error).
|
||||
fn query_devices(conn: &RustConnection) -> Vec<Device> {
|
||||
let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
|
||||
let kinds = device_kinds(conn);
|
||||
@@ -265,14 +347,16 @@ mod x11 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to
|
||||
/// 65,535.
|
||||
/// The valuators of a Wacom pen as the X server lists them: x and y, pressure from 0 to
|
||||
/// 65,535, tilt in degrees, and an Art Pen's barrel rotation.
|
||||
fn pen_valuators() -> Vec<(u16, String, f64, f64)> {
|
||||
vec![
|
||||
(0, "Abs X".to_string(), 0.0, 44704.0),
|
||||
(1, "Abs Y".to_string(), 0.0, 27940.0),
|
||||
(2, "Abs Pressure".to_string(), 0.0, 65535.0),
|
||||
(3, "Abs Tilt X".to_string(), -64.0, 63.0),
|
||||
(4, "Abs Tilt Y".to_string(), -64.0, 63.0),
|
||||
(5, "Abs Rotary Z".to_string(), -900.0, 899.0),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -295,19 +379,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_device_with_an_abs_pressure_valuator_is_a_pen() {
|
||||
assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));
|
||||
assert_eq!(mouse(12).pressure, None);
|
||||
assert_eq!(pen(9).axis(AxisKind::Pressure), Some(&Axis { kind: AxisKind::Pressure, number: 2, min: 0.0, max: 65535.0 }));
|
||||
assert_eq!(pen(9).axes.len(), 4, "pressure, tilt and rotation");
|
||||
assert!(!mouse(12).is_pen());
|
||||
let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
|
||||
assert_eq!(pad.pressure, None, "a tablet's pad is no pen");
|
||||
assert!(!pad.is_pen() && pad.axes.is_empty(), "a tablet's pad is no pen");
|
||||
let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
|
||||
assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");
|
||||
assert!(!far.is_pen(), "valuator numbers past the limit are ignored");
|
||||
let tilt_only = Device::new(14, "", Some("STYLUS"), [(3, "Abs Tilt X".to_string(), -64.0, 63.0), (4, "Abs Tilt Y".to_string(), -64.0, 63.0)]);
|
||||
assert!(!tilt_only.is_pen() && tilt_only.axes.is_empty(), "no pressure, no pen");
|
||||
// The types libinput and XWayland give pens, and a device whose type wasn't listed.
|
||||
for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {
|
||||
assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");
|
||||
assert!(Device::new(15, "", kind, pen_valuators()).is_pen(), "{kind:?}");
|
||||
}
|
||||
// xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".
|
||||
for kind in ["TOUCHPAD", "TOUCHSCREEN"] {
|
||||
assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");
|
||||
assert!(Device::new(16, "", Some(kind), pen_valuators()).axes.is_empty(), "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +411,7 @@ mod tests {
|
||||
];
|
||||
for (name, kind) in erasers {
|
||||
let d = Device::new(10, name, kind, pen_valuators());
|
||||
assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");
|
||||
assert!(d.eraser && d.is_pen(), "{name} ({kind:?})");
|
||||
}
|
||||
let tips = [
|
||||
("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),
|
||||
@@ -343,13 +430,14 @@ mod tests {
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);
|
||||
let feed = StylusFeed::default();
|
||||
let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
|
||||
pens.raw_event(&feed, 9, &[0b100], &[32767.5]);
|
||||
assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));
|
||||
assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false, pose })));
|
||||
assert_eq!(feed.eraser(), Some(false));
|
||||
// Turned over, the pen hovers with its eraser end: the eraser device's first event, with
|
||||
// no pressure yet.
|
||||
pens.raw_event(&feed, 10, &[0b1], &[100.0]);
|
||||
assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));
|
||||
assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true, pose })));
|
||||
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));
|
||||
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
|
||||
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));
|
||||
@@ -361,6 +449,71 @@ mod tests {
|
||||
assert_eq!(feed.eraser(), None);
|
||||
}
|
||||
|
||||
/// #764: a pen's tilt goes into the feed in degrees and its barrel rotation as a turn, the
|
||||
/// middle of the valuator's range at 0°; a pen without those valuators reads neither.
|
||||
#[test]
|
||||
fn a_pens_tilt_and_rotation_go_into_the_feed() {
|
||||
let mut pens = Pens::default();
|
||||
let pressure_only = Device::new(10, "", Some("STYLUS"), pen_valuators().into_iter().take(3));
|
||||
let one_tilt = Device::new(11, "", Some("STYLUS"), pen_valuators().into_iter().take(4));
|
||||
pens.set_devices([pen(9), pressure_only, one_tilt, mouse(12)]);
|
||||
let feed = StylusFeed::default();
|
||||
// Pressure, tilt x, tilt y and rotation: valuators 2 to 5.
|
||||
pens.raw_event(&feed, 9, &[0b11_1100], &[32767.5, -30.0, 45.0, 0.0]);
|
||||
let pose = feed.pose().unwrap();
|
||||
assert_eq!((feed.held(), pose.tilt), (Some(0.5), Some([-30.0, 45.0])));
|
||||
assert!(pose.rotation.is_some_and(|r| r < 0.2), "{pose:?}");
|
||||
// A quarter of the range back is a quarter turn back; the tilt stays as it was.
|
||||
pens.raw_event(&feed, 9, &[0b10_0000], &[-450.0]);
|
||||
let pose = feed.pose().unwrap();
|
||||
assert_eq!(pose.tilt, Some([-30.0, 45.0]));
|
||||
assert!(pose.rotation.is_some_and(|r| (r - 270.0).abs() < 0.2), "{pose:?}");
|
||||
// A pen with no tilt or rotation valuators, or with one tilt valuator only, reads none.
|
||||
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
|
||||
assert_eq!((feed.held(), feed.pose()), (Some(1.0), None));
|
||||
pens.raw_event(&feed, 11, &[0b1100], &[65535.0, 20.0]);
|
||||
assert_eq!(feed.pose(), None);
|
||||
// The mouse: none.
|
||||
pens.raw_event(&feed, 12, &[0b1], &[3.0]);
|
||||
assert_eq!(feed.pose(), None);
|
||||
// A pen whose tilt and rotation no event has carried yet is upright and unturned.
|
||||
let mut fresh = Pens::default();
|
||||
fresh.set_devices([pen(9)]);
|
||||
let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
|
||||
assert_eq!(fresh.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));
|
||||
}
|
||||
|
||||
/// A device listed again unchanged keeps its last tilt and rotation; another device listed
|
||||
/// under the same id starts upright and unturned.
|
||||
#[test]
|
||||
fn a_device_replaced_under_the_same_id_starts_without_the_old_pose() {
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices([pen(9)]);
|
||||
pens.handle(9, [(3, -30.0), (4, 45.0), (5, -450.0)]);
|
||||
pens.set_devices([pen(9)]);
|
||||
let Update::Set(Some(PenSample { pose, .. })) = pens.handle(9, [(2, 0.0)]) else { panic!("no pen") };
|
||||
assert_eq!(pose.tilt, Some([-30.0, 45.0]));
|
||||
let other = Device::new(9, "HUION Kamvas Pen Pen (0x1)", Some("STYLUS"), pen_valuators());
|
||||
pens.set_devices([other]);
|
||||
let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
|
||||
assert_eq!(pens.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));
|
||||
}
|
||||
|
||||
/// Tilt valuators whose range does not fit in ±90 are scaled to ±60°.
|
||||
#[test]
|
||||
fn tilt_ranges_that_are_not_degrees_are_scaled() {
|
||||
let d = Device::new(
|
||||
1,
|
||||
"",
|
||||
None,
|
||||
[(2, "Abs Pressure".into(), 0.0, 1023.0), (3, "Abs Tilt X".into(), 0.0, 127.0), (4, "Abs Tilt Y".into(), -1000.0, 1000.0)],
|
||||
);
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices([d]);
|
||||
let Update::Set(Some(PenSample { pose, .. })) = pens.handle(1, [(3, 127.0), (4, -1000.0)]) else { panic!("no pen") };
|
||||
assert_eq!(pose.tilt, Some([60.0, -60.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {
|
||||
let mut pens = Pens::default();
|
||||
@@ -389,13 +542,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hostile_device_data_gives_a_pressure_within_0_to_1() {
|
||||
let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];
|
||||
let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN), (90.0, -90.0)];
|
||||
for (min, max) in ranges {
|
||||
let mut pens = Pens::default();
|
||||
pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);
|
||||
let labels = ["Abs Pressure", "Abs Tilt X", "Abs Tilt Y", "Abs Rotary Z"];
|
||||
pens.set_devices([Device::new(1, "", None, (2..).zip(labels).map(|(n, l)| (n, l.to_string(), min, max)))]);
|
||||
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {
|
||||
let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
|
||||
let Update::Set(Some(PenSample { pressure: p, pose, .. })) = pens.handle(1, (2..6).map(|n| (n, v))) else {
|
||||
panic!("{min}..{max}: {v} gave no pressure")
|
||||
};
|
||||
assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");
|
||||
let tilt = pose.tilt.unwrap_or_default();
|
||||
assert!(tilt.iter().all(|t| (-90.0..=90.0).contains(t)), "{min}..{max}: {v} gave {pose:?}");
|
||||
assert!(pose.rotation.is_some_and(|r| (-180.0..=180.0).contains(&r)), "{min}..{max}: {v} gave {pose:?}");
|
||||
}
|
||||
}
|
||||
let mut pens = Pens::default();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ use std::cell::RefCell;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
|
||||
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
|
||||
|
||||
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
|
||||
/// `wParam`, a pen in contact with its barrel button pressed.
|
||||
@@ -114,7 +114,7 @@ fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) ->
|
||||
_ if in_contact => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 }
|
||||
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() }
|
||||
}
|
||||
|
||||
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
|
||||
@@ -403,7 +403,11 @@ mod tests {
|
||||
fn a_pens_pressure_is_the_one_winit_gives_its_touch() {
|
||||
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);
|
||||
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);
|
||||
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering");
|
||||
assert_eq!(
|
||||
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),
|
||||
PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() },
|
||||
"hovering"
|
||||
);
|
||||
assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
|
||||
assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");
|
||||
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");
|
||||
@@ -420,7 +424,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pen_messages_set_the_feed_and_other_pointers_clear_it() {
|
||||
let pen = PenSample { pressure: 0.0, eraser: true };
|
||||
let pen = PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() };
|
||||
// `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word.
|
||||
let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7;
|
||||
let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") };
|
||||
|
||||
@@ -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, ¬_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
|
||||
|
||||
@@ -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 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5h9M10.5 5v11"/><rect x="14" y="12" width="7" height="8" stroke-dasharray="1.5 1.5"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10M20 7v10M17 20H7M4 17V7" stroke-width="1"/><path d="M8 10V8h8v2M12 8v8"/><rect x="2.5" y="2.5" width="3" height="3" rx=".75" stroke-width="1"/><rect x="18.5" y="2.5" width="3" height="3" rx=".75" stroke-width="1"/><rect x="18.5" y="18.5" width="3" height="3" rx=".75" stroke-width="1"/><rect x="2.5" y="18.5" width="3" height="3" rx=".75" stroke-width="1"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 563 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" stroke-dasharray="2 2"/><path d="M8 8h8M12 8v9"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="2.5" width="17" height="19" rx="2.5" stroke-width="1.25"/><path d="M8 8V6h8v2M12 6v7"/><path d="M7.5 16.5h9M7.5 18.5H13" stroke-width="1"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 346 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 19 C8 9 16 9 21 19"/><path d="M8 5h8M12 5v8"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.5v-2h8v2M12 3.5v10"/><path d="M3 18.5c3-3.5 6-3.5 9 0s6 3.5 9 0" stroke-width="1.25"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 248 B After Width: | Height: | Size: 290 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="2.5" width="13" height="19" rx="2.5" stroke-width="1.25"/><path d="M11.5 8V6h7v2M15 6v8"/><path d="M12 17h6" stroke-width="1"/><path d="M4 4v15.5M1.75 17.25 4 19.5l2.25-2.25" stroke-width="1.25"/></svg>
|
||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 5.5v-2h10v2M15 3.5V13"/><path d="M4 3v10.5M1.75 11.25 4 13.5l2.25-2.25" stroke-width="1.25"/><path d="M3 18.5c3-3.5 6-3.5 9 0s6 3.5 9 0" stroke-width="1.25"/></svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10M12 4v8"/><path d="M9 15h6M12 15v6"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 6.5v-2h10v2M15 4.5V19"/><path d="M4 4v15.5M1.75 17.25 4 19.5l2.25-2.25" stroke-width="1.25"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 296 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6.5v-2h14v2M12 4.5v15"/></svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -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"
|
||||
@@ -183,3 +180,16 @@ 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
|
||||
|
||||
[people.JonRC]
|
||||
real_name = "Jonathan Cardoso"
|
||||
display_name = "Jonathan Cardoso"
|
||||
|
||||
@@ -17,12 +17,13 @@ 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) |
|
||||
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
|
||||
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
|
||||
| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight |
|
||||
| Fill layers (`FRst`) | filled rectangles |
|
||||
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
|
||||
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
|
||||
@@ -33,7 +34,7 @@ Affinity files.
|
||||
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
|
||||
|
||||
Everything else is reported in the import warnings, one line per kind with a count, never
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
|
||||
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
|
||||
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
|
||||
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
|
||||
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
|
||||
|
||||
@@ -15,6 +15,13 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
|
||||
|
||||
/// A save appends one table; real files have one to a few hundred.
|
||||
const MAX_TABLES: usize = 4096;
|
||||
/// Allocation records read over the whole table chain. Each save lists every entry again, so a
|
||||
/// document with a few hundred saves of a few thousand entries stays far below; tables whose
|
||||
/// record regions overlap (or a table chain of thousands of huge tables) can't multiply the work.
|
||||
const MAX_INDEX_RECORDS: usize = 1 << 22;
|
||||
/// Bytes of entry names kept by the index (after lossy UTF-8 decoding, which can triple a name).
|
||||
/// Real names (`doc.dat`, `d/12`) are a few bytes each.
|
||||
const MAX_INDEX_NAME_BYTES: usize = 16 << 20;
|
||||
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
|
||||
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
|
||||
|
||||
@@ -75,6 +82,9 @@ pub struct Archive<'a> {
|
||||
pub revisions: usize,
|
||||
extracted: usize,
|
||||
limits: Limits,
|
||||
/// Entries whose extraction failed after their size was charged: asked again, they fail
|
||||
/// again without being decompressed again.
|
||||
failed: HashMap<String, Error>,
|
||||
}
|
||||
|
||||
pub(crate) struct Cursor<'a> {
|
||||
@@ -157,7 +167,15 @@ pub fn header(bytes: &[u8]) -> Result<Header, Error> {
|
||||
|
||||
impl<'a> Archive<'a> {
|
||||
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
|
||||
Self::open_within(bytes, limits, MAX_INDEX_RECORDS, MAX_INDEX_NAME_BYTES)
|
||||
}
|
||||
|
||||
/// [`Archive::open`] reading at most `max_records` allocation records and keeping at most
|
||||
/// `max_name_bytes` bytes of entry names, both over the whole table chain.
|
||||
pub(crate) fn open_within(bytes: &'a [u8], limits: Limits, max_records: usize, max_name_bytes: usize) -> Result<Self, Error> {
|
||||
let header = header(bytes)?;
|
||||
let mut records_left = max_records;
|
||||
let mut name_bytes_left = max_name_bytes;
|
||||
// Newest record per id: (table date, table index, record).
|
||||
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
|
||||
let mut names: HashMap<u32, String> = HashMap::new();
|
||||
@@ -189,6 +207,7 @@ impl<'a> Archive<'a> {
|
||||
let dirs = c.u16()?;
|
||||
c.take(1)?;
|
||||
for _ in 0..files {
|
||||
records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?;
|
||||
let id = c.u32()?;
|
||||
let flag = c.u8()?;
|
||||
let mut record = None;
|
||||
@@ -224,8 +243,13 @@ impl<'a> Archive<'a> {
|
||||
}
|
||||
if flag == 0 {
|
||||
let len = usize::from(c.u16()?);
|
||||
let name = String::from_utf8_lossy(c.take(len)?).into_owned();
|
||||
names.entry(id).or_insert(name);
|
||||
let raw = c.take(len)?;
|
||||
// Only the first name of an id is kept: later ones are not even decoded.
|
||||
if let std::collections::hash_map::Entry::Vacant(slot) = names.entry(id) {
|
||||
let name = String::from_utf8_lossy(raw);
|
||||
name_bytes_left = name_bytes_left.checked_sub(name.len()).ok_or(Error::Limit("archive entry names too long"))?;
|
||||
slot.insert(name.into_owned());
|
||||
}
|
||||
}
|
||||
// Equal dates: the first table in the chain (the newest) wins, as in afread.
|
||||
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
|
||||
@@ -234,6 +258,7 @@ impl<'a> Archive<'a> {
|
||||
}
|
||||
}
|
||||
for _ in 0..dirs {
|
||||
records_left = records_left.checked_sub(1).ok_or(Error::Limit("too many archive entries"))?;
|
||||
let len = usize::from(c.u16()?);
|
||||
c.take(10)?;
|
||||
c.take(len)?;
|
||||
@@ -241,12 +266,12 @@ impl<'a> Archive<'a> {
|
||||
}
|
||||
let mut entries = HashMap::new();
|
||||
for (id, (_, _, record)) in latest {
|
||||
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
|
||||
e.name = name.clone();
|
||||
if let (Some(mut e), Some(name)) = (record, names.remove(&id)) {
|
||||
e.name = name;
|
||||
entries.insert(e.name.clone(), e);
|
||||
}
|
||||
}
|
||||
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
|
||||
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits, failed: HashMap::new() })
|
||||
}
|
||||
|
||||
pub fn entry(&self, name: &str) -> Option<&Entry> {
|
||||
@@ -279,7 +304,10 @@ impl<'a> Archive<'a> {
|
||||
}
|
||||
|
||||
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
|
||||
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
|
||||
if let Some(e) = self.failed.get(name) {
|
||||
return Err(*e);
|
||||
}
|
||||
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?;
|
||||
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
|
||||
if size > self.limits.max_entry {
|
||||
return Err(Error::Limit("archive entry too large"));
|
||||
@@ -288,30 +316,41 @@ impl<'a> Archive<'a> {
|
||||
if total > self.limits.max_total {
|
||||
return Err(Error::Limit("document too large"));
|
||||
}
|
||||
let mut c = Cursor::new(self.bytes, offset(e.offset)?);
|
||||
if c.take(4)? != b"#Fil" {
|
||||
return Err(Error::Malformed("archive entry has no data block"));
|
||||
}
|
||||
let method = e.compression & 3;
|
||||
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
|
||||
let data = c.take(offset(stored_len)?)?;
|
||||
let mut out = match method {
|
||||
1 => inflate(data, size)?,
|
||||
2 => unzstd(data, size)?,
|
||||
_ => data.to_vec(),
|
||||
};
|
||||
if out.len() != size {
|
||||
return Err(Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
unpredict(&mut out, e.compression)?;
|
||||
if crc32fast::hash(&out) != e.crc {
|
||||
return Err(Error::Malformed("archive entry checksum mismatch"));
|
||||
}
|
||||
// Charged before the work, and kept when it fails: an entry that fails its checksum
|
||||
// can't be decompressed over and over for free.
|
||||
self.extracted = total;
|
||||
Ok(out)
|
||||
let result = extract(self.bytes, e, size);
|
||||
if let Err(err) = result {
|
||||
self.failed.insert(name.to_owned(), err);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompress entry `e` of `bytes` (declared `size` bytes), undo its predictor and check its CRC.
|
||||
fn extract(bytes: &[u8], e: &Entry, size: usize) -> Result<Vec<u8>, Error> {
|
||||
let mut c = Cursor::new(bytes, offset(e.offset)?);
|
||||
if c.take(4)? != b"#Fil" {
|
||||
return Err(Error::Malformed("archive entry has no data block"));
|
||||
}
|
||||
let method = e.compression & 3;
|
||||
let stored_len = if matches!(method, 1 | 2) { e.compressed } else { e.size };
|
||||
let data = c.take(offset(stored_len)?)?;
|
||||
let mut out = match method {
|
||||
1 => inflate(data, size)?,
|
||||
2 => unzstd(data, size)?,
|
||||
_ => data.to_vec(),
|
||||
};
|
||||
if out.len() != size {
|
||||
return Err(Error::Malformed("archive entry size mismatch"));
|
||||
}
|
||||
unpredict(&mut out, e.compression)?;
|
||||
if crc32fast::hash(&out) != e.crc {
|
||||
return Err(Error::Malformed("archive entry checksum mismatch"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read at most `size + 1` bytes so a stream that decodes past its declared size is caught
|
||||
/// without unbounded allocation.
|
||||
fn read_bounded(mut r: impl Read, size: usize) -> Result<Vec<u8>, Error> {
|
||||
|
||||
@@ -169,6 +169,88 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Pat
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful.
|
||||
const MAX_PRESSURE_SEGMENTS: usize = 256;
|
||||
|
||||
/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its
|
||||
/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling
|
||||
/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`]
|
||||
/// points; the document's limit counts all of them.
|
||||
const PRESSURE_POINTS: usize = 512;
|
||||
|
||||
/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read
|
||||
/// counts this many against the document's limit.
|
||||
pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS;
|
||||
|
||||
/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of
|
||||
/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as
|
||||
/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most
|
||||
/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more
|
||||
/// points), so a dip between samples is kept → (position, width) in order along the path,
|
||||
/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the
|
||||
/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments.
|
||||
pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option<Vec<(f64, f64)>> {
|
||||
let s = r.s;
|
||||
let data = s.obj(crvd, b"Data")?;
|
||||
let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?;
|
||||
// Every segment takes at least an anchor record.
|
||||
if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 {
|
||||
return None;
|
||||
}
|
||||
let curve = subpath(&anchors(records)?, false, None)?;
|
||||
if curve.segments.len() > MAX_PRESSURE_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2);
|
||||
let mut samples = vec![(curve.start.x, curve.start.y)];
|
||||
let mut from = curve.start;
|
||||
for [c1, c2, to] in &curve.segments {
|
||||
let mut ts: Vec<f64> = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect();
|
||||
ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten());
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
for t in ts {
|
||||
let u = 1.0 - t;
|
||||
let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
|
||||
samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y)));
|
||||
}
|
||||
from = *to;
|
||||
}
|
||||
let mut last = 0.0f64;
|
||||
Some(
|
||||
samples
|
||||
.into_iter()
|
||||
.map(|(x, y)| {
|
||||
last = x.clamp(last, 1.0);
|
||||
(last, y.clamp(0.0, 1.0))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`,
|
||||
/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into
|
||||
/// -1..1 first, so huge ones can't overflow the discriminant.
|
||||
fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option<f64>; 2] {
|
||||
let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs());
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return [None, None];
|
||||
}
|
||||
let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale);
|
||||
let (d0, d1, d2) = (b - a, c - b, d - c);
|
||||
let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0);
|
||||
let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t);
|
||||
if qa.abs() < 1e-12 {
|
||||
return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None];
|
||||
}
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if disc < 0.0 {
|
||||
return [None, None];
|
||||
}
|
||||
let root = disc.sqrt();
|
||||
[inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))]
|
||||
}
|
||||
|
||||
struct Anchor {
|
||||
p: Point,
|
||||
inh: Option<Point>,
|
||||
@@ -260,3 +342,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn turning_points_of_huge_control_values_are_found() {
|
||||
let small = turning_points(1.0, -3.0, 3.0, 1.0);
|
||||
let huge = turning_points(1e200, -3e200, 3e200, 1e200);
|
||||
assert!(small.iter().all(Option::is_some), "{small:?}");
|
||||
for (s, h) in small.iter().zip(&huge) {
|
||||
let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") };
|
||||
assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ const MAX_DOCUMENT_CHARS: usize = 16 << 20;
|
||||
/// Table cells of all tables together (each becomes a text object with its edge lines).
|
||||
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
|
||||
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
|
||||
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
@@ -39,10 +42,18 @@ pub(crate) struct Budget {
|
||||
pub(crate) chars: usize,
|
||||
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
|
||||
pub(crate) cells: usize,
|
||||
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
|
||||
pub(crate) pressure: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
|
||||
pub(crate) const FULL: Budget = Budget {
|
||||
nodes: MAX_NODES,
|
||||
pixels: MAX_LAYER_PIXELS,
|
||||
chars: MAX_DOCUMENT_CHARS,
|
||||
cells: MAX_DOCUMENT_CELLS,
|
||||
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
|
||||
};
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
|
||||
@@ -332,7 +332,7 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
|
||||
};
|
||||
let to_unit = match o.source {
|
||||
Source::Gradient(g) => match invert(g.transform) {
|
||||
Some(m) => Some((img.transform.then(m), g)),
|
||||
Some(m) => Some((img.transform.then(m), g, stop_reach(g))),
|
||||
None => return false,
|
||||
},
|
||||
Source::Solid(_) => None,
|
||||
@@ -343,9 +343,9 @@ fn pixels(img: &mut Image, o: &Overlay) -> bool {
|
||||
}
|
||||
let (s, sa) = match (solid, &to_unit) {
|
||||
(Some(s), _) => s,
|
||||
(None, Some((m, g))) => {
|
||||
(None, Some((m, g, reach))) => {
|
||||
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
|
||||
let c = sample(g, q);
|
||||
let c = sample(g, reach, q);
|
||||
(rgb(c), c.alpha())
|
||||
}
|
||||
(None, None) => continue,
|
||||
@@ -376,8 +376,22 @@ fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
Some(reader.decode().ok()?.into_rgba8().into_raw())
|
||||
}
|
||||
|
||||
/// The gradient's colour at unit-space point `q`.
|
||||
fn sample(g: &Gradient, q: Point) -> Color {
|
||||
/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset of stops `1..=k + 1`:
|
||||
/// the first pair whose end offset a `t` doesn't pass is the first whose reach it doesn't pass, and
|
||||
/// reaches never decrease, so [`sample`] finds it by binary search, sorted stops or not.
|
||||
fn stop_reach(g: &Gradient) -> Vec<f64> {
|
||||
g.stops
|
||||
.iter()
|
||||
.skip(1)
|
||||
.scan(f64::NEG_INFINITY, |m, s| {
|
||||
*m = m.max(s.offset);
|
||||
Some(*m)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The gradient's colour at unit-space point `q` (`reach`: [`stop_reach`] of `g`).
|
||||
fn sample(g: &Gradient, reach: &[f64], q: Point) -> Color {
|
||||
let t = match g.kind {
|
||||
GradientKind::Linear => q.x,
|
||||
GradientKind::Radial => q.x.hypot(q.y),
|
||||
@@ -388,18 +402,16 @@ fn sample(g: &Gradient, q: Point) -> Color {
|
||||
if t <= first.offset {
|
||||
return first.color;
|
||||
}
|
||||
for w in g.stops.windows(2) {
|
||||
let [a, b] = [&w[0], &w[1]];
|
||||
if t <= b.offset {
|
||||
let span = b.offset - a.offset;
|
||||
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
|
||||
// Affinity's midpoint bias (see `Stop::half_point`).
|
||||
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
|
||||
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
|
||||
let (x, y) = (rgb(a.color), rgb(b.color));
|
||||
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
|
||||
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
|
||||
}
|
||||
let k = reach.partition_point(|r| *r < t);
|
||||
if let (Some(a), Some(b)) = (g.stops.get(k), g.stops.get(k + 1)) {
|
||||
let span = b.offset - a.offset;
|
||||
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
|
||||
// Affinity's midpoint bias (see `Stop::half_point`).
|
||||
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
|
||||
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
|
||||
let (x, y) = (rgb(a.color), rgb(b.color));
|
||||
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
|
||||
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
|
||||
}
|
||||
last.color
|
||||
}
|
||||
|
||||
@@ -121,6 +121,9 @@ pub struct Stroke {
|
||||
pub dash: Option<(Vec<f64>, f64)>,
|
||||
/// Painted below the fill instead of above it.
|
||||
pub behind: bool,
|
||||
/// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in
|
||||
/// order along the path, `None` for a stroke of the same width all along.
|
||||
pub profile: Option<Vec<(f64, f64)>>,
|
||||
}
|
||||
|
||||
/// Gradient stops per gradient.
|
||||
@@ -350,10 +353,38 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
|
||||
r.warn("brush and pressure strokes (imported as plain strokes)");
|
||||
if s.obj(lsty, b"Brus").is_some() {
|
||||
r.warn("brush strokes (imported as plain strokes)");
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
// The pressure curve sets the width along the path; one at full width all along is a plain
|
||||
// stroke. Every curve read counts against the document's points: one at full width counts its
|
||||
// points, and one that can't be read counts the most a curve may have. Once the points are
|
||||
// used up the rest are not read.
|
||||
const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)";
|
||||
let ldep = s.obj(ldsc, b"LDeP");
|
||||
if ldep.is_some() && r.budget.pressure == 0 {
|
||||
r.warn(BEYOND);
|
||||
}
|
||||
let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) {
|
||||
Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) {
|
||||
Some(left) => {
|
||||
r.budget.pressure = left;
|
||||
(!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points)
|
||||
}
|
||||
None => {
|
||||
r.budget.pressure = 0;
|
||||
r.warn(BEYOND);
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(None) => {
|
||||
r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS);
|
||||
r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)");
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,8 +13,7 @@ const MAX_SIDES: i64 = 10_000;
|
||||
|
||||
/// The last value of a tag: a shape changed from one kind to another keeps a stale first block.
|
||||
fn last<'a>(r: &'a Reader, id: ObjId, tag: &[u8; 4]) -> Option<&'a Value> {
|
||||
let t = Tag::of(tag);
|
||||
r.s.object(id)?.fields.iter().rev().find(|(k, _)| *k == t).map(|(_, v)| v)
|
||||
r.s.object(id)?.get_last(Tag::of(tag))
|
||||
}
|
||||
|
||||
fn num(r: &Reader, id: ObjId, tag: &[u8; 4]) -> Option<f64> {
|
||||
@@ -51,12 +50,13 @@ pub(crate) fn shape(r: &mut Reader, shape: ObjId, b: [f64; 4], world: Affine) ->
|
||||
let class = r.s.class(shape)?;
|
||||
let sub = match &class.0.to_be_bytes() {
|
||||
b"ShNR" | b"ShRR" => {
|
||||
let types = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
// Only the four corners' entries can matter: longer arrays are read no further.
|
||||
let types: Vec<Option<u16>> = match last(r, shape, b"CTyp") {
|
||||
Some(Value::Array(a)) => a.iter().take(4).map(|t| if let Value::Enum { id, .. } = t { Some(*id) } else { None }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let radii = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
let radii: Vec<f64> = match last(r, shape, b"ShCR") {
|
||||
Some(Value::Floats(v)) => v.iter().take(4).map(|f| if f.is_finite() && *f > 0.0 { *f } else { 0.0 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
// Corners follow the first one (top left) unless they were unlocked; then each has
|
||||
|
||||
@@ -73,11 +73,67 @@ pub struct Object {
|
||||
pub chain: Vec<Tag>,
|
||||
/// Fields in stream order; positional fields have tag 0.
|
||||
pub fields: Vec<(Tag, Value)>,
|
||||
/// For objects with many fields: each tag with the positions of its first and last field,
|
||||
/// sorted by tag, so a lookup isn't a scan of every field (a shared object is looked up
|
||||
/// again on every visit). Built by [`Object::index`]; empty for small objects.
|
||||
index: Vec<(Tag, u32, u32)>,
|
||||
}
|
||||
|
||||
/// Objects with more fields than this get an [`Object::index`]; fewer are scanned.
|
||||
const INDEXED_FIELDS: usize = 16;
|
||||
|
||||
impl Object {
|
||||
pub fn new(class: Tag, kind: Kind, chain: Vec<Tag>, fields: Vec<(Tag, Value)>) -> Self {
|
||||
let mut o = Self { class, kind, chain, fields, index: Vec::new() };
|
||||
o.index();
|
||||
o
|
||||
}
|
||||
|
||||
/// (Re)build the tag index after `fields` changed.
|
||||
fn index(&mut self) {
|
||||
self.index.clear();
|
||||
if self.fields.len() <= INDEXED_FIELDS {
|
||||
return;
|
||||
}
|
||||
let mut index: Vec<(Tag, u32, u32)> = Vec::new();
|
||||
if index.try_reserve_exact(self.fields.len()).is_err() {
|
||||
return;
|
||||
}
|
||||
// Fields beyond u32 positions are impossible: a stream holds at most MAX_VALUES values.
|
||||
index.extend(self.fields.iter().enumerate().filter_map(|(i, (t, _))| u32::try_from(i).ok().map(|i| (*t, i, i))));
|
||||
// Stable: equal tags stay in stream order, so the run of a tag starts at its first field.
|
||||
index.sort_by_key(|(t, _, _)| *t);
|
||||
index.dedup_by(|later, first| {
|
||||
if later.0 == first.0 {
|
||||
first.2 = later.2;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
index.shrink_to_fit();
|
||||
self.index = index;
|
||||
}
|
||||
|
||||
fn position(&self, tag: Tag, last: bool) -> Option<usize> {
|
||||
if self.index.is_empty() {
|
||||
let mut fields = self.fields.iter().enumerate();
|
||||
let hit = if last { fields.rev().find(|(_, (t, _))| *t == tag) } else { fields.find(|(_, (t, _))| *t == tag) };
|
||||
return hit.map(|(i, _)| i);
|
||||
}
|
||||
let at = self.index.binary_search_by_key(&tag, |(t, _, _)| *t).ok()?;
|
||||
let (_, first, end) = self.index.get(at)?;
|
||||
usize::try_from(if last { *end } else { *first }).ok()
|
||||
}
|
||||
|
||||
/// The first field tagged `tag`.
|
||||
pub fn get(&self, tag: Tag) -> Option<&Value> {
|
||||
self.fields.iter().find(|(t, _)| *t == tag).map(|(_, v)| v)
|
||||
self.fields.get(self.position(tag, false)?).map(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// The last field tagged `tag` (a shape changed from one kind to another keeps a stale first one).
|
||||
pub fn get_last(&self, tag: Tag) -> Option<&Value> {
|
||||
self.fields.get(self.position(tag, true)?).map(|(_, v)| v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,10 +180,11 @@ pub fn parse(bytes: &[u8]) -> Result<Stream, Error> {
|
||||
c.u32()?;
|
||||
}
|
||||
let mut p = Parser { c, objects: Vec::new(), shared: std::collections::HashMap::new(), depth: 0, values: 0 };
|
||||
let root = p.push(Object { class, kind: Kind::Root, chain: vec![class], fields: Vec::new() })?;
|
||||
let root = p.push(Object::new(class, Kind::Root, vec![class], Vec::new()))?;
|
||||
let fields = p.fields(true, class)?;
|
||||
if let Some(o) = p.objects.get_mut(root) {
|
||||
o.fields = fields;
|
||||
o.index();
|
||||
}
|
||||
Ok(Stream { root, objects: p.objects })
|
||||
}
|
||||
@@ -252,7 +309,7 @@ impl Parser<'_> {
|
||||
}
|
||||
0x30 => {
|
||||
let fields = self.fields(false, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Positional, chain: Vec::new(), fields })?;
|
||||
let id = self.push(Object::new(class, Kind::Positional, Vec::new(), fields))?;
|
||||
Value::Obj(Some(id))
|
||||
}
|
||||
0x31 | 0x32 if !tagged => return Err(Error::Malformed("object without a field tag")),
|
||||
@@ -354,7 +411,7 @@ impl Parser<'_> {
|
||||
}
|
||||
};
|
||||
let fields = self.fields(true, class)?;
|
||||
let id = self.push(Object { class, kind: Kind::Inline, chain: vec![class], fields })?;
|
||||
let id = self.push(Object::new(class, Kind::Inline, vec![class], fields))?;
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
|
||||
@@ -373,7 +430,7 @@ impl Parser<'_> {
|
||||
return Err(Error::Malformed("object defined twice"));
|
||||
}
|
||||
// Register before the fields: an object's own fields may refer back to it.
|
||||
let id = self.push(Object { class: Tag(0), kind: Kind::Shared, chain: Vec::new(), fields: Vec::new() })?;
|
||||
let id = self.push(Object::new(Tag(0), Kind::Shared, Vec::new(), Vec::new()))?;
|
||||
self.shared.insert(key, id);
|
||||
let mut chain = Vec::new();
|
||||
let mut fields = Vec::new();
|
||||
@@ -399,8 +456,36 @@ impl Parser<'_> {
|
||||
let class = chain.first().copied().unwrap_or(Tag(0));
|
||||
fields.extend(self.fields(true, class)?);
|
||||
if let Some(o) = self.objects.get_mut(id) {
|
||||
*o = Object { class, kind: Kind::Shared, chain, fields };
|
||||
*o = Object::new(class, Kind::Shared, chain, fields);
|
||||
}
|
||||
Ok(Value::Obj(Some(id)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lookups_in_a_large_object_find_the_first_and_last_fields_of_a_tag() {
|
||||
let tag = |i: u32| Tag(0x4100_0000 + i);
|
||||
let mut fields: Vec<(Tag, Value)> = (0..1000).map(|i| (tag(i % 300), Value::UInt(u64::from(i)))).collect();
|
||||
fields.push((tag(7), Value::Str("last".into())));
|
||||
let o = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), fields.clone());
|
||||
assert!(!o.index.is_empty());
|
||||
// The index has one entry per distinct tag, not per field.
|
||||
assert_eq!(o.index.len(), 300);
|
||||
for t in 0..300 {
|
||||
let first = fields.iter().find(|(k, _)| *k == tag(t)).map(|(_, v)| v);
|
||||
let last = fields.iter().rev().find(|(k, _)| *k == tag(t)).map(|(_, v)| v);
|
||||
assert_eq!(o.get(tag(t)), first);
|
||||
assert_eq!(o.get_last(tag(t)), last);
|
||||
}
|
||||
assert_eq!(o.get(tag(300)), None);
|
||||
assert_eq!(o.get_last(tag(7)), Some(&Value::Str("last".into())));
|
||||
let small = Object::new(Tag::of(b"Grup"), Kind::Shared, Vec::new(), vec![(tag(1), Value::Bool(true)), (tag(1), Value::Bool(false))]);
|
||||
assert!(small.index.is_empty());
|
||||
assert_eq!(small.get(tag(1)), Some(&Value::Bool(true)));
|
||||
assert_eq!(small.get_last(tag(1)), Some(&Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,6 +261,34 @@ mod archive {
|
||||
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_that_fails_its_checksum_is_charged_once_and_not_decompressed_again() {
|
||||
let d = doc();
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Zstd)], None);
|
||||
let mut broken = file.clone();
|
||||
broken[90] ^= 0x55;
|
||||
let mut a = Archive::open(&broken, Limits::default()).unwrap();
|
||||
let first = a.read("doc.dat").unwrap_err();
|
||||
// The work was done, so it counts against the import even though it failed.
|
||||
assert_eq!(a.extracted(), d.len());
|
||||
assert_eq!(a.read("doc.dat").unwrap_err(), first);
|
||||
assert_eq!(a.extracted(), d.len());
|
||||
// A budget that only covers one attempt is not exceeded by asking again.
|
||||
let mut a = Archive::open(&broken, Limits { max_entry: usize::MAX, max_total: d.len() }).unwrap();
|
||||
assert!(a.read("doc.dat").is_err());
|
||||
assert_eq!(a.read("doc.dat").unwrap_err(), first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_index_has_a_budget_for_records_and_names() {
|
||||
let d = doc();
|
||||
let long = "n".repeat(300);
|
||||
let file = synth::container(&[("doc.dat", &d, Method::Stored), (&long, b"tile", Method::Stored)], None);
|
||||
Archive::open_within(&file, Limits::default(), 2, 307).unwrap();
|
||||
assert_eq!(Archive::open_within(&file, Limits::default(), 1, 1 << 20).unwrap_err(), Error::Limit("too many archive entries"));
|
||||
assert_eq!(Archive::open_within(&file, Limits::default(), 2, 306).unwrap_err(), Error::Limit("archive entry names too long"));
|
||||
}
|
||||
|
||||
// Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's
|
||||
// window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than
|
||||
// the format permits for a block. 0.9.1 refuses it.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
|
||||
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"]
|
||||
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"]
|
||||
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"]
|
||||
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"]
|
||||
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
|
||||
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
|
||||
@@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1,
|
||||
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
|
||||
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
|
||||
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
|
||||
|
||||
@@ -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))])),
|
||||
(
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
|
||||
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
|
||||
//!
|
||||
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
|
||||
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
|
||||
//! sample takes the nib for the pressure there.
|
||||
//! A Calligraphic brush with Pressure, Tilt, Bearing or Rotation variation changes its nib along
|
||||
//! the stroke with what the pen recorded on it (`StrokeLayer::pressure`, `tilt`, `bearing` and
|
||||
//! `rotation`): the sweep is sampled densely and each sample takes the nib for the pen there.
|
||||
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
|
||||
use vectorcraft_doc::{Node, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
|
||||
|
||||
use crate::track::{Rng, Track, normal, seed_of, tracks};
|
||||
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
|
||||
use crate::{Bristle, BristleShape, Calligraphic, PenAt, Variation, filled, tolerance};
|
||||
|
||||
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
|
||||
/// it takes along a whole subpath.
|
||||
@@ -127,18 +127,16 @@ fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
|
||||
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
|
||||
}
|
||||
|
||||
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
|
||||
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
|
||||
/// kept within its range; the others are `base`'s.
|
||||
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
|
||||
/// The nib's angle, roundness and size where the pen recorded `pen`: each value on a mode that
|
||||
/// follows the pen goes from the brush's value less its variation (where the pen reads 0 for it)
|
||||
/// to the value plus the variation (where it reads 1), the angle turning round past ±180° and the
|
||||
/// others kept within their ranges; the other values are `base`'s.
|
||||
fn pressured(c: &Calligraphic, base: [f64; 3], pen: PenAt) -> [f64; 3] {
|
||||
let values = [c.angle, c.roundness, c.size];
|
||||
let mut out = base;
|
||||
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
|
||||
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
|
||||
if mode == Variation::Pressure {
|
||||
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
|
||||
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
|
||||
}
|
||||
let (Some(&value), Some(&var), Some(p)) = (values.get(i), c.variation.get(i), pen.control(mode)) else { continue };
|
||||
*v = Calligraphic::moved(i, value, var * (2.0 * p - 1.0));
|
||||
}
|
||||
let [angle, round, size] = out;
|
||||
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
|
||||
@@ -147,14 +145,13 @@ fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
|
||||
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let base = randomized(c, &mut rng);
|
||||
let subs: Vec<SubPath> = if c.uses_pressure() {
|
||||
let pressure = stroke.pressure.as_ref();
|
||||
let subs: Vec<SubPath> = if c.uses_pen() {
|
||||
let nib = |f: f64| {
|
||||
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
|
||||
let [angle, round, size] = pressured(c, base, PenAt::of(stroke, f));
|
||||
Nib::new(angle, round / 100.0, size * weight)
|
||||
};
|
||||
// Flattened for the largest nib the pressure gives.
|
||||
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
|
||||
// Flattened for the largest nib the pen gives.
|
||||
let size = pressured(c, base, PenAt::uniform(0.0))[2].max(pressured(c, base, PenAt::uniform(1.0))[2]) * weight;
|
||||
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
|
||||
} else {
|
||||
let [angle, round, size] = base;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -159,6 +159,24 @@ impl Calligraphic {
|
||||
pub fn uses_pressure(&self) -> bool {
|
||||
self.modes().contains(&Variation::Pressure)
|
||||
}
|
||||
/// Does the pen (its pressure, tilt, bearing or rotation) change the nib?
|
||||
pub fn uses_pen(&self) -> bool {
|
||||
self.modes().iter().any(|m| m.follows_pen())
|
||||
}
|
||||
/// Value `i` (0 angle, 1 roundness, 2 size) moved by `by`: the angle turns round past ±180°,
|
||||
/// roundness and size stay within their [`Self::RANGES`]. A result that is not a number is
|
||||
/// `value`, kept within its range.
|
||||
pub fn moved(i: usize, value: f64, by: f64) -> f64 {
|
||||
let (lo, hi) = Self::RANGES.get(i).copied().unwrap_or((f64::MIN, f64::MAX));
|
||||
let x = value + by;
|
||||
if !x.is_finite() {
|
||||
value.clamp(lo, hi)
|
||||
} else if i == 0 {
|
||||
vectorcraft_geom::normalize_deg(x)
|
||||
} else {
|
||||
x.clamp(lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
|
||||
@@ -174,16 +192,29 @@ pub enum Variation {
|
||||
/// From the value less the variation at the lightest pen pressure to the value plus the
|
||||
/// variation at the heaviest.
|
||||
Pressure,
|
||||
/// From the value less the variation with the pen lying flat to the value plus the variation
|
||||
/// with it upright.
|
||||
Tilt,
|
||||
/// The value plus the variation times the pen's bearing over 180°: the bearing is which way the
|
||||
/// pen leans, counter-clockwise on the page from leaning to the right (0°) round to ±180°.
|
||||
Bearing,
|
||||
/// The value plus the variation times the pen barrel's rotation over 180°, counter-clockwise
|
||||
/// from where the pen reports 0° round to ±180°.
|
||||
Rotation,
|
||||
}
|
||||
|
||||
impl Variation {
|
||||
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
|
||||
pub const ALL: [Variation; 6] =
|
||||
[Variation::Fixed, Variation::Random, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation];
|
||||
/// The name brush definitions use.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Variation::Fixed => "fixed",
|
||||
Variation::Random => "random",
|
||||
Variation::Pressure => "pressure",
|
||||
Variation::Tilt => "tilt",
|
||||
Variation::Bearing => "bearing",
|
||||
Variation::Rotation => "rotation",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -191,11 +222,63 @@ impl Variation {
|
||||
Variation::Fixed => "Fixed",
|
||||
Variation::Random => "Random",
|
||||
Variation::Pressure => "Pressure",
|
||||
Variation::Tilt => "Tilt",
|
||||
Variation::Bearing => "Bearing",
|
||||
Variation::Rotation => "Rotation",
|
||||
}
|
||||
}
|
||||
pub fn parse(id: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|v| v.id() == id)
|
||||
}
|
||||
/// Does the value follow something the pen records along a stroke (Pressure, Tilt, Bearing,
|
||||
/// Rotation)?
|
||||
pub fn follows_pen(self) -> bool {
|
||||
matches!(self, Variation::Pressure | Variation::Tilt | Variation::Bearing | Variation::Rotation)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the pen recorded at one point of a stroke, each as the 0..1 that [`Variation`]'s pen modes
|
||||
/// read: 0 gives the value less the variation (a Scatter brush's first value), 1 the value plus it
|
||||
/// (the second value), and ½ the value itself, as a stroke without that recording does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct PenAt {
|
||||
/// The pressure, lightest 0.
|
||||
pressure: f64,
|
||||
/// 1 upright, 0 lying flat.
|
||||
tilt: f64,
|
||||
/// ½ plus the bearing over 360°.
|
||||
bearing: f64,
|
||||
/// ½ plus the counter-clockwise barrel rotation over 360°.
|
||||
rotation: f64,
|
||||
}
|
||||
|
||||
impl PenAt {
|
||||
/// Every input at `c`.
|
||||
pub(crate) fn uniform(c: f64) -> Self {
|
||||
Self { pressure: c, tilt: c, bearing: c, rotation: c }
|
||||
}
|
||||
/// What `stroke` recorded at `f` (the fraction of its path's length).
|
||||
pub(crate) fn of(stroke: &StrokeLayer, f: f64) -> Self {
|
||||
let mid = vectorcraft_doc::PressureProfile::MID;
|
||||
let turn = |p: Option<&vectorcraft_doc::AngleProfile>| 0.5 + p.map_or(0.0, |p| p.at(f)) / 360.0;
|
||||
Self {
|
||||
pressure: stroke.pressure.as_ref().map_or(mid, |p| p.at(f)),
|
||||
tilt: 1.0 - stroke.tilt.as_ref().map_or(mid, |p| p.at(f)),
|
||||
bearing: turn(stroke.bearing.as_ref()),
|
||||
rotation: turn(stroke.rotation.as_ref()),
|
||||
}
|
||||
}
|
||||
/// The 0..1 a value on `mode` takes, or `None` for a mode that doesn't follow the pen.
|
||||
pub(crate) fn control(&self, mode: Variation) -> Option<f64> {
|
||||
let c = match mode {
|
||||
Variation::Fixed | Variation::Random => return None,
|
||||
Variation::Pressure => self.pressure,
|
||||
Variation::Tilt => self.tilt,
|
||||
Variation::Bearing => self.bearing,
|
||||
Variation::Rotation => self.rotation,
|
||||
};
|
||||
Some(if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })
|
||||
}
|
||||
}
|
||||
|
||||
/// Scatter brush: copies of the art along the path.
|
||||
@@ -216,8 +299,10 @@ pub struct Scatter {
|
||||
pub colorization: Colorization,
|
||||
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
|
||||
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
|
||||
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
|
||||
/// the two differ, else Fixed — see [`Scatter::modes`].
|
||||
/// the second at the heaviest, Tilt the first with the pen lying flat and the second upright,
|
||||
/// Bearing and Rotation the first at −180° and the second at 180° (half way at 0°). `None`
|
||||
/// (brushes saved before there were modes): Random where the two differ, else Fixed (see
|
||||
/// [`Scatter::modes`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub modes: Option<[Variation; 4]>,
|
||||
}
|
||||
@@ -386,10 +471,22 @@ impl CornerSource {
|
||||
#[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")]
|
||||
@@ -400,8 +497,16 @@ pub struct PatternBrush {
|
||||
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.
|
||||
@@ -422,12 +527,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,
|
||||
@@ -500,6 +615,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) {
|
||||
@@ -600,6 +723,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) => {
|
||||
@@ -653,10 +781,9 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String {
|
||||
return base.to_string();
|
||||
}
|
||||
// At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free.
|
||||
(2..=lib.len() + 2)
|
||||
.map(|i| format!("{base} {i}"))
|
||||
.find(|n| !lib.iter().any(|b| &b.name == n))
|
||||
.unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
|
||||
// Each is looked up in a set: scanning the library per candidate is quadratic in its size.
|
||||
let taken: std::collections::HashSet<&str> = lib.iter().map(|b| b.name.as_str()).collect();
|
||||
(2..=lib.len() + 2).map(|i| format!("{base} {i}")).find(|n| !taken.contains(n.as_str())).unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
|
||||
}
|
||||
|
||||
// ---------- geometry ----------
|
||||
@@ -676,7 +803,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
|
||||
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name),
|
||||
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
|
||||
};
|
||||
let colorization = brush.kind.colorization();
|
||||
@@ -721,9 +848,15 @@ fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool {
|
||||
};
|
||||
// Without recursion, each one looked at once: they may use one another without end.
|
||||
let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def));
|
||||
// A brush name the library lacks draws a plain stroke ([`node_pieces`]), which runs no brush.
|
||||
let mut names: Option<std::collections::HashSet<String>> = None;
|
||||
let mut resolves = |n: &Node| {
|
||||
let names = names.get_or_insert_with(|| library(doc).into_iter().map(|b| b.name).collect());
|
||||
n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && s.brush.as_ref().is_some_and(|b| names.contains(b))))
|
||||
};
|
||||
let mut answer = false;
|
||||
while let Some(n) = stack.pop() {
|
||||
if has_brush(n) {
|
||||
if has_brush(n) && resolves(n) {
|
||||
answer = true;
|
||||
break;
|
||||
}
|
||||
@@ -756,7 +889,16 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
|
||||
walk(doc, Arc::make_mut(c), known);
|
||||
}
|
||||
}
|
||||
if n.extras().mask.is_some()
|
||||
// A mask's art is drawn too. Its root is filtered like any other node: a symbol instance
|
||||
// there that draws a brushed stroke would run the brush again from inside the mask, so the
|
||||
// mask goes, as such an instance goes from the pieces and their children.
|
||||
let rejected_root = matches!(
|
||||
n.extras().mask.as_ref().map(|m| &m.art.kind),
|
||||
Some(NodeKind::SymbolInstance { symbol, .. }) if draws_brushed(doc, (false, symbol.as_str()), known)
|
||||
);
|
||||
if rejected_root {
|
||||
n.extras_mut().mask = None;
|
||||
} else if n.extras().mask.is_some()
|
||||
&& let Some(mask) = n.extras_mut().mask.as_mut()
|
||||
{
|
||||
walk(doc, Arc::make_mut(&mut mask.art), known);
|
||||
|
||||
@@ -561,6 +561,43 @@ fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() {
|
||||
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();
|
||||
@@ -852,6 +889,163 @@ fn pressure_varies_angle_and_roundness() {
|
||||
};
|
||||
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
|
||||
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
|
||||
// The angle turns on past 180°: 170 + 40 is 210°, the nib at 30° (not stopped at 180°).
|
||||
let turned = Calligraphic { angle: 170.0, roundness: 10.0, variation: [40.0, 0.0, 0.0], ..c([Pressure, Fixed, Fixed]) };
|
||||
let expected = 2.0 * (25.0 * 0.25 + 0.25 * 0.75f64).sqrt();
|
||||
let h = draw(turned, 1.0);
|
||||
assert!(near(h, expected, 0.05), "{h} vs {expected}");
|
||||
}
|
||||
|
||||
/// A stroke that recorded the pen's tilt (`tilt`: 0 upright to 1 flat), bearing and barrel
|
||||
/// rotation (degrees counter-clockwise), each the same all along it where given.
|
||||
fn posed(tilt: Option<f64>, bearing: Option<f64>, rotation: Option<f64>) -> StrokeLayer {
|
||||
let flat = |v: f64| vec![(0.0, v), (1.0, v)];
|
||||
StrokeLayer {
|
||||
tilt: tilt.map(|t| vectorcraft_doc::PressureProfile { points: flat(t) }),
|
||||
bearing: bearing.map(|b| vectorcraft_doc::AngleProfile { points: flat(b) }),
|
||||
rotation: rotation.map(|r| vectorcraft_doc::AngleProfile { points: flat(r) }),
|
||||
..stroke(1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// #764: with Roundness on Tilt the nib goes from roundness − variation with the pen lying flat to
|
||||
/// roundness + variation with it upright, along the stroke as the pen leaned.
|
||||
#[test]
|
||||
fn tilt_maps_to_roundness_along_the_stroke() {
|
||||
let c = Calligraphic {
|
||||
roundness: 50.0,
|
||||
size: 10.0,
|
||||
variation: [0.0, 50.0, 0.0],
|
||||
modes: Some([Variation::Fixed, Variation::Tilt, Variation::Fixed]),
|
||||
..Default::default()
|
||||
};
|
||||
let b = Brush { name: "t".into(), kind: BrushKind::Calligraphic(c) };
|
||||
let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &upright_to_flat);
|
||||
// Upright: round (half 5); half way: roundness 50 (half 2.5); flat: a hairline.
|
||||
assert!(near(half_width(&out, 0.5, 1.5), 5.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
|
||||
assert!(near(half_width(&out, 49.5, 50.5), 2.5, 0.1), "{}", half_width(&out, 49.5, 50.5));
|
||||
assert!(half_width(&out, 98.5, 99.5) < 0.2, "{}", half_width(&out, 98.5, 99.5));
|
||||
}
|
||||
|
||||
/// #764: with Angle on Bearing or on Rotation and a variation of 180°, the nib turns with the way
|
||||
/// the pen leans or with its barrel.
|
||||
#[test]
|
||||
fn bearing_and_rotation_turn_the_nib() {
|
||||
let horizontal = line(0.0, 0.0, 100.0, 0.0);
|
||||
let height = |mode: Variation, st: &StrokeLayer| {
|
||||
let c = Calligraphic {
|
||||
roundness: 10.0,
|
||||
size: 10.0,
|
||||
variation: [180.0, 0.0, 0.0],
|
||||
modes: Some([mode, Variation::Fixed, Variation::Fixed]),
|
||||
..Default::default()
|
||||
};
|
||||
let b = Brush { name: "b".into(), kind: BrushKind::Calligraphic(c) };
|
||||
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, st)).height()
|
||||
};
|
||||
// Leaning right (0°): the nib lies along the stroke (1 pt across); leaning up the page (90°)
|
||||
// or down it (−90°), the nib stands across it (10 pt).
|
||||
assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(0.0), None)), 1.0, 0.05));
|
||||
assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(90.0), None)), 10.0, 0.05));
|
||||
assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(-90.0), None)), 10.0, 0.05));
|
||||
// The barrel turned a quarter turn stands it across the stroke too.
|
||||
assert!(near(height(Variation::Rotation, &posed(None, None, Some(0.0))), 1.0, 0.05));
|
||||
assert!(near(height(Variation::Rotation, &posed(None, None, Some(90.0))), 10.0, 0.05));
|
||||
// Each reads only its own recording.
|
||||
assert!(near(height(Variation::Rotation, &posed(None, Some(90.0), None)), 1.0, 0.05));
|
||||
assert!(near(height(Variation::Bearing, &posed(None, None, Some(90.0))), 1.0, 0.05));
|
||||
}
|
||||
|
||||
/// A stroke without the pen's tilt, bearing or rotation (a mouse, a pen without that input, or a
|
||||
/// path the brush was applied to) draws Tilt, Bearing and Rotation values at the brush's own value.
|
||||
#[test]
|
||||
fn pen_modes_without_a_recording_draw_the_value_itself() {
|
||||
let horizontal = line(0.0, 0.0, 100.0, 0.0);
|
||||
for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {
|
||||
let c = Calligraphic { angle: 90.0, roundness: 50.0, size: 10.0, variation: [90.0, 50.0, 6.0], modes: Some([mode; 3]) };
|
||||
let b = Brush { name: "n".into(), kind: BrushKind::Calligraphic(c) };
|
||||
let h = bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &stroke(1.0))).height();
|
||||
assert!(near(h, 10.0, 0.05), "{mode:?}: {h}");
|
||||
let sc = Scatter {
|
||||
size: (50.0, 300.0),
|
||||
modes: Some([mode, Variation::Fixed, Variation::Fixed, Variation::Fixed]),
|
||||
..scatter(&scatter_brush(100.0))
|
||||
};
|
||||
let s = Brush { name: "s".into(), kind: BrushKind::Scatter(sc) };
|
||||
let heights: Vec<f64> =
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &s, &horizontal, &stroke(1.0)).iter().map(|n| n.geometric_bounds().unwrap().height()).collect();
|
||||
assert!(!heights.is_empty() && heights.iter().all(|h| near(*h, 7.0, 1e-6)), "{mode:?}: {heights:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A mouse stroke records no pressure, tilt, bearing or rotation. On it, a brush whose values are
|
||||
/// on Tilt, Bearing or Rotation draws exactly what the same brush on Pressure draws, the output a
|
||||
/// mouse stroke gave before these modes existed.
|
||||
#[test]
|
||||
fn a_mouse_stroke_draws_pen_modes_as_pressure_did() {
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let wavy = {
|
||||
let mut bp = line(0.0, 0.0, 60.0, 0.0);
|
||||
bp.curve_to((90.0, 0.0), (90.0, 40.0), (120.0, 40.0));
|
||||
bp
|
||||
};
|
||||
let mouse = stroke(1.0);
|
||||
assert!(mouse.pressure.is_none() && mouse.tilt.is_none() && mouse.bearing.is_none() && mouse.rotation.is_none());
|
||||
let calli = |mode: Variation| {
|
||||
let c = Calligraphic { angle: 30.0, roundness: 40.0, size: 8.0, variation: [60.0, 30.0, 4.0], modes: Some([mode; 3]) };
|
||||
stroke_pieces(&doc, &Brush { name: "c".into(), kind: BrushKind::Calligraphic(c) }, &wavy, &mouse)
|
||||
};
|
||||
let scattered = |mode: Variation| {
|
||||
let sc = Scatter {
|
||||
size: (50.0, 300.0),
|
||||
spacing: (80.0, 160.0),
|
||||
scatter: (-40.0, 40.0),
|
||||
rotation: (-90.0, 90.0),
|
||||
modes: Some([mode; 4]),
|
||||
..scatter(&scatter_brush(100.0))
|
||||
};
|
||||
stroke_pieces(&doc, &Brush { name: "s".into(), kind: BrushKind::Scatter(sc) }, &wavy, &mouse)
|
||||
};
|
||||
let (calli_before, scatter_before) = (calli(Variation::Pressure), scattered(Variation::Pressure));
|
||||
assert!(!calli_before.is_empty() && !scatter_before.is_empty());
|
||||
for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {
|
||||
assert_eq!(calli(mode), calli_before, "{mode:?}");
|
||||
assert_eq!(scattered(mode), scatter_before, "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The scatter brush of `b`.
|
||||
fn scatter(b: &Brush) -> Scatter {
|
||||
let BrushKind::Scatter(sc) = &b.kind else { panic!("not a scatter brush") };
|
||||
sc.clone()
|
||||
}
|
||||
|
||||
/// #764: Scatter values on Tilt go from the first value with the pen lying flat to the second
|
||||
/// upright; on Bearing, from the first at −180° to the second at 180°.
|
||||
#[test]
|
||||
fn scatter_values_follow_tilt_and_bearing() {
|
||||
let bp = line(0.0, 0.0, 400.0, 0.0);
|
||||
let boxes = |b: &Brush, st: &StrokeLayer| -> Vec<Rect> {
|
||||
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap()).collect()
|
||||
};
|
||||
// Size on Tilt, 50 % to 300 %: upright at the start (12 pt tall), flat at the end (2 pt).
|
||||
let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
|
||||
let h: Vec<f64> = boxes(&sized_scatter(50.0, 300.0, Variation::Tilt), &upright_to_flat).iter().map(Rect::height).collect();
|
||||
assert!(near(h[0], 12.0, 0.2) && h[h.len() - 1] < 3.0, "{h:?}");
|
||||
assert!(h.windows(2).all(|w| w[1] <= w[0] + 1e-9), "shrinks as the pen tips over: {h:?}");
|
||||
// Rotation on Bearing, −180° to 180°: the copies turn with the bearing, a quarter turn
|
||||
// standing the 10 × 4 pt art on end.
|
||||
let sc = Scatter {
|
||||
rotation: (-180.0, 180.0),
|
||||
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Fixed, Variation::Bearing]),
|
||||
..scatter(&scatter_brush(100.0))
|
||||
};
|
||||
let turning = Brush { name: "r".into(), kind: BrushKind::Scatter(sc) };
|
||||
for (bearing, (w, h)) in [(0.0, (10.0, 4.0)), (90.0, (4.0, 10.0)), (-90.0, (4.0, 10.0))] {
|
||||
let b = boxes(&turning, &posed(Some(0.5), Some(bearing), None));
|
||||
assert!(!b.is_empty() && b.iter().all(|r| near(r.width(), w, 1e-6) && near(r.height(), h, 1e-6)), "{bearing}: {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Brushes saved before the variation modes keep their look: a variation means Random, none
|
||||
@@ -883,7 +1077,7 @@ fn brushes_without_modes_vary_randomly_as_before() {
|
||||
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
|
||||
);
|
||||
// Modes are saved by name.
|
||||
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
|
||||
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure", "tilt", "bearing", "rotation"]));
|
||||
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
|
||||
}
|
||||
|
||||
@@ -1097,7 +1291,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 });
|
||||
@@ -1107,6 +1301,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
|
||||
};
|
||||
let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into());
|
||||
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
|
||||
store(&mut doc, std::slice::from_ref(&b));
|
||||
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(!out.is_empty());
|
||||
for piece in &out {
|
||||
@@ -1122,6 +1317,46 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A symbol instance at the root of an opacity mask is filtered like one among the children: when
|
||||
/// its symbol draws a brushed stroke the mask goes (it would run the brush again from inside the
|
||||
/// mask, without end), and a mask whose root symbol is plain stays.
|
||||
#[test]
|
||||
fn pieces_leave_out_a_mask_whose_root_draws_a_brushed_stroke() {
|
||||
use vectorcraft_doc::{OpacityMask, Symbol};
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
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) }));
|
||||
// A brush the library lacks draws a plain stroke: its mask stays.
|
||||
let mut unresolved = rect_art(10.0, 4.0);
|
||||
unresolved.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("missing".into()), ..stroke(1.0) }));
|
||||
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed), ("unresolved", unresolved)] {
|
||||
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art) });
|
||||
}
|
||||
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let masked = |symbol: &str| {
|
||||
let mut n = rect_art(10.0, 4.0);
|
||||
n.extras_mut().mask = Some(Box::new(OpacityMask::new(instance(symbol), true)));
|
||||
n
|
||||
};
|
||||
let art = Node::group(NodeId(0), [masked("plain"), masked("brushed"), masked("unresolved")].map(std::sync::Arc::new).into());
|
||||
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) };
|
||||
store(&mut doc, std::slice::from_ref(&b));
|
||||
let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
|
||||
assert!(!out.is_empty());
|
||||
for piece in &out {
|
||||
let roots: Vec<Option<&str>> = piece
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| match c.extras().mask.as_ref().map(|m| &m.art.kind) {
|
||||
Some(NodeKind::SymbolInstance { symbol, .. }) => Some(symbol.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(roots, [Some("plain"), None, Some("unresolved")], "the plain and unresolved masks stay and the brushed one goes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Anchors a brushed stroke emitted, which is what the output budget counts.
|
||||
fn anchors(nodes: &[Node]) -> usize {
|
||||
nodes
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use kurbo::ParamCurveNearest;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::track::{Rng, Track, normal, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
|
||||
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, PenAt, Scatter, Variation, tolerance};
|
||||
|
||||
/// Longest mapped segment (points in document space).
|
||||
const MAX_SEG: f64 = 1.5;
|
||||
@@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32;
|
||||
/// 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.
|
||||
/// 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);
|
||||
@@ -63,9 +64,21 @@ 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)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -234,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 {
|
||||
@@ -338,7 +361,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
|
||||
/// 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.
|
||||
/// `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);
|
||||
@@ -365,6 +389,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
map_mask(&mut out, |art| prepared(art, width_scale));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -416,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
|
||||
}
|
||||
|
||||
@@ -510,18 +536,19 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
|
||||
}
|
||||
|
||||
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
|
||||
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
|
||||
/// draws, so a copy's random values stay the same whatever the other modes.
|
||||
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
|
||||
/// value between them, and a mode that follows the pen the first where `pen` reads 0 for it and
|
||||
/// the second where it reads 1. Every value draws, so a copy's random values stay the same
|
||||
/// whatever the other modes.
|
||||
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pen: PenAt) -> f64 {
|
||||
let r = rng.range(a, b);
|
||||
match mode {
|
||||
Variation::Fixed => a,
|
||||
Variation::Random => r,
|
||||
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
|
||||
match pen.control(mode) {
|
||||
Some(c) => a + (b - a) * c,
|
||||
None if mode == Variation::Random => r,
|
||||
None => a,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
|
||||
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
|
||||
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
|
||||
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
@@ -532,8 +559,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
|
||||
break;
|
||||
}
|
||||
let l = t.len();
|
||||
// The pen pressure at arc length `at` (half way without a recorded one).
|
||||
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
|
||||
// What the pen recorded at arc length `at` (the values themselves without a recording).
|
||||
let pen_at = |at: f64| PenAt::of(stroke, if l > 1e-9 { at / l } else { 0.0 });
|
||||
let mut s: Option<f64> = None;
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
@@ -541,8 +568,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
|
||||
if guard > 20000 {
|
||||
break;
|
||||
}
|
||||
// Size and spacing place the next copy, so they take the pressure at the last one.
|
||||
let p = pressure_at(s.unwrap_or(0.0));
|
||||
// Size and spacing place the next copy, so they take the pen at the last one.
|
||||
let p = pen_at(s.unwrap_or(0.0));
|
||||
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
|
||||
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
|
||||
let step = aw * size * spacing;
|
||||
@@ -554,7 +581,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
|
||||
break;
|
||||
}
|
||||
s = Some(pos);
|
||||
let p = pressure_at(pos);
|
||||
let p = pen_at(pos);
|
||||
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
|
||||
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
|
||||
let (p, tg) = t.frame(pos);
|
||||
@@ -590,6 +617,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
|
||||
}
|
||||
@@ -781,13 +811,13 @@ fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option<Node> {
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -1133,4 +1163,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
|
||||
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
|
||||
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine)
|
||||
# and in fonts built in code (`vectorcraft_text::test_fonts`).
|
||||
vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] }
|
||||
|
||||
@@ -23,10 +23,19 @@ const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
const THINNEST: f64 = 0.25;
|
||||
/// The most objects an import makes.
|
||||
const MAX_NODES: usize = 2_000_000;
|
||||
/// The most block visits (one per insert copy) an import makes, whatever they produce.
|
||||
const MAX_VISITS: usize = 10_000_000;
|
||||
/// The most block work an import does, whatever it produces: one per insert copy drawn, and one
|
||||
/// per item of the block looked at for it (art on hidden layers too).
|
||||
const MAX_VISITS: usize = if cfg!(test) { 1_000_000 } else { 20_000_000 };
|
||||
/// The most path elements an import makes (blocks drawn as art are copied for every placement).
|
||||
const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 };
|
||||
/// The most dash lengths an import keeps (every dashed path holds its own copy of its pattern).
|
||||
const MAX_DASH_VALUES: usize = if cfg!(test) { 1_000 } else { 4_000_000 };
|
||||
/// The most bytes of text, font names and symbol names an import copies into objects (a block's
|
||||
/// text and name are copied for every placement).
|
||||
const MAX_TEXT_BYTES: usize = if cfg!(test) { 64 << 10 } else { 64 << 20 };
|
||||
/// The longest font family a text style may name; a longer one is not a font, and is set in the
|
||||
/// default.
|
||||
const MAX_FAMILY_BYTES: usize = 256;
|
||||
/// How far from the origin art may lie (points): the canvas.
|
||||
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
|
||||
|
||||
@@ -34,6 +43,9 @@ const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are
|
||||
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
|
||||
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
|
||||
const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out";
|
||||
const TOO_MUCH_BLOCK_WORK: &str = "the drawing's blocks take too much work to place: the rest are left out";
|
||||
const TOO_MANY_DASHES: &str = "the drawing has more dashed lines than can be kept: the rest are drawn solid";
|
||||
const TOO_MUCH_TEXT: &str = "the drawing has more than 64 MB of text and block names: the rest are left out";
|
||||
|
||||
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
|
||||
/// and opacity where the art says "by block", and its layer for art on layer 0.
|
||||
@@ -71,6 +83,9 @@ pub(crate) struct Build<'d, 'a> {
|
||||
visits: usize,
|
||||
/// Path elements made so far.
|
||||
elements: usize,
|
||||
/// Dash lengths and text bytes kept so far.
|
||||
dash_values: usize,
|
||||
text_bytes: usize,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -128,6 +143,8 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
nodes: 0,
|
||||
visits: 0,
|
||||
elements: 0,
|
||||
dash_values: 0,
|
||||
text_bytes: 0,
|
||||
warnings: vec![],
|
||||
}
|
||||
}
|
||||
@@ -138,6 +155,20 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take `bytes` of text from [`MAX_TEXT_BYTES`]; false (with a warning) when they don't fit.
|
||||
fn charge_text(&mut self, bytes: usize) -> bool {
|
||||
match self.text_bytes.checked_add(bytes).filter(|t| *t <= MAX_TEXT_BYTES) {
|
||||
Some(t) => {
|
||||
self.text_bytes = t;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.warn(TOO_MUCH_TEXT);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Import `layout`; `natural` and `ratio` are points per drawing unit in the drawing's own
|
||||
/// unit and at the scale asked for (fitting computes its own).
|
||||
pub fn run(mut self, layout: &LayoutDef, natural: f64, ratio: f64) -> Result<(Document, Vec<String>), String> {
|
||||
@@ -469,6 +500,9 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
super::curve::bounds(&bp)?;
|
||||
let paint = Paint::solid(rgb_color(self.rgb(p, inh)));
|
||||
let scale = map.determinant().abs().sqrt();
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
// A compound path's loops each keep a copy of its appearance, beside its own.
|
||||
let copies = if path.subpaths.len() > 1 { path.subpaths.len() + 1 } else { 1 };
|
||||
let appearance = if fill {
|
||||
Appearance::basic(paint, Paint::None, 0.0)
|
||||
} else {
|
||||
@@ -484,15 +518,20 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
if let Some(AppearanceItem::Stroke(s)) = ap.items.last_mut()
|
||||
&& let Some((dash, dots)) = self.dash(&self.linetype(p, inh), p, scale)
|
||||
{
|
||||
s.dash = Some(dash);
|
||||
if dots {
|
||||
s.cap = LineCap::Round;
|
||||
match dash.pattern.len().checked_mul(copies).and_then(|v| self.dash_values.checked_add(v)).filter(|n| *n <= MAX_DASH_VALUES) {
|
||||
Some(n) => {
|
||||
self.dash_values = n;
|
||||
s.dash = Some(dash);
|
||||
if dots {
|
||||
s.cap = LineCap::Round;
|
||||
}
|
||||
}
|
||||
None => self.warn(TOO_MANY_DASHES),
|
||||
}
|
||||
}
|
||||
ap
|
||||
};
|
||||
let rule = if fill { FillRule::EvenOdd } else { FillRule::NonZero };
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let kind = |path: PathData| NodeKind::Path { path, rule, live: None, clipping: false, guide: false };
|
||||
let mut n = if path.subpaths.len() > 1 {
|
||||
// Several loops (a hatch with islands) paint as one compound path. Each loop is an
|
||||
@@ -527,10 +566,15 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
if !(s > 1e-12 && s.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let family = t.family.clone().or_else(|| self.d.styles.get(&t.style).filter(|f| !f.is_empty()).cloned());
|
||||
let d = self.d;
|
||||
let family = t.family.as_deref().or_else(|| d.styles.get(&t.style).map(String::as_str).filter(|f| !f.is_empty()));
|
||||
let family = family.filter(|f| f.len() <= MAX_FAMILY_BYTES);
|
||||
if !self.charge_text(t.text.len().saturating_add(family.map_or(0, str::len))) {
|
||||
return None;
|
||||
}
|
||||
let default = CharStyle::default();
|
||||
let style = CharStyle {
|
||||
font_family: family.unwrap_or(default.font_family.clone()),
|
||||
font_family: family.map_or_else(|| default.font_family.clone(), str::to_string),
|
||||
size: t.height / CAP_HEIGHT * s,
|
||||
h_scale: t.width * 100.0,
|
||||
leading: t.leading.map(|l| l * s),
|
||||
@@ -558,6 +602,13 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
for cell in &ins.cells {
|
||||
let node = match &symbol {
|
||||
Some(name) => {
|
||||
if self.nodes >= MAX_NODES {
|
||||
self.warn(TOO_MANY);
|
||||
break;
|
||||
}
|
||||
if !self.charge_text(name.len()) {
|
||||
break;
|
||||
}
|
||||
let xf = map * *cell * self.symbol_map.inverse();
|
||||
self.nodes += 1;
|
||||
Some(Node::new(self.doc.alloc_id(), NodeKind::SymbolInstance { symbol: name.clone(), xf }))
|
||||
@@ -581,14 +632,19 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
|
||||
/// The art of block `name` drawn by `map`, as one object.
|
||||
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
|
||||
self.visits += 1;
|
||||
if self.visits > MAX_VISITS {
|
||||
if self.visits == MAX_VISITS + 1 {
|
||||
self.warn(TOO_MANY);
|
||||
}
|
||||
if self.visits >= MAX_VISITS {
|
||||
self.warn(TOO_MUCH_BLOCK_WORK);
|
||||
return None;
|
||||
}
|
||||
let items = self.block(name)?;
|
||||
let items = self.block(name);
|
||||
// Each placement looks at every item of the block, drawn or not (art on a hidden layer
|
||||
// makes nothing, so the node budget doesn't bound it).
|
||||
self.visits = self.visits.saturating_add(1 + items.as_ref().map_or(0, |i| i.len()));
|
||||
if self.visits > MAX_VISITS {
|
||||
self.warn(TOO_MUCH_BLOCK_WORK);
|
||||
return None;
|
||||
}
|
||||
let items = items?;
|
||||
if !self.enter(name, depth) {
|
||||
return None;
|
||||
}
|
||||
@@ -618,12 +674,16 @@ impl<'d, 'a> Build<'d, 'a> {
|
||||
let base = self.d.blocks.get(name).map_or(Point::ZERO, |b| b.base);
|
||||
let map = self.symbol_map * Affine::translate(-base.to_vec2());
|
||||
let art = self.art(name, map, &key.1, depth);
|
||||
let made = art.map(|art| {
|
||||
let made = art.and_then(|art| {
|
||||
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
|
||||
// The symbol keeps its name: text, as its instances' copies of it are.
|
||||
if !self.charge_text(label.len()) {
|
||||
return None;
|
||||
}
|
||||
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) });
|
||||
symbol
|
||||
self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) });
|
||||
Some(symbol)
|
||||
});
|
||||
self.symbols.insert(key, made.clone());
|
||||
made
|
||||
|
||||
@@ -814,7 +814,7 @@ fn nested_array_inserts_of_nothing_stop_at_the_work_budget() {
|
||||
blocks.extend(block("*C", None));
|
||||
let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")];
|
||||
let r = open(&drawing(&[], &[], &blocks, &top), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings);
|
||||
assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -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.
|
||||
@@ -1149,3 +1177,173 @@ fn many_array_inserts_of_a_missing_block_stay_bounded() {
|
||||
assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}");
|
||||
assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashed_lines_past_the_dash_budget_are_drawn_solid() {
|
||||
// 600 lines of a two-length linetype: 1,200 dash lengths, past the 1,000 tests import with.
|
||||
let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")];
|
||||
let line = [(0, "LINE"), (6, "DASHED"), (10, "0"), (20, "0"), (11, "100"), (21, "0")];
|
||||
let e: Vec<(i32, &str)> = (0..600).flat_map(|_| line).collect();
|
||||
let r = open(&drawing(&[], &tables, &[], &e), &points());
|
||||
let (mut dashed, mut solid, mut kept) = (0, 0, 0);
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Path { .. } = n.kind {
|
||||
match n.appearance.stroke().and_then(|s| s.dash.as_ref()) {
|
||||
Some(d) => {
|
||||
dashed += 1;
|
||||
kept += d.pattern.len();
|
||||
}
|
||||
None => solid += 1,
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(dashed + solid, 600);
|
||||
assert!(dashed > 0 && solid > 0, "{dashed} dashed, {solid} solid");
|
||||
assert!(kept <= 1_000, "{kept} dash lengths kept");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The bytes of text in the document's text objects.
|
||||
fn text_bytes(doc: &Document) -> usize {
|
||||
let mut bytes = 0;
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
bytes += t.runs.iter().map(|r| r.text.len()).sum::<usize>();
|
||||
}
|
||||
});
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_copied_into_array_cells_stops_at_the_text_budget() {
|
||||
// 1,000 characters of text in an anonymous block placed 10 × 10: 100 KB of copies, past the
|
||||
// 64 KB tests import with.
|
||||
let long = "x".repeat(1000);
|
||||
let blocks =
|
||||
[(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0"), (0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, long.as_str()), (0, "ENDBLK")];
|
||||
let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "1"), (45, "1")];
|
||||
let r = open(&drawing(&[], &[], &blocks, &insert), &points());
|
||||
let bytes = text_bytes(&r.document);
|
||||
assert!(bytes > 0, "some copies are kept");
|
||||
assert!(bytes <= 64 << 10, "{bytes} bytes of text");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_style_naming_an_overlong_font_family_takes_the_default() {
|
||||
let family = "F".repeat(10_000);
|
||||
let tables = [(0, "TABLE"), (2, "STYLE"), (0, "STYLE"), (2, "Big"), (3, "x.ttf"), (1001, "ACAD"), (1000, family.as_str()), (0, "ENDTAB")];
|
||||
let e = [(0, "TEXT"), (10, "0"), (20, "0"), (40, "1"), (1, "Hi"), (7, "Big")];
|
||||
let doc = open(&drawing(&[], &tables, &[], &e), &points()).document;
|
||||
let mut families = vec![];
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
families.extend(t.runs.iter().map(|r| r.style.font_family.clone()));
|
||||
}
|
||||
});
|
||||
assert_eq!(families, [vectorcraft_doc::CharStyle::default().font_family]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_instances_of_a_long_block_name_stop_at_the_text_budget() {
|
||||
// A 1,000-byte block name placed 10 × 10: each instance keeps its own copy of the name.
|
||||
let name = "N".repeat(1000);
|
||||
let blocks = [(0, "BLOCK"), (2, name.as_str()), (10, "0"), (20, "0"), (0, "LINE"), (10, "0"), (20, "0"), (11, "1"), (21, "0"), (0, "ENDBLK")];
|
||||
let insert = [(0, "INSERT"), (2, name.as_str()), (10, "0"), (20, "0"), (70, "10"), (71, "10"), (44, "2"), (45, "2")];
|
||||
let r = open(&drawing(&[], &[], &blocks, &insert), &points());
|
||||
let mut instances = 0;
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::SymbolInstance { .. } = n.kind {
|
||||
instances += 1;
|
||||
}
|
||||
});
|
||||
assert!(instances > 0 && instances < 100, "{instances} instances");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_of_blocks_of_hidden_art_stop_at_the_work_budget() {
|
||||
// 500 lines on a hidden layer in an anonymous block placed 50 × 50: they make nothing, but
|
||||
// each placement looks at all of them (1,250,000 items, past the 1,000,000 tests import with).
|
||||
let tables = [(0, "TABLE"), (2, "LAYER"), (0, "LAYER"), (2, "Off"), (70, "0"), (62, "-1"), (0, "ENDTAB")];
|
||||
let mut blocks = vec![(0, "BLOCK"), (2, "*U1"), (70, "1"), (10, "0"), (20, "0")];
|
||||
for _ in 0..500 {
|
||||
blocks.extend([(0, "LINE"), (8, "Off"), (10, "0"), (20, "0"), (11, "1"), (21, "0")]);
|
||||
}
|
||||
blocks.push((0, "ENDBLK"));
|
||||
let insert = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "50"), (71, "50"), (44, "1"), (45, "1")];
|
||||
let r = open(&drawing(&[], &tables, &blocks, &insert), &points());
|
||||
assert!(r.warnings.iter().any(|w| w.contains("too much work")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashed_compound_paths_charge_every_copy_of_their_pattern() {
|
||||
// 100 pattern hatches of 9 loops in a two-length linetype: each keeps its dashes on the
|
||||
// compound path and on each loop, 2,000 dash lengths, past the 1,000 tests import with.
|
||||
let tables = [(0, "TABLE"), (2, "LTYPE"), (0, "LTYPE"), (2, "DASHED"), (73, "2"), (40, "15"), (49, "10"), (49, "-5"), (0, "ENDTAB")];
|
||||
let corners = ["0", "10", "20", "30", "40", "50", "60", "70", "80", "90"];
|
||||
let mut hatch = vec![(0, "HATCH"), (6, "DASHED"), (10, "0"), (20, "0"), (30, "0"), (2, "ANSI31"), (70, "0"), (71, "0"), (91, "9")];
|
||||
for i in 0..9 {
|
||||
let (a, b) = (corners[i], corners[i + 1]);
|
||||
hatch.extend([
|
||||
(92, "2"),
|
||||
(72, "0"),
|
||||
(73, "1"),
|
||||
(93, "4"),
|
||||
(10, a),
|
||||
(20, "0"),
|
||||
(10, b),
|
||||
(20, "0"),
|
||||
(10, b),
|
||||
(20, "5"),
|
||||
(10, a),
|
||||
(20, "5"),
|
||||
(97, "0"),
|
||||
]);
|
||||
}
|
||||
hatch.extend([(75, "0"), (76, "1"), (98, "0")]);
|
||||
let e: Vec<(i32, &str)> = (0..100).flat_map(|_| hatch.iter().copied()).collect();
|
||||
let r = open(&drawing(&[], &tables, &[], &e), &points());
|
||||
let (mut compounds, mut kept) = (0, 0);
|
||||
r.document.walk(|n| {
|
||||
compounds += usize::from(matches!(n.kind, NodeKind::Compound { .. }));
|
||||
kept += n.appearance.stroke().and_then(|s| s.dash.as_ref()).map_or(0, |d| d.pattern.len());
|
||||
});
|
||||
assert_eq!(compounds, 100, "{:?}", r.warnings);
|
||||
assert!(kept > 0 && kept <= 1_000, "{kept} dash lengths kept");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn solid")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_of_a_long_block_name_stop_at_the_text_budget() {
|
||||
// A 1,000-byte block name inserted in 100 colours its art takes: a symbol per colour, each
|
||||
// keeping its own copy of the name, past the 64 KB tests import with.
|
||||
let name = "N".repeat(1000);
|
||||
let blocks = [
|
||||
(0, "BLOCK"),
|
||||
(2, name.as_str()),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(0, "LINE"),
|
||||
(8, "0"),
|
||||
(62, "0"),
|
||||
(10, "0"),
|
||||
(20, "0"),
|
||||
(11, "1"),
|
||||
(21, "0"),
|
||||
(0, "ENDBLK"),
|
||||
];
|
||||
let colours: Vec<String> = (1..=100).map(|c| c.to_string()).collect();
|
||||
let inserts: Vec<(i32, &str)> =
|
||||
colours.iter().flat_map(|c| [(0, "INSERT"), (2, name.as_str()), (62, c.as_str()), (10, "0"), (20, "0")]).collect();
|
||||
let r = open(&drawing(&[], &[], &blocks, &inserts), &points());
|
||||
let mut bytes: usize = r.document.symbols.iter().map(|s| s.name.len()).sum();
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind {
|
||||
bytes += symbol.len();
|
||||
}
|
||||
});
|
||||
assert!(!r.document.symbols.is_empty());
|
||||
assert!(bytes <= 64 << 10, "{bytes} bytes of symbol names");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("64 MB of text")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,33 @@ fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Stri
|
||||
arg
|
||||
}
|
||||
|
||||
/// The character a `\U+XXXX` escape writes (`after` is the text just after its backslash), with
|
||||
/// the escape's length; a UTF-16 surrogate pair written as two escapes is one character.
|
||||
fn unicode_escape(after: &str) -> Option<(char, usize)> {
|
||||
let hex = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+"))?.get(..4)?;
|
||||
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let value = u32::from_str_radix(hex, 16).ok()?;
|
||||
if (0xD800..=0xDBFF).contains(&value) {
|
||||
let next = after.get(6..)?;
|
||||
let low = next.strip_prefix("\\U+").or_else(|| next.strip_prefix("\\u+"))?.get(..4)?;
|
||||
if !low.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let low = u32::from_str_radix(low, 16).ok()?;
|
||||
if !(0xDC00..=0xDFFF).contains(&low) {
|
||||
return None;
|
||||
}
|
||||
let scalar = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00);
|
||||
return char::from_u32(scalar).map(|ch| (ch, 13));
|
||||
}
|
||||
if (0xDC00..=0xDFFF).contains(&value) {
|
||||
return None;
|
||||
}
|
||||
char::from_u32(value).map(|ch| (ch, 6))
|
||||
}
|
||||
|
||||
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
|
||||
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. In
|
||||
/// multiline text (`mtext`) `\\` is an escaped backslash and is kept as is, so that a `U+` after
|
||||
@@ -77,12 +104,10 @@ fn escapes(s: &str, mtext: bool) -> String {
|
||||
continue;
|
||||
}
|
||||
if c == '\\'
|
||||
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
|
||||
&& hex.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
|
||||
&& let Some((ch, consumed)) = unicode_escape(after)
|
||||
{
|
||||
out.push(ch);
|
||||
rest = after.get(6..).unwrap_or("");
|
||||
rest = after.get(consumed..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
if mtext
|
||||
@@ -161,6 +186,14 @@ mod tests {
|
||||
assert_eq!(plain("\\U+00C9"), "É");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escape_decodes_surrogate_pairs() {
|
||||
assert_eq!(plain("\\U+D83D\\U+DE00"), "😀");
|
||||
assert_eq!(plain("\\U+D83D"), "\\U+D83D");
|
||||
assert_eq!(plain("\\U+DE00"), "\\U+DE00");
|
||||
assert_eq!(plain("\\U+D83D\\U+0041"), "\\U+D83DA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_codes_and_escapes() {
|
||||
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
@@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
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)
|
||||
}
|
||||
@@ -231,7 +271,22 @@ const MAX_ENTITIES: u32 = 10_000_000;
|
||||
|
||||
/// The largest x or y a path may have to be flattened, and the most points its polygons keep.
|
||||
const MAX_COORD: f64 = 1e9;
|
||||
const MAX_POINTS: usize = 4_000_000;
|
||||
const MAX_POINTS: usize = if cfg!(test) { 100_000 } else { 4_000_000 };
|
||||
/// The most segments flattening one path makes, kept or not (repeats of a point are dropped).
|
||||
const MAX_FLATTEN_SEGMENTS: usize = 4 * MAX_POINTS;
|
||||
|
||||
const SYMBOLS_LEFT_OUT: &str = "symbols placed inside symbols hold too many objects for DXF: the instances past that were left out";
|
||||
|
||||
/// 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>> {
|
||||
@@ -245,14 +300,19 @@ fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
}
|
||||
let mut out: Vec<Vec<Point>> = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let mut points = 0usize;
|
||||
let mut push = |p: Point, cur: &mut Vec<Point>| {
|
||||
if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
points += 1;
|
||||
// Points kept, and segments made: once either budget is spent, no further curve is flattened.
|
||||
let points = std::cell::Cell::new(0usize);
|
||||
let segments = std::cell::Cell::new(0usize);
|
||||
let push = |p: Point, cur: &mut Vec<Point>| {
|
||||
segments.set(segments.get() + 1);
|
||||
if points.get() < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
||||
points.set(points.get() + 1);
|
||||
cur.push(p);
|
||||
}
|
||||
};
|
||||
kurbo::flatten(bp.iter(), FLATNESS, |el| match el {
|
||||
let elements = bp.iter().take_while(|_| points.get() < MAX_POINTS && segments.get() < MAX_FLATTEN_SEGMENTS);
|
||||
// Each curve is flattened into a bounded number of segments, however large it is.
|
||||
vectorcraft_geom::flatten(elements, FLATNESS, |el| match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
push(p, &mut cur);
|
||||
@@ -361,6 +421,9 @@ pub(crate) struct Scene<'a> {
|
||||
opacity: f32,
|
||||
/// Symbols and brush art being written inside one another.
|
||||
nest: u32,
|
||||
/// Objects the symbol instances written held, together
|
||||
/// ([`vectorcraft_doc::MAX_SYMBOL_NODES`]).
|
||||
symbol_nodes: u64,
|
||||
}
|
||||
|
||||
impl<'a> Scene<'a> {
|
||||
@@ -390,6 +453,8 @@ impl<'a> Scene<'a> {
|
||||
extents: None,
|
||||
opacity: 1.0,
|
||||
nest: 0,
|
||||
// Tests spend all but a million of it up front.
|
||||
symbol_nodes: if cfg!(test) { vectorcraft_doc::MAX_SYMBOL_NODES - 1_000_000 } else { 0 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,14 +556,36 @@ impl<'a> Scene<'a> {
|
||||
NodeKind::Text(t) => self.text(n, t),
|
||||
NodeKind::Image(im) => self.image(im),
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
let doc = self.doc;
|
||||
if self.symbol_nodes >= vectorcraft_doc::MAX_SYMBOL_NODES {
|
||||
// Spent: no more instances are looked at, so what's left costs nothing.
|
||||
self.warn(SYMBOLS_LEFT_OUT);
|
||||
} else if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol)
|
||||
&& self.nest < MAX_NEST
|
||||
{
|
||||
let mut art = (*sym.art).clone();
|
||||
art.transform(*xf, false);
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
// Symbols holding instances of symbols are written in full for each instance,
|
||||
// empty or not: every instance's art counts against one budget for the export.
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let count = art.count_with_masks() as u64;
|
||||
// Counted as `place_symbol_art_unclipped` places it: no clip groups.
|
||||
let most = doc.symbol_slice_map(symbol, *xf).map_or(count, |m| m.most_placed_unclipped(&art, doc));
|
||||
let placed = vectorcraft_doc::charge_symbol_art(&mut self.symbol_nodes, most, || {
|
||||
let placed = doc.place_symbol_art_unclipped(symbol, &art, *xf);
|
||||
let n = placed.as_ref().map_or(0, |a| a.count_with_masks() as u64);
|
||||
(placed, n)
|
||||
});
|
||||
match placed {
|
||||
Some(Some(art)) => {
|
||||
self.nest += 1;
|
||||
self.node(&art, true);
|
||||
self.nest -= 1;
|
||||
}
|
||||
Some(None) => {}
|
||||
None => {
|
||||
self.symbol_nodes = vectorcraft_doc::MAX_SYMBOL_NODES;
|
||||
self.warn(SYMBOLS_LEFT_OUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
|
||||
@@ -612,8 +699,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);
|
||||
@@ -621,6 +707,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();
|
||||
@@ -673,7 +774,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(®ion.to_bezpath(), FillRule::NonZero, &style, true);
|
||||
@@ -740,9 +845,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);
|
||||
@@ -755,11 +860,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 {
|
||||
@@ -778,6 +889,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>) {
|
||||
@@ -787,7 +908,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);
|
||||
@@ -887,6 +1009,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) {
|
||||
@@ -929,7 +1098,8 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
|
||||
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
|
||||
/// entity per run of each line.
|
||||
/// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its
|
||||
/// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
|
||||
fn text(&mut self, n: &Node, t: &TextObject) {
|
||||
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
|
||||
if outline {
|
||||
@@ -941,6 +1111,8 @@ impl<'a> Scene<'a> {
|
||||
if !n.appearance.items.is_empty() {
|
||||
self.warn(TYPE_ITEMS);
|
||||
}
|
||||
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
|
||||
let t = &*resolved;
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let plain = t.plain_text();
|
||||
let xf = self.to_dxf * t.xf;
|
||||
@@ -1127,3 +1299,47 @@ fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> {
|
||||
});
|
||||
own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flattening_a_path_of_huge_curves_stops_at_its_budget() {
|
||||
// Each curve, a hundred million points across, flattens to thousands of segments at
|
||||
// FLATNESS; a small square after them is past the budget and left out.
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
for i in 0..20_000 {
|
||||
let y = if i % 2 == 0 { 1e8 } else { -1e8 };
|
||||
bp.curve_to((1e8, y), (-1e8, y), (0.0, 0.0));
|
||||
}
|
||||
bp.move_to((10.0, 10.0));
|
||||
bp.line_to((20.0, 10.0));
|
||||
bp.line_to((20.0, 20.0));
|
||||
bp.close_path();
|
||||
let polys = polygons(&bp);
|
||||
let points: usize = polys.iter().map(Vec::len).sum();
|
||||
assert!(points <= MAX_POINTS, "{points} points");
|
||||
assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_a_path_of_repeated_points_stops_at_its_segment_budget() {
|
||||
// Repeats of a point are dropped, so these keep one point but still count as segments:
|
||||
// the segment budget, not the point budget, leaves out the square after them.
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
for _ in 0..MAX_FLATTEN_SEGMENTS {
|
||||
bp.line_to((0.0, 0.0));
|
||||
}
|
||||
bp.move_to((10.0, 10.0));
|
||||
bp.line_to((20.0, 10.0));
|
||||
bp.line_to((20.0, 20.0));
|
||||
bp.close_path();
|
||||
let polys = polygons(&bp);
|
||||
let points: usize = polys.iter().map(Vec::len).sum();
|
||||
assert!(points < MAX_POINTS, "{points} points");
|
||||
assert!(!polys.iter().any(|p| p.contains(&Point::new(20.0, 20.0))), "the square past the budget is left out");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
@@ -453,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();
|
||||
@@ -471,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 {
|
||||
@@ -594,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() {
|
||||
assert_eq!(text(DxfVersion::R2018), "Größe");
|
||||
}
|
||||
|
||||
/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt
|
||||
/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14.
|
||||
#[test]
|
||||
fn a_font_height_reference_is_written_as_the_size_drawn() {
|
||||
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
|
||||
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
|
||||
let mut d = text_doc("Plan A", 14.0);
|
||||
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.runs[0].style.font_family = HEIGHT_FAMILY.into();
|
||||
t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
|
||||
}
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
let e = entities(&editable);
|
||||
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_link_png_or_jpeg_files() {
|
||||
let mut png = vec![];
|
||||
@@ -674,7 +1003,26 @@ 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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() {
|
||||
// Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty
|
||||
// instances, which write no entities, past the million objects tests export with.
|
||||
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "L5".into(), xf: Affine::IDENTITY })]);
|
||||
let empty = Node::group(d.alloc_id(), vec![]);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "L0".into(), art: Arc::new(empty) });
|
||||
for level in 1..=5 {
|
||||
let below = format!("L{}", level - 1);
|
||||
let children =
|
||||
(0..100).map(|_| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: below.clone(), xf: Affine::IDENTITY }))).collect();
|
||||
let art = Node::group(d.alloc_id(), children);
|
||||
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(art) });
|
||||
}
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert!(entities(&out).is_empty());
|
||||
assert!(out.warnings.iter().any(|w| w.contains("symbols placed inside symbols")), "{:?}", out.warnings);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -106,6 +106,12 @@ impl IccProfile {
|
||||
if !localized_text_fits(bytes) {
|
||||
return Err(CmsError::BadProfile(format!("its localized names take more than {} KB", MAX_LOCALIZED_TEXT >> 10)));
|
||||
}
|
||||
if let Some(tag) = repeated_tag(bytes) {
|
||||
return Err(CmsError::BadProfile(format!(
|
||||
"its tag table lists the `{}` tag more than once",
|
||||
String::from_utf8_lossy(&tag.to_be_bytes())
|
||||
)));
|
||||
}
|
||||
let p = ColorProfile::new_from_slice(bytes).map_err(|e| CmsError::BadProfile(format!("{e:?}")))?;
|
||||
Ok(Self { source: bytes.to_vec(), ..Self::from_profile(name, p)? })
|
||||
}
|
||||
@@ -149,11 +155,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)))
|
||||
}
|
||||
|
||||
@@ -408,6 +423,23 @@ pub(super) fn gray_profile(name: &str) -> ColorProfile {
|
||||
labelled(p, name)
|
||||
}
|
||||
|
||||
/// The first tag signature the tag table of ICC `bytes` lists twice, which the ICC
|
||||
/// specification forbids. moxcms parses every entry of the table, so a profile listing one
|
||||
/// description, copyright or table tag thousands of times, each pointing at the same large data,
|
||||
/// would decode and copy that data once per entry. A table it can't read is left to moxcms.
|
||||
pub(crate) fn repeated_tag(bytes: &[u8]) -> Option<u32> {
|
||||
let be32 = |at: usize| bytes.get(at..at.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]]));
|
||||
let tags = be32(128)? as usize;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in 0..tags {
|
||||
let sig = be32(i.checked_mul(12)?.checked_add(132)?)?;
|
||||
if !seen.insert(sig) {
|
||||
return Some(sig);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Most bytes of UTF-16 the localized names (`mluc` tags) of a profile may take together.
|
||||
const MAX_LOCALIZED_TEXT: usize = 1 << 20;
|
||||
|
||||
|
||||
@@ -181,6 +181,32 @@ fn aliased_localized_names_are_refused() {
|
||||
assert!(!super::icc::localized_text_fits(&profile(4096, 64 << 10)));
|
||||
}
|
||||
|
||||
/// A tag table that lists one tag many times, every entry pointing at the same long text, is
|
||||
/// refused before moxcms decodes the text once per entry; distinct tags pass.
|
||||
#[test]
|
||||
fn repeated_tags_are_refused() {
|
||||
let profile = |sigs: &[&[u8; 4]], text: usize| {
|
||||
let mut b = vec![0u8; 128];
|
||||
b.extend((sigs.len() as u32).to_be_bytes());
|
||||
let tag_at = 128 + 4 + 12 * sigs.len();
|
||||
let size = 8 + text;
|
||||
for sig in sigs {
|
||||
b.extend(*sig);
|
||||
b.extend((tag_at as u32).to_be_bytes());
|
||||
b.extend((size as u32).to_be_bytes());
|
||||
}
|
||||
b.extend(b"text");
|
||||
b.extend([0; 4]);
|
||||
b.extend(vec![b'a'; text]);
|
||||
b
|
||||
};
|
||||
assert_eq!(super::icc::repeated_tag(&profile(&[b"desc", b"cprt", b"dmdd"], 64)), None);
|
||||
let many: Vec<&[u8; 4]> = std::iter::repeat_n(b"desc", 4096).collect();
|
||||
let bytes = profile(&many, 256 << 10);
|
||||
assert_eq!(super::icc::repeated_tag(&bytes), Some(u32::from_be_bytes(*b"desc")));
|
||||
assert!(matches!(super::icc::IccProfile::from_bytes(None, &bytes), Err(super::CmsError::BadProfile(_))));
|
||||
}
|
||||
|
||||
/// Reloading an RGB profile under the name it had (a new file for "My RGB") changes what CMYK
|
||||
/// converts to in it: the direct CMYK → RGB transform isn't kept from the old file.
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
@@ -100,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
|
||||
}
|
||||
}
|
||||
|
||||
/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
|
||||
/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
|
||||
fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec<f32> {
|
||||
let mut vs = Vec::new();
|
||||
perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
|
||||
vs.push(0.5);
|
||||
perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
|
||||
vs
|
||||
}
|
||||
|
||||
/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint
|
||||
/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it.
|
||||
fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 {
|
||||
let m = a.midpoint.clamp(0.01, 0.99);
|
||||
let u = if v == 0.5 {
|
||||
a.midpoint
|
||||
} else if v < 0.5 {
|
||||
2.0 * v * m
|
||||
} else {
|
||||
m + (v - 0.5) * 2.0 * (1.0 - m)
|
||||
};
|
||||
a.offset + (b.offset - a.offset) * u
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct GradientStop {
|
||||
/// 0..=1 along the gradient.
|
||||
@@ -143,6 +175,40 @@ fn between(a: &GradientStop, b: &GradientStop, t: f32, mix: &impl Fn(&Color, &Co
|
||||
(mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v)
|
||||
}
|
||||
|
||||
/// For each pair of neighbouring stops (`k`, `k + 1`), the largest offset among stops `1..=k + 1`.
|
||||
/// [`Gradient::sample_with`] picks the first pair whose end offset `t` doesn't pass; that is the
|
||||
/// first pair whose reach `t` doesn't pass, and the reaches never decrease, so the pair is found by
|
||||
/// binary search whether or not the stops are in order.
|
||||
struct StopReach(Vec<f32>);
|
||||
|
||||
impl StopReach {
|
||||
fn new(stops: &[GradientStop]) -> Self {
|
||||
// `f32::max` skips a NaN offset, which `t <= offset` never chooses either.
|
||||
let reach = stops.iter().skip(1).scan(f32::NEG_INFINITY, |m, s| {
|
||||
*m = m.max(s.offset);
|
||||
Some(*m)
|
||||
});
|
||||
Self(reach.collect())
|
||||
}
|
||||
|
||||
/// What [`Gradient::sample_with`] gives at `t` for `stops`, the stops this was built from.
|
||||
fn sample_with(&self, stops: &[GradientStop], t: f32, mix: &impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) {
|
||||
let Some(first) = stops.first() else { return (Color::BLACK, 1.0) };
|
||||
if t <= first.offset {
|
||||
return (first.color, first.opacity);
|
||||
}
|
||||
let last = || stops.last().map_or((Color::BLACK, 1.0), |l| (l.color, l.opacity));
|
||||
if t.is_nan() {
|
||||
return last();
|
||||
}
|
||||
let k = self.0.partition_point(|r| *r < t);
|
||||
match (stops.get(k), stops.get(k + 1)) {
|
||||
(Some(a), Some(b)) => between(a, b, t, mix),
|
||||
_ => last(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
@@ -179,6 +245,12 @@ impl Gradient {
|
||||
pub fn sample(&self, t: f32) -> (Color, f32) {
|
||||
self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
|
||||
}
|
||||
/// [`Self::sample`] for many points in no order (a gradient drawn pixel by pixel): the stops
|
||||
/// are prepared once, so each sample costs a binary search rather than a scan of the stops.
|
||||
pub fn sampler(&self) -> impl Fn(f32) -> (Color, f32) + '_ {
|
||||
let reach = StopReach::new(&self.stops);
|
||||
move |t| reach.sample_with(&self.stops, t, &|a, b, v| self.method.mix(a, b, v))
|
||||
}
|
||||
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
|
||||
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
|
||||
/// gradient's as [`Self::sample`] mixes them.
|
||||
@@ -213,9 +285,11 @@ impl Gradient {
|
||||
/// the one before it stopped, so the work is the samples plus the stops, not their product.
|
||||
pub fn sample_increasing_with(&self, ts: impl IntoIterator<Item = f32>, mix: impl Fn(&Color, &Color, f32) -> Color) -> Vec<(Color, f32)> {
|
||||
let stops = &self.stops;
|
||||
// The shared index assumes ordered stops; others (a file's, unsorted) sample one by one.
|
||||
// The shared cursor assumes ordered stops; others (a file's, unsorted) look each sample up
|
||||
// in a [`StopReach`], so they cost a binary search each rather than a scan of the stops.
|
||||
if stops.windows(2).any(|w| w[1].offset < w[0].offset) {
|
||||
return ts.into_iter().map(|t| self.sample_with(t, &mix)).collect();
|
||||
let reach = StopReach::new(stops);
|
||||
return ts.into_iter().map(|t| reach.sample_with(stops, t, &mix)).collect();
|
||||
}
|
||||
let mut j = 0;
|
||||
ts.into_iter()
|
||||
@@ -245,8 +319,16 @@ impl Gradient {
|
||||
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
|
||||
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
|
||||
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
|
||||
self.expanded_with_stops().map(|(t, c, o, mid, _)| (t, c, o, mid))
|
||||
}
|
||||
/// [`Self::expanded`], each of the gradient's own stops with its index in [`Self::stops`]
|
||||
/// (`None` on the stops in between).
|
||||
fn expanded_with_stops(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>, Option<usize>)> + '_ {
|
||||
// Direct interpolation matches `sample` only for strictly increasing offsets.
|
||||
let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset);
|
||||
// Otherwise (repeated or unsorted offsets) each midpoint is looked up as `sample` would
|
||||
// find it, but by binary search: a scan of all the stops per midpoint cost their square.
|
||||
let reach = if increasing || !self.method.is_classic() { None } else { Some(StopReach::new(&self.stops)) };
|
||||
self.stops.iter().enumerate().flat_map(move |(i, s)| {
|
||||
let next = self.stops.get(i + 1);
|
||||
let off_centre = (s.midpoint - 0.5).abs() > 1e-3;
|
||||
@@ -256,34 +338,104 @@ impl Gradient {
|
||||
// Mixed from the two stops it lies between, as `sample` would: searching all the
|
||||
// stops again for each midpoint made many stops cost their square.
|
||||
let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v);
|
||||
let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) };
|
||||
(t, c, o, Some(s.midpoint))
|
||||
let (c, o) = match &reach {
|
||||
Some(reach) => reach.sample_with(&self.stops, t, &mix),
|
||||
None => between(s, n, t, &mix),
|
||||
};
|
||||
(t, c, o, Some(s.midpoint), None)
|
||||
});
|
||||
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
|
||||
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
|
||||
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
|
||||
let mut vs = Vec::new();
|
||||
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
|
||||
vs.push(0.5);
|
||||
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
|
||||
let m = s.midpoint.clamp(0.01, 0.99);
|
||||
vs.into_iter().map(move |v| {
|
||||
perceptual_mixes(s, n).into_iter().map(move |v| {
|
||||
let mid = v == 0.5;
|
||||
let u = if mid {
|
||||
s.midpoint
|
||||
} else if v < 0.5 {
|
||||
2.0 * v * m
|
||||
} else {
|
||||
m + (v - 0.5) * 2.0 * (1.0 - m)
|
||||
};
|
||||
let t = s.offset + (n.offset - s.offset) * u;
|
||||
let t = curve_offset(s, n, v);
|
||||
let o = s.opacity + (n.opacity - s.opacity) * v;
|
||||
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
|
||||
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint), None)
|
||||
})
|
||||
});
|
||||
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
|
||||
std::iter::once((s.offset, s.color, s.opacity, None, Some(i))).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, except at
|
||||
/// the gradient's own stops, which take their tints from `stop_tints` (one per stop, in
|
||||
/// order) where it has one. 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, stop_tints: &[f32]) -> 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_with_stops().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, _, i)| (t, i.and_then(|i| stop_tints.get(i).copied()).unwrap_or_else(|| 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()
|
||||
}
|
||||
/// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks:
|
||||
/// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop
|
||||
/// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops).
|
||||
/// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
|
||||
/// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
|
||||
/// position itself when the two colors' lightness differs by less than
|
||||
/// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
|
||||
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
|
||||
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
|
||||
use crate::libraries::srgb_to_oklab;
|
||||
if self.method.is_classic() || !self.fits_ink_tints() {
|
||||
return None;
|
||||
}
|
||||
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
|
||||
let mut out = Vec::new();
|
||||
for (i, s) in self.stops.iter().enumerate() {
|
||||
out.push((s.offset, i, 0.0, s.opacity));
|
||||
let Some(n) = self.stops.get(i + 1) else { continue };
|
||||
let (l0, l1) = (lightness(&s.color), lightness(&n.color));
|
||||
for v in perceptual_mixes(s, n) {
|
||||
let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
|
||||
v
|
||||
} else {
|
||||
let want = lightness(&self.method.mix(&s.color, &n.color, v));
|
||||
let (mut lo, mut hi) = (0.0f32, 1.0f32);
|
||||
for _ in 0..TINT_STEPS {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
// The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
|
||||
if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
(lo + hi) / 2.0
|
||||
};
|
||||
out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
/// 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) {
|
||||
|
||||
@@ -23,6 +23,64 @@ fn increasing_samples_match_single_samples() {
|
||||
assert_eq!(g.sample_increasing_with(ts, Color::lerp_keep_model), one_by_one);
|
||||
}
|
||||
|
||||
/// The prepared sampler gives what sampling each position alone does, out-of-order stops included.
|
||||
#[test]
|
||||
fn the_sampler_matches_single_samples() {
|
||||
let stop = |offset: f32, hex: &str| GradientStop::new(offset, Color::from_hex(hex).unwrap());
|
||||
let g = Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![stop(0.1, "#ff0000"), stop(0.4, "#00ff00"), stop(0.4, "#0000ff"), stop(0.2, "#ffff00"), stop(0.9, "#000000")],
|
||||
);
|
||||
let sample = g.sampler();
|
||||
for k in (0..=40).rev() {
|
||||
let t = k as f32 / 40.0;
|
||||
assert_eq!(sample(t), g.sample(t), "{t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Looking stops up by their reach finds what scanning them does, for any order of offsets,
|
||||
/// repeated, out-of-order and NaN ones included, and for positions outside and between them.
|
||||
#[test]
|
||||
fn stop_reach_matches_scanning_the_stops() {
|
||||
let colors = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#000000"];
|
||||
let offsets: [&[f32]; 5] = [
|
||||
&[0.1, 0.4, 0.4, 0.2, 0.9, 0.5],
|
||||
&[0.5, 0.5, 0.5, 0.5],
|
||||
&[0.9, 0.1, 0.8, 0.2, 0.7, 0.3],
|
||||
&[0.2, f32::NAN, 0.6, 0.1, f32::NAN, 1.0],
|
||||
&[f32::NAN, 0.3, 0.3, 0.8],
|
||||
];
|
||||
for offsets in offsets {
|
||||
let stops: Vec<GradientStop> = offsets
|
||||
.iter()
|
||||
.zip(colors.iter().cycle())
|
||||
.map(|(&o, hex)| GradientStop { midpoint: 0.3, ..GradientStop::new(o, Color::from_hex(hex).unwrap()) })
|
||||
.collect();
|
||||
let g = Gradient::new(GradientKind::Linear, stops);
|
||||
let reach = StopReach::new(&g.stops);
|
||||
for t in (-4..=44).map(|k| k as f32 / 40.0).chain([f32::NAN, f32::INFINITY, f32::NEG_INFINITY]) {
|
||||
// Compared as printed: a NaN offset mixes NaN colours, which are equal only so.
|
||||
let (got, want) = (reach.sample_with(&g.stops, t, &Color::lerp_keep_model), g.sample_with(t, Color::lerp_keep_model));
|
||||
assert_eq!(format!("{got:?}"), format!("{want:?}"), "{offsets:?} at {t}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repeated offsets turn off the direct midpoint mix, and the midpoints still come out as
|
||||
/// `sample` gives them, with work that stays near linear in the stops (thousands of stops at one
|
||||
/// offset used to scan them all for each midpoint).
|
||||
#[test]
|
||||
fn expanded_with_repeated_offsets_samples_midpoints() {
|
||||
let stop = |offset: f32, hex: &str| GradientStop { midpoint: 0.25, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
|
||||
let g = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000"), stop(0.5, "#00ff00"), stop(0.5, "#0000ff"), stop(1.0, "#000000")]);
|
||||
for (t, c, o, mid) in g.expanded().filter(|e| e.3.is_some()) {
|
||||
assert_eq!(mid, Some(0.25));
|
||||
assert_eq!((c, o), g.sample(t), "midpoint at {t}");
|
||||
}
|
||||
let many = Gradient::new(GradientKind::Linear, (0..200_000).map(|i| stop(if i == 0 { 0.0 } else { 0.5 }, "#808080")).collect());
|
||||
assert_eq!(many.expanded().count(), 2 * 200_000 - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_shifts() {
|
||||
let mut g = Gradient::default();
|
||||
@@ -455,3 +513,75 @@ 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:?}");
|
||||
// The stops' own tints, when given, are kept exactly; the tints in between are the same.
|
||||
let own = g.ink_tints(&ink, &[0.8, 0.0]).expect("tints");
|
||||
assert!(own.len() == tints.len() && own[0].1 == 0.8 && own[own.len() - 1].1 == 0.0, "{own:?}");
|
||||
assert!(own[1..own.len() - 1] == tints[1..tints.len() - 1], "{own:?}");
|
||||
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());
|
||||
}
|
||||
|
||||
/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their
|
||||
/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of
|
||||
/// the color on the curve there.
|
||||
#[test]
|
||||
fn ink_mixes_follow_the_perceptual_curve() {
|
||||
use crate::libraries::srgb_to_oklab;
|
||||
let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0));
|
||||
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]);
|
||||
g.stops[0].midpoint = 0.3;
|
||||
assert_eq!(g.ink_mixes(), None, "a Classic gradient");
|
||||
g.method = GradientMethod::Perceptual;
|
||||
let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
|
||||
let curve = g.expanded_stops();
|
||||
assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve");
|
||||
assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops");
|
||||
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
|
||||
for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) {
|
||||
assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}");
|
||||
if *i == 0 && *t > 0.0 {
|
||||
let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w);
|
||||
assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}");
|
||||
}
|
||||
}
|
||||
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
|
||||
assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}");
|
||||
assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}");
|
||||
// Two colors of one lightness: the weights are the mix positions.
|
||||
g.stops[1].color = magenta;
|
||||
let mixes = g.ink_mixes().expect("the curve");
|
||||
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
|
||||
assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}");
|
||||
// More stops on the curve than MAX_INK_TINTS: no mixes.
|
||||
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 { magenta } else { Color::WHITE })).collect();
|
||||
assert_eq!(g.ink_mixes(), None);
|
||||
}
|
||||
|
||||
@@ -244,6 +244,12 @@ pub fn is_tint(c: Color, base: Color) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` carry the same ink in `a`'s model, within [`is_tint`]'s tolerance.
|
||||
pub fn same_ink(a: Color, b: Color) -> bool {
|
||||
let (x, y) = (ink(a, a.model()), ink(b, a.model()));
|
||||
x.iter().zip(y).all(|(p, q)| (p - q).abs() <= 0.01)
|
||||
}
|
||||
|
||||
/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab.
|
||||
pub fn shaded(c: Color, f: f32) -> Color {
|
||||
let f = f.clamp(0.0, 1.0);
|
||||
@@ -255,6 +261,77 @@ pub fn shaded(c: Color, f: f32) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recolor Artwork's Global Adjust: the same change to every color, by four amounts from -1 to 1
|
||||
/// (-100% to +100%), where 0 leaves a color as it is. The steps run in this order:
|
||||
///
|
||||
/// 1. Saturation multiplies HSB saturation by 1 + `saturation` (-1 gives a gray, 1 doubles it).
|
||||
/// 2. Brightness moves HSB brightness that share of the way to its top (above 0) or to black
|
||||
/// (below 0).
|
||||
/// 3. Temperature adds 0.2 × `temperature` to display red and takes as much from blue (warmer
|
||||
/// above 0, cooler below).
|
||||
/// 4. Luminosity moves Lab lightness that share of the way to 100 (above 0) or to 0 (below 0),
|
||||
/// with Lab a and b, and so the Lab hue, kept.
|
||||
///
|
||||
/// The result is in the model of the color it changes, with its components clamped to their
|
||||
/// ranges. An amount that is not finite counts as 0.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct GlobalAdjust {
|
||||
pub saturation: f32,
|
||||
pub brightness: f32,
|
||||
pub temperature: f32,
|
||||
pub luminosity: f32,
|
||||
}
|
||||
|
||||
impl GlobalAdjust {
|
||||
/// The four amounts, in order, each finite and within -1..=1.
|
||||
fn amounts(&self) -> [f32; 4] {
|
||||
[self.saturation, self.brightness, self.temperature, self.luminosity].map(|v| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 })
|
||||
}
|
||||
|
||||
/// Whether every color stays as it is (all four amounts 0).
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.amounts().iter().all(|v| *v == 0.0)
|
||||
}
|
||||
|
||||
/// `c` with the adjustment, in `c`'s model; `c` itself when [`GlobalAdjust::is_identity`].
|
||||
pub fn apply(&self, c: Color) -> Color {
|
||||
let [sat, bri, temp, lum] = self.amounts();
|
||||
if [sat, bri, temp, lum].iter().all(|v| *v == 0.0) {
|
||||
return c;
|
||||
}
|
||||
// `v` moved the share `a` of the way to `top` (a > 0) or to 0 (a < 0).
|
||||
let toward = |v: f32, a: f32, top: f32| if a >= 0.0 { v + (top - v) * a } else { v * (1.0 + a) };
|
||||
let mut out = c;
|
||||
if sat != 0.0 || bri != 0.0 {
|
||||
let [h, s, v] = out.to_hsb();
|
||||
out = Color::from_hsb(h, (s * (1.0 + sat)).clamp(0.0, 1.0), toward(v.clamp(0.0, 1.0), bri, 1.0));
|
||||
}
|
||||
if temp != 0.0 {
|
||||
let [r, g, b] = out.to_rgb();
|
||||
out = Color::rgb((r + 0.2 * temp).clamp(0.0, 1.0), g.clamp(0.0, 1.0), (b - 0.2 * temp).clamp(0.0, 1.0));
|
||||
}
|
||||
if lum != 0.0 {
|
||||
let Lab { l, a, b } = match out {
|
||||
Color::Lab { l, a, b } => Lab::new(l, a, b),
|
||||
_ => out.to_lab(),
|
||||
};
|
||||
out = Color::lab(toward(l.clamp(0.0, 100.0), lum, 100.0), a, b);
|
||||
}
|
||||
clamped(keep_model(c, out))
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` with its components in their ranges: RGB, CMYK and Gray 0..1, Lab lightness 0..100.
|
||||
fn clamped(c: Color) -> Color {
|
||||
let u = |v: f32| v.clamp(0.0, 1.0);
|
||||
match c {
|
||||
Color::Rgb { r, g, b } => Color::rgb(u(r), u(g), u(b)),
|
||||
Color::Cmyk { c, m, y, k } => Color::cmyk(u(c), u(m), u(y), u(k)),
|
||||
Color::Gray { k } => Color::gray(u(k)),
|
||||
Color::Lab { l, a, b } => Color::lab(l.clamp(0.0, 100.0), a, b),
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RowMap {
|
||||
@@ -356,10 +433,11 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
|
||||
.collect();
|
||||
let w = |i: usize| weights.get(i).copied().unwrap_or(1.0).max(1e-6);
|
||||
let d2 = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
|
||||
let n = n.max(1);
|
||||
if pts.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// No more rows than colours: each colour seeds one at most.
|
||||
let n = n.clamp(1, pts.len());
|
||||
// Seeds: the heaviest colour, then each time the one farthest from every seed so far.
|
||||
let first = (0..pts.len()).fold(0, |best, i| if w(i) > w(best) { i } else { best });
|
||||
let mut centres = vec![pts[first]];
|
||||
@@ -370,6 +448,8 @@ pub fn cluster(colors: &[Color], weights: &[f32], n: usize) -> Vec<Vec<usize>> {
|
||||
break;
|
||||
}
|
||||
centres.push(pts[i]);
|
||||
// Picked: a colour whose distances aren't finite (an infinite coordinate) stays farthest.
|
||||
near[i] = 0.0;
|
||||
for (j, p) in pts.iter().enumerate() {
|
||||
near[j] = near[j].min(d2(p, &pts[i]));
|
||||
}
|
||||
@@ -593,6 +673,12 @@ mod tests {
|
||||
assert!(matches!(shaded(lab[0], 0.5), Color::Lab { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_seeds_each_colour_once_even_unbounded() {
|
||||
let colors = [Color::lab(f32::INFINITY, 0.0, 0.0), Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)];
|
||||
assert!(cluster(&colors, &[], usize::MAX).len() <= colors.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustering_reduces_to_n_rows_of_similar_colours() {
|
||||
let colors =
|
||||
@@ -615,6 +701,77 @@ mod tests {
|
||||
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
|
||||
}
|
||||
|
||||
fn adjust(saturation: f32, brightness: f32, temperature: f32, luminosity: f32) -> GlobalAdjust {
|
||||
GlobalAdjust { saturation, brightness, temperature, luminosity }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_adjust_at_zero_leaves_every_color_as_it_is() {
|
||||
let zero = GlobalAdjust::default();
|
||||
assert!(zero.is_identity());
|
||||
assert!(adjust(f32::NAN, 0.0, f32::INFINITY, 0.0).is_identity(), "amounts that aren't finite count as 0");
|
||||
for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.1234, 0.5, 0.0, 0.07), Color::gray(0.4), Color::lab(55.0, 60.5, -40.25)] {
|
||||
assert_eq!(zero.apply(c), c);
|
||||
}
|
||||
assert!(!adjust(0.0, 0.0, 0.0, 0.01).is_identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_adjust_saturation_and_brightness_reach_their_ends() {
|
||||
let c = Color::rgb(0.8, 0.4, 0.2);
|
||||
let [h, s, v] = c.to_hsb();
|
||||
let gray = adjust(-1.0, 0.0, 0.0, 0.0).apply(c).to_hsb();
|
||||
assert!(gray[1].abs() < 1e-4 && (gray[2] - v).abs() < 1e-4, "-100% saturation gives a gray as bright: {gray:?}");
|
||||
let more = adjust(0.5, 0.0, 0.0, 0.0).apply(c).to_hsb();
|
||||
assert!((more[0] - h).abs() < 0.5 && more[1] > s && (more[1] - (s * 1.5).min(1.0)).abs() < 1e-3, "{more:?}");
|
||||
assert!(adjust(-1.0, 0.0, 0.0, 0.0).apply(Color::rgb(0.5, 0.5, 0.5)) == Color::rgb(0.5, 0.5, 0.5), "a gray stays gray");
|
||||
assert!(adjust(0.0, -1.0, 0.0, 0.0).apply(c).to_hsb()[2] < 1e-4, "-100% brightness is black");
|
||||
assert!((adjust(0.0, 1.0, 0.0, 0.0).apply(c).to_hsb()[2] - 1.0).abs() < 1e-4, "+100% brightness is full brightness");
|
||||
let half = adjust(0.0, 0.5, 0.0, 0.0).apply(c).to_hsb();
|
||||
assert!((half[2] - (v + (1.0 - v) * 0.5)).abs() < 1e-3 && (half[1] - s).abs() < 1e-3, "{half:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_adjust_temperature_warms_and_cools() {
|
||||
let c = Color::rgb(0.5, 0.5, 0.5);
|
||||
let warm = adjust(0.0, 0.0, 1.0, 0.0).apply(c).to_rgb();
|
||||
let cool = adjust(0.0, 0.0, -1.0, 0.0).apply(c).to_rgb();
|
||||
assert!(warm[0] > warm[1] && warm[1] > warm[2], "warmer: more red, less blue {warm:?}");
|
||||
assert!(cool[2] > cool[1] && cool[1] > cool[0], "cooler: more blue, less red {cool:?}");
|
||||
let edge = adjust(0.0, 0.0, 1.0, 0.0).apply(Color::rgb(1.0, 0.0, 0.0));
|
||||
assert_eq!(edge, Color::rgb(1.0, 0.0, 0.0), "clamped at the ends of the channels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_adjust_luminosity_keeps_the_hue() {
|
||||
let lab = Color::lab(50.0, 30.0, -20.0);
|
||||
assert_eq!(adjust(0.0, 0.0, 0.0, 0.5).apply(lab), Color::lab(75.0, 30.0, -20.0), "a Lab color keeps a and b exactly");
|
||||
assert_eq!(adjust(0.0, 0.0, 0.0, -0.5).apply(lab), Color::lab(25.0, 30.0, -20.0));
|
||||
let c = Color::rgb(0.6, 0.35, 0.3);
|
||||
let (before, after) = (c.to_lab(), adjust(0.0, 0.0, 0.0, 0.2).apply(c).to_lab());
|
||||
assert!(after.l > before.l, "lighter: {after:?}");
|
||||
assert!((after.hue() - before.hue()).abs() < 2.0, "hue kept: {} vs {}", after.hue(), before.hue());
|
||||
let darkest = adjust(0.0, 0.0, 0.0, -1.0).apply(c).to_lab();
|
||||
assert!(darkest.l < before.l * 0.5, "-100% is far darker: {darkest:?}");
|
||||
assert_eq!(adjust(0.0, 0.0, 0.0, -1.0).apply(Color::gray(0.4)), Color::gray(1.0), "-100% makes a gray black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_adjust_keeps_the_model_and_the_ranges() {
|
||||
let all = adjust(1.0, 1.0, 1.0, 1.0);
|
||||
for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.2, 0.9, 0.1, 0.3), Color::gray(0.4), Color::lab(55.0, 60.0, -40.0)] {
|
||||
let out = all.apply(c);
|
||||
assert_eq!(out.model(), c.model(), "{c:?} → {out:?}");
|
||||
let comps: Vec<f32> = match out {
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
Color::Gray { k } => vec![k],
|
||||
Color::Lab { l, .. } => vec![l / 100.0],
|
||||
};
|
||||
assert!(comps.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_snaps_to_the_nearest_colour() {
|
||||
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
|
||||
|
||||
@@ -10,10 +10,12 @@ mod stroke_style;
|
||||
|
||||
pub(crate) use layers::HEAD_REACH;
|
||||
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
|
||||
pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
|
||||
pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
|
||||
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
|
||||
/// One entry of the appearance stack.
|
||||
// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||
pub enum AppearanceItem {
|
||||
@@ -126,11 +128,15 @@ impl Appearance {
|
||||
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
|
||||
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
|
||||
}
|
||||
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
|
||||
/// This appearance for other art: the pen pressure, tilt, bearing and rotation its strokes
|
||||
/// were drawn with stay with them.
|
||||
pub fn without_pressure(mut self) -> Self {
|
||||
for it in &mut self.items {
|
||||
if let AppearanceItem::Stroke(s) = it {
|
||||
s.pressure = None;
|
||||
s.tilt = None;
|
||||
s.bearing = None;
|
||||
s.rotation = None;
|
||||
}
|
||||
}
|
||||
self
|
||||
@@ -504,6 +510,27 @@ impl Appearance {
|
||||
self.set_contents_at(others_below + usize::from(now_below));
|
||||
Some(to)
|
||||
}
|
||||
/// Move items `from` (ascending paint-order indices, no repeats) as one block, in their order,
|
||||
/// so the lowest lands at `to` (clamped); returns it. The contents stay between the same unmoved
|
||||
/// items; a block landing right at them keeps its lowest item's side. None (and nothing changed)
|
||||
/// when an index is missing.
|
||||
pub fn move_items(&mut self, from: &[usize], to: usize) -> Option<usize> {
|
||||
let (&first, _) = from.split_first()?;
|
||||
if from.iter().any(|i| *i >= self.items.len()) {
|
||||
return None;
|
||||
}
|
||||
let below = first < self.contents_at();
|
||||
// From the top down, so the lower indices stay valid; then back in order.
|
||||
let mut block: Vec<AppearanceItem> = from.iter().rev().filter_map(|&i| self.remove_item(i)).collect();
|
||||
block.reverse();
|
||||
let others_below = self.contents_at();
|
||||
let to = to.min(self.items.len());
|
||||
let m = block.len();
|
||||
self.items.splice(to..to, block);
|
||||
let now_below = if below { to <= others_below } else { to < others_below };
|
||||
self.set_contents_at(others_below + if now_below { m } else { 0 });
|
||||
Some(to)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most fills, strokes or effects a stack may have to be matched against another's
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::profile::{PressureProfile, WidthProfile};
|
||||
use super::profile::{AngleProfile, PressureProfile, WidthProfile};
|
||||
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
use super::{BlendMode, Deserialize, Paint, Serialize};
|
||||
|
||||
@@ -93,6 +93,21 @@ pub struct StrokeLayer {
|
||||
/// art leave it behind ([`Appearance::without_pressure`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pressure: Option<PressureProfile>,
|
||||
/// How far the pen leaned from upright along the stroke (Paintbrush with a tablet that reads
|
||||
/// tilt): 0 upright to 1 lying flat (90°). Brushes with Tilt variation vary with it. Like the
|
||||
/// pressure, it stays with this path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tilt: Option<PressureProfile>,
|
||||
/// Which way the pen leaned along the stroke, in degrees counter-clockwise on the page from the
|
||||
/// x axis. Brushes with Bearing variation vary with it. Like the pressure, it stays with this
|
||||
/// path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bearing: Option<AngleProfile>,
|
||||
/// How far the pen's barrel was turned along the stroke, in degrees counter-clockwise (a pen
|
||||
/// that reads barrel rotation). Brushes with Rotation variation vary with it. Like the
|
||||
/// pressure, it stays with this path.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rotation: Option<AngleProfile>,
|
||||
}
|
||||
|
||||
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
|
||||
@@ -167,6 +182,9 @@ impl StrokeLayer {
|
||||
overprint: false,
|
||||
gradient_mode: StrokeGradientMode::Within,
|
||||
pressure: None,
|
||||
tilt: None,
|
||||
bearing: None,
|
||||
rotation: None,
|
||||
}
|
||||
}
|
||||
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
|
||||
@@ -53,6 +55,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() })
|
||||
@@ -92,7 +103,8 @@ impl WidthProfile {
|
||||
|
||||
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
|
||||
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
|
||||
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
|
||||
/// are. The pen's tilt is kept the same way ([`StrokeLayer::tilt`]). Calligraphic and Scatter
|
||||
/// brushes with Pressure or Tilt variation read them; everything else ignores them.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PressureProfile {
|
||||
pub points: Vec<(f64, f64)>,
|
||||
@@ -109,18 +121,7 @@ impl PressureProfile {
|
||||
|
||||
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
|
||||
pub fn at(&self, t: f64) -> f64 {
|
||||
let p = &self.points;
|
||||
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
|
||||
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
|
||||
let v = if t <= first.0 {
|
||||
first.1
|
||||
} else {
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
|
||||
_ => last.1,
|
||||
}
|
||||
};
|
||||
let v = interpolated(&self.points, t).unwrap_or(Self::MID);
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
|
||||
}
|
||||
|
||||
@@ -130,44 +131,21 @@ impl PressureProfile {
|
||||
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
|
||||
let pts: Vec<(f64, f64)> =
|
||||
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
|
||||
if pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
|
||||
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
|
||||
// Positions only ever go forward.
|
||||
let mut prev = 0.0;
|
||||
for p in &mut pts {
|
||||
p.0 = p.0.max(prev);
|
||||
prev = p.0;
|
||||
}
|
||||
let keep = simplify_1d(&pts, Self::TOLERANCE);
|
||||
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
|
||||
let round = |v: f64| (v * 1e4).round() / 1e4;
|
||||
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
|
||||
simplified(pts, Self::TOLERANCE).map(|points| Self { points })
|
||||
}
|
||||
|
||||
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
|
||||
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
|
||||
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
|
||||
if old.is_none() && added.is_none() {
|
||||
return None;
|
||||
match extended_points(old.map(|p| p.points.as_slice()), old_len, added.map(|p| p.points.as_slice()), added_len, Self::MID)? {
|
||||
Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),
|
||||
Cow::Owned(points) => Self::from_samples(points),
|
||||
}
|
||||
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
|
||||
let total = old_len + added_len;
|
||||
if !total.is_finite() || total <= 1e-9 {
|
||||
return old.or(added).cloned();
|
||||
}
|
||||
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
|
||||
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
|
||||
let o = old_len / total;
|
||||
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
|
||||
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
|
||||
}
|
||||
|
||||
/// The pressure of the path reversed.
|
||||
pub fn reversed(&self) -> Self {
|
||||
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
|
||||
Self { points: reversed_points(&self.points) }
|
||||
}
|
||||
|
||||
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
|
||||
@@ -176,6 +154,142 @@ impl PressureProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pen angle recorded along a stroke (the Paintbrush with a tablet that reads it): (position 0..1
|
||||
/// along the path, degrees) samples in order, positions measured as a [`PressureProfile`]'s are.
|
||||
/// Each sample is joined to the one before it the shorter way round, so the degrees can run past
|
||||
/// ±180° and the angle read between two samples turns through the smaller angle between them.
|
||||
/// Calligraphic and Scatter brushes with Bearing or Rotation variation read it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AngleProfile {
|
||||
pub points: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl AngleProfile {
|
||||
/// How far (in degrees) simplifying may move the recorded curve.
|
||||
const TOLERANCE: f64 = 1.0;
|
||||
|
||||
/// The angle at `t` (0..1), linear between samples, in degrees above −180 up to 180; 0
|
||||
/// without any samples.
|
||||
pub fn at(&self, t: f64) -> f64 {
|
||||
let v = interpolated(&self.points, t).unwrap_or(0.0);
|
||||
if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A profile from (position, degrees) samples in order along the stroke: non-finite ones are
|
||||
/// dropped, each sample is joined to the one before it the shorter way round, and the curve is
|
||||
/// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
|
||||
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
|
||||
let mut prev: Option<f64> = None;
|
||||
let pts: Vec<(f64, f64)> = samples
|
||||
.into_iter()
|
||||
.filter(|(t, a)| t.is_finite() && a.is_finite())
|
||||
.map(|(t, a)| {
|
||||
let a = prev.map_or(vectorcraft_geom::normalize_deg(a), |p| p + vectorcraft_geom::normalize_deg(a - p));
|
||||
prev = Some(a);
|
||||
(t.clamp(0.0, 1.0), a)
|
||||
})
|
||||
.collect();
|
||||
simplified(pts, Self::TOLERANCE).map(|points| Self { points })
|
||||
}
|
||||
|
||||
/// The angle of a path `old_len` long (`old`; None: drawn without it, 0°) continued at its end
|
||||
/// by a stroke `added_len` long (`added`). None when neither has one. Each part keeps the turns
|
||||
/// it recorded, more than half a turn between two samples included; the added part is moved by
|
||||
/// whole turns so that it starts the shorter way round from where the old part ends.
|
||||
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
|
||||
let end = old.map_or(Some(0.0), |o| o.points.last().map(|p| p.1));
|
||||
let added = added.map(|a| {
|
||||
let shift = match (end, a.points.first()) {
|
||||
(Some(end), Some(&(_, start))) => end + vectorcraft_geom::normalize_deg(start - end) - start,
|
||||
_ => 0.0,
|
||||
};
|
||||
Self { points: a.points.iter().map(|&(t, v)| (t, v + shift)).collect() }
|
||||
});
|
||||
match extended_points(old.map(|p| p.points.as_slice()), old_len, added.as_ref().map(|p| p.points.as_slice()), added_len, 0.0)? {
|
||||
Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),
|
||||
Cow::Owned(points) => {
|
||||
let pts = points.into_iter().filter(|(t, a)| t.is_finite() && a.is_finite()).map(|(t, a)| (t.clamp(0.0, 1.0), a)).collect();
|
||||
simplified(pts, Self::TOLERANCE).map(|points| Self { points })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The angle along the path reversed (the angles stay as they were on the page).
|
||||
pub fn reversed(&self) -> Self {
|
||||
Self { points: reversed_points(&self.points) }
|
||||
}
|
||||
|
||||
/// One turn through every angle along the stroke, for previews of brushes that vary with one.
|
||||
pub fn sample() -> Self {
|
||||
Self { points: vec![(0.0, -179.0), (1.0, 179.0)] }
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any.
|
||||
fn interpolated(p: &[(f64, f64)], t: f64) -> Option<f64> {
|
||||
let (first, last) = (p.first()?, p.last()?);
|
||||
if t <= first.0 {
|
||||
return Some(first.1);
|
||||
}
|
||||
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
Some(match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
|
||||
_ => last.1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Samples (position, value) with positions in 0..1 and finite values, simplified to within `tol`
|
||||
/// of the value and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
|
||||
fn simplified(pts: Vec<(f64, f64)>, tol: f64) -> Option<Vec<(f64, f64)>> {
|
||||
if pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
|
||||
let mut pts = thin(pts, 16 * PressureProfile::MAX_POINTS);
|
||||
// Positions only ever go forward.
|
||||
let mut prev = 0.0;
|
||||
for p in &mut pts {
|
||||
p.0 = p.0.max(prev);
|
||||
prev = p.0;
|
||||
}
|
||||
let keep = simplify_1d(&pts, tol);
|
||||
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), PressureProfile::MAX_POINTS);
|
||||
let round = |v: f64| (v * 1e4).round() / 1e4;
|
||||
Some(pts.into_iter().map(|(t, p)| (round(t), round(p))).collect())
|
||||
}
|
||||
|
||||
/// The samples of a path `old_len` long (`old`) continued at its end by a stroke `added_len` long
|
||||
/// (`added`), in order along the whole path, a side without samples (`None`) flat at `none`.
|
||||
/// `None` when neither side has samples; the side that has them, as they are, when the two
|
||||
/// lengths add up to no length.
|
||||
fn extended_points<'a>(
|
||||
old: Option<&'a [(f64, f64)]>,
|
||||
old_len: f64,
|
||||
added: Option<&'a [(f64, f64)]>,
|
||||
added_len: f64,
|
||||
none: f64,
|
||||
) -> Option<Cow<'a, [(f64, f64)]>> {
|
||||
if old.is_none() && added.is_none() {
|
||||
return None;
|
||||
}
|
||||
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
|
||||
let total = old_len + added_len;
|
||||
if !total.is_finite() || total <= 1e-9 {
|
||||
return old.or(added).map(Cow::Borrowed);
|
||||
}
|
||||
let flat = [(0.0, none), (1.0, none)];
|
||||
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
|
||||
let o = old_len / total;
|
||||
let old_pts = old.iter().map(|&(t, p)| (t * o, p));
|
||||
Some(Cow::Owned(old_pts.chain(added.iter().map(|&(u, p)| (o + u * (1.0 - o), p))).collect()))
|
||||
}
|
||||
|
||||
/// Samples (position, value) of a path, for the path reversed.
|
||||
fn reversed_points(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
|
||||
points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect()
|
||||
}
|
||||
|
||||
/// At most `max` of `pts`, evenly spread (the ends kept).
|
||||
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
|
||||
let n = pts.len();
|
||||
|
||||
@@ -125,11 +125,50 @@ fn pressure_profile_extends_and_reverses() {
|
||||
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
|
||||
}
|
||||
|
||||
/// #764: a pen's bearing or barrel rotation is kept the shorter way round between samples, so
|
||||
/// 170° then −170° turns through 180°, not through 0°.
|
||||
#[test]
|
||||
fn angle_profiles_join_samples_the_shorter_way_round() {
|
||||
let across = AngleProfile::from_samples([(0.0, 170.0), (1.0, -170.0)]).unwrap();
|
||||
assert_eq!(across.points, vec![(0.0, 170.0), (1.0, 190.0)]);
|
||||
assert_eq!(across.at(0.5), 180.0);
|
||||
assert!((across.at(0.75) + 175.0).abs() < 1e-9, "{}", across.at(0.75));
|
||||
// Turns past a whole turn keep counting, and read back within −180..180°.
|
||||
let spin = AngleProfile::from_samples((0..=40).map(|i| (f64::from(i) / 40.0, f64::from(i * 30) - 180.0))).unwrap();
|
||||
assert!((spin.points.last().unwrap().1 - 1380.0).abs() < 1e-9, "{spin:?}");
|
||||
assert!((spin.at(1.0) + 60.0).abs() < 1e-9 && spin.points.len() == 2, "{spin:?}");
|
||||
// Junk is dropped; nothing at all is no profile, and no profile reads 0°.
|
||||
let junk = AngleProfile::from_samples([(f64::NAN, 5.0), (0.5, f64::INFINITY), (0.2, 400.0)]).unwrap();
|
||||
assert_eq!(junk.points, vec![(0.2, 40.0)]);
|
||||
assert!(AngleProfile::from_samples([(0.5, f64::NAN)]).is_none());
|
||||
assert_eq!(AngleProfile::default().at(0.3), 0.0);
|
||||
let noisy = AngleProfile::from_samples((0..1_000_000).map(|i| (f64::from(i) / 1e6, f64::from(i % 2) * 90.0))).unwrap();
|
||||
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
|
||||
// Continued: the old part, then the new one; a side without an angle is 0°. Reversed, the
|
||||
// angles stay what they were.
|
||||
let old = AngleProfile { points: vec![(0.0, 90.0), (1.0, 90.0)] };
|
||||
let joined = AngleProfile::extended(Some(&old), 30.0, None, 10.0).unwrap();
|
||||
assert!((joined.at(0.5) - 90.0).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
|
||||
assert!(AngleProfile::extended(None, 10.0, None, 10.0).is_none());
|
||||
assert_eq!(AngleProfile::extended(Some(&old), 0.0, None, 0.0), Some(old.clone()));
|
||||
assert_eq!(across.reversed().points, vec![(0.0, 190.0), (1.0, 170.0)]);
|
||||
// A part that turned more than half a turn between two of its samples keeps that turn when
|
||||
// the path is continued, and so does the new part.
|
||||
let quarter_turns = AngleProfile::from_samples([(0.0, 0.0), (1.0 / 3.0, 90.0), (2.0 / 3.0, 180.0), (1.0, 270.0)]).unwrap();
|
||||
assert_eq!(quarter_turns.points, vec![(0.0, 0.0), (1.0, 270.0)]);
|
||||
let added = AngleProfile { points: vec![(0.0, -80.0), (1.0, 280.0)] };
|
||||
let joined = AngleProfile::extended(Some(&quarter_turns), 10.0, Some(&added), 10.0).unwrap();
|
||||
assert!((joined.at(0.25) - 135.0).abs() < 1e-9, "{joined:?}");
|
||||
assert!((joined.at(0.75) - 100.0).abs() < 1e-9, "{joined:?}");
|
||||
}
|
||||
|
||||
/// Strokes saved before pen pressure load as they were, and are written without it.
|
||||
#[test]
|
||||
fn strokes_without_pressure_round_trip_unchanged() {
|
||||
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
|
||||
assert!(json.get("pressure").is_none());
|
||||
for key in ["pressure", "tilt", "bearing", "rotation"] {
|
||||
assert!(json.get(key).is_none(), "{key}");
|
||||
}
|
||||
let st: StrokeLayer = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(st.pressure, None);
|
||||
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
|
||||
@@ -138,6 +177,19 @@ fn strokes_without_pressure_round_trip_unchanged() {
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
|
||||
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
|
||||
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
|
||||
// The pen's tilt, bearing and rotation load, save and stay behind the same way.
|
||||
let posed = StrokeLayer {
|
||||
tilt: Some(PressureProfile::sample()),
|
||||
bearing: Some(AngleProfile::sample()),
|
||||
rotation: Some(AngleProfile { points: vec![(0.0, -45.0)] }),
|
||||
..back
|
||||
};
|
||||
let again: StrokeLayer = serde_json::from_value(serde_json::to_value(&posed).unwrap()).unwrap();
|
||||
assert_eq!(again, posed);
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
|
||||
*ap.stroke_mut().unwrap() = posed;
|
||||
let st = ap.without_pressure().stroke().cloned().unwrap();
|
||||
assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2};
|
||||
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
|
||||
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
||||
@@ -862,6 +862,52 @@ impl Spine {
|
||||
pub fn anchor_length(&self, i: usize) -> Option<f64> {
|
||||
self.anchors.get(i).copied()
|
||||
}
|
||||
/// Arc length of the point of the spine nearest `p`. An open spine's end segments run on past
|
||||
/// its ends as lines, so the result is below 0 before the start and above [`Spine::length`]
|
||||
/// beyond the end. With `from`, the arc length of an earlier point a drag followed, each point
|
||||
/// counts as farther by half its distance along the spine from there, so a pointer passing
|
||||
/// near another part of the spine stays on the part it follows. Where two points count as
|
||||
/// near as each other, the one earlier along the spine counts.
|
||||
pub fn length_near(&self, p: Point, from: Option<f64>) -> f64 {
|
||||
self.nearest(p, from).map_or(0.0, |(_, s)| s)
|
||||
}
|
||||
/// [`Spine::length_near`] with how near the point counts: its distance from `p`, plus half its
|
||||
/// distance along the spine from `from`. `None` for a point that is not finite.
|
||||
pub fn nearest(&self, p: Point, from: Option<f64>) -> Option<(f64, f64)> {
|
||||
if !p.x.is_finite() || !p.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let last = self.pts.len().saturating_sub(2);
|
||||
let mut best: Option<(f64, f64)> = None;
|
||||
for (i, (w, c)) in self.pts.windows(2).zip(&self.cum).enumerate() {
|
||||
let [a, b] = [w[0], w[1]];
|
||||
let d = b - a;
|
||||
let l2 = d.dot(d);
|
||||
if !l2.is_finite() || l2 <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let t = (p - a).dot(d) / l2;
|
||||
let t = match (self.closed, i == 0, i == last) {
|
||||
(false, true, true) => t,
|
||||
(false, true, false) => t.min(1.0),
|
||||
(false, false, true) => t.max(0.0),
|
||||
_ => t.clamp(0.0, 1.0),
|
||||
};
|
||||
let s = c + t * l2.sqrt();
|
||||
let dist = p.distance(a + d * t) + from.filter(|f| f.is_finite()).map_or(0.0, |f| self.apart(s, f) / 2.0);
|
||||
if dist.is_finite() && best.is_none_or(|(b, _)| dist < b) {
|
||||
best = Some((dist, s));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
/// How far apart arc lengths `s` and `f` are along the spine: the shorter way round a closed
|
||||
/// spine, across its first anchor.
|
||||
pub fn apart(&self, s: f64, f: f64) -> f64 {
|
||||
let len = self.length();
|
||||
let d = (s - f).abs();
|
||||
if self.closed && len > 0.0 { d.rem_euclid(len).min(len - d.rem_euclid(len)) } else { d }
|
||||
}
|
||||
/// Point and tangent angle (radians) at arc-length fraction `f` (0..1).
|
||||
pub fn at(&self, f: f64) -> (Point, f64) {
|
||||
self.at_length(f.clamp(0.0, 1.0) * self.length())
|
||||
@@ -1029,6 +1075,27 @@ impl Rail {
|
||||
}
|
||||
}
|
||||
|
||||
/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps
|
||||
/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest
|
||||
/// any key paints from its centre. A step's shape lies within that distance of the point on the
|
||||
/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing
|
||||
/// moves.
|
||||
pub(crate) fn rail_reach(keys: &[Arc<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> {
|
||||
if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath {
|
||||
return None;
|
||||
}
|
||||
let spine = blend_spine(keys, spec)?.0.bounds()?;
|
||||
let radius = keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
let (c, b) = (center_of(k), k.visual(symbols)?);
|
||||
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)];
|
||||
Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max))
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
Some(spine.inflate(3.0 * radius, 3.0 * radius))
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Blend evaluation
|
||||
// =====================================================================================
|
||||
|
||||
@@ -107,15 +107,21 @@ fn nested_deeper(now: &[Arc<Node>], depth: usize) -> bool {
|
||||
/// an opacity mask too deep are left out); what it held stays hidden or locked as it was. Returns
|
||||
/// whether anything changed. Recurses no deeper than the cap.
|
||||
fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
|
||||
let mut changed = false;
|
||||
let mut i = 0;
|
||||
while let Some(n) = nodes.get_mut(i) {
|
||||
if !nested_deeper(std::slice::from_ref(n), depth) {
|
||||
i += 1;
|
||||
if !nested_deeper(nodes, depth) {
|
||||
return false;
|
||||
}
|
||||
// The list is rebuilt in order rather than spliced in place: replacing one container of many
|
||||
// by what it holds would move all the siblings after it each time.
|
||||
let mut out = Vec::with_capacity(nodes.len());
|
||||
// What is left to look at, the next one last.
|
||||
let mut left: Vec<Arc<Node>> = std::mem::take(nodes);
|
||||
left.reverse();
|
||||
while let Some(mut a) = left.pop() {
|
||||
if !nested_deeper(std::slice::from_ref(&a), depth) {
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let n = Arc::make_mut(n);
|
||||
let n = Arc::make_mut(&mut a);
|
||||
if n.extras().mask.as_ref().is_some_and(|m| nested_deeper(std::slice::from_ref(&m.art), depth + 1)) {
|
||||
n.extras_mut().mask = None;
|
||||
}
|
||||
@@ -123,7 +129,7 @@ fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
|
||||
if let Some(ch) = n.children_mut() {
|
||||
limit(ch, depth + 1);
|
||||
}
|
||||
i += 1;
|
||||
out.push(a);
|
||||
continue;
|
||||
}
|
||||
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
|
||||
@@ -140,9 +146,10 @@ fn limit(nodes: &mut Vec<Arc<Node>>, depth: usize) -> bool {
|
||||
}
|
||||
}
|
||||
// What it held takes its place, and is looked at in its turn.
|
||||
nodes.splice(i..=i, held);
|
||||
left.extend(held.into_iter().rev());
|
||||
}
|
||||
changed
|
||||
*nodes = out;
|
||||
true
|
||||
}
|
||||
|
||||
/// A symbol (`false`) or a pattern (`true`) of the document, by name: what art draws by name, an
|
||||
@@ -439,6 +446,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_containers_at_the_cap_are_merged_in_order() {
|
||||
// A group just above the cap holding many groups of two paths each: every group is
|
||||
// replaced by its two paths, in order (in time linear in their number).
|
||||
const PAIRS: u64 = 50_000;
|
||||
let path = |id| Arc::new(Node::path(NodeId(id), Default::default(), Default::default()));
|
||||
let pairs = (0..PAIRS).map(|i| Arc::new(Node::group(NodeId(1_000_000 + i), vec![path(2 * i + 1), path(2 * i + 2)]))).collect();
|
||||
let mut art = Arc::new(Node::group(NodeId(500_000), pairs));
|
||||
for i in 0..MAX_DEPTH - 3 {
|
||||
art = Arc::new(Node::group(NodeId(600_000 + i as u64), vec![art]));
|
||||
}
|
||||
let mut d = doc(art);
|
||||
assert!(d.limit_depth());
|
||||
assert!(!d.too_deep(None));
|
||||
let holder = d.node(NodeId(500_000)).and_then(Node::children).map(|c| c.iter().map(|c| c.id.0).collect::<Vec<_>>());
|
||||
assert_eq!(holder, Some((1..=2 * PAIRS).collect()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_path_and_a_mask_too_deep_are_left_out_and_hidden_art_stays_hidden() {
|
||||
let mut inner = Node::new(NodeId(9000), NodeKind::Group { children: vec![nested(0), nested(0)], clip: true });
|
||||
@@ -485,7 +510,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 +529,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 +598,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"));
|
||||
|
||||
@@ -47,6 +47,7 @@ impl Document {
|
||||
patterns,
|
||||
pattern_edit,
|
||||
mask_edit,
|
||||
symbol_edit,
|
||||
next_id: _,
|
||||
unknown,
|
||||
page_isolate,
|
||||
@@ -113,6 +114,7 @@ impl Document {
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
}
|
||||
&& *mask_edit == other.mask_edit
|
||||
&& *symbol_edit == other.symbol_edit
|
||||
&& *unknown == other.unknown
|
||||
&& *page_isolate == other.page_isolate
|
||||
&& *page_knockout == other.page_knockout
|
||||
@@ -221,6 +223,7 @@ impl Document {
|
||||
patterns: vec![],
|
||||
pattern_edit: None,
|
||||
mask_edit: None,
|
||||
symbol_edit: None,
|
||||
next_id: 1,
|
||||
unknown: Default::default(),
|
||||
page_isolate: false,
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::graphic_style::GraphicStyle;
|
||||
use super::limits::MAX_NEXT_ID;
|
||||
use super::records::{ImageBlob, SavedSelection, SavedView};
|
||||
use super::{
|
||||
Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice,
|
||||
SymbolRegistration,
|
||||
Arc, Document, EnvelopeKind, ImageObject, LiveShape, Node, NodeKind, Point, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION,
|
||||
SymbolExportType, SymbolNineSlice, SymbolRegistration, placed_document,
|
||||
};
|
||||
|
||||
impl Document {
|
||||
@@ -98,24 +98,134 @@ impl Document {
|
||||
self.views = kept;
|
||||
}
|
||||
|
||||
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
|
||||
/// mask already holds (the engine syncs it after every edit).
|
||||
/// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working
|
||||
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit).
|
||||
pub fn drop_edit_modes(&mut self) {
|
||||
if let Some(me) = self.mask_edit.take() {
|
||||
self.layers.retain(|l| l.id != me.layer);
|
||||
}
|
||||
if let Some(se) = self.symbol_edit.take() {
|
||||
self.layers.retain(|l| l.id != se.layer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
|
||||
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
|
||||
/// The document as saved and exported: without the opacity-mask or symbol editing layer.
|
||||
/// Borrowed when neither mode is on. (Pattern editing is saved: its tile layer holds unapplied
|
||||
/// edits.)
|
||||
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
|
||||
if self.mask_edit.is_none() {
|
||||
if self.mask_edit.is_none() && self.symbol_edit.is_none() {
|
||||
return std::borrow::Cow::Borrowed(self);
|
||||
}
|
||||
let mut d = self.clone();
|
||||
d.drop_edit_modes();
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Round every path's anchors and handles to `decimals` decimal places: path objects (with
|
||||
/// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine
|
||||
/// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being
|
||||
/// edited included), opacity masks and the brushes kept as foreign data. Colors, transforms,
|
||||
/// opacity, live shapes' own sizes, meshes and type (its frames and paths included) are left
|
||||
/// as they are.
|
||||
pub fn round_geometry(&mut self, decimals: u8) {
|
||||
let scale = 10f64.powi(i32::from(decimals));
|
||||
let round = |v: f64| {
|
||||
let r = (v * scale).round() / scale;
|
||||
if !r.is_finite() {
|
||||
v
|
||||
} else if r == 0.0 {
|
||||
// `-0.0` is written as `0.0`.
|
||||
0.0
|
||||
} else {
|
||||
r
|
||||
}
|
||||
};
|
||||
let round_path = |path: &mut vectorcraft_geom::PathData| {
|
||||
for a in path.subpaths.iter_mut().flat_map(|s| s.anchors.iter_mut()) {
|
||||
for p in [&mut a.p, &mut a.h_in, &mut a.h_out] {
|
||||
*p = Point::new(round(p.x), round(p.y));
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut fix = |n: &mut Node| {
|
||||
match &mut n.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
round_path(path);
|
||||
// Live Corners' uncut outline.
|
||||
if let Some(LiveShape::Path { base, .. }) = live.as_deref_mut() {
|
||||
round_path(base);
|
||||
}
|
||||
}
|
||||
NodeKind::Blend { spec, .. } => {
|
||||
if let Some(spine) = &mut spec.spine {
|
||||
round_path(spine);
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { kind, .. } => {
|
||||
if let EnvelopeKind::TopObject { path } = kind.as_mut() {
|
||||
round_path(path);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(e) = n.extras_if_any() {
|
||||
for area in e.intertwine.as_deref_mut().into_iter().chain(e.intertwine_tiers.iter_mut()) {
|
||||
round_path(area);
|
||||
}
|
||||
}
|
||||
};
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
placed_document::each_mut(Arc::make_mut(root), &mut fix);
|
||||
}
|
||||
if self.unknown.contains_key("brushes")
|
||||
&& let Some(brushes) = self.unknown.get_mut("brushes")
|
||||
{
|
||||
round_json_paths(brushes, &round);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every
|
||||
/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as
|
||||
/// [`Document::round_geometry`] rounds the nodes it holds.
|
||||
fn round_json_paths(v: &mut serde_json::Value, round: &impl Fn(f64) -> f64) {
|
||||
use serde_json::Value;
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.get("type").and_then(Value::as_str) == Some("text") {
|
||||
return;
|
||||
}
|
||||
if let Some(subpaths) = o.get_mut("subpaths").and_then(Value::as_array_mut) {
|
||||
let anchors = subpaths.iter_mut().filter_map(|s| s.get_mut("anchors").and_then(Value::as_array_mut)).flatten();
|
||||
for a in anchors.filter_map(Value::as_object_mut) {
|
||||
let points = a.iter_mut().filter(|(k, _)| matches!(k.as_str(), "p" | "in" | "out")).filter_map(|(_, v)| v.as_array_mut());
|
||||
for xy in points {
|
||||
for n in xy.iter_mut().filter(|n| n.is_f64()) {
|
||||
if let Some(r) = n.as_f64().and_then(|x| serde_json::Number::from_f64(round(x))) {
|
||||
*n = Value::Number(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in o.values_mut() {
|
||||
round_json_paths(c, round);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for c in a {
|
||||
round_json_paths(c, round);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphic styles.
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Hit testing over the document tree.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||||
@@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>
|
||||
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
|
||||
}
|
||||
|
||||
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
|
||||
/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated
|
||||
/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the
|
||||
/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else.
|
||||
pub type LiveArt = fn(Option<&Document>, &Node) -> Vec<Node>;
|
||||
|
||||
/// A number that changes whenever something outside the document that [`LiveArt`] reads changes,
|
||||
/// such as the installed fonts or the plug-ins.
|
||||
pub type LiveInputs = fn() -> u64;
|
||||
|
||||
static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new();
|
||||
|
||||
/// Register how a live object's art is evaluated for clicks, and the number that changes with what
|
||||
/// else it reads (the first registration stays).
|
||||
pub fn set_live_art(f: LiveArt, inputs: LiveInputs) {
|
||||
// Already set: the same engine, registered by another session.
|
||||
let _ = LIVE_ART.set((f, inputs));
|
||||
}
|
||||
|
||||
/// The registered [`LiveInputs`] now, 0 while nothing is registered.
|
||||
fn live_inputs() -> u64 {
|
||||
LIVE_ART.get().map_or(0, |(_, inputs)| inputs())
|
||||
}
|
||||
|
||||
/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by
|
||||
/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`].
|
||||
struct EvaluatedLive {
|
||||
node: Node,
|
||||
symbols: Vec<(String, Arc<Node>)>,
|
||||
patterns: Vec<crate::PatternDef>,
|
||||
inputs: u64,
|
||||
art: Rc<Vec<Node>>,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
impl EvaluatedLive {
|
||||
/// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now?
|
||||
fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool {
|
||||
self.inputs == inputs
|
||||
&& self.symbols.len() == doc.symbols.len()
|
||||
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art))
|
||||
&& self.node == *n
|
||||
&& self.patterns == doc.patterns
|
||||
}
|
||||
}
|
||||
|
||||
/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an
|
||||
/// envelope's stroke band hits it again and again, and an envelope can hold a blend.
|
||||
const LIVE_KEPT: usize = 4;
|
||||
|
||||
/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than
|
||||
/// this is not kept.
|
||||
const LIVE_KEPT_SIZE: usize = 250_000;
|
||||
|
||||
/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths.
|
||||
fn art_size(art: &[Node]) -> usize {
|
||||
let mut size = 0usize;
|
||||
for n in art {
|
||||
n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count())));
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes
|
||||
/// when the file is read, so [`live_art`] does not keep an evaluation of `n`.
|
||||
fn holds_placed(doc: &Document, n: &Node) -> bool {
|
||||
let mut placed = false;
|
||||
let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
|
||||
n.walk(&mut find);
|
||||
doc.symbols.iter().for_each(|s| s.art.walk(&mut find));
|
||||
doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find));
|
||||
placed
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The evaluations [`live_art`] keeps, the latest first.
|
||||
static EVALUATED: RefCell<Vec<EvaluatedLive>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while
|
||||
/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of
|
||||
/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's
|
||||
/// symbols or patterns hold a placed document ([`holds_placed`]).
|
||||
fn live_art(doc: &Document, n: &Node) -> Rc<Vec<Node>> {
|
||||
let inputs = live_inputs();
|
||||
let kept = EVALUATED.with_borrow_mut(|kept| {
|
||||
let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?;
|
||||
let e = kept.remove(i);
|
||||
let art = e.art.clone();
|
||||
kept.insert(0, e);
|
||||
Some(art)
|
||||
});
|
||||
if let Some(art) = kept {
|
||||
return art;
|
||||
}
|
||||
#[cfg(test)]
|
||||
LIVE_EVALUATED.with(|c| c.set(c.get() + 1));
|
||||
let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n)));
|
||||
let size = art_size(&art);
|
||||
if size > LIVE_KEPT_SIZE || holds_placed(doc, n) {
|
||||
return art;
|
||||
}
|
||||
let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect();
|
||||
let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size };
|
||||
EVALUATED.with_borrow_mut(|kept| {
|
||||
kept.insert(0, e);
|
||||
kept.truncate(LIVE_KEPT);
|
||||
let mut total = 0usize;
|
||||
kept.retain(|e| {
|
||||
total = total.saturating_add(e.size);
|
||||
total <= LIVE_KEPT_SIZE
|
||||
});
|
||||
});
|
||||
art
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many live objects [`live_art`] evaluated on this thread.
|
||||
static LIVE_EVALUATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Topmost editable object under `p`.
|
||||
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
|
||||
hit_test_skipping(doc, p, opt, &|_| false, true)
|
||||
@@ -110,6 +234,12 @@ 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
|
||||
@@ -447,7 +577,41 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
|
||||
// (An envelope's content sits where it was, not where the envelope draws it.)
|
||||
edits_contents(c)
|
||||
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|
||||
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
|| cull_reach(c, symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
}
|
||||
|
||||
/// A box round everything `c` can paint, for culling clicks: [`Node::reach_bounds_with`], except
|
||||
/// that paths and compound paths are measured by their control points ([`PathData::control_bounds`],
|
||||
/// which hold the curves) instead of along their curves, which a compound path of thousands of
|
||||
/// members takes milliseconds to do on every click and hover. The box only rules points out, and it
|
||||
/// is at least as large, so the same objects are hit.
|
||||
///
|
||||
/// [`PathData::control_bounds`]: vectorcraft_geom::PathData::control_bounds
|
||||
fn cull_reach(c: &Node, symbols: &SymbolBoxes) -> Option<Rect> {
|
||||
let control = match &c.kind {
|
||||
NodeKind::Path { path, .. } => path.control_bounds(),
|
||||
NodeKind::Compound { children, .. } if children.iter().all(|m| m.path_data().is_some()) => {
|
||||
children.iter().filter_map(|m| m.path_data().and_then(vectorcraft_geom::PathData::control_bounds)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
_ => return c.reach_bounds_with(symbols),
|
||||
};
|
||||
let o = c.appearance.outset();
|
||||
control.map(|b| b.inflate(o, o))
|
||||
}
|
||||
|
||||
/// The members of compound path `children` that a click at `p` within `tol` can touch, as one
|
||||
/// path: those whose control points' box, grown by `tol`, holds `p` (edges included). A member
|
||||
/// outside that box is farther than `tol` from `p` and winds zero times round it, so this path's
|
||||
/// outline and fill answer a click as the whole compound path's do, without assembling it.
|
||||
fn compound_members_near(children: &[std::sync::Arc<Node>], p: Point, tol: f64) -> vectorcraft_geom::BezPath {
|
||||
let near = |b: Rect| b.x0 - tol <= p.x && p.x <= b.x1 + tol && b.y0 - tol <= p.y && p.y <= b.y1 + tol;
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
for pd in children.iter().filter_map(|m| m.path_data()) {
|
||||
if pd.control_bounds().is_some_and(near) {
|
||||
bp.extend(pd.to_bezpath());
|
||||
}
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
/// Is the first child of `n` the isolated container `scope`?
|
||||
@@ -538,20 +702,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
|
||||
@@ -564,7 +723,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
|
||||
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many objects [`hit_leaf`] tested on this thread.
|
||||
static LEAVES_TESTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||||
#[cfg(test)]
|
||||
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
@@ -577,12 +744,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
}
|
||||
None
|
||||
}
|
||||
NodeKind::Compound { rule, .. } => {
|
||||
let bp = n.stroke_path()?;
|
||||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||||
return Some(k);
|
||||
NodeKind::Compound { children, rule } => {
|
||||
// Painted strokes follow the whole compound path (alignment, dashes, width profiles):
|
||||
// they are measured along all of it, as before.
|
||||
if !opt.outline && n.appearance.painted_strokes().next().is_some() {
|
||||
let bp = n.stroke_path()?;
|
||||
if n.appearance.stroke_hit(&bp, *rule, p, opt.tol) {
|
||||
return Some(HitKind::Stroke);
|
||||
}
|
||||
}
|
||||
(!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
|
||||
// The outline and the fill only need the members near `p`. (Measured without
|
||||
// `stroke_contains`'s box check: the box of the members near `p` is smaller than the
|
||||
// whole path's, and its far edges would turn away points exactly `tol` away.)
|
||||
let near = compound_members_near(children, p, opt.tol);
|
||||
if vectorcraft_geom::hit::distance_to_outline(&near, p) <= opt.tol {
|
||||
return Some(HitKind::Outline);
|
||||
}
|
||||
(!opt.outline && !opt.path_only && fill_contains(&near, *rule, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
// The type path lies within the bounds: they rule out the rest before it is measured.
|
||||
NodeKind::Text(t) if opt.type_path_only => {
|
||||
@@ -611,9 +789,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
|
||||
})
|
||||
}
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => {
|
||||
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
|
||||
}
|
||||
// A blend or an envelope is hit within its bounds, and outside them where its evaluated
|
||||
// art paints: a blend's keys and steps on a spine can reach past the keys and the spine,
|
||||
// and the strokes of an envelope's distorted contents past its bounds.
|
||||
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
|
||||
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return Some(HitKind::Bounds);
|
||||
}
|
||||
live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols))
|
||||
}
|
||||
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
|
||||
NodeKind::Mesh(m) => {
|
||||
let bp = m.outline().to_bezpath();
|
||||
@@ -650,8 +837,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
|
||||
|
||||
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
|
||||
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
|
||||
// Hidden art does not hit.
|
||||
if !n.visible {
|
||||
return None;
|
||||
}
|
||||
// Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are
|
||||
// passed by without being measured.
|
||||
if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return None;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
|
||||
// A clip group only hits inside its clipping path.
|
||||
NodeKind::Group { children, clip } => {
|
||||
if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) {
|
||||
return None;
|
||||
}
|
||||
children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols))
|
||||
}
|
||||
// An intertwine group hits by its members, as on the canvas.
|
||||
NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
|
||||
_ => hit_leaf(n, p, opt, symbols),
|
||||
}
|
||||
}
|
||||
@@ -676,6 +880,16 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
|
||||
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
|
||||
/// only within that region's bounds.
|
||||
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
|
||||
marquee_with(doc, r, scope, leaves, false)
|
||||
}
|
||||
|
||||
/// Candidates for whole-object enclosure by a loop bounded by `r`. Eligibility and group
|
||||
/// traversal follow [`marquee`]; compounds contribute the complete path they paint.
|
||||
pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
marquee_with(doc, r, scope, true, true)
|
||||
}
|
||||
|
||||
fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, whole_compounds: bool) -> Vec<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
|
||||
let tops: Vec<(&std::sync::Arc<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
|
||||
@@ -720,13 +934,19 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
/// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched
|
||||
/// by the copies it draws, as clicks hit them. Objects of every other kind except paths,
|
||||
/// compound paths, layers and groups are touched by their box.
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>) -> bool {
|
||||
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, whole_compounds: bool) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
|
||||
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
|
||||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
|
||||
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
|
||||
marquee_in(n, r).is_some_and(|r| {
|
||||
children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope, whole_compounds))
|
||||
})
|
||||
}
|
||||
NodeKind::Compound { children, .. } if whole_compounds => {
|
||||
children.iter().filter_map(|c| c.path_data()).any(|path| vectorcraft_geom::hit::intersects_rect(path, r))
|
||||
}
|
||||
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds)),
|
||||
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds)),
|
||||
// A box with no width or no height (an instance of a symbol that draws a straight
|
||||
// line) is touched where the marquee crosses it.
|
||||
_ => n.geometric_bounds_with(s).is_some_and(|b| {
|
||||
@@ -735,18 +955,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
|
||||
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, whole_compounds: bool, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => {
|
||||
let Some(r) = marquee_in(n, r) else { return };
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
if c.visible && !c.locked && !shaper_hides(n, i, scope) {
|
||||
collect_leaves(c, s, r, scope, out);
|
||||
collect_leaves(c, s, r, scope, whole_compounds, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if touches(n, s, r, scope) {
|
||||
if touches(n, s, r, scope, whole_compounds) {
|
||||
out.push(n.id);
|
||||
}
|
||||
}
|
||||
@@ -759,8 +979,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
|
||||
continue;
|
||||
}
|
||||
if leaves {
|
||||
collect_leaves(t, &symbols, r, scope, &mut out);
|
||||
} else if touches(t, &symbols, r, scope) {
|
||||
collect_leaves(t, &symbols, r, scope, whole_compounds, &mut out);
|
||||
} else if touches(t, &symbols, r, scope, whole_compounds) {
|
||||
out.push(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps
|
||||
/// whose own reach holds the point, not every step of the evaluated blend.
|
||||
#[test]
|
||||
fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let l = d.layers[0].id;
|
||||
// 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart.
|
||||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||||
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||||
let count = |p: Point| {
|
||||
LEAVES_TESTED.with(|c| c.set(0));
|
||||
let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf);
|
||||
(h, LEAVES_TESTED.with(std::cell::Cell::get))
|
||||
};
|
||||
// On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it.
|
||||
let (h, tested) = count(Point::new(450.0, 291.0));
|
||||
assert_eq!(h, Some(blend));
|
||||
assert!(tested < 20, "{tested} objects tested");
|
||||
// In the corner of the reach, outside every stroke: nothing hits.
|
||||
let (h, tested) = count(Point::new(6.0, 291.0));
|
||||
assert_eq!(h, None);
|
||||
assert!(tested < 20, "{tested} objects tested");
|
||||
}
|
||||
|
||||
/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its
|
||||
/// members.
|
||||
#[test]
|
||||
fn evaluated_art_hits_an_intertwine_group_by_its_members() {
|
||||
let d = Document::new(200.0, 200.0);
|
||||
let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()));
|
||||
let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] });
|
||||
let symbols = d.symbol_boxes();
|
||||
assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill));
|
||||
assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None);
|
||||
}
|
||||
|
||||
/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend
|
||||
/// or a symbol of the document changes.
|
||||
#[test]
|
||||
fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let l = d.layers[0].id;
|
||||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||||
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||||
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
|
||||
let before = evaluated();
|
||||
for x in [448.0, 450.0, 452.0] {
|
||||
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||||
}
|
||||
assert_eq!(evaluated(), before + 1);
|
||||
// 100 steps 8.7 pt apart: a step still reaches (450, 291).
|
||||
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) {
|
||||
spec.spacing = crate::live::BlendSpacing::Steps(100);
|
||||
}
|
||||
assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||||
assert_eq!(evaluated(), before + 2);
|
||||
let art = circle(d.alloc_id(), 0.0);
|
||||
d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art });
|
||||
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
|
||||
assert_eq!(evaluated(), before + 3);
|
||||
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
|
||||
assert_eq!(evaluated(), before + 3);
|
||||
}
|
||||
|
||||
/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band
|
||||
/// evaluates it again.
|
||||
#[test]
|
||||
fn evaluated_art_past_the_kept_size_is_not_kept() {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let l = d.layers[0].id;
|
||||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||||
// A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps.
|
||||
let circle = |id, x: f64| {
|
||||
let points: Vec<Point> = (0..2000)
|
||||
.map(|i| {
|
||||
let a = f64::from(i) * std::f64::consts::TAU / 2000.0;
|
||||
Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
Arc::new(Node::path(id, path, stroked.clone()))
|
||||
};
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||||
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
|
||||
let before = evaluated();
|
||||
for x in [448.0, 450.0] {
|
||||
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||||
}
|
||||
assert_eq!(evaluated(), before + 2);
|
||||
}
|
||||
|
||||
/// While the document's symbols hold a placed document, whose art changes when its file is read,
|
||||
/// each click in a blend's stroke band evaluates the blend again.
|
||||
#[test]
|
||||
fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
let l = d.layers[0].id;
|
||||
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
|
||||
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
|
||||
let blend = d.alloc_id();
|
||||
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
|
||||
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
|
||||
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
|
||||
let placed = crate::PlacedDocument {
|
||||
link: crate::LinkInfo::new("/art/logo.vectorcraft"),
|
||||
key: "logo".into(),
|
||||
bounding: false,
|
||||
page_box: crate::PageBox::Crop,
|
||||
width: 80.0,
|
||||
height: 40.0,
|
||||
xf: vectorcraft_geom::Affine::IDENTITY,
|
||||
placement: Default::default(),
|
||||
};
|
||||
let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))));
|
||||
d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art });
|
||||
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
|
||||
let before = evaluated();
|
||||
for x in [448.0, 450.0] {
|
||||
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
|
||||
}
|
||||
assert_eq!(evaluated(), before + 2);
|
||||
}
|
||||
|
||||
/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins
|
||||
/// ([`LiveInputs`]) are as they were.
|
||||
#[test]
|
||||
fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
|
||||
let d = Document::new(100.0, 100.0);
|
||||
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
|
||||
let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 };
|
||||
assert!(e.is_of(&d, &n, 7));
|
||||
assert!(!e.is_of(&d, &n, 8));
|
||||
}
|
||||
|
||||
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
|
||||
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
|
||||
/// clipping path and the art.
|
||||
@@ -841,7 +986,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();
|
||||
@@ -975,3 +1120,223 @@ fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
/// Deterministic xorshift in 0..1.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 % 1_000_000) as f64 / 1_000_000.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed blob of `n` smooth anchors round `c` (radius about `r`), turning either way, with
|
||||
/// handles long enough that its control points reach past its curves.
|
||||
fn blob(rng: &mut Rng, c: Point, r: f64, n: usize, clockwise: bool) -> vectorcraft_geom::SubPath {
|
||||
use vectorcraft_geom::{Anchor, Vec2};
|
||||
let anchors = (0..n)
|
||||
.map(|i| {
|
||||
let k = if clockwise { i as f64 } else { (n - i) as f64 };
|
||||
let a = k / n as f64 * std::f64::consts::TAU;
|
||||
let rr = r * (0.6 + 0.8 * rng.next());
|
||||
let p = c + Vec2::new(a.cos(), a.sin()) * rr;
|
||||
let t = Vec2::new(-a.sin(), a.cos()) * (if clockwise { 1.0 } else { -1.0 }) * rr * (0.2 + 1.3 * rng.next());
|
||||
Anchor::smooth(p, p + t)
|
||||
})
|
||||
.collect();
|
||||
vectorcraft_geom::SubPath::new(anchors, true)
|
||||
}
|
||||
|
||||
/// A document with one compound path of `members` blobs (some of them holes), its id and the
|
||||
/// members' paths.
|
||||
fn compound_doc(members: usize, rule: vectorcraft_geom::FillRule, appearance: Appearance) -> (Document, NodeId, Vec<vectorcraft_geom::PathData>) {
|
||||
let mut d = Document::new(800.0, 800.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
|
||||
let mut paths = vec![];
|
||||
let mut children = vec![];
|
||||
for i in 0..members {
|
||||
let c = Point::new(20.0 + rng.next() * 600.0, 20.0 + rng.next() * 600.0);
|
||||
let r = 3.0 + rng.next() * 9.0;
|
||||
let pd = vectorcraft_geom::PathData::single(blob(&mut rng, c, r, 3 + i % 4, i % 3 != 0));
|
||||
children.push(Arc::new(Node::path(d.alloc_id(), pd.clone(), Appearance::default())));
|
||||
paths.push(pd);
|
||||
}
|
||||
let id = d.alloc_id();
|
||||
let mut compound = Node::new(id, NodeKind::Compound { children, rule });
|
||||
compound.appearance = appearance.into();
|
||||
d.insert(Some(l), 0, compound).unwrap();
|
||||
(d, id, paths)
|
||||
}
|
||||
|
||||
/// What a click on compound path `n` gave before compound paths were culled by their control
|
||||
/// points and their members near the click: the whole path assembled (`full`), culled by its
|
||||
/// reach measured along the curves.
|
||||
fn compound_hit_before(n: &Node, full: &vectorcraft_geom::BezPath, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
let NodeKind::Compound { rule, .. } = &n.kind else { return None };
|
||||
if !n.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
|
||||
return None;
|
||||
}
|
||||
if !opt.outline && n.appearance.stroke_hit(full, *rule, p, opt.tol) {
|
||||
return Some(HitKind::Stroke);
|
||||
}
|
||||
if stroke_contains(full, 0.0, opt.tol, p) {
|
||||
return Some(HitKind::Outline);
|
||||
}
|
||||
(!opt.outline && !opt.path_only && fill_contains(full, *rule, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
|
||||
/// The points a test clicks for `paths`: random ones over them and past them, and, for some
|
||||
/// members, their anchors, points just over the tolerance from them, and the corners and edge
|
||||
/// midpoints of their control and curve boxes, on them and grown by the tolerance.
|
||||
fn probe_points(paths: &[vectorcraft_geom::PathData], tol: f64, random: usize, every: usize) -> Vec<Point> {
|
||||
let all = paths.iter().filter_map(vectorcraft_geom::PathData::control_bounds).reduce(|a, b| a.union(b)).unwrap().inflate(30.0, 30.0);
|
||||
let mut rng = Rng(7);
|
||||
let mut pts: Vec<Point> = (0..random).map(|_| Point::new(all.x0 + rng.next() * all.width(), all.y0 + rng.next() * all.height())).collect();
|
||||
for pd in paths.iter().step_by(every) {
|
||||
for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) {
|
||||
pts.extend([a.p, Point::new(a.p.x + tol, a.p.y), Point::new(a.p.x, a.p.y - tol), Point::new(a.p.x + tol * 1.0001, a.p.y)]);
|
||||
}
|
||||
for b in [pd.control_bounds().unwrap(), pd.bounds().unwrap()] {
|
||||
for b in [b, b.inflate(tol, tol)] {
|
||||
let (cx, cy) = (b.center().x, b.center().y);
|
||||
pts.extend(
|
||||
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1), (cx, b.y0), (cx, b.y1), (b.x0, cy), (b.x1, cy)]
|
||||
.map(|(x, y)| Point::new(x, y)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// A compound path of thousands of members hits exactly as it did when it was assembled
|
||||
/// whole for every click: at random points, on and next to anchors, and on the edges of the
|
||||
/// members' boxes (control points' and curves', with and without the tolerance), for both fill
|
||||
/// rules, in outline mode and with Object Selection by Path Only.
|
||||
#[test]
|
||||
fn culled_compound_hits_as_the_whole_path() {
|
||||
use vectorcraft_geom::FillRule;
|
||||
for (rule, opt) in [
|
||||
(FillRule::NonZero, HitOptions::default()),
|
||||
(FillRule::EvenOdd, HitOptions { tol: 0.5, ..Default::default() }),
|
||||
(FillRule::NonZero, HitOptions { outline: true, tol: 1.0, ..Default::default() }),
|
||||
(FillRule::EvenOdd, HitOptions { path_only: true, ..Default::default() }),
|
||||
] {
|
||||
let (d, id, paths) = compound_doc(2000, rule, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let n = d.node(id).unwrap();
|
||||
let full = n.stroke_path().unwrap();
|
||||
// The whole path's box grown by the tolerance. Before, a point on its far edges (right
|
||||
// and bottom: `Rect::contains` leaves them out) never hit, even exactly `tol` from the
|
||||
// outline; now it hits there as on the near edges. Those points are clicked too, and
|
||||
// checked against the distance alone.
|
||||
let far = vectorcraft_geom::Shape::bounding_box(&full).inflate(opt.tol, opt.tol);
|
||||
let mut pts = probe_points(&paths, opt.tol, 200, 120);
|
||||
let rightmost = paths.iter().filter_map(vectorcraft_geom::PathData::bounds).max_by(|a, b| a.x1.total_cmp(&b.x1)).unwrap();
|
||||
pts.push(Point::new(far.x1, rightmost.center().y));
|
||||
let (mut hits, mut kinds) = (0, HashSet::new());
|
||||
for p in pts {
|
||||
let before = compound_hit_before(n, &full, p, opt);
|
||||
let now = hit_test(&d, p, opt);
|
||||
if p.x == far.x1 || p.y == far.y1 {
|
||||
let outline = vectorcraft_geom::hit::distance_to_outline(&full, p) <= opt.tol;
|
||||
assert_eq!(now.is_some(), outline || before.is_some(), "{rule:?} {opt:?} on the far edge at {p:?}");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(now.as_ref().map(|h| (h.leaf, h.kind)), before.map(|k| (id, k)), "{rule:?} {opt:?} at {p:?}");
|
||||
hits += usize::from(before.is_some());
|
||||
kinds.extend(before.map(|k| format!("{k:?}")));
|
||||
}
|
||||
// The points reach both hits and misses, and the outline as well as the fill.
|
||||
assert!(hits > 100, "{rule:?} {opt:?}: {hits} hits");
|
||||
assert!(kinds.contains("Outline"), "{rule:?} {opt:?}: {kinds:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A wide stroke paints far past a path's control points: clicks on it there still hit, for a
|
||||
/// path and for a compound path (whose strokes are measured along all of it), exactly as when
|
||||
/// they were culled by their reach measured along the curves.
|
||||
#[test]
|
||||
fn wide_strokes_hit_past_the_control_points() {
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 40.0);
|
||||
// A zigzag 10 pt high whose 40 pt stroke reaches 20 pt (more at its mitred turns) past it.
|
||||
let zig = PathData::single(SubPath::new(
|
||||
[(100.0, 100.0), (110.0, 110.0), (120.0, 100.0), (130.0, 110.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(),
|
||||
false,
|
||||
));
|
||||
let path = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::path(path, zig.clone(), stroke.clone())).unwrap();
|
||||
// A compound path of two small rings with the same stroke, further along.
|
||||
let mut rng = Rng(3);
|
||||
let members: Vec<Arc<Node>> = [Point::new(250.0, 250.0), Point::new(262.0, 250.0)]
|
||||
.into_iter()
|
||||
.map(|c| Arc::new(Node::path(d.alloc_id(), PathData::single(blob(&mut rng, c, 4.0, 4, true)), Appearance::default())))
|
||||
.collect();
|
||||
let compound = d.alloc_id();
|
||||
let mut node = Node::new(compound, NodeKind::Compound { children: members, rule: vectorcraft_geom::FillRule::NonZero });
|
||||
node.appearance = stroke.into();
|
||||
d.insert(Some(l), 9, node).unwrap();
|
||||
let symbols = d.symbol_boxes();
|
||||
let opt = HitOptions::default();
|
||||
for id in [path, compound] {
|
||||
let n = d.node(id).unwrap();
|
||||
let full = n.stroke_path().unwrap();
|
||||
let control = match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.control_bounds().unwrap(),
|
||||
_ => n.children().unwrap().iter().filter_map(|m| m.path_data()?.control_bounds()).reduce(|a, b| a.union(b)).unwrap(),
|
||||
};
|
||||
let (mut outside, mut checked) = (0, 0);
|
||||
let area = control.inflate(40.0, 40.0);
|
||||
for i in 0..=80 {
|
||||
for j in 0..=80 {
|
||||
let p = Point::new(area.x0 + area.width() * f64::from(i) / 80.0, area.y0 + area.height() * f64::from(j) / 80.0);
|
||||
let before = match &n.kind {
|
||||
NodeKind::Path { .. } => n
|
||||
.reach_bounds_with(&symbols)
|
||||
.is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|
||||
.then(|| hit_leaf(n, p, opt, &symbols))
|
||||
.flatten(),
|
||||
_ => compound_hit_before(n, &full, p, opt),
|
||||
};
|
||||
let now = hit_test(&d, p, opt).filter(|h| h.leaf == id).map(|h| h.kind);
|
||||
assert_eq!(now, before, "{id:?} at {p:?}");
|
||||
checked += 1;
|
||||
outside += usize::from(before == Some(HitKind::Stroke) && !control.contains(p));
|
||||
}
|
||||
}
|
||||
assert!(outside > 50, "{id:?}: {outside} of {checked} points hit the stroke outside the control points' box");
|
||||
}
|
||||
}
|
||||
|
||||
/// The box a click is culled by holds the object's reach for paths and compound paths (it is
|
||||
/// measured by their control points, which hold their curves, and grown by the same stroke reach),
|
||||
/// so culling by it never turns away a click the reach let through. (Up to rounding: where a curve's
|
||||
/// extreme is an anchor, the two boxes measure the same point in different ways.)
|
||||
#[test]
|
||||
fn cull_box_holds_the_reach_of_paths_and_compound_paths() {
|
||||
const E: f64 = 1e-9;
|
||||
let holds =
|
||||
|outer: Rect, inner: Rect| outer.x0 - E <= inner.x0 && outer.y0 - E <= inner.y0 && inner.x1 <= outer.x1 + E && inner.y1 <= outer.y1 + E;
|
||||
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 12.0);
|
||||
let (mut d, id, _) = compound_doc(200, vectorcraft_geom::FillRule::NonZero, stroke.clone());
|
||||
let path_id = d.alloc_id();
|
||||
let symbols = d.symbol_boxes();
|
||||
let n = d.node(id).unwrap();
|
||||
let mut checked = 0;
|
||||
for node in std::iter::once(n).chain(n.children().unwrap().iter().map(|m| &**m)) {
|
||||
let (cull, reach) = (cull_reach(node, &symbols).unwrap(), node.reach_bounds_with(&symbols).unwrap());
|
||||
assert!(holds(cull, reach), "{:?}: {cull:?} does not hold {reach:?}", node.id);
|
||||
checked += 1;
|
||||
}
|
||||
// A stroked plain path, its handles reaching past its curves.
|
||||
let mut rng = Rng(11);
|
||||
let path = Node::path(path_id, vectorcraft_geom::PathData::single(blob(&mut rng, Point::new(50.0, 50.0), 20.0, 5, true)), stroke);
|
||||
let (cull, reach) = (cull_reach(&path, &symbols).unwrap(), path.reach_bounds_with(&symbols).unwrap());
|
||||
assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}");
|
||||
assert_eq!(checked, 201);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -25,11 +25,16 @@ 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_nine_slice::{
|
||||
EditedNineSlice, MAX_SLICE_GROWTH, MIN_CENTER_BAND, NineSliceGuide, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice,
|
||||
charge_symbol_art,
|
||||
};
|
||||
pub use symbol_overrides::SymbolOverride;
|
||||
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
|
||||
pub mod links;
|
||||
pub mod live;
|
||||
@@ -74,8 +79,8 @@ pub(crate) mod skip;
|
||||
|
||||
pub use appearance::StrokeGradientMode;
|
||||
pub use appearance::{
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
|
||||
StrokeAlign, StrokeLayer, WidthProfile,
|
||||
AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset,
|
||||
SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use compound_shape::ShapeMode;
|
||||
@@ -105,10 +110,12 @@ pub use selection::{AnchorRef, SegmentRef, Selection};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::FontHeightReference;
|
||||
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};
|
||||
@@ -139,7 +146,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
|
||||
#[cfg(test)]
|
||||
use limits::MAX_NEXT_ID;
|
||||
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit};
|
||||
pub use units::{ColorMode, Unit, parse_number};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -219,6 +226,10 @@ pub struct Document {
|
||||
/// still read so files saved mid-edit by older versions load without the editing layer.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub mask_edit: Option<MaskEdit>,
|
||||
/// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the
|
||||
/// symbol already holds the art of its editing layer.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub symbol_edit: Option<SymbolEdit>,
|
||||
next_id: u64,
|
||||
/// Foreign data preserved on round-trip.
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -293,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();
|
||||
|
||||
@@ -35,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 {
|
||||
@@ -157,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);
|
||||
@@ -170,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
|
||||
@@ -263,7 +353,49 @@ 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
|
||||
@@ -347,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 {
|
||||
@@ -383,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() {
|
||||
@@ -466,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);
|
||||
@@ -508,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
|
||||
@@ -595,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;
|
||||
@@ -632,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;
|
||||
@@ -646,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(|_| {
|
||||
|
||||
@@ -253,6 +253,10 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
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>()
|
||||
});
|
||||
|
||||
@@ -44,6 +44,11 @@ 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
|
||||
@@ -58,6 +63,9 @@ pub struct NodeExtras {
|
||||
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.
|
||||
@@ -76,11 +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
|
||||
|
||||
@@ -185,8 +185,13 @@ impl Node {
|
||||
match &self.kind {
|
||||
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
|
||||
// Sized up front: grown by doubling, the path of a compound path of hundreds of
|
||||
// thousands of anchors is copied into ever larger buffers, up to tens of MB, on
|
||||
// every call.
|
||||
let subpaths = || children.iter().filter_map(|c| c.path_data()).flat_map(|pd| &pd.subpaths);
|
||||
let elements = subpaths().filter(|sp| !sp.anchors.is_empty()).map(|sp| 1 + sp.segment_count() + usize::from(sp.closed)).sum();
|
||||
let mut bp = vectorcraft_geom::BezPath::from_vec(Vec::with_capacity(elements));
|
||||
subpaths().for_each(|sp| sp.to_bezpath_into(&mut bp));
|
||||
Some(bp)
|
||||
}
|
||||
_ => None,
|
||||
@@ -198,7 +203,8 @@ impl Node {
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
|
||||
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
|
||||
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
|
||||
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box
|
||||
/// takes in where its spine can put its keys and steps.
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
self.reach(None)
|
||||
}
|
||||
@@ -234,6 +240,11 @@ impl Node {
|
||||
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
|
||||
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
|
||||
}),
|
||||
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
|
||||
// keys and the spine.
|
||||
NodeKind::Blend { children, spec } => {
|
||||
vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols))
|
||||
}
|
||||
_ => self.visual(symbols),
|
||||
}
|
||||
}
|
||||
@@ -387,6 +398,12 @@ impl Node {
|
||||
self.transform_scaled(a, &scaling.into());
|
||||
}
|
||||
|
||||
/// The path of the child an Objects on Path group's objects are attached to.
|
||||
fn on_path_data(&self) -> Option<&PathData> {
|
||||
let r = self.extras().objects_on_path.as_deref()?;
|
||||
self.children()?.iter().find(|c| c.id == r.path)?.path_data()
|
||||
}
|
||||
|
||||
fn transform_scaled(&mut self, a: Affine, sc: &Scaling) {
|
||||
// A dimension's measurement moves with it.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
@@ -394,12 +411,8 @@ impl Node {
|
||||
{
|
||||
dimension.transform(a);
|
||||
}
|
||||
// The frames of the objects attached to a path move with them.
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.transform(a);
|
||||
}
|
||||
// The path the objects are attached to, before it is transformed.
|
||||
let on_path_before = self.on_path_data().cloned();
|
||||
if !self.is_layer() {
|
||||
self.bbox_angle = crate::orient::transformed_angle(self.bbox_angle, a);
|
||||
}
|
||||
@@ -490,14 +503,27 @@ impl Node {
|
||||
NodeKind::Mesh(m) => m.transform(a),
|
||||
NodeKind::Repeat(r) => r.transform(a, *sc),
|
||||
}
|
||||
// The frames of the objects attached to a path move with them, and the distances along the
|
||||
// path follow the path as it is now (a live shape's path is regenerated).
|
||||
let on_path_after = on_path_before.as_ref().and_then(|_| self.on_path_data().cloned());
|
||||
if let Some(e) = self.extras_if_any()
|
||||
&& let Some(on_path) = &mut e.objects_on_path
|
||||
{
|
||||
on_path.transform(a, on_path_before.as_ref().zip(on_path_after.as_ref()));
|
||||
}
|
||||
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
&& m.linked
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -683,9 +709,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);
|
||||
@@ -693,13 +725,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))))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,34 @@ fn uneven_scales_keep_live_corners_circular() {
|
||||
assert_round_corners(&n, Affine::IDENTITY, 40.0);
|
||||
}
|
||||
|
||||
/// Objects attached to a rounded rectangle that keeps its corners when scaled stay where the scale
|
||||
/// takes them on the regenerated path, which is not twice as long when twice the size.
|
||||
#[test]
|
||||
fn objects_on_a_live_rectangle_follow_its_regenerated_path() {
|
||||
use crate::objects_on_path::{CENTER, ObjectsOnPath, stops};
|
||||
let path = live_rect(100.0, 100.0, 20.0, Affine::IDENTITY);
|
||||
let objects: Vec<Node> =
|
||||
(2..5).map(|i| Node::path(NodeId(i), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default())).collect();
|
||||
let members: Vec<&Node> = objects.iter().collect();
|
||||
let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &members, CENTER, 0.0) };
|
||||
let mut children = vec![Arc::new(path)];
|
||||
children.extend(objects.iter().cloned().map(Arc::new));
|
||||
let mut g = Node::new(NodeId(9), NodeKind::Group { children, clip: false });
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
let path_of = |g: &Node| g.children().and_then(|c| c.first()).and_then(|p| p.path_data().cloned()).unwrap();
|
||||
let rec_of = |g: &Node| g.extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
let r = rec_of(&g);
|
||||
let before = stops(&path_of(&g), 3, r.spacing, r.start).unwrap();
|
||||
let a = Affine::scale(2.0);
|
||||
g.transform(a, KEEP_CORNERS);
|
||||
assert_round_corners(&g.children().unwrap()[0], Affine::IDENTITY, 20.0);
|
||||
let r = rec_of(&g);
|
||||
let after = stops(&path_of(&g), 3, r.spacing, r.start).unwrap();
|
||||
for ((p, _), (q, _)) in before.iter().zip(&after) {
|
||||
assert!((a * *p).distance(*q) < 1e-6, "{p:?} went to {q:?}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_and_reflected_live_rectangles_scale_along_their_own_sides() {
|
||||
let place = Affine::translate((200.0, 100.0)) * Affine::rotate(30f64.to_radians());
|
||||
@@ -339,6 +367,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};
|
||||
@@ -455,6 +512,23 @@ fn objects_are_read_within_the_node_budget() {
|
||||
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
|
||||
@@ -472,3 +546,23 @@ fn fields_before_the_type_are_buffered_within_the_value_budget() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// A compound path's path is its members' paths one after another, open and closed, empty
|
||||
/// members and single anchors included.
|
||||
#[test]
|
||||
fn compound_stroke_path_is_its_members_in_order() {
|
||||
use vectorcraft_geom::{Anchor, PathData, SubPath};
|
||||
let open = PathData::single(SubPath::new([(0.0, 0.0), (5.0, 1.0), (9.0, 7.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), false));
|
||||
let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(3.0, 3.0))], false));
|
||||
let members =
|
||||
[shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), open, PathData::default(), lone, shapes::ellipse(Rect::new(10.0, 10.0, 20.0, 16.0))];
|
||||
let mut expected = vectorcraft_geom::BezPath::new();
|
||||
for pd in &members {
|
||||
expected.extend(pd.to_bezpath());
|
||||
}
|
||||
let children = members.iter().enumerate().map(|(i, pd)| Arc::new(Node::path(NodeId(i as u64 + 1), pd.clone(), Appearance::default()))).collect();
|
||||
let n = Node::new(NodeId(99), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::EvenOdd });
|
||||
let bp = n.stroke_path().unwrap();
|
||||
assert_eq!(bp.elements(), expected.elements());
|
||||
assert!(!bp.elements().is_empty());
|
||||
}
|
||||
|
||||
@@ -46,11 +46,13 @@ 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();
|
||||
@@ -85,11 +87,13 @@ 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()
|
||||
}
|
||||
}
|
||||
@@ -123,11 +127,39 @@ pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
|
||||
/// 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(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?;
|
||||
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(id)
|
||||
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
|
||||
@@ -186,6 +218,8 @@ 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)]
|
||||
@@ -196,6 +230,8 @@ pub(super) struct NodeWire {
|
||||
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 {
|
||||
@@ -215,11 +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,
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! the other children are the attached objects, kept where they are drawn: the group draws, hits,
|
||||
//! saves and exports as a group. The record lists the objects in their order along the path, each
|
||||
//! with the frame it is drawn in and the angle that frame is turned by. [`place`] spreads them
|
||||
//! evenly along the path from its first anchor (the first object sits on that anchor, and the last
|
||||
//! one sits on the end of an open path), puts the attachment point of each one
|
||||
//! along the path ([`stops`]): evenly from its first anchor by default (the first object sits on
|
||||
//! that anchor, and the last one sits on the end of an open path), or [`ObjectsOnPath::spacing`]
|
||||
//! apart from [`ObjectsOnPath::start`]. It puts the attachment point of each one
|
||||
//! ([`ObjectsOnPath::pivot`]) on the path, and turns its frame to the path's direction there,
|
||||
//! tilted by [`ObjectsOnPath::rotate`].
|
||||
//!
|
||||
@@ -45,6 +46,14 @@ pub struct ObjectsOnPath {
|
||||
/// upright object has its frame's x axis along the path's direction at its attachment point.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub rotate: f64,
|
||||
/// Space: the distance along the path (points) from each object's attachment point to the
|
||||
/// next one's. `None` (Auto) spreads the objects evenly ([`layout`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub spacing: Option<f64>,
|
||||
/// The distance along the path (points) from its first anchor to the first object's
|
||||
/// attachment point.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub start: f64,
|
||||
}
|
||||
|
||||
/// An object attached to the path of an Objects on Path group.
|
||||
@@ -95,19 +104,38 @@ impl ObjectsOnPath {
|
||||
/// in its frame as drawn ([`upright_frame`]).
|
||||
pub fn new(path: NodeId, members: &[&Node], pivot: u8, rotate: f64) -> Self {
|
||||
let members = members.iter().map(|n| Member::new(n)).collect();
|
||||
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 } }
|
||||
Self { path, members, pivot: pivot.min(8), rotate: if rotate.is_finite() { rotate } else { 0.0 }, spacing: None, start: 0.0 }
|
||||
}
|
||||
/// The frames go with the group, and each frame's angle becomes the transformed path's
|
||||
/// direction at the object plus the tilt, so that [`place`] keeps each object's scale, shear
|
||||
/// and reflection when it spreads the objects along the transformed path again. A reflection
|
||||
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path.
|
||||
pub fn transform(&mut self, a: Affine) {
|
||||
/// also reverses Rotate: the objects keep the mirrored tilt to the mirrored path. With `paths`,
|
||||
/// the group's path before the transform and as it is after it, the start and a Space value
|
||||
/// keep the first and last objects where the transform takes them on the path; a value that
|
||||
/// cannot be followed that way scales by how much the path's length changed. Without `paths`,
|
||||
/// they scale by the transform's average scale (the square root of its area scale). A
|
||||
/// value that would not be finite once scaled stays as it is.
|
||||
pub fn transform(&mut self, a: Affine, paths: Option<(&PathData, &PathData)>) {
|
||||
let before = tilt(self.rotate);
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
if a.determinant() < 0.0 && self.rotate != 0.0 {
|
||||
self.rotate = -self.rotate;
|
||||
}
|
||||
let along = paths.and_then(|(old, new)| {
|
||||
let s = path_length(new)?.0 / path_length(old)?.0;
|
||||
(s.is_finite() && s > 0.0).then_some(s)
|
||||
});
|
||||
let scale = along.unwrap_or_else(|| a.determinant().abs().sqrt());
|
||||
// A move leaves the distances along the path as they are.
|
||||
let moves = a0 == 1.0 && a1 == 0.0 && a2 == 0.0 && a3 == 1.0;
|
||||
if !moves && scale.is_finite() && scale > 0.0 {
|
||||
let scaled = |v: f64| Some(v * scale).filter(|s| s.is_finite()).unwrap_or(v);
|
||||
let moved = paths.and_then(|(old, new)| self.moved_along(a, old, new, scale));
|
||||
let (spacing, start) = moved.unwrap_or((None, None));
|
||||
self.spacing = self.spacing.map(|g| spacing.unwrap_or_else(|| scaled(g)));
|
||||
self.start = start.unwrap_or_else(|| scaled(self.start));
|
||||
}
|
||||
let after = tilt(self.rotate);
|
||||
let [a0, a1, a2, a3, _, _] = a.as_coeffs();
|
||||
for m in &mut self.members {
|
||||
m.frame = a * m.frame;
|
||||
let (sin, cos) = (m.angle - before).sin_cos();
|
||||
@@ -117,6 +145,41 @@ impl ObjectsOnPath {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The start and Space value on `new`, the path `old` after the transform `a` (regenerated, for
|
||||
/// a live shape), that keep the first object and the last one where `a` takes them: each one's
|
||||
/// point on `old`, mapped by `a`, measured along `new` near `scale` times its distance along
|
||||
/// `old`. Either one is `None` when it cannot be followed that way: a start before or beyond the
|
||||
/// ends of an open path, or a Space value of 0, one the path has no room for, or one with fewer
|
||||
/// than two objects. `None` for a path without length or a start that is not finite.
|
||||
fn moved_along(&self, a: Affine, old: &PathData, new: &PathData, scale: f64) -> Option<(Option<f64>, Option<f64>)> {
|
||||
let (was, is) = (Spine::new(old)?, Spine::new(new)?);
|
||||
let (len, closed) = path_length(old)?;
|
||||
let (new_len, _) = path_length(new)?;
|
||||
if !self.start.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let n = self.members.len();
|
||||
let (s0, gap) = layout(len, closed, n, self.spacing, self.start);
|
||||
let follow = |s: f64| -> Option<f64> {
|
||||
let (p, _) = was.at_length(s);
|
||||
let t = is.nearest(a * p, Some(s * scale))?.1;
|
||||
t.is_finite().then_some(if closed { wrap(t, new_len) } else { t.clamp(0.0, new_len) })
|
||||
};
|
||||
let t0 = follow(s0)?;
|
||||
// On a closed path the start keeps the number of times it goes round, when that stays finite.
|
||||
let start =
|
||||
if closed { Some(t0 + ((self.start - s0) / len).round() * new_len).filter(|s| s.is_finite()) } else { (s0 == self.start).then_some(t0) };
|
||||
let spacing = self.spacing.filter(|g| n > 1 && g.is_finite() && *g > 0.0 && *g <= gap).and_then(|g| {
|
||||
let t1 = follow(nth_length(len, closed, s0, gap, n - 1))?;
|
||||
let steps = (n - 1) as f64;
|
||||
// Round a closed path, the span nearest the scaled one.
|
||||
let expected = steps * g * scale;
|
||||
let span = if closed { wrap(t1 - t0 - expected + new_len / 2.0, new_len) - new_len / 2.0 + expected } else { t1 - t0 };
|
||||
let g = span / steps;
|
||||
(g.is_finite() && g >= 0.0).then_some(g)
|
||||
});
|
||||
Some((spacing, start))
|
||||
}
|
||||
/// The children given new ids (a copy of the group): the path and the objects by their new ids.
|
||||
pub fn rename(&mut self, ids: &HashMap<NodeId, NodeId>) {
|
||||
let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id);
|
||||
@@ -210,19 +273,75 @@ pub fn upright_frame(n: &Node) -> Affine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
|
||||
/// (radians, as `atan2` gives in the document's axes). They are spread evenly by length from the
|
||||
/// first anchor: on an open path the last one is at its end, on a closed path the gaps go all the way
|
||||
/// around. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
if !len.is_finite() || len <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
/// The length of `path`'s first subpath and whether it is closed. `None` for a path without
|
||||
/// length.
|
||||
pub fn path_length(path: &PathData) -> Option<(f64, bool)> {
|
||||
let len = Spine::new(path)?.length();
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
let gaps = if closed { n } else { n.saturating_sub(1) }.max(1);
|
||||
Some((0..n).map(|i| spine.at_length(len * i as f64 / gaps as f64)).collect())
|
||||
(len.is_finite() && len > 1e-9).then_some((len, closed))
|
||||
}
|
||||
|
||||
/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the
|
||||
/// distance from each object's attachment point to the next one's, both along the path (points),
|
||||
/// for Space `spacing` (`None`: Auto) from `start`:
|
||||
/// - The first object is `start` from the first anchor: on an open path no further than its end,
|
||||
/// and on a closed path less than once around.
|
||||
/// - Auto spreads the objects evenly from there: on an open path the last one is at its end, and on
|
||||
/// a closed path the gaps go all the way around.
|
||||
/// - A Space value is kept to what the path has room for, so a shorter path draws the objects
|
||||
/// closer while the value stays: on an open path the last object reaches its end at most, and on
|
||||
/// a closed path the last one stops at least a gap short of the first.
|
||||
///
|
||||
/// A Space value that is negative or not finite counts as Auto, and a `start` that is not finite as 0.
|
||||
pub fn layout(len: f64, closed: bool, n: usize, spacing: Option<f64>, start: f64) -> (f64, f64) {
|
||||
let start = match (start.is_finite(), closed) {
|
||||
(false, _) => 0.0,
|
||||
(true, true) => wrap(start, len),
|
||||
(true, false) => start.clamp(0.0, len.max(0.0)),
|
||||
};
|
||||
let room = if closed {
|
||||
len / n.max(1) as f64
|
||||
} else if n > 1 {
|
||||
(len - start) / (n - 1) as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let room = if room.is_finite() { room.max(0.0) } else { 0.0 };
|
||||
match spacing.filter(|g| g.is_finite() && *g >= 0.0) {
|
||||
Some(g) => (start, g.min(room)),
|
||||
None => (start, room),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` taken round a closed path of length `len`: 0 or more, and less than `len`.
|
||||
fn wrap(s: f64, len: f64) -> f64 {
|
||||
let w = s.rem_euclid(len);
|
||||
if w.is_finite() && w < len { w } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Where `n` objects attach along `path` (its first subpath): the point and the direction there
|
||||
/// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them
|
||||
/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize, spacing: Option<f64>, start: f64) -> Option<Vec<(Point, f64)>> {
|
||||
let spine = Spine::new(path)?;
|
||||
let (len, closed) = path_length(path)?;
|
||||
let (start, gap) = layout(len, closed, n, spacing, start);
|
||||
Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect())
|
||||
}
|
||||
|
||||
/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others.
|
||||
pub fn stop(path: &PathData, n: usize, spacing: Option<f64>, start: f64, i: usize) -> Option<(Point, f64)> {
|
||||
let spine = Spine::new(path)?;
|
||||
let (len, closed) = path_length(path)?;
|
||||
let (start, gap) = layout(len, closed, n, spacing, start);
|
||||
(i < n).then(|| spine.at_length(nth_length(len, closed, start, gap, i)))
|
||||
}
|
||||
|
||||
/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and
|
||||
/// `gap` apart as [`layout`] gives them.
|
||||
pub fn nth_length(len: f64, closed: bool, start: f64, gap: f64, i: usize) -> f64 {
|
||||
let s = start + gap * i as f64;
|
||||
if closed { wrap(s, len) } else { s.min(len) }
|
||||
}
|
||||
|
||||
/// The tilt Rotate gives (radians, the document's axes, where y points down): `rotate` degrees
|
||||
@@ -277,7 +396,7 @@ pub fn place(g: &mut Node, symbols: &SymbolBoxes) -> bool {
|
||||
}
|
||||
}
|
||||
let mut moved = false;
|
||||
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len())) {
|
||||
if let Some(stops) = path.and_then(|p| stops(&p, rec.members.len(), rec.spacing, rec.start)) {
|
||||
let turn = tilt(rec.rotate);
|
||||
for (m, (p, dir)) in rec.members.iter_mut().zip(stops) {
|
||||
let Some(c) = at.get(&m.id).and_then(|i| children.get_mut(*i)) else { continue };
|
||||
@@ -309,15 +428,192 @@ mod tests {
|
||||
#[test]
|
||||
fn stops_spread_from_the_first_anchor() {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let s = stops(&open, 4).unwrap();
|
||||
let s = stops(&open, 4, None, 0.0).unwrap();
|
||||
let xs: Vec<f64> = s.iter().map(|(p, _)| p.x.round()).collect();
|
||||
assert_eq!(xs, [0.0, 100.0, 200.0, 300.0], "first at the start, last at the end of an open path");
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let s = stops(&closed, 4).unwrap();
|
||||
let s = stops(&closed, 4, None, 0.0).unwrap();
|
||||
let mut pts: Vec<(i64, i64)> = s.iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect();
|
||||
pts.sort();
|
||||
assert_eq!(pts, [(0, 0), (0, 100), (100, 0), (100, 100)], "a closed path spreads them all the way round");
|
||||
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2).is_none());
|
||||
assert!(stops(&shapes::line(Point::ZERO, Point::ZERO), 2, None, 0.0).is_none());
|
||||
}
|
||||
|
||||
/// The x of each stop on the `len` pt line from (0, 0) to the right, to 1/1000 pt.
|
||||
fn xs_on_line(len: f64, n: usize, spacing: Option<f64>, start: f64) -> Vec<f64> {
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(len, 0.0));
|
||||
stops(&open, n, spacing, start).unwrap().iter().map(|(p, _)| (p.x * 1000.0).round() / 1000.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_sets_the_distance_between_the_stops() {
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(60.0), 0.0), [0.0, 60.0, 120.0, 180.0], "the first stays on the first anchor");
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(60.0), 60.0), [60.0, 120.0, 180.0, 240.0], "centered on the path");
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(0.0), 0.0), [0.0; 4], "no space stacks them");
|
||||
// More than the path has room for: the last one reaches the end.
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(500.0), 0.0), [0.0, 100.0, 200.0, 300.0]);
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(500.0), 150.0), [150.0, 200.0, 250.0, 300.0]);
|
||||
// A shorter path draws them closer, while the Space value stays to come back with the length.
|
||||
assert_eq!(xs_on_line(90.0, 4, Some(60.0), 0.0), [0.0, 30.0, 60.0, 90.0]);
|
||||
assert_eq!(layout(90.0, false, 4, Some(60.0), 0.0), (0.0, 30.0));
|
||||
assert_eq!(layout(300.0, false, 4, Some(60.0), 0.0), (0.0, 60.0));
|
||||
// Values that are not usable count as Auto, and a start beyond the end stops at it.
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(f64::NAN), f64::INFINITY), [0.0, 100.0, 200.0, 300.0]);
|
||||
assert_eq!(xs_on_line(300.0, 4, Some(-5.0), 0.0), [0.0, 100.0, 200.0, 300.0]);
|
||||
assert_eq!(xs_on_line(300.0, 2, Some(10.0), 400.0), [300.0, 300.0]);
|
||||
assert_eq!(xs_on_line(300.0, 1, Some(10.0), 20.0), [20.0], "one object has no space to set");
|
||||
assert_eq!(xs_on_line(300.0, 0, Some(10.0), 0.0), Vec::<f64>::new());
|
||||
// One stop on its own is where the stops put it.
|
||||
let open = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let all = stops(&open, 4, Some(60.0), 60.0).unwrap();
|
||||
for (i, s) in all.iter().enumerate() {
|
||||
assert_eq!(stop(&open, 4, Some(60.0), 60.0, i), Some(*s));
|
||||
}
|
||||
assert_eq!(stop(&open, 4, Some(60.0), 60.0, 4), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object() {
|
||||
// The square's outline is 400 pt round, from (0, 0) to the right and on clockwise.
|
||||
let closed = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let pts = |spacing, start| -> Vec<(i64, i64)> {
|
||||
stops(&closed, 4, spacing, start).unwrap().iter().map(|(p, _)| (p.x.round() as i64, p.y.round() as i64)).collect()
|
||||
};
|
||||
assert_eq!(pts(Some(50.0), 0.0), [(0, 0), (50, 0), (100, 0), (100, 50)]);
|
||||
// Centered on the first anchor: the span goes back past it.
|
||||
assert_eq!(pts(Some(50.0), -75.0), [(0, 75), (0, 25), (25, 0), (75, 0)]);
|
||||
// At most a quarter of the way round each: no two objects meet at the first anchor.
|
||||
assert_eq!(pts(Some(300.0), 0.0), [(0, 0), (100, 0), (100, 100), (0, 100)]);
|
||||
assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_saved_without_space_reads_as_auto() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let rec = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0);
|
||||
let v = serde_json::to_value(&rec).unwrap();
|
||||
assert!(v.get("spacing").is_none() && v.get("start").is_none(), "Auto is not written: {v}");
|
||||
let back: ObjectsOnPath = serde_json::from_value(v).unwrap();
|
||||
assert_eq!((back.spacing, back.start), (None, 0.0));
|
||||
let set = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..rec };
|
||||
let back: ObjectsOnPath = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap();
|
||||
assert_eq!(back, set);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforming_the_group_scales_its_space() {
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut rec = ObjectsOnPath { spacing: Some(60.0), start: 12.5, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 30.0) };
|
||||
rec.transform(Affine::new([-2.0, 0.0, 0.0, 2.0, 5.0, 5.0]), None);
|
||||
assert_eq!((rec.spacing, rec.start, rec.rotate), (Some(120.0), 25.0, -30.0));
|
||||
rec.transform(Affine::translate((40.0, 0.0)) * Affine::rotate(0.7), None);
|
||||
assert!((rec.spacing.unwrap() - 120.0).abs() < 1e-9 && (rec.start - 25.0).abs() < 1e-9, "{rec:?}");
|
||||
// Twice as wide and no higher: a level path is stretched twice along its length.
|
||||
let level = shapes::line(Point::new(0.0, 0.0), Point::new(300.0, 0.0));
|
||||
let stretch = Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
|
||||
rec.transform(stretch, Some((&level, &level.transformed(stretch))));
|
||||
assert!((rec.spacing.unwrap() - 240.0).abs() < 1e-9 && (rec.start - 50.0).abs() < 1e-9, "{rec:?}");
|
||||
let mut auto = ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0);
|
||||
auto.transform(Affine::scale(3.0), None);
|
||||
assert_eq!((auto.spacing, auto.start), (None, 0.0), "Auto stays Auto");
|
||||
let mut huge = ObjectsOnPath { spacing: Some(1e308), start: -1e308, ..auto };
|
||||
huge.transform(Affine::scale(2.0), None);
|
||||
assert_eq!((huge.spacing, huge.start), (Some(1e308), -1e308), "kept finite");
|
||||
}
|
||||
|
||||
/// Stretching a path that turns a corner keeps the first and last objects where the stretch
|
||||
/// takes them, not where the whole path's change in length would.
|
||||
#[test]
|
||||
fn a_stretch_keeps_the_first_and_last_objects_where_it_takes_them() {
|
||||
// 100 pt to the right, then 100 pt down; twice as wide, the first part is 200 pt long.
|
||||
let points = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)];
|
||||
let corner = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false));
|
||||
let stretch = Affine::scale_non_uniform(2.0, 1.0);
|
||||
let wide = corner.transformed(stretch);
|
||||
let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)));
|
||||
let mut rec = ObjectsOnPath { spacing: Some(40.0), start: 50.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) };
|
||||
rec.transform(stretch, Some((&corner, &wide)));
|
||||
assert!((rec.start - 100.0).abs() < 1e-9 && (rec.spacing.unwrap() - 80.0).abs() < 1e-9, "{rec:?}");
|
||||
// Round a closed path, from before its first anchor: the start goes round as many times.
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let wide = square.transformed(stretch);
|
||||
let mut rec = ObjectsOnPath { spacing: Some(50.0), start: -75.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) };
|
||||
let before = stops(&square, 2, rec.spacing, rec.start).unwrap();
|
||||
rec.transform(stretch, Some((&square, &wide)));
|
||||
let after = stops(&wide, 2, rec.spacing, rec.start).unwrap();
|
||||
for ((p, _), (q, _)) in before.iter().zip(&after) {
|
||||
assert!((stretch * *p).distance(*q) < 1e-9, "{p:?} went to {q:?}: {rec:?}");
|
||||
}
|
||||
assert!(rec.start < 0.0, "{rec:?}");
|
||||
}
|
||||
|
||||
/// A start round a closed path so many times that scaling it would not be finite stays as it is.
|
||||
#[test]
|
||||
fn a_closed_path_start_too_large_to_scale_stays_finite() {
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let grow = Affine::scale(2.0);
|
||||
let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut rec = ObjectsOnPath { start: 1e308, ..ObjectsOnPath::new(NodeId(1), &[&a], CENTER, 0.0) };
|
||||
rec.transform(grow, Some((&square, &square.transformed(grow))));
|
||||
assert_eq!(rec.start, 1e308, "{rec:?}");
|
||||
}
|
||||
|
||||
/// Stretching the group across its level path only keeps the objects where the stretch takes
|
||||
/// them: twice as far apart.
|
||||
#[test]
|
||||
fn a_group_stretched_along_its_path_keeps_the_objects_in_proportion() {
|
||||
let line = Node::path(NodeId(1), shapes::line(Point::new(0.0, 100.0), Point::new(300.0, 100.0)), Default::default());
|
||||
let (a, b) = (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)));
|
||||
let mut g = Node::new(NodeId(4), NodeKind::Group { children: vec![Arc::new(line), Arc::new(a.clone()), Arc::new(b.clone())], clip: false });
|
||||
let rec = ObjectsOnPath { spacing: Some(20.0), start: 10.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b], CENTER, 0.0) };
|
||||
g.extras_mut().objects_on_path = Some(Box::new(rec));
|
||||
let empty = crate::Document::new(1.0, 1.0);
|
||||
let symbols = empty.symbol_boxes();
|
||||
place(&mut g, &symbols);
|
||||
g.transform(Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]), false);
|
||||
let rec = g.extras().objects_on_path.as_deref().cloned().unwrap();
|
||||
assert_eq!((rec.spacing, rec.start), (Some(40.0), 20.0));
|
||||
place(&mut g, &symbols);
|
||||
let x = |id| g.children().and_then(|c| c.iter().find(|c| c.id == id).cloned()).unwrap().geometric_bounds().unwrap().center().x;
|
||||
assert!((x(NodeId(2)) - 20.0).abs() < 1e-9 && (x(NodeId(3)) - 60.0).abs() < 1e-9, "{} {}", x(NodeId(2)), x(NodeId(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_near_measures_along_the_path_and_past_its_ends() {
|
||||
// An L: 400 pt to the right, then 300 pt up.
|
||||
let points = [Point::new(0.0, 300.0), Point::new(400.0, 300.0), Point::new(400.0, 0.0)];
|
||||
let l = PathData::single(vectorcraft_geom::SubPath::polyline(&points, false));
|
||||
let spine = Spine::new(&l).unwrap();
|
||||
assert!((spine.length_near(Point::new(300.0, 320.0), None) - 300.0).abs() < 1e-9);
|
||||
assert!((spine.length_near(Point::new(420.0, 100.0), None) - 600.0).abs() < 1e-9);
|
||||
assert!((spine.length_near(Point::new(400.0, -26.0), None) - 726.0).abs() < 1e-9, "beyond the end");
|
||||
assert!((spine.length_near(Point::new(-50.0, 300.0), None) + 50.0).abs() < 1e-9, "before the start");
|
||||
// A path that turns back near its start: beyond its end, the pointer is nearer the first
|
||||
// segment than the last one, but on the line of the last one.
|
||||
let hook = [Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 10.0), Point::new(100.0, 10.0)];
|
||||
let hook = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&hook, false))).unwrap();
|
||||
assert!((hook.length_near(Point::new(80.0, 10.0), None) - 330.0).abs() < 1e-9);
|
||||
assert!((hook.length_near(Point::new(50.0, 10.0), None) - 360.0).abs() < 1e-9);
|
||||
let closed = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap();
|
||||
assert!((closed.length_near(Point::new(-30.0, -30.0), None)).abs() < 1e-9, "a closed path has no ends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_near_a_point_followed_stays_on_that_part_of_the_path() {
|
||||
// A narrow U, open on the left: its ends are 20 pt apart, 620 pt from each other along it.
|
||||
let u = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 20.0), Point::new(0.0, 20.0)];
|
||||
let u = Spine::new(&PathData::single(vectorcraft_geom::SubPath::polyline(&u, false))).unwrap();
|
||||
assert!((u.length_near(Point::new(-26.0, 20.0), None) - 646.0).abs() < 1e-9, "beyond the end");
|
||||
// Nearer the first segment's line, the pointer stays beyond the end it was following.
|
||||
assert!((u.length_near(Point::new(-26.0, 8.0), None) + 26.0).abs() < 1e-9);
|
||||
assert!((u.length_near(Point::new(-26.0, 8.0), Some(646.0)) - 646.0).abs() < 1e-9);
|
||||
// Following the path round, it goes on from where it was.
|
||||
assert!((u.length_near(Point::new(305.0, 10.0), Some(312.0)) - 310.0).abs() < 1e-9);
|
||||
// Round a closed path's first anchor: from 10 pt short of it to 20 pt past it.
|
||||
let square = Spine::new(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))).unwrap();
|
||||
let (at, _) = square.at_length(390.0);
|
||||
assert!((square.length_near(at, None) - 390.0).abs() < 1e-9);
|
||||
assert!((square.length_near(Point::new(20.0, -5.0), Some(390.0)) - 20.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,7 +633,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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -522,7 +522,28 @@ pub struct RepeatSpec {
|
||||
/// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps.
|
||||
const MAX_REPEAT_INSTANCES: usize = 250_000;
|
||||
|
||||
/// Counts the instances Repeats make in all, following symbol instances into their symbol's art
|
||||
/// Most nodes and anchors a Repeat copies in all, those of the Repeats nested in its source and
|
||||
/// of the opacity masks in it included: each instance copies the whole source, so a large source
|
||||
/// keeps fewer instances than [`MAX_REPEAT_INSTANCES`] allows (a 500 × 500 grid of a shape of up
|
||||
/// to 39 anchors keeps them all).
|
||||
const MAX_REPEAT_COPIES: usize = 10_000_000;
|
||||
|
||||
/// What the Repeats in some art make in all ([`NestedCount::nodes`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Cost {
|
||||
/// Instances the Repeats make (0 when there are none).
|
||||
instances: usize,
|
||||
/// Nodes and path anchors drawn, each Repeat's source counted once per instance it keeps.
|
||||
copies: usize,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
fn plus(self, o: Cost) -> Cost {
|
||||
Cost { instances: self.instances.saturating_add(o.instances), copies: self.copies.saturating_add(o.copies) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts the instances Repeats make, and the nodes they copy, in all, following symbol instances into their symbol's art
|
||||
/// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol
|
||||
/// inside its own art, counts as none.
|
||||
///
|
||||
@@ -549,21 +570,27 @@ impl<'a> NestedCount<'a> {
|
||||
}
|
||||
let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 };
|
||||
self.symbols.borrow_mut().insert(name.to_owned(), None);
|
||||
let n = self.nodes(std::slice::from_ref(&sym.art));
|
||||
let n = self.nodes(std::slice::from_ref(&sym.art)).instances;
|
||||
self.symbols.borrow_mut().insert(name.to_owned(), Some(n));
|
||||
n
|
||||
}
|
||||
|
||||
/// Instances the Repeats in `nodes` make in all (0 when there are none).
|
||||
fn nodes(&self, nodes: &[Arc<Node>]) -> usize {
|
||||
let mut sum = 0usize;
|
||||
/// What the Repeats in `nodes` make in all, in the art of their enabled opacity masks too
|
||||
/// (drawn again with each copy of the object they mask).
|
||||
fn nodes(&self, nodes: &[Arc<Node>]) -> Cost {
|
||||
let mut sum = Cost::default();
|
||||
for n in nodes {
|
||||
sum = sum.saturating_add(match (&n.kind, n.children()) {
|
||||
(NodeKind::Repeat(r), _) => r.instances_in_all(self),
|
||||
(NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol),
|
||||
(_, Some(c)) => self.nodes(c),
|
||||
_ => 0,
|
||||
});
|
||||
let own = match (&n.kind, n.children()) {
|
||||
(NodeKind::Repeat(r), _) => r.in_all(self),
|
||||
(NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 },
|
||||
(NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) },
|
||||
(_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }),
|
||||
_ => Cost { instances: 0, copies: 1 },
|
||||
};
|
||||
sum = sum.plus(own);
|
||||
if let Some(m) = n.extras().mask.as_ref().filter(|m| !m.disabled) {
|
||||
sum = sum.plus(self.nodes(std::slice::from_ref(&m.art)));
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
@@ -643,21 +670,22 @@ impl RepeatSpec {
|
||||
v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source)));
|
||||
v
|
||||
}
|
||||
/// How many of its instances this Repeat keeps when each makes `nested` instances of the
|
||||
/// Repeats in its source: as many as fit in [`MAX_REPEAT_INSTANCES`], at least one.
|
||||
fn kept(&self, nested: usize) -> usize {
|
||||
self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1)
|
||||
/// How many of its instances this Repeat keeps when its source costs `nested`: as many as fit
|
||||
/// in [`MAX_REPEAT_INSTANCES`] and [`MAX_REPEAT_COPIES`], at least one.
|
||||
fn kept(&self, nested: Cost) -> usize {
|
||||
self.kind.count().min(MAX_REPEAT_INSTANCES / nested.instances.max(1)).min(MAX_REPEAT_COPIES / nested.copies.max(1)).max(1)
|
||||
}
|
||||
/// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its
|
||||
/// options make, fewer when the Repeats in its source would take it past
|
||||
/// [`MAX_REPEAT_INSTANCES`].
|
||||
/// [`MAX_REPEAT_INSTANCES`], or the copies of its source past [`MAX_REPEAT_COPIES`].
|
||||
pub fn instances(&self, doc: Option<&Document>) -> usize {
|
||||
self.kept(NestedCount::new(doc).nodes(&self.source))
|
||||
}
|
||||
/// Instances made in all, those of the Repeats nested in the source included.
|
||||
fn instances_in_all(&self, count: &NestedCount) -> usize {
|
||||
/// Instances made and nodes copied in all, those of the Repeats nested in the source included.
|
||||
fn in_all(&self, count: &NestedCount) -> Cost {
|
||||
let nested = count.nodes(&self.source);
|
||||
self.kept(nested).saturating_mul(nested.max(1))
|
||||
let kept = self.kept(nested);
|
||||
Cost { instances: kept.saturating_mul(nested.instances.max(1)), copies: kept.saturating_mul(nested.copies).saturating_add(1) }
|
||||
}
|
||||
/// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many
|
||||
/// as [`Self::transforms`] keeps for `doc`.
|
||||
@@ -1005,6 +1033,42 @@ mod tests {
|
||||
assert_eq!(made(&small), 18);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeats_in_opacity_masks_share_the_instance_budget() {
|
||||
let grid = |source: Arc<Node>| {
|
||||
let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0);
|
||||
if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind {
|
||||
*rows = 500;
|
||||
*cols = 500;
|
||||
}
|
||||
g
|
||||
};
|
||||
// A shape masked by a 500 × 500 grid draws the grid again for each copy.
|
||||
let inner = Node::new(NodeId(3), NodeKind::Repeat(grid(sq(0.0, 0.0, 1.0))));
|
||||
let mut masked = (*sq(0.0, 0.0, 1.0)).clone();
|
||||
masked.extras_mut().mask = Some(Box::new(crate::node::OpacityMask::new(inner, true)));
|
||||
let outer = grid(Arc::new(masked.clone()));
|
||||
assert_eq!(outer.transforms(None).len(), 1);
|
||||
// A disabled mask is not drawn, so it costs nothing.
|
||||
if let Some(m) = &mut masked.extras_mut().mask {
|
||||
m.disabled = true;
|
||||
}
|
||||
assert_eq!(grid(Arc::new(masked)).transforms(None).len(), 250_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_source_keeps_fewer_copies() {
|
||||
let mut g = RepeatSpec::grid((0..1000).map(|i| sq(i as f64, 0.0, 1.0)).collect(), 0.0, 0.0);
|
||||
if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind {
|
||||
*rows = 500;
|
||||
*cols = 500;
|
||||
}
|
||||
// Each copy is 1000 paths of 4 anchors: far fewer than 250 000 of them fit.
|
||||
let kept = g.transforms(None).len();
|
||||
assert!(kept.saturating_mul(5000) <= MAX_REPEAT_COPIES, "{kept} copies kept");
|
||||
assert!(kept > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip() {
|
||||
let mut def = PatternDef::new("Dots", vec![sq(0.0, 0.0, 10.0)]);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,6 +559,9 @@ impl Document {
|
||||
reads += usize::from(read_full && depth > 0);
|
||||
let full = if read_full { full_bytes(&p, &blob) } else { None };
|
||||
if full.and_then(|b| read(&p, &b)).is_none() {
|
||||
// A full read cached for another caller (one allowed to read the file) must not
|
||||
// stand in for the one refused or not tried here: the preview shows instead.
|
||||
cache().entries.remove(&(p.key.clone(), false));
|
||||
let preview = if blob.is_proxy() { Some(blob.bytes.clone()) } else { blob.proxy.clone() };
|
||||
if let Some(bytes) = preview {
|
||||
read_preview(&p, &bytes);
|
||||
@@ -667,7 +670,7 @@ mod tests {
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Star".into(), art: Arc::new(art) });
|
||||
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
|
||||
let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))];
|
||||
if bytes.starts_with(b"nest") {
|
||||
@@ -702,7 +705,11 @@ mod tests {
|
||||
fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() {
|
||||
set_loader(stub_loader);
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) });
|
||||
d.symbols.push(Symbol {
|
||||
dynamic: false,
|
||||
name: "Star".into(),
|
||||
art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })),
|
||||
});
|
||||
d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec()));
|
||||
let p = placed("doc1-1");
|
||||
let layer = d.layers[0].id;
|
||||
|
||||
@@ -17,7 +17,9 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
|
||||
.ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
|
||||
};
|
||||
let mut out = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Past each run of indices picked so far, the index after it: an interval skips the runs it
|
||||
// overlaps instead of walking them again, so repeating `1-` costs no more than writing it once.
|
||||
let mut next = std::collections::HashMap::new();
|
||||
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.replace('\u{2013}', "-").split_once('-') {
|
||||
Some((a, b)) if a.trim().is_empty() && b.trim().is_empty() => return Err(format!("range `{s}`: `{part}` names no number")),
|
||||
@@ -30,10 +32,11 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
|
||||
if a > b {
|
||||
return Err(format!("range `{s}`: `{part}` runs backwards"));
|
||||
}
|
||||
for i in a - 1..b {
|
||||
if seen.insert(i) {
|
||||
out.push(i);
|
||||
}
|
||||
let mut i = unpicked(&mut next, a - 1);
|
||||
while i < b {
|
||||
out.push(i);
|
||||
next.insert(i, i + 1);
|
||||
i = unpicked(&mut next, i + 1);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
@@ -42,6 +45,23 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The first index from `i` on not picked yet, `next` linking each picked index onward (the links
|
||||
/// followed are then pointed straight at it).
|
||||
fn unpicked(next: &mut std::collections::HashMap<usize, usize>, i: usize) -> usize {
|
||||
let mut end = i;
|
||||
while let Some(&j) = next.get(&end) {
|
||||
end = j;
|
||||
}
|
||||
let mut at = i;
|
||||
while at != end {
|
||||
match next.insert(at, end) {
|
||||
Some(j) => at = j,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_range;
|
||||
@@ -53,4 +73,20 @@ mod tests {
|
||||
assert!(parse_range("-+2", 5).is_err());
|
||||
assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeats_and_overlaps_are_dropped_in_order() {
|
||||
assert_eq!(parse_range("3-5, 1-4, 2, 6-, -1", 7), Ok(vec![2, 3, 4, 0, 1, 5, 6]));
|
||||
assert_eq!(parse_range("2,2,1-2", 4), Ok(vec![1, 0]));
|
||||
assert_eq!(parse_range("4, 2, 1-", 5), Ok(vec![3, 1, 0, 2, 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_repeated_many_times_is_read_once() {
|
||||
// 100 000 pages named 50 000 times: walking the pages for each would take billions of steps.
|
||||
let s = "1-,".repeat(50_000);
|
||||
let pages = parse_range(&s, 100_000).unwrap();
|
||||
assert_eq!(pages.len(), 100_000);
|
||||
assert!(pages.iter().enumerate().all(|(i, p)| i == *p));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ pub trait ColorVisitor {
|
||||
fn image(&mut self, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
|
||||
/// embedded images.
|
||||
/// What a recolouring pass reaches: fills (including colorized image fills and gradient-mesh
|
||||
/// points), strokes and the pixels of embedded images without a colorize paint.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reach {
|
||||
pub fill: bool,
|
||||
@@ -33,11 +33,18 @@ impl Reach {
|
||||
}
|
||||
|
||||
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
|
||||
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
|
||||
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
|
||||
/// number of paints, meshes and images that changed.
|
||||
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked and colorized
|
||||
/// images keep their pixels). Members that change are replaced by copies; the others stay shared.
|
||||
/// Returns the number of paints, meshes and images that changed.
|
||||
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
|
||||
let mut changed = 0;
|
||||
let colorized = matches!(&n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some();
|
||||
if colorized
|
||||
&& reach.fill
|
||||
&& let Some(paint) = n.extras_mut().colorize.as_deref_mut()
|
||||
{
|
||||
changed += v.paint(paint) as usize;
|
||||
}
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
|
||||
@@ -67,7 +74,7 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi
|
||||
}
|
||||
// An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its
|
||||
// colours, as the glyph does in type.
|
||||
NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => {
|
||||
NodeKind::Image(im) if reach.images && !colorized && im.link.is_none() && im.pixels.is_none() => {
|
||||
if let Some(k) = v.image(&im.key) {
|
||||
im.key = k;
|
||||
changed += 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, Deserialize, Node, NodeId, Point, Serialize};
|
||||
use super::{Affine, Arc, Deserialize, Node, NodeId, Point, Serialize};
|
||||
|
||||
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
|
||||
/// place, so a selection change that sets it doesn't replace the document.
|
||||
@@ -44,6 +44,29 @@ pub struct MaskEdit {
|
||||
pub layer: NodeId,
|
||||
}
|
||||
|
||||
/// Symbol editing mode: the art of symbol `symbol` lives on the temporary `layer` while editing,
|
||||
/// placed by `xf` (from the symbol's coordinates to the document's), and goes back into the symbol
|
||||
/// after each edit, so every instance follows it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SymbolEdit {
|
||||
pub symbol: String,
|
||||
pub layer: NodeId,
|
||||
/// The instance editing was entered from (a double-click on it), selected again on leaving after an edit;
|
||||
/// `None` when it was entered from the Symbols panel.
|
||||
pub instance: Option<NodeId>,
|
||||
pub xf: Affine,
|
||||
/// The symbol's art is a group whose members are on the layer: the group takes them back.
|
||||
pub group: bool,
|
||||
/// An edit changed the art since editing was entered.
|
||||
pub changed: bool,
|
||||
/// The isolated object from before editing was entered, isolated again on leaving.
|
||||
#[serde(default)]
|
||||
pub back_isolation: Option<NodeId>,
|
||||
/// The current layer from before editing was entered, current again on leaving.
|
||||
#[serde(default)]
|
||||
pub back_layer: Option<NodeId>,
|
||||
}
|
||||
|
||||
/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedView {
|
||||
@@ -108,6 +131,10 @@ impl SavedSelection {
|
||||
pub struct Symbol {
|
||||
pub name: String,
|
||||
pub art: Arc<Node>,
|
||||
/// Symbol Options › Symbol Type › Dynamic Symbol: each instance may change the appearance of
|
||||
/// objects in the art and stay linked (see [`crate::symbol_overrides`]). Static by default.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub dynamic: bool,
|
||||
}
|
||||
|
||||
/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects.
|
||||
|
||||
@@ -107,14 +107,17 @@ impl Quotes {
|
||||
/// Single-quote pairs offered by Document Setup.
|
||||
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
|
||||
|
||||
/// Is `c` one of these opening quotes?
|
||||
/// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not
|
||||
/// count; a straight quote has no direction, so it still opens.
|
||||
fn is_open(&self, c: char) -> bool {
|
||||
c == self.double[0] || c == self.single[0]
|
||||
(c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1]))
|
||||
}
|
||||
|
||||
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
|
||||
fn opens(&self, prev: Option<char>) -> bool {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
|
||||
/// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether
|
||||
/// it opened; that settles glyphs which serve as both opening and closing quote.
|
||||
fn opens(&self, prev: Option<char>, quote_opened: Option<bool>) -> bool {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
|
||||
}
|
||||
|
||||
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
|
||||
@@ -122,11 +125,16 @@ impl Quotes {
|
||||
/// and is updated to its last character, so text can be converted in pieces.
|
||||
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut quote_opened = None;
|
||||
for c in text.chars() {
|
||||
let r = match c {
|
||||
'"' => self.double[usize::from(!self.opens(*prev))],
|
||||
'\'' => self.single[usize::from(!self.opens(*prev))],
|
||||
c => c,
|
||||
quote_opened = match c {
|
||||
'"' | '\'' => Some(self.opens(*prev, quote_opened)),
|
||||
_ => None,
|
||||
};
|
||||
let r = match (c, quote_opened) {
|
||||
('"', Some(open)) => self.double[usize::from(!open)],
|
||||
('\'', Some(open)) => self.single[usize::from(!open)],
|
||||
(c, _) => c,
|
||||
};
|
||||
out.push(r);
|
||||
*prev = Some(r);
|
||||
@@ -135,34 +143,74 @@ impl Quotes {
|
||||
}
|
||||
}
|
||||
|
||||
/// Document languages and the quote characters each uses (choosing a language in Document Setup
|
||||
/// also picks its quotes).
|
||||
pub const LANGUAGES: &[(&str, Quotes)] = &[
|
||||
("English: USA", Quotes::ENGLISH),
|
||||
("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
|
||||
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
|
||||
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
|
||||
("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
|
||||
("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
|
||||
("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
|
||||
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
/// Document and character languages: the name, the BCP 47 tag text is shaped in (so that fonts use
|
||||
/// their language's forms, OpenType `locl`) and the quote characters each uses (choosing a
|
||||
/// language in Document Setup also picks its quotes). The first is the default.
|
||||
pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
|
||||
("English: USA", "en-US", Quotes::ENGLISH),
|
||||
("English: UK", "en-GB", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("German", "de", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("German: Swiss", "de-CH", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
|
||||
("French", "fr", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
|
||||
("Italian", "it", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Spanish", "es", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Catalan", "ca", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Portuguese", "pt", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Dutch", "nl", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Danish", "da", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
|
||||
("Swedish", "sv", Quotes { double: ['”', '”'], single: ['’', '’'] }),
|
||||
("Finnish", "fi", Quotes { double: ['”', '”'], single: ['’', '’'] }),
|
||||
("Norwegian", "nb", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||
// „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN
|
||||
// 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR
|
||||
// (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`).
|
||||
("Serbian: Cyrillic", "sr-Cyrl", Quotes { double: ['„', '”'], single: ['’', '’'] }),
|
||||
("Serbian: Latin", "sr-Latn", Quotes { double: ['„', '”'], single: ['’', '’'] }),
|
||||
// „…“ outside, ‚…‘ inside: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml,
|
||||
// `delimiters`).
|
||||
("Croatian", "hr", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }),
|
||||
("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Hindi", "hi", Quotes::ENGLISH),
|
||||
("Marathi", "mr", Quotes::ENGLISH),
|
||||
("Gujarati", "gu", Quotes::ENGLISH),
|
||||
("Bengali", "bn", Quotes::ENGLISH),
|
||||
("Punjabi", "pa", Quotes::ENGLISH),
|
||||
("Tamil", "ta", Quotes::ENGLISH),
|
||||
("Telugu", "te", Quotes::ENGLISH),
|
||||
("Kannada", "kn", Quotes::ENGLISH),
|
||||
("Malayalam", "ml", Quotes::ENGLISH),
|
||||
("Odia", "or", Quotes::ENGLISH),
|
||||
("Urdu", "ur", Quotes::ENGLISH),
|
||||
];
|
||||
|
||||
/// The quotes of a [`LANGUAGES`] entry (case-insensitive).
|
||||
pub fn language_quotes(language: &str) -> Option<Quotes> {
|
||||
LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q)
|
||||
LANGUAGES.iter().find(|(l, _, _)| l.eq_ignore_ascii_case(language)).map(|(_, _, q)| *q)
|
||||
}
|
||||
|
||||
/// The [`LANGUAGES`] entry named or tagged `language` (case-insensitive) as a character's
|
||||
/// language ([`crate::CharStyle::language`]) stores it: its tag, or `None` for the default (the
|
||||
/// first entry); `Err` for a language not in the list.
|
||||
pub fn char_language(language: &str) -> Result<Option<&'static str>, String> {
|
||||
let i = LANGUAGES
|
||||
.iter()
|
||||
.position(|(n, t, _)| n.eq_ignore_ascii_case(language) || t.eq_ignore_ascii_case(language))
|
||||
.ok_or_else(|| format!("unknown language `{language}` (one of {})", LANGUAGES.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")))?;
|
||||
Ok(LANGUAGES.get(i).filter(|_| i > 0).map(|(_, t, _)| *t))
|
||||
}
|
||||
|
||||
/// The name of the language a character's stored `language` tag stands for: the default's for
|
||||
/// none, `None` for a tag not in [`LANGUAGES`] (from a file).
|
||||
pub fn char_language_name(language: Option<&str>) -> Option<&'static str> {
|
||||
match language {
|
||||
None => LANGUAGES.first().map(|(n, _, _)| *n),
|
||||
Some(tag) => LANGUAGES.iter().find(|(_, t, _)| t.eq_ignore_ascii_case(tag)).map(|(n, _, _)| *n),
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparency flattener preset Document Setup uses unless another is chosen.
|
||||
@@ -300,9 +348,19 @@ impl DocSetup {
|
||||
pub fn keep_in_range(&mut self) {
|
||||
self.bleed = bleed_in_range(self.bleed);
|
||||
for m in [&mut self.superscript, &mut self.subscript] {
|
||||
// `clamp` passes NaN through, so a non-finite value falls back to the default first.
|
||||
if !m.size.is_finite() {
|
||||
m.size = ScriptMetrics::DEFAULT.size;
|
||||
}
|
||||
if !m.position.is_finite() {
|
||||
m.position = ScriptMetrics::DEFAULT.position;
|
||||
}
|
||||
m.size = m.size.clamp(1.0, MAX_SCRIPT);
|
||||
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
|
||||
}
|
||||
if !self.small_caps_size.is_finite() {
|
||||
self.small_caps_size = DocSetup::default().small_caps_size;
|
||||
}
|
||||
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
|
||||
}
|
||||
}
|
||||
@@ -311,6 +369,31 @@ impl DocSetup {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Characters store a language of the list by its tag, the default as none.
|
||||
#[test]
|
||||
fn char_languages_are_stored_by_tag() {
|
||||
assert_eq!(char_language("Turkish"), Ok(Some("tr")));
|
||||
assert_eq!(char_language("MR"), Ok(Some("mr")));
|
||||
assert_eq!(char_language("english: usa"), Ok(None));
|
||||
assert_eq!(char_language("en-US"), Ok(None));
|
||||
assert!(char_language("Klingon").is_err() && char_language("").is_err());
|
||||
assert_eq!(
|
||||
(char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))),
|
||||
(Some("English: USA"), Some("Gujarati"), None)
|
||||
);
|
||||
// Serbian in either script, and Croatian.
|
||||
assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl")));
|
||||
assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn")));
|
||||
assert_eq!(char_language("Croatian"), Ok(Some("hr")));
|
||||
assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic"));
|
||||
// Every entry has a tag of its own, and a name of its own.
|
||||
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
|
||||
assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight quotes become the language's: opening or closing by what comes before, inner
|
||||
/// quotes as single ones, and the context carried across pieces converted apart.
|
||||
#[test]
|
||||
fn quotes_open_and_close_by_context() {
|
||||
let mut prev = None;
|
||||
@@ -319,6 +402,13 @@ mod tests {
|
||||
let mut prev = Some('(');
|
||||
assert_eq!(german.apply("\"a\"", &mut prev), "„a“");
|
||||
assert_eq!(prev, Some('“'));
|
||||
// Serbian closes high (”), with ’ on both sides of an inner quote; Croatian as German.
|
||||
for name in ["Serbian: Cyrillic", "Serbian: Latin"] {
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes(name).unwrap().apply("\"a 'b' c\"", &mut prev), "„a ’b’ c”", "{name}");
|
||||
}
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
|
||||
// Converting in pieces keeps the context.
|
||||
let mut prev = None;
|
||||
let a = Quotes::ENGLISH.apply("\"", &mut prev);
|
||||
@@ -326,6 +416,18 @@ mod tests {
|
||||
assert_eq!((a.as_str(), b.as_str()), ("“", "“"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() {
|
||||
let q = Quotes { double: ['”', '”'], single: ['‘', '’'] };
|
||||
let mut prev = None;
|
||||
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’");
|
||||
let q = Quotes { double: ['"', '"'], single: ['‘', '’'] };
|
||||
let mut prev = None;
|
||||
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’");
|
||||
let mut prev = None;
|
||||
assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bleed_grows_the_artboard() {
|
||||
let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() };
|
||||
@@ -333,6 +435,20 @@ mod tests {
|
||||
assert!(s.has_bleed() && !DocSetup::default().has_bleed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_in_range_replaces_non_finite_type_values() {
|
||||
let mut s = DocSetup::default();
|
||||
s.superscript.size = f64::NAN;
|
||||
s.superscript.position = f64::INFINITY;
|
||||
s.subscript.size = f64::NEG_INFINITY;
|
||||
s.subscript.position = f64::NAN;
|
||||
s.small_caps_size = f64::NAN;
|
||||
s.keep_in_range();
|
||||
for v in [s.superscript.size, s.superscript.position, s.subscript.size, s.subscript.position, s.small_caps_size] {
|
||||
assert!(v.is_finite(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_and_defaults() {
|
||||
let d = DocSetup::default();
|
||||
|
||||
@@ -110,8 +110,9 @@ impl Document {
|
||||
|
||||
/// Visit every color that can link to a swatch (solid paints and gradient stops of fills,
|
||||
/// strokes and text runs) in the art (opacity masks included), symbol definitions, graph
|
||||
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles
|
||||
/// and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles,
|
||||
/// character styles' fill and stroke and the swatches themselves (tint swatches, gradient
|
||||
/// swatches' stops).
|
||||
/// `f(colour, link, tint)` may change them and returns true when it did
|
||||
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
|
||||
/// copied. Returns the number of changed paints.
|
||||
@@ -134,6 +135,11 @@ impl Document {
|
||||
for w in self.swatches_iter_mut() {
|
||||
n += usize::from(w.paint.map_links(f));
|
||||
}
|
||||
for def in &mut self.char_styles {
|
||||
for key in ["fill", "stroke"] {
|
||||
n += usize::from(map_style_paint(&mut def.attrs, key, f));
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
@@ -198,6 +204,23 @@ pub fn color_name(c: Color) -> String {
|
||||
/// true when it changed them.
|
||||
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
|
||||
/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`]
|
||||
/// does, and store the result in the attribute. True when it changed.
|
||||
fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key: &str, f: &mut LinkFn) -> bool {
|
||||
let Some(value) = attrs.get_mut(key) else { return false };
|
||||
let Ok(mut paint) = serde_json::from_value::<Paint>(value.clone()) else { return false };
|
||||
if !paint.map_links(f) {
|
||||
return false;
|
||||
}
|
||||
match serde_json::to_value(&paint) {
|
||||
Ok(changed) => {
|
||||
*value = changed;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
|
||||
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
|
||||
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
|
||||
|
||||