mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 19:21:22 +00:00
Merge main into feat/character-anti-aliasing-method-r
This commit is contained in:
@@ -1,2 +1,9 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
|
||||
# WebAssembly SIMD for the web build: without it vello_cpu's SIMD layer (fearless_simd) runs its
|
||||
# scalar fallback on wasm32 and the canvas renders about 2.4x slower (#2786). The web build then
|
||||
# needs Chrome/Edge 91+, Firefox 89+ or Safari 16.4+. A RUSTFLAGS environment variable replaces
|
||||
# these flags.
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["-C", "target-feature=+simd128"]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -20,6 +20,12 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
@@ -60,7 +66,7 @@ jobs:
|
||||
PY
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
needs: [git-sources, credits]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -72,6 +78,7 @@ jobs:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -82,6 +89,7 @@ jobs:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# VectorCraft — instructions for agents
|
||||
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
|
||||
@@ -6,10 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
|
||||
## Start every session here
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
2. `plan/` is an optional gitignored notebook. Behaviour notes may be in `plan/illustrator/*.md`. Do not ask for it and do not stop when it is absent. Orient, implement, test, verify, then record in the public docs.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
|
||||
@@ -45,7 +95,6 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **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). 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`).
|
||||
|
||||
@@ -88,3 +137,5 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Asset attribution
|
||||
|
||||
## Indic hyphenation patterns
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/text/patterns/hyph-hi.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-hi.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-mr.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-mr.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-gu.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-gu.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-bn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-bn.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-pa.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pa.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ta.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ta.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-te.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-te.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-kn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-kn.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ml.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ml.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-or.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-or.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-as.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-as.tex | MIT (selected option) | Unmodified; full license in file |
|
||||
|
||||
Every non-code asset in this repository (icons, images, fonts, example art, colour profiles, presets) is listed here with its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset file is missing from this table.
|
||||
|
||||
**Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
@@ -115,6 +131,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-contour.svg` | VectorCraft contributors | Original work, drawn for VectorCraft (a solid rounded square inside four corner arcs, its offset outline); not derived from any other application's icon | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
@@ -351,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `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` |
|
||||
| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` |
|
||||
| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` |
|
||||
| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` |
|
||||
| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` |
|
||||
| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` |
|
||||
| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` |
|
||||
| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` |
|
||||
| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` |
|
||||
| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` |
|
||||
| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` |
|
||||
| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` |
|
||||
| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` |
|
||||
|
||||
## Assets embedded by the PDF backend
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Attribution
|
||||
|
||||
The complete historical asset manifest is [ASSETS.md](ASSETS.md). These hyphenation assets use the MIT option, with the complete copyright and permission notices preserved in each file.
|
||||
|
||||
| Asset | Author | Source | SPDX license | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/text/patterns/hyph-hi.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-hi.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-mr.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-mr.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-gu.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-gu.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-bn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-bn.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-pa.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pa.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ta.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ta.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-te.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-te.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-kn.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-kn.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-ml.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-ml.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-or.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-or.tex | MIT | Unmodified; full license in file |
|
||||
| `crates/text/patterns/hyph-as.tex` | Santhosh Thottingal | https://github.com/hyphenation/tex-hyphen/blob/5684c0f51c0b81133db2efbe60a408b4155a3ff5/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-as.tex | MIT | Unmodified; full license in file |
|
||||
@@ -1,76 +1 @@
|
||||
# VectorCraft — instructions for agents
|
||||
|
||||
VectorCraft is a clean-room, open-source, Rust-native vector illustration app targeting Adobe Illustrator parity (and beyond). It runs natively on macOS, Windows and Linux, and on the web via WASM. Siblings: `../photocraft` (Photoshop-class) and `../pdfcraft` (Acrobat-class); same conventions.
|
||||
|
||||
Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format": "drawcraft"` headers still open (`vectorcraft_format::LEGACY_EXTENSION`), and preferences migrate from the old config folder. Keep those paths working; use the new name everywhere else.
|
||||
|
||||
## Start every session here
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, numbers by dimension, what's next) and the ranked work list in [`docs/gaps.md`](docs/gaps.md). Unless the user gives you a task, pick work from that list.
|
||||
2. Read `plan/STATUS.md` (local session notes, may lag the ROADMAP), then the task in `plan/execution-plan.md` §3 and the relevant `plan/architecture.md` section. Behaviour reference: public documentation only (see Clean-room below); `plan/illustrator/*.md` holds notes from it.
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §7): orient → plan → implement + test → verify → record → commit. Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room: never use Adobe software (absolute rule, from the project owner).** Adobe's Terms of Use forbid reverse engineering, including observing a program's inputs and outputs to recreate it. So:
|
||||
- Never launch, run, script, automate (COM, AppleScript, ExtendScript, UI automation), screenshot or measure Illustrator or any other Adobe application, and never compare our output against one. Don't make test files with them, not even "synthetic" ones.
|
||||
- Never read, disassemble or copy anything inside an Adobe install (binaries, plug-ins, presets, procsets, resources, help files, fonts, ICC profiles).
|
||||
- Never copy Adobe-authored code or text into this repo, tests included: no PostScript procsets or prologs (AGM, CoolType, the Illustrator prologs), no sample files. Write test inputs from scratch from the public specifications (PostScript Language Reference, PDF / ISO 32000, TIFF, EPSF/DCS).
|
||||
- Behaviour comes only from public documentation (helpx, published specs, public forum posts), cited by URL, and from files users send us that they own (used locally, never committed). Never copy Adobe icons, artwork, presets or wording beyond feature names.
|
||||
- If a task seems to need any of this, stop and say so instead. `cargo xtask cleanroom` (run by `cargo xtask ci`) fails on Adobe copyright notices and on fixtures made with Adobe applications.
|
||||
- Never copy GPL/AGPL code (Inkscape, lib2geom…).
|
||||
- **Assets: no Adobe iconography or images — ever (absolute rule, from the project owner).**
|
||||
- Never add, copy, trace, redraw-from, embed or ship any icon, image, artwork, cursor, preset, swatch/brush/symbol/pattern/style library, ICC profile or screenshot from Adobe products or from any other source whose licence doesn't allow it.
|
||||
- Every image, icon, font or other asset must be one of:
|
||||
- original work created for VectorCraft by a contributor (who licenses it MIT OR Apache-2.0);
|
||||
- OSI open source;
|
||||
- public domain / CC0;
|
||||
- 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**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
|
||||
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
|
||||
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
|
||||
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
|
||||
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
|
||||
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it.
|
||||
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
|
||||
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
|
||||
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
|
||||
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
|
||||
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
|
||||
- **Adding a cursor.** The app decides which cursors exist; whoever draws them works from the list. For a new tool state with a cursor of its own: add the `Cursor` variant (`crates/tools/src/lib.rs`, with a doc comment), return it from the tool's `cursor()` with a tool test, cover it in `cursor_icon` (`crates/ui-egui/src/canvas.rs`), draw it in `crates/ui-egui/src/cursors.rs` or give it a stand-in in `Cursor::stand_in()` until it's drawn, and add its `[[cursor]]` entry to `crates/tools/src/cursors.toml` (when it shows, in plain behaviour terms, and its hotspot; never another app's look). A missing entry only warns (`cargo xtask cursors`): the cursor still works.
|
||||
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **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). 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
|
||||
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`.
|
||||
- **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with the public documentation (`plan/illustrator/02-ui-ux.md` notes). `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `vectorcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests).
|
||||
- **Performance:** `vectorcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `vectorcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise).
|
||||
- MCP: `vectorcraft-cli mcp` (see `docs/mcp.md`).
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
- 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:
|
||||
- [`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.
|
||||
- Checklists: [`ui-parity.md`](docs/ui-parity.md), [`file-format-parity.md`](docs/file-format-parity.md), [`hardware-parity.md`](docs/hardware-parity.md), [`localization-parity.md`](docs/localization-parity.md), [`effects-parity.md`](docs/effects-parity.md), [`type-parity.md`](docs/type-parity.md); [`architecture.md`](docs/architecture.md).
|
||||
|
||||
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
|
||||
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
|
||||
- Keep the README's Status section in step with the ROADMAP headline.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+79
-6
@@ -1093,6 +1093,17 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1431,7 +1442,7 @@ dependencies = [
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
"resvg",
|
||||
"resvg 0.45.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2275,6 +2286,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2967,8 +2987,7 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
[[package]]
|
||||
name = "linesweeper"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=481f66d287b2a06845f3667a19daf35f0fe958ed#481f66d287b2a06845f3667a19daf35f0fe958ed"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4419,11 +4438,29 @@ dependencies = [
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes 0.15.3",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
"usvg 0.45.1",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gif 0.14.2",
|
||||
"image-webp",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes 0.16.1",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg 0.48.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
@@ -4708,7 +4745,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5574,6 +5611,21 @@ dependencies = [
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.18.1",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
@@ -6100,6 +6152,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"smithay-clipboard",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-text",
|
||||
@@ -6163,6 +6216,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-engine",
|
||||
@@ -6214,7 +6268,9 @@ version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"resvg 0.48.1",
|
||||
"serde_json",
|
||||
"svgtypes 0.16.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
@@ -6234,10 +6290,12 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"hayro-jpeg2000",
|
||||
"heic-rs",
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"rayon",
|
||||
"roxmltree 0.21.2",
|
||||
"rustix 1.1.6",
|
||||
"ruzstd",
|
||||
@@ -6252,6 +6310,7 @@ dependencies = [
|
||||
"vectorcraft-eps",
|
||||
"vectorcraft-format",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-js",
|
||||
"vectorcraft-metafile",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-pdf",
|
||||
@@ -6270,6 +6329,7 @@ dependencies = [
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"fax",
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -6278,6 +6338,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -6319,6 +6380,14 @@ dependencies = [
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-js"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.21.0"
|
||||
@@ -6400,8 +6469,10 @@ dependencies = [
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -6456,7 +6527,7 @@ dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"resvg 0.45.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simplecss",
|
||||
@@ -6469,6 +6540,7 @@ dependencies = [
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
@@ -6591,6 +6663,7 @@ dependencies = [
|
||||
name = "vectorcraft-web"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
@@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" }
|
||||
vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
vectorcraft-js = { path = "crates/js" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -153,6 +154,7 @@ hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
linesweeper = { git = "https://github.com/storytold/craft-libs", rev = "481f66d287b2a06845f3667a19daf35f0fe958ed" }
|
||||
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" }
|
||||
|
||||
@@ -20,10 +20,15 @@ list (author, source, license) is in ASSETS.md.
|
||||
(Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font
|
||||
(MIT). Their license texts ship with that crate.
|
||||
|
||||
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are
|
||||
Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, egui, eframe, …) are
|
||||
fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also
|
||||
BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0).
|
||||
|
||||
linesweeper 0.5.0 (MIT OR Apache-2.0) is fetched from https://github.com/storytold/craft-libs at
|
||||
revision 481f66d287b2a06845f3667a19daf35f0fe958ed. The copy in craft-libs' vendor/linesweeper is
|
||||
the crates.io package with no file changed, and it keeps the package's LICENSE-MIT and
|
||||
LICENSE-APACHE.
|
||||
|
||||
JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is
|
||||
based in part on the work of the Independent JPEG Group.
|
||||
|
||||
@@ -136,3 +141,8 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Indic hyphenation patterns (crates/text/patterns/):
|
||||
Copyright (C) 2016 Santhosh Thottingal, used under the MIT license option.
|
||||
Original copyright and full license notices are preserved in each hyph-*.tex file.
|
||||
Source: hyphenation/tex-hyphen, commit 5684c0f51c0b81133db2efbe60a408b4155a3ff5.
|
||||
|
||||
@@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
|
||||
- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art);
|
||||
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
|
||||
@@ -317,7 +317,7 @@ Installers and executables are code-signed.
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `vectorcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
| Static site | `vectorcraft-web-<ver>.zip` | Runs in a browser with WebAssembly SIMD (Chrome/Edge 91, Firefox 89, Safari 16.4 or newer), see `packaging/web/README.md`; host it on any static server |
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
|
||||
+13
-4
@@ -1,5 +1,8 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged.
|
||||
- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged.
|
||||
|
||||
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
|
||||
|
||||
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
|
||||
@@ -11,7 +14,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -20,8 +23,8 @@ and the work list in [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
**Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance,
|
||||
type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's
|
||||
own `.ai` files open with a wrong layer structure in real cases and can't be saved back with Illustrator's
|
||||
editing data, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that
|
||||
keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the
|
||||
app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow
|
||||
gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print
|
||||
production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without
|
||||
@@ -31,6 +34,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
@@ -104,7 +108,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| English | `en` | full | 100% |
|
||||
| Simplified Chinese | `zh-hans` | partial (messages in English) | 85% |
|
||||
| Spanish | `es` | full | 100% |
|
||||
| Hindi | `hi` | none | 0% |
|
||||
| Hindi | `hi` | catalog complete; machine-assisted draft, native review pending | complete under the scanner |
|
||||
| Arabic | `ar` | none (no right-to-left interface) | 0% |
|
||||
| French | `fr` | full | 100% |
|
||||
| Portuguese (Brazil) | `pt-br` | full | 100% |
|
||||
@@ -131,6 +135,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged.
|
||||
- 2026-11-05: Release Type on a Path joins the Type Tools add-on (Object › Plug-ins › Type Tools): each selected type on a path splits into point type with the same text and attributes, on one straight line starting where its first character sat, and the plain path it was set on, with its exact shape and place in the stacking order and no paint; or keeps only the path or only the text, optionally leaving the original. A dialog with Preview, one undo step (`addon.type.releasePath`, #5532). A VectorCraft addition: headline numbers unchanged.
|
||||
- 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged.
|
||||
- 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged.
|
||||
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
|
||||
@@ -219,6 +225,9 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
|
||||
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
|
||||
| 2026-11-03 | minor | Progress log row for the Type Tools add-on's Text Tweaker (#4924); headline numbers unchanged. |
|
||||
| 2026-11-02 | minor | Closed font-family fields accept wheel input for selected text in Character, Properties and Control (#5253); scores and estimates unchanged. |
|
||||
| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. |
|
||||
|
||||
@@ -16,7 +16,7 @@ workspace = true
|
||||
[dependencies]
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-svg = { workspace = true }
|
||||
@@ -45,5 +45,6 @@ objc2-core-foundation = { version = "0.3", default-features = false, features =
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli --safe-mode SUBCOMMAND ...
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
//! vectorcraft-cli convert IN OUT [--scale 2] [--ppi 300] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
//! vectorcraft-cli info FILE
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000] [--deep]
|
||||
@@ -72,6 +73,11 @@ const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
USAGE:
|
||||
vectorcraft-cli --safe-mode SUBCOMMAND ...
|
||||
Run any subcommand without plug-ins: none loads or runs, and plug-in commands report that
|
||||
Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and
|
||||
does not take --connect.
|
||||
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
@@ -81,11 +87,12 @@ USAGE:
|
||||
allowed, and a kind left out grants none of its access. They imply --headless: a running app
|
||||
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports
|
||||
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
|
||||
is a template, written as file.saveAsTemplate writes it). Raster exports use the document's
|
||||
raster effects resolution unless --scale (pixels per point) or --ppi (pixels per inch, wins
|
||||
over --scale) is given; other formats leave them out. Prints one JSON result per step.
|
||||
--commands reads steps from FILE, or from standard input for -, where it stands among the
|
||||
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
|
||||
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
|
||||
@@ -94,10 +101,10 @@ USAGE:
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
vectorcraft-cli convert IN OUT [--scale N] [--ppi N] [--artboard I | --range R] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--preserve-editing] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
|
||||
Open IN (any readable format) and export OUT in the format its extension picks (see Writable
|
||||
formats). --scale sizes a raster OUT (pixels per point, default 1); other formats leave it
|
||||
out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
formats). Raster resolution: --ppi, else 72 times --scale, else the document's raster effects
|
||||
resolution; other formats leave them out. --artboard is 0-based, --range 1-based (\"1-3,5\"); a PDF gets every artboard
|
||||
unless one of them is given, EPS the bounds of the art, the other formats the first artboard.
|
||||
A .ait OUT is a template of every artboard, written as file.saveAsTemplate writes it (not with
|
||||
--artboard or --range).
|
||||
@@ -127,12 +134,17 @@ USAGE:
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N] [--deep]
|
||||
vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows), with the peak so far on
|
||||
Windows and Linux.
|
||||
--native-io instead measures the application's native save/open commands (one warmup,
|
||||
five samples, no disk I/O), including preview, PDF, compression and API base64 costs.
|
||||
It reports the default and explicit PDF opt-out, with and without preview/compression;
|
||||
--artboards repeats the same artwork
|
||||
on 1 to 4 overlapping artboards. This mode allows at most 50000 paths.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
@@ -174,12 +186,18 @@ fn main() -> ExitCode {
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
prefs_dir::install_fonts();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// Before any subcommand attaches the plug-in folder.
|
||||
let safe_mode = args.first().is_some_and(|a| a == "--safe-mode");
|
||||
if safe_mode {
|
||||
args.remove(0);
|
||||
vectorcraft_engine::cmd::plugin::start_in_safe_mode();
|
||||
}
|
||||
let r = match args.first().map(String::as_str) {
|
||||
// `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the
|
||||
// usage. The other subcommands check for it themselves.
|
||||
Some("commands" | "info") if help_requested(&args[1..], &[]) => help(),
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
Some("mcp") => mcp(&args[1..], safe_mode),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("commands") => commands(),
|
||||
Some("convert") => convert(&args[1..]),
|
||||
@@ -212,7 +230,9 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
h
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own
|
||||
/// plug-ins, so `--connect` is refused and the default does not connect.
|
||||
fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) {
|
||||
return help();
|
||||
}
|
||||
@@ -239,6 +259,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
if safe_mode && connect.is_some() {
|
||||
return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control <port>`".into());
|
||||
}
|
||||
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
|
||||
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
@@ -251,7 +274,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
file_access::confine_process(roots.clone());
|
||||
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
} else if headless || safe_mode {
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
@@ -279,13 +302,15 @@ fn commands() -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let valued = ["--scale", "-s", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
|
||||
let valued =
|
||||
["--scale", "-s", "--ppi", "--artboard", "-a", "--range", "-r", "--decimals", "--ai-version", "--hidden-objects", "--transparent-images"];
|
||||
if help_requested(args, &valued) {
|
||||
return help();
|
||||
}
|
||||
let mut files = vec![];
|
||||
let (mut scale, mut artboard, mut range, mut outline_text, mut replace_lossy, mut editing_data, mut preserve_editing) =
|
||||
(1.0f64, None::<u64>, None::<String>, false, false, false, false);
|
||||
(None::<f64>, None::<u64>, None::<String>, false, false, false, false);
|
||||
let mut ppi = None::<f64>;
|
||||
let mut decimals = None::<u64>;
|
||||
// Legacy `.ai` options, passed on as the export's own (`aiVersion`, `hiddenObjects`,
|
||||
// `transparentImages`); the engine checks their values.
|
||||
@@ -293,7 +318,15 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--scale" | "-s" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--scale" | "-s" => scale = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?),
|
||||
"--ppi" => {
|
||||
ppi = Some(
|
||||
it.next()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.filter(|n: &f64| n.is_finite() && *n > 0.0)
|
||||
.ok_or("--ppi needs a positive finite number")?,
|
||||
);
|
||||
}
|
||||
"--artboard" | "-a" => artboard = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--artboard needs an index")?),
|
||||
"--range" | "-r" => range = Some(it.next().cloned().ok_or("--range needs artboards such as 1-3,5")?),
|
||||
"--outline-text" => outline_text = true,
|
||||
@@ -326,7 +359,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
// A conversion keeps hidden layers and objects, written hidden, where the format can (SVG,
|
||||
// PSD).
|
||||
let params = json!({"path": output, "artboard": artboard, "range": range, "outlineText": outline_text, "hiddenLayers": true, "acknowledgeLoss": replace_lossy});
|
||||
let mut params = with_pixel_scale(params, &output, scale);
|
||||
let mut params = with_pixel_scale(params, &output, scale, ppi);
|
||||
if let Some(decimals) = decimals {
|
||||
params["decimals"] = json!(decimals);
|
||||
}
|
||||
@@ -372,12 +405,17 @@ fn is_template(path: &str) -> bool {
|
||||
vectorcraft_engine::cmd::fileio::format_for_name(path).is_some_and(|f| f.id == "ait")
|
||||
}
|
||||
|
||||
/// The export `params` for `path`, with `--scale` (pixels per point) when its format is raster.
|
||||
/// The export `params` for `path`, with explicit raster resolution options only for raster formats.
|
||||
/// DXF has a `scale` of its own (drawing units per unit), which would resize the drawing and leave
|
||||
/// its units unset.
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: f64) -> Value {
|
||||
fn with_pixel_scale(mut params: Value, path: &str, scale: Option<f64>, ppi: Option<f64>) -> Value {
|
||||
if vectorcraft_engine::cmd::fileio::writable_format(None, Some(path)).is_ok_and(|f| f.raster) {
|
||||
params["scale"] = json!(scale);
|
||||
if let Some(scale) = scale {
|
||||
params["scale"] = json!(scale);
|
||||
}
|
||||
if let Some(ppi) = ppi {
|
||||
params["ppi"] = json!(ppi);
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
@@ -485,7 +523,7 @@ fn command_step(v: Value) -> Result<Step, String> {
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s"];
|
||||
let valued = ["--in", "-i", "--cmd", "-c", "--params", "-p", "--commands", "--export", "-o", "--scale", "-s", "--ppi"];
|
||||
if help_requested(args, &valued) {
|
||||
return help();
|
||||
}
|
||||
@@ -494,7 +532,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
// The step of the `--cmd` just given, which a `--params` sets.
|
||||
let mut params_for: Option<usize> = None;
|
||||
let mut stdin_read = false;
|
||||
let mut scale = 1.0;
|
||||
let mut scale = None::<f64>;
|
||||
let mut ppi = None::<f64>;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||
@@ -531,7 +570,16 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
"--scale" | "-s" => {
|
||||
let raw = val("a number")?;
|
||||
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
||||
scale = Some(raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?);
|
||||
}
|
||||
"--ppi" => {
|
||||
let raw = val("a number")?;
|
||||
ppi = Some(
|
||||
raw.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|n| n.is_finite() && *n > 0.0)
|
||||
.ok_or_else(|| format!("--ppi {raw}: needs a positive finite number"))?,
|
||||
);
|
||||
}
|
||||
other => return Err(format!("unknown run option `{other}`")),
|
||||
}
|
||||
@@ -576,7 +624,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let r = if is_template(&path) {
|
||||
h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": path, "format": "ait"}}))
|
||||
} else {
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
|
||||
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale, ppi))
|
||||
};
|
||||
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
|
||||
@@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
v.get(v.len() / 2).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Exercise the application's native save/open commands, including previews, PDF output,
|
||||
/// compression and API base64 conversion. Session setup and validation are outside timing.
|
||||
/// Five measured runs follow one warmup; no filesystem or GPU window is involved.
|
||||
fn native_io(doc: &Document) -> Result<(), String> {
|
||||
for (label, compatible, preview) in [
|
||||
("default options", None, false),
|
||||
("PDF disabled", Some(false), false),
|
||||
("preview+gzip", None, true),
|
||||
("preview+gzip, PDF disabled", Some(false), true),
|
||||
] {
|
||||
let mut session = vectorcraft_engine::Session::new();
|
||||
session.add_document(doc.clone(), None);
|
||||
let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview});
|
||||
if let Some(value) = compatible {
|
||||
params["pdfCompatible"] = json!(value);
|
||||
}
|
||||
let (mut saves, mut opens) = (Vec::new(), Vec::new());
|
||||
let (mut size, mut has_pdf, mut compressed) = (0, false, false);
|
||||
for run in 0..6 {
|
||||
let start = Instant::now();
|
||||
let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?;
|
||||
let save_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?;
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?;
|
||||
size = bytes.len();
|
||||
has_pdf = vectorcraft_format::pdf_content(&bytes).is_some();
|
||||
if compatible == Some(false) && has_pdf {
|
||||
return Err("native save ignored the explicit PDF opt-out".into());
|
||||
}
|
||||
compressed = vectorcraft_format::is_compressed(&bytes);
|
||||
if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview {
|
||||
return Err("native save did not apply the requested preview/compression options".into());
|
||||
}
|
||||
let args = json!({"name":"perf.vectorcraft","dataBase64":data});
|
||||
let mut opened = vectorcraft_engine::Session::new();
|
||||
let start = Instant::now();
|
||||
opened.execute("document.open", &args).map_err(|e| e.to_string())?;
|
||||
let open_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() {
|
||||
return Err("native round trip changed the artboard count".into());
|
||||
}
|
||||
if run > 0 {
|
||||
saves.push(save_ms);
|
||||
opens.push(open_ms);
|
||||
}
|
||||
}
|
||||
saves.sort_by(f64::total_cmp);
|
||||
opens.sort_by(f64::total_cmp);
|
||||
let save = saves.get(2).copied().ok_or("no save measurements")?;
|
||||
let open = opens.get(2).copied().ok_or("no open measurements")?;
|
||||
outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn load_average() -> Option<f64> {
|
||||
@@ -187,6 +242,48 @@ fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
|
||||
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
|
||||
}
|
||||
|
||||
/// Tracked rows for pointing at things in an n-path document: what hovering, clicking, marquee
|
||||
/// selection and snapping cost when nothing narrows the search.
|
||||
fn pointing(n: usize, rows: &mut Vec<(String, f64)>) -> Result<(), String> {
|
||||
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
|
||||
let err = |e: vectorcraft_engine::EngineError| e.to_string();
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(n)?, None);
|
||||
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
|
||||
s.select_tool("selection", view).map_err(err)?;
|
||||
// Off the art (nothing under the pointer), the case every hover over empty canvas pays.
|
||||
let off = Point::new(-200.0, -200.0);
|
||||
let hover = |s: &mut vectorcraft_engine::Session| {
|
||||
let _ = s.pointer(&PointerEvent::new(PointerKind::Move, off.x, off.y), view);
|
||||
let _ = s.cursor(off, Mods::default(), view);
|
||||
};
|
||||
rows.push((format!("hover over empty canvas ({n} paths)"), median_ms(5, || hover(&mut s))));
|
||||
s.execute("select.all", &json!({})).map_err(err)?;
|
||||
rows.push((format!("hover over empty canvas, all selected ({n} paths)"), median_ms(5, || hover(&mut s))));
|
||||
let doc = s.doc().map_err(err)?.doc.clone();
|
||||
rows.push((format!("hit test, a miss ({n} paths)"), median_ms(5, || drop(hit::hit_test(&doc, off, hit::HitOptions::default())))));
|
||||
let r = Rect::new(600.0, 450.0, 1000.0, 750.0);
|
||||
rows.push((format!("marquee 400×300 ({n} paths)"), median_ms(5, || drop(hit::marquee(&doc, r, None, false)))));
|
||||
let view_rect = Rect::new(0.0, 0.0, 1600.0, 1200.0);
|
||||
rows.push((
|
||||
format!("smart guide targets in view ({n} paths)"),
|
||||
median_ms(5, || drop(vectorcraft_engine::tools::guides::Targets::collect(&doc, &[], Some(view_rect)))),
|
||||
));
|
||||
// Adding one more object to a selection of half the document: the command a shift-click ends
|
||||
// in, without the pointer handling and hit test before it.
|
||||
let ids: Vec<u64> = doc.layers[0].children().map(|c| c.iter().map(|n| n.id.0).collect()).unwrap_or_default();
|
||||
s.execute("select.set", &json!({"ids": ids[..ids.len() / 2]})).map_err(err)?;
|
||||
let mut k = ids.len() / 2;
|
||||
rows.push((
|
||||
format!("select.toggle in a {}-object selection", ids.len() / 2),
|
||||
median_ms(5, || {
|
||||
k += 1;
|
||||
let _ = s.execute("select.toggle", &json!({"id": ids[k % ids.len()]}));
|
||||
}),
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
|
||||
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
|
||||
/// the peak so far where `rss_mb` reads one.
|
||||
@@ -282,6 +379,8 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
}),
|
||||
));
|
||||
note("after typing");
|
||||
pointing(n, &mut rows)?;
|
||||
note("after pointing");
|
||||
// Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render
|
||||
// worker and a panel each draw them.
|
||||
{
|
||||
@@ -349,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if crate::help_requested(args, &["--paths"]) {
|
||||
if crate::help_requested(args, &["--paths", "--artboards"]) {
|
||||
return crate::help();
|
||||
}
|
||||
let mut n = 50_000usize;
|
||||
let mut want_deep = false;
|
||||
let mut want_native = false;
|
||||
let mut artboards = None::<usize>;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--deep" => want_deep = true,
|
||||
"--native-io" => want_native = true,
|
||||
"--artboards" => {
|
||||
artboards =
|
||||
Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?)
|
||||
}
|
||||
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
}
|
||||
if want_native && n > 50_000 {
|
||||
return Err("--native-io allows at most 50000 paths".into());
|
||||
}
|
||||
if want_native && want_deep {
|
||||
return Err("--native-io and --deep are separate measurement modes".into());
|
||||
}
|
||||
if artboards.is_some() && !want_native {
|
||||
return Err("--artboards requires --native-io".into());
|
||||
}
|
||||
let artboards = artboards.unwrap_or(1);
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
|
||||
#[cfg(not(windows))]
|
||||
@@ -374,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n)?;
|
||||
let mut doc = synthetic(n)?;
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
if want_native {
|
||||
let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?;
|
||||
// Overlapping artboards intentionally repeat the same paths on every PDF page.
|
||||
for index in 1..artboards {
|
||||
let mut page = first.clone();
|
||||
page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?;
|
||||
page.name = format!("Perf {}", index + 1);
|
||||
doc.artboards.push(page);
|
||||
}
|
||||
outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O");
|
||||
return native_io(&doc);
|
||||
}
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -469,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_io_exercises_the_application_save_and_open_commands() {
|
||||
native_io(&synthetic(3).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_io_rejects_conflicting_modes_and_excessive_workloads() {
|
||||
for (args, expected) in [
|
||||
(vec!["--native-io", "--deep"], "separate measurement modes"),
|
||||
(vec!["--native-io", "--paths", "50001"], "at most 50000"),
|
||||
(vec!["--native-io", "--artboards", "5"], "1 to 4"),
|
||||
(vec!["--artboards", "1"], "requires --native-io"),
|
||||
(vec!["--artboards", "2"], "requires --native-io"),
|
||||
] {
|
||||
assert!(run(&args.into_iter().map(str::to_owned).collect::<Vec<_>>()).unwrap_err().contains(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_share_leaves_out_idle_time() {
|
||||
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
|
||||
|
||||
@@ -283,6 +283,22 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `--safe-mode mcp` serves a headless session without plug-ins: it refuses `--connect`, and by
|
||||
/// default it does not connect to an app listening on the default address, which loads its own
|
||||
/// plug-ins.
|
||||
#[test]
|
||||
fn safe_mode_mcp_does_not_connect_to_a_running_app() {
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}");
|
||||
// Something listening on the default address, as a running app does; a running app or another
|
||||
// program already holding it serves as well.
|
||||
let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR);
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}");
|
||||
}
|
||||
|
||||
/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
|
||||
@@ -105,6 +105,38 @@ fn images_open_at_their_pixel_size() {
|
||||
assert_eq!(&std::fs::read(&png).unwrap()[16..24], [0, 0, 0, 6, 0, 0, 0, 4], "IHDR 6×4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_exports_use_document_resolution_unless_overridden() {
|
||||
let source = tmp("resolution.vectorcraft");
|
||||
ok(&["run", "--cmd", "file.new", "--params", r#"{"width":72,"height":36,"rasterEffectsPpi":300}"#, "--export", &source]);
|
||||
for (flags, width, height) in [(vec![], 300u32, 150u32), (vec!["--scale", "1"], 72, 36), (vec!["--ppi", "150", "--scale", "2"], 150, 75)] {
|
||||
for mode in ["run", "convert"] {
|
||||
for format in ["png", "psd"] {
|
||||
let path = tmp(&format!("resolution-{mode}-{width}.{format}"));
|
||||
let mut args = if mode == "run" { vec!["run", "--in", &source, "--export", &path] } else { vec!["convert", &source, &path] };
|
||||
args.extend_from_slice(&flags);
|
||||
ok(&args);
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let dimensions = if format == "png" { &bytes[16..24] } else { &bytes[14..22] };
|
||||
let expected =
|
||||
if format == "png" { [width.to_be_bytes(), height.to_be_bytes()] } else { [height.to_be_bytes(), width.to_be_bytes()] };
|
||||
assert_eq!(dimensions, expected.concat(), "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_export_rejects_invalid_ppi() {
|
||||
for ppi in ["0", "-150", "NaN", "inf", "high"] {
|
||||
for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] {
|
||||
let out = Command::new(BIN).args(&args).output().unwrap();
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_readable_formats() {
|
||||
let out = ok(&["--help"]);
|
||||
|
||||
@@ -14,8 +14,10 @@ name = "vectorcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
# The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! `window.vectorcraft`: the control channel for the page, as promises.
|
||||
//!
|
||||
//! `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` come from
|
||||
//! `craft-web-api` (storytold/craft-libs#413), the same in every Craft app; VectorCraft adds its own
|
||||
//! functions on the same object.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving VectorCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await vectorcraft.execute("object.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.execute("shape.rectangle", {x: 0, y: 0, width: 100, height: 50}) // engine command → its result
|
||||
//! await vectorcraft.request("ui.inspect") // control method → its result
|
||||
//! await vectorcraft.commands() // command list
|
||||
//! const {base64} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! const {data} = await vectorcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! await vectorcraft.openFile("logo.svg", bytes) // opened as a dropped file is
|
||||
//! vectorcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! vectorcraft.files(); await vectorcraft.readFile("logo.png") // …to be read back
|
||||
@@ -17,21 +21,24 @@
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_ui_egui::ControlRequest;
|
||||
use vectorcraft_ui_egui::{ControlRequest, ControlTicket as Ticket};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
use crate::web::Inbox;
|
||||
|
||||
type Waiting = (Receiver<Value>, js_sys::Function, js_sys::Function);
|
||||
|
||||
thread_local! {
|
||||
static TX: RefCell<Option<Sender<ControlRequest>>> = const { RefCell::new(None) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static INBOX: RefCell<Option<Inbox>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
@@ -69,77 +76,96 @@ fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
|
||||
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
|
||||
} else {
|
||||
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(v) => {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(v.get("result").unwrap_or(&Value::Null)));
|
||||
} else {
|
||||
let msg = v.get("error").and_then(Value::as_str).unwrap_or("error").to_string();
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&msg));
|
||||
}
|
||||
false
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired) gives the request up if the app has not started it, so the app never runs it late.
|
||||
struct Waiting {
|
||||
reply: Receiver<Value>,
|
||||
ticket: Ticket,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Waiting {
|
||||
/// The app's reply once it has answered or gone away; `None` while the request is waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "request dropped"}))
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (req, rx) = ControlRequest::new(method.clone(), params.clone());
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if sent {
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
} else {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("VectorCraft is not running yet"));
|
||||
impl Drop for Waiting {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
self.ticket.give_up();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a control request for the app and wait for its reply, as the protocol's `{ok, result}` /
|
||||
/// `{ok: false, error}` object.
|
||||
fn send(method: &str, params: Value) -> Pin<Box<dyn Future<Output = Value>>> {
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
let ticket = req.ticket();
|
||||
let sent = TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok()));
|
||||
if !sent {
|
||||
return Box::pin(async { json!({"ok": false, "error": "VectorCraft is not running yet"}) });
|
||||
}
|
||||
repaint();
|
||||
let mut waiting = Waiting { reply: rx, ticket, answered: false };
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = waiting.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
repaint();
|
||||
p
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.vectorcraft`.
|
||||
/// Install `window.vectorcraft`: the crate's functions, then VectorCraft's own (`inspect` is its
|
||||
/// `ui.inspect`).
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
let request: Request = Rc::new(|method, params| send(&method, params));
|
||||
Rc::new(PageApi::new("vectorcraft", Methods::default(), request)).install()?;
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let obj: js_sys::Object = js_sys::Reflect::get(&window, &"vectorcraft".into())?.unchecked_into();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("ui.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
"inspect",
|
||||
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("ui.inspect", json!({})).await) })).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
@@ -178,7 +204,5 @@ pub fn install() -> Result<(), JsValue> {
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"vectorcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -370,7 +370,6 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
let Some(app) = &mut self.app else { return };
|
||||
app.tick(ctx);
|
||||
crate::api::poll_replies();
|
||||
app.finish_ime_logic(ctx);
|
||||
}
|
||||
|
||||
@@ -423,7 +422,6 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
app.ui(ui);
|
||||
}
|
||||
crate::api::poll_replies();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"]
|
||||
accessibility = ["eframe/accesskit"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] }
|
||||
vectorcraft-ui-egui = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
|
||||
@@ -119,6 +119,7 @@ zbus = "=5.19.0"
|
||||
winresource = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
# Bound socket buffers in real loopback transport deadline regressions.
|
||||
socket2 = { workspace = true }
|
||||
# src/portal_save.rs's tests build and read the portal's D-Bus values on every system.
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::windows_stylus;
|
||||
use super::{App, VectorcraftApp, file_access, window};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::{mac_menu, mac_window, open_documents};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode;
|
||||
|
||||
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
|
||||
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
|
||||
@@ -51,6 +52,9 @@ impl eframe::App for App {
|
||||
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
|
||||
#[cfg(windows)]
|
||||
windows_stylus::raw_input_hook(raw);
|
||||
// The characters Alt + keypad codes typed (#5108).
|
||||
#[cfg(windows)]
|
||||
super::windows_alt_codes::raw_input_hook(raw);
|
||||
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
|
||||
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
|
||||
// that no mouse button is down.
|
||||
@@ -110,18 +114,35 @@ impl App {
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
// An app that stays in Safe Mode leaves later launches to start their own app with plug-ins.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if safe_mode::staying_on(&self.app) {
|
||||
single_instance::resign();
|
||||
}
|
||||
// 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() {
|
||||
// dialog is open: what asked for it runs again on its document when it answers. They also
|
||||
// wait for the choice about a stopped plug-in, so they open after the plug-ins it loads, as
|
||||
// do the command line's files held back for it. Files later launches handed over before the
|
||||
// choice ended in Safe Mode open in a new app with plug-ins.
|
||||
if !self.app.file_dialog_open() && !safe_mode::choosing(&self.app) {
|
||||
let held = std::mem::take(&mut self.held_files);
|
||||
#[cfg(target_os = "macos")]
|
||||
let files = open_documents::take();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let files = single_instance::take();
|
||||
let files = single_instance::take_for_frame(safe_mode::staying_on(&self.app), single_instance::launch_with_plug_ins);
|
||||
if !files.is_empty() {
|
||||
bring_window_forward(ctx);
|
||||
}
|
||||
open_files(&mut self.app, held);
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor());
|
||||
if let Some(w) = &self.winit_window
|
||||
&& window::monitors_due(self.monitors_listed, now, zoom)
|
||||
{
|
||||
self.app.services.dock_monitors = window::dock_monitors(w, zoom);
|
||||
self.monitors_listed = Some((now, zoom));
|
||||
}
|
||||
self.app.tick(ctx);
|
||||
if self.pending_maximize {
|
||||
if let Some(w) = &self.winit_window {
|
||||
@@ -141,13 +162,15 @@ impl App {
|
||||
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
|
||||
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
|
||||
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
|
||||
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
|
||||
/// app as it quits on Macs with a Touch Bar. On a normal quit, only the launch marker is needed
|
||||
/// after the window, so it is ended here as `main` ends it after the window on other platforms.
|
||||
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
|
||||
/// failure then starts the app again on another adapter (#651).
|
||||
#[allow(unused_variables)]
|
||||
fn end_before_teardown(frames: u64) {
|
||||
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
|
||||
if frames >= gpu::STARTUP_FRAMES {
|
||||
safe_mode::end_launch();
|
||||
log::info!("quitting");
|
||||
log::logger().flush();
|
||||
std::process::exit(0);
|
||||
@@ -162,6 +185,16 @@ pub(crate) fn bring_window_forward(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
|
||||
/// Open the command line's `files` now, or, while the choice about a stopped plug-in is up
|
||||
/// ([`safe_mode::choosing`]), return them to open once it is made, with the plug-ins it loads.
|
||||
pub(crate) fn open_or_hold(app: &mut VectorcraftApp, files: Vec<String>) -> Vec<String> {
|
||||
if safe_mode::choosing(app) {
|
||||
return files;
|
||||
}
|
||||
open_files(app, files);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
|
||||
@@ -176,8 +176,7 @@ mod win {
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
|
||||
use super::{
|
||||
BITMAP, ClipboardRead, 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, windows_formats,
|
||||
};
|
||||
|
||||
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
|
||||
@@ -211,8 +210,6 @@ mod win {
|
||||
pub struct Clipboard {
|
||||
/// The clipboard's sequence number right after our last write.
|
||||
seq: Option<NonZeroU32>,
|
||||
/// The text of our last write.
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl SystemClipboard for Clipboard {
|
||||
@@ -243,18 +240,17 @@ mod win {
|
||||
}
|
||||
}
|
||||
self.seq = raw::seq_num();
|
||||
self.text = text_of(flavours);
|
||||
result
|
||||
}
|
||||
|
||||
fn holds_ours(&mut self) -> bool {
|
||||
if self.seq.is_some() && raw::seq_num() == self.seq {
|
||||
return true;
|
||||
}
|
||||
// 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).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes())
|
||||
// The same text copied later by another app is a different clipboard entry. A
|
||||
// clipboard manager that republishes our data is also a new entry.
|
||||
self.seq.is_some() && raw::seq_num() == self.seq
|
||||
}
|
||||
|
||||
fn change_count(&mut self) -> Option<u64> {
|
||||
raw::seq_num().map(|n| u64::from(n.get()))
|
||||
}
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
@@ -982,10 +978,17 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-clipboard-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
fn dir(name: &str) -> tempfile::TempDir {
|
||||
tempfile::Builder::new().prefix(&format!("vectorcraft-clipboard-{name}-")).tempdir().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directory_is_removed_when_its_handle_is_dropped() {
|
||||
let dir = dir("cleanup");
|
||||
let path = dir.path().to_path_buf();
|
||||
assert!(path.exists());
|
||||
drop(dir);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
@@ -997,12 +1000,12 @@ mod tests {
|
||||
#[test]
|
||||
fn the_first_copied_file_that_is_art_pastes() {
|
||||
let d = dir("art");
|
||||
let (notes, red, art) = (d.join("notes.txt"), d.join("red image.png"), d.join("art.svg"));
|
||||
let (notes, red, art) = (d.path().join("notes.txt"), d.path().join("red image.png"), d.path().join("art.svg"));
|
||||
std::fs::write(¬es, "not art").unwrap();
|
||||
std::fs::write(&red, png(3, 2)).unwrap();
|
||||
std::fs::write(&art, "<svg xmlns=\"http://www.w3.org/2000/svg\"/>").unwrap();
|
||||
// Missing files, folders and text are skipped.
|
||||
let f = copied_file(vec![d.join("gone.png"), d.clone(), notes.clone(), red.clone(), art.clone()], &PASTE_ORDER).unwrap();
|
||||
let f = copied_file(vec![d.path().join("gone.png"), d.path().to_path_buf(), notes.clone(), red.clone(), art.clone()], &PASTE_ORDER).unwrap();
|
||||
assert_eq!((f.mime, f.data), (PNG, png(3, 2)));
|
||||
assert_eq!(copied_file(vec![art.clone(), red.clone()], &PASTE_ORDER).map(|f| f.mime), Some(SVG));
|
||||
// Only what Paste asks for.
|
||||
@@ -1036,7 +1039,8 @@ mod tests {
|
||||
/// An .eps file copied in a file manager pastes as EPS.
|
||||
#[test]
|
||||
fn a_copied_eps_file_pastes() {
|
||||
let eps = dir("eps").join("equation.eps");
|
||||
let d = dir("eps");
|
||||
let eps = d.path().join("equation.eps");
|
||||
std::fs::write(&eps, "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n0 0 10 10 rectfill\n").unwrap();
|
||||
assert_eq!(copied_file(vec![eps.clone()], &PASTE_ORDER).map(|f| f.mime), Some(EPS));
|
||||
assert!(copied_file(vec![eps], &[BITMAP, TEXT]).is_none());
|
||||
@@ -1044,7 +1048,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_crlf_left_by_the_uri_list_is_ignored() {
|
||||
let red = dir("crlf").join("red.png");
|
||||
let d = dir("crlf");
|
||||
let red = d.path().join("red.png");
|
||||
std::fs::write(&red, png(2, 2)).unwrap();
|
||||
let with_cr = PathBuf::from(format!("{}\r", red.display()));
|
||||
assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG));
|
||||
|
||||
@@ -150,18 +150,16 @@ mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-dev-char-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let dir = tempfile::Builder::new().prefix("vectorcraft-dev-char-").tempdir()?;
|
||||
// /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU.
|
||||
std::fs::create_dir_all(dir.join("226:128/device"))?;
|
||||
std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?;
|
||||
std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?;
|
||||
std::fs::create_dir_all(dir.path().join("226:128/device"))?;
|
||||
std::fs::write(dir.path().join("226:128/device/vendor"), "0x10de\n")?;
|
||||
std::fs::write(dir.path().join("226:128/device/device"), "0x25ac\n")?;
|
||||
// A device that isn't a PCI GPU (a virtual one) has no ids.
|
||||
std::fs::create_dir_all(dir.join("226:0/device"))?;
|
||||
let render = super::pci_ids(&dir, makedev(226, 128));
|
||||
let virtual_gpu = super::pci_ids(&dir, makedev(226, 0));
|
||||
let missing = super::pci_ids(&dir, makedev(226, 1));
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
std::fs::create_dir_all(dir.path().join("226:0/device"))?;
|
||||
let render = super::pci_ids(dir.path(), makedev(226, 128));
|
||||
let virtual_gpu = super::pci_ids(dir.path(), makedev(226, 0));
|
||||
let missing = super::pci_ids(dir.path(), makedev(226, 1));
|
||||
assert_eq!(render, Some((0x10de, 0x25ac)));
|
||||
assert_eq!(virtual_gpu, None);
|
||||
assert_eq!(missing, None);
|
||||
|
||||
@@ -152,6 +152,7 @@ fn spawn_connection(
|
||||
|
||||
fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let closer = stream.try_clone().ok();
|
||||
let read = DeadlineReader::new(read, timeouts.idle, timeouts.line);
|
||||
// Replies reach the writer in [`REPLY_CHUNK`] pieces ([`write_line`]), so checking the remaining
|
||||
// budget costs one socket option call per piece, not one per JSON token.
|
||||
@@ -166,6 +167,33 @@ fn serve_socket(stream: TcpStream, timeouts: ConnectionTimeouts, handle: impl Fn
|
||||
},
|
||||
DeadlineWriter::begin_reply,
|
||||
);
|
||||
if let Some(s) = closer {
|
||||
close_gracefully(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long [`close_gracefully`] waits for the peer to close its side.
|
||||
const CLOSE_DRAIN: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The most [`close_gracefully`] reads from a peer that keeps sending.
|
||||
const MAX_DRAIN: usize = 1 << 20;
|
||||
|
||||
/// Ends a connection: no more replies (the peer reads them all, then end of stream), then what the
|
||||
/// peer still sends is read and dropped until it closes too, for at most [`CLOSE_DRAIN`] and
|
||||
/// [`MAX_DRAIN`] bytes. On Windows, dropping a socket with unread input resets the connection, and
|
||||
/// the reset can discard replies the peer has not read yet.
|
||||
fn close_gracefully(mut s: TcpStream) {
|
||||
// Each step is best effort: a peer that is already gone needs none of them.
|
||||
let _ = s.shutdown(std::net::Shutdown::Write);
|
||||
let _ = s.set_read_timeout(Some(CLOSE_DRAIN));
|
||||
let deadline = std::time::Instant::now() + CLOSE_DRAIN;
|
||||
let (mut buf, mut left) = ([0u8; 4096], MAX_DRAIN);
|
||||
while left > 0 && std::time::Instant::now() < deadline {
|
||||
match s.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => left = left.saturating_sub(n),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed line.
|
||||
|
||||
@@ -334,13 +334,12 @@ fn tcp_connection_is_closed_after_junk() {
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
serve_socket(s, ConnectionTimeouts::default(), |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(WAIT)).unwrap();
|
||||
// Nothing after the junk line: on Windows, closing a socket with unread input resets the
|
||||
// connection, and the reset can discard the reply before the client reads it.
|
||||
// The server closes gracefully ([`close_gracefully`]): the client reads both replies and then
|
||||
// the end of the stream, not a reset.
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
|
||||
@@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
|
||||
let field = |key: &str| {
|
||||
device_id.split(['\\', '&']).find_map(|part| {
|
||||
let (name, hex) = part.split_at_checked(key.len())?;
|
||||
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
})
|
||||
};
|
||||
Some((field("VEN_")?, field("DEV_")?))
|
||||
|
||||
@@ -242,8 +242,8 @@ fn internal_panels_are_edp_lvds_and_dsi_connectors() {
|
||||
/// PCI ids. Each GPU is listed once, the one with the panel as primary.
|
||||
#[test]
|
||||
fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-drm-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let temp = tempfile::Builder::new().prefix("vectorcraft-drm-").tempdir()?;
|
||||
let dir = temp.path();
|
||||
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name).join("device"))?;
|
||||
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
|
||||
@@ -262,11 +262,11 @@ fn linux_display_gpus_come_from_the_connected_connectors() -> std::io::Result<()
|
||||
connector("card1-DP-2", "connected")?;
|
||||
connector("card2-Virtual-1", "connected")?;
|
||||
std::fs::write(dir.join("renderD128"), "")?;
|
||||
let mut gpus = linux_display_gpus(&dir);
|
||||
let mut gpus = linux_display_gpus(dir);
|
||||
gpus.sort_by_key(|g| g.pci);
|
||||
assert_eq!(gpus, [primary(AMD_IGPU), monitor(NVIDIA_3090)]);
|
||||
assert!(linux_display_gpus(&dir.join("no such dir")).is_empty());
|
||||
std::fs::remove_dir_all(&dir)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -616,11 +616,26 @@ fn a_normal_exit_or_another_failure_does_not_restart() {
|
||||
/// A log folder of its own for each test, and the guard that keeps other tests from starting a
|
||||
/// program while it runs: the test releases the start file's lock and takes it again, and a child
|
||||
/// starting meanwhile would keep it held (`crate::no_spawns`).
|
||||
fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) {
|
||||
struct TestDir(tempfile::TempDir);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
fn start_dir(tag: &str) -> (TestDir, std::sync::RwLockReadGuard<'static, ()>) {
|
||||
let spawns = crate::no_spawns();
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
(d, spawns)
|
||||
let dir = tempfile::Builder::new().prefix(&format!("vectorcraft-gpu-start-{tag}-")).tempdir().unwrap();
|
||||
(TestDir(dir), spawns)
|
||||
}
|
||||
|
||||
/// What a start in `dir` leaves out; it then ends without crashing.
|
||||
@@ -670,7 +685,6 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
|
||||
assert_eq!(left_out.adapters, ["Dx12:8086:3184"], "kept");
|
||||
assert_eq!(left_out.crash, None, "the note is shown once");
|
||||
third.ended();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A start whose first frame reached the screen, or that ended without crashing (a normal exit,
|
||||
@@ -693,7 +707,6 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
|
||||
assert!(finish(Ok(Ok(())), &first).is_ok());
|
||||
drop(first);
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The process ended while the adapters were being listed (wgpu's DX12 backend creates a
|
||||
@@ -721,7 +734,6 @@ fn a_crash_while_listing_lists_opengl_alone() {
|
||||
second.none_left();
|
||||
second.ended();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `WGPU_BACKEND` or `WGPU_ADAPTER_NAME` choose for a start whatever crashed before; when that
|
||||
@@ -742,7 +754,6 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
|
||||
explicit.picked(&c[0].key, &c[0].name, true);
|
||||
explicit.presented();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default(), "a new driver shows the window");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A damaged record, a file that can't be opened and one another starting copy of the app holds
|
||||
@@ -777,10 +788,9 @@ fn a_damaged_or_unreadable_start_file_is_ignored() {
|
||||
assert_eq!(next_start(&dir, false).adapters, ["Dx12:8086:3184"]);
|
||||
|
||||
// A folder in the file's place.
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::remove_file(dir.join(START_FILE)).unwrap();
|
||||
std::fs::create_dir_all(dir.join(START_FILE)).unwrap();
|
||||
assert_eq!(next_start(&dir, false), LeftOut::default());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -810,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
|
||||
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
|
||||
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
|
||||
}
|
||||
|
||||
/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id.
|
||||
#[test]
|
||||
fn pci_ids_reject_a_sign_in_the_hex_digits() {
|
||||
assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None);
|
||||
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None);
|
||||
}
|
||||
|
||||
@@ -198,6 +198,30 @@ fn read_file_refuses_what_is_not_a_regular_file() {
|
||||
assert!(read_file("/dev/zero").is_err());
|
||||
}
|
||||
|
||||
/// Read the first `max` bytes of a file (all of it when it is shorter), and nothing past them. Only
|
||||
/// a regular file is read.
|
||||
fn read_file_head(path: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
let mut out = vec![];
|
||||
std::fs::File::open(path).and_then(|f| f.take(max as u64).read_to_end(&mut out)).map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn read_file_head_reads_no_more_than_it_is_asked_for() {
|
||||
use std::io::Write as _;
|
||||
let mut f = tempfile::Builder::new().prefix("vectorcraft-read-head-").tempfile().unwrap();
|
||||
f.write_all(b"0123456789").unwrap();
|
||||
let p = f.path().to_string_lossy();
|
||||
assert_eq!(read_file_head(&p, 4).unwrap(), b"0123");
|
||||
assert_eq!(read_file_head(&p, 20).unwrap(), b"0123456789", "a shorter file is read whole");
|
||||
assert!(read_file_head(&std::env::temp_dir().to_string_lossy(), 4).is_err(), "a folder is not read");
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -274,13 +298,16 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
|
||||
// up its Format menu before the sheet begins (#866); rfd shows the others.
|
||||
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
|
||||
// The dialog thread blocks here until the sheet answers.
|
||||
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
None => {
|
||||
Ok(mac_save_format::Begin::Shown(picked)) => Box::pin(async move { picked.recv().unwrap_or_default() }),
|
||||
Ok(mac_save_format::Begin::DeferToRfd) => {
|
||||
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
|
||||
// made recent macOS versions dismiss the sheet at once (#1800).
|
||||
let picked = dialog.save_file();
|
||||
Box::pin(async move { picked.await.iter().map(path).collect() })
|
||||
}
|
||||
// AppKit returned nil for the panel. rfd uses the same `+savePanel` and would panic
|
||||
// on that nil; treat the pick as cancelled instead (#5980).
|
||||
Err(()) => Box::pin(async move { Vec::new() }),
|
||||
},
|
||||
PickRequest::Folder => {
|
||||
let picked = dialog.pick_folder();
|
||||
@@ -325,6 +352,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
#[cfg(windows)]
|
||||
stylus: Some(windows_stylus::start()),
|
||||
read: Some(Box::new(read_file)),
|
||||
// Load Actions… refuses a file over its size limit without reading all of it.
|
||||
read_head: Some(Box::new(read_file_head)),
|
||||
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.
|
||||
@@ -569,22 +598,20 @@ 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
|
||||
fn scratch(name: &str) -> tempfile::TempDir {
|
||||
tempfile::Builder::new().prefix(&format!("vc-open-with-{name}-")).tempdir().unwrap()
|
||||
}
|
||||
|
||||
/// #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 temp = scratch("check");
|
||||
let d = temp.path();
|
||||
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).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");
|
||||
@@ -600,19 +627,19 @@ mod open_with_tests {
|
||||
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");
|
||||
let temp = scratch("find");
|
||||
let d = temp.path();
|
||||
if cfg!(target_os = "macos") {
|
||||
let candidates = photocraft_candidates(Some(&d), None);
|
||||
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"));
|
||||
assert!(find_photocraft(Some(d), None).unwrap().ends_with("PhotoCraft.app"));
|
||||
} else {
|
||||
let bin = d.join("bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
@@ -636,7 +663,6 @@ mod open_with_tests {
|
||||
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
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
struct TestPath {
|
||||
_parent: tempfile::TempDir,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TestPath {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for TestPath {
|
||||
fn as_ref(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> TestPath {
|
||||
let parent = tempfile::Builder::new().prefix(&format!("vectorcraft-logging-{tag}-")).tempdir().unwrap();
|
||||
let path = parent.path().join("log");
|
||||
TestPath { _parent: parent, path }
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
|
||||
@@ -63,11 +63,10 @@ mod tests {
|
||||
/// fonts, is listed while it is registered.
|
||||
#[test]
|
||||
fn fonts_registered_from_other_folders_are_listed() {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-mac-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dir = tempfile::Builder::new().prefix("vectorcraft-mac-fonts-").tempdir().unwrap();
|
||||
// A space in the name: the URL's escapes are decoded. The bundled serif isn't a font macOS
|
||||
// installs.
|
||||
let file = dir.join("Registered Font.ttf");
|
||||
let file = dir.path().join("Registered Font.ttf");
|
||||
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSerif4-Regular.ttf"), &file).unwrap();
|
||||
let want = std::fs::canonicalize(&file).unwrap();
|
||||
let listed = || super::font_files().iter().any(|f| f.ends_with("/Registered Font.ttf") && std::fs::canonicalize(f).is_ok_and(|p| p == want));
|
||||
@@ -78,7 +77,6 @@ mod tests {
|
||||
let registered = listed();
|
||||
// SAFETY: as above.
|
||||
unsafe { CTFontManagerUnregisterFontsForURL(&url, CTFontManagerScope::Process, std::ptr::null_mut()) };
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert!(registered);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use block2::RcBlock;
|
||||
use objc2::rc::{Retained, Weak};
|
||||
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2::{ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
|
||||
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
|
||||
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
@@ -108,21 +108,42 @@ fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
|
||||
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
|
||||
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
|
||||
/// named file types, or `window` has no AppKit window; nothing is shown then.
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
|
||||
let mtm = MainThreadMarker::new()?;
|
||||
/// How [`begin`] decided to show a save sheet.
|
||||
#[derive(Debug)]
|
||||
pub enum Begin {
|
||||
/// The Format-menu sheet is open; wait on the receiver for the path (empty when canceled).
|
||||
Shown(Receiver<Vec<String>>),
|
||||
/// Fewer than two named file types: the caller should show an rfd panel instead.
|
||||
DeferToRfd,
|
||||
}
|
||||
|
||||
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu.
|
||||
///
|
||||
/// `Ok(Begin::Shown(…))` when the sheet is open. `Ok(Begin::DeferToRfd)` when the request has
|
||||
/// fewer than two named file types (or no AppKit window yet) so rfd should show the panel.
|
||||
/// `Err(())` when AppKit returned no panel: the caller must not call rfd, whose `+savePanel`
|
||||
/// panics on the same nil (#5980).
|
||||
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Result<Begin, ()> {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
let filters = menu_formats(&pick.filters);
|
||||
if filters.is_empty() {
|
||||
return None;
|
||||
return Ok(Begin::DeferToRfd);
|
||||
}
|
||||
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
|
||||
let Some(RawWindowHandle::AppKit(handle)) = window.window_handle().ok().map(|h| h.as_raw()) else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
|
||||
// window is open, and winit hands out the handle only while it is.
|
||||
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
|
||||
let parent = view.window()?;
|
||||
let panel = NSSavePanel::savePanel(mtm);
|
||||
let Some(parent) = view.window() else {
|
||||
return Ok(Begin::DeferToRfd);
|
||||
};
|
||||
let Some(panel) = new_panel(mtm) else {
|
||||
log::error!("macOS save panel unavailable: AppKit returned nil for NSSavePanel (#5980)");
|
||||
return Err(());
|
||||
};
|
||||
let selected = initial_format(&pick.name, &filters);
|
||||
set_allowed_types(&panel, &filters, selected);
|
||||
if let Some(folder) = &pick.folder {
|
||||
@@ -145,7 +166,23 @@ pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver
|
||||
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
|
||||
// until the sheet closes.
|
||||
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
|
||||
Some(rx)
|
||||
Ok(Begin::Shown(rx))
|
||||
}
|
||||
|
||||
/// An `NSSavePanel` without panicking when AppKit returns nil (#5980).
|
||||
///
|
||||
/// Prefers `-[NSSavePanel init]` (Apple's current Swift API) over `+[NSSavePanel savePanel]`,
|
||||
/// which objc2 turns into a panic on nil. Falls back to the class method, still as `Option`.
|
||||
fn new_panel(mtm: MainThreadMarker) -> Option<Retained<NSSavePanel>> {
|
||||
// SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses.
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::alloc(mtm), init] };
|
||||
if panel.is_some() {
|
||||
return panel;
|
||||
}
|
||||
// SAFETY: class method; returns an autoreleased panel or nil. Typed as Option so nil is not a
|
||||
// panic (unlike `NSSavePanel::savePanel`).
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::class(), savePanel] };
|
||||
panel
|
||||
}
|
||||
|
||||
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
|
||||
@@ -234,6 +271,16 @@ mod tests {
|
||||
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
|
||||
}
|
||||
|
||||
/// A nil AppKit panel must be `None`, not a panic. `NSSavePanel::savePanel` used to panic here
|
||||
/// (#5980); `new_panel` types the same message send as `Option`.
|
||||
#[test]
|
||||
fn a_null_save_panel_pointer_is_none_not_a_panic() {
|
||||
let null: *mut NSSavePanel = std::ptr::null_mut();
|
||||
// SAFETY: retaining a null object pointer is defined to yield None.
|
||||
let panel: Option<Retained<NSSavePanel>> = unsafe { Retained::retain(null) };
|
||||
assert!(panel.is_none());
|
||||
}
|
||||
|
||||
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
|
||||
#[test]
|
||||
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
|
||||
|
||||
+118
-41
@@ -1,7 +1,10 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
|
||||
//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//! [--in-window-menus] [--new-instance] [--safe-mode] [--] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//!
|
||||
//! Any other argument starting with '-' (not `-` alone) is an error; after `--`, every argument is
|
||||
//! a file.
|
||||
//!
|
||||
//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given
|
||||
//! (Windows and Linux; `single_instance`).
|
||||
@@ -56,6 +59,7 @@ mod system_fonts;
|
||||
mod tablet;
|
||||
mod window;
|
||||
mod window_frame;
|
||||
mod windows_alt_codes;
|
||||
#[cfg(windows)]
|
||||
mod windows_cores;
|
||||
mod windows_stylus;
|
||||
@@ -64,6 +68,7 @@ mod xwayland;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::picks::PickRequest;
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
@@ -76,7 +81,7 @@ mod titlebar;
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use app::bring_window_forward;
|
||||
use app::native_input_hook;
|
||||
use app::open_files;
|
||||
use app::open_or_hold;
|
||||
use host_services::services;
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
|
||||
@@ -91,7 +96,9 @@ use titlebar::custom_titlebar_from;
|
||||
use titlebar::opted_into_system_title_bar;
|
||||
#[cfg(windows)]
|
||||
use titlebar::set_taskbar_icon;
|
||||
use titlebar::{custom_titlebar, native_options};
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame};
|
||||
use titlebar::{custom_titlebar, native_options, titlebar_from_env};
|
||||
|
||||
struct App {
|
||||
app: VectorcraftApp,
|
||||
@@ -110,6 +117,10 @@ struct App {
|
||||
pending_maximize: bool,
|
||||
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||
maximize_tries: u8,
|
||||
/// The command line's files, held back until the choice about a stopped plug-in is made.
|
||||
held_files: Vec<String>,
|
||||
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
|
||||
monitors_listed: Option<(f64, f32)>,
|
||||
}
|
||||
|
||||
/// `vectorcraft --help`. Headless work (convert, run, MCP) is `vectorcraft-cli`'s.
|
||||
@@ -129,13 +140,89 @@ Options:
|
||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||
--new-instance Open the files in a new window even when VectorCraft
|
||||
already runs (Windows and Linux)
|
||||
--safe-mode Start without plug-ins: none loads or runs until
|
||||
VectorCraft starts normally (Help › Safe Mode); the
|
||||
files open in a new window, as with --new-instance
|
||||
--version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
-- Treat every argument after it as a file
|
||||
|
||||
Any other argument starting with '-' is an error.
|
||||
|
||||
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
vectorcraft-cli --help
|
||||
";
|
||||
|
||||
/// The command line's options other than the automation roots and the control port.
|
||||
struct CommandLine {
|
||||
files: Vec<String>,
|
||||
in_window_menus: bool,
|
||||
new_instance: bool,
|
||||
/// `--safe-mode`: no plug-in loads or runs in this launch.
|
||||
safe_mode: bool,
|
||||
}
|
||||
|
||||
/// Reads the arguments after the program name. The automation roots and the control port go into
|
||||
/// `roots_args` and `control`. `Err` is the exit code when the process ends here: `--version` and
|
||||
/// `--help` print and succeed; a bad automation option or an unknown option prints an error and fails.
|
||||
fn read_command_line(
|
||||
mut args: impl Iterator<Item = String>,
|
||||
control: &mut automation_args::ControlArg,
|
||||
roots_args: &mut automation_args::AutomationArgs,
|
||||
) -> Result<CommandLine, std::process::ExitCode> {
|
||||
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 safe_mode = false;
|
||||
let mut unknown = None;
|
||||
while let Some(a) = args.next() {
|
||||
// After `--`, every argument is a file, even one whose name starts with '-'.
|
||||
if a == "--" {
|
||||
files.extend(args.by_ref());
|
||||
break;
|
||||
}
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return Err(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--new-instance" => new_instance = true,
|
||||
"--safe-mode" => safe_mode = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Err(std::process::ExitCode::SUCCESS);
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return Err(std::process::ExitCode::SUCCESS);
|
||||
}
|
||||
// The process serial number older macOS versions pass to an app the Finder starts.
|
||||
#[cfg(target_os = "macos")]
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
// An option this version doesn't know (a typo such as `--safe-mod`) is an error, not a
|
||||
// file to open. Reported after the loop, so a later `--help` or `--version` still wins.
|
||||
_ if a.starts_with('-') && a != "-" => {
|
||||
unknown.get_or_insert(a);
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
if let Some(a) = unknown {
|
||||
eprintln!("vectorcraft: unknown option {a}; see vectorcraft --help, or put -- before a file whose name starts with '-'");
|
||||
return Err(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
Ok(CommandLine { files, in_window_menus, new_instance, safe_mode })
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
@@ -151,37 +238,11 @@ fn main() -> std::process::ExitCode {
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut roots_args =
|
||||
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) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let CommandLine { files, in_window_menus, new_instance, safe_mode: safe_mode_flag } =
|
||||
match read_command_line(std::env::args().skip(1), &mut control, &mut roots_args) {
|
||||
Ok(command_line) => command_line,
|
||||
Err(code) => return code,
|
||||
};
|
||||
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
|
||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||
let control_port = match control.port {
|
||||
@@ -199,10 +260,10 @@ fn main() -> std::process::ExitCode {
|
||||
// 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) {
|
||||
match single_instance::start(control_port.is_some(), new_instance, safe_mode_flag, &files) {
|
||||
single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS,
|
||||
single_instance::Start::Running(primary) => primary,
|
||||
};
|
||||
single_instance::Start::Running(primary) => single_instance::hold(primary),
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = new_instance;
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
@@ -314,13 +375,18 @@ fn main() -> std::process::ExitCode {
|
||||
};
|
||||
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
|
||||
// is down; the message hook notes each pointer contact and whether it starts with the barrel
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`). It also notes the
|
||||
// characters Alt + keypad codes type (`windows_alt_codes`).
|
||||
#[cfg(windows)]
|
||||
let options = {
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows as _;
|
||||
let mut options = options;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_msg_hook(windows_stylus::msg_hook);
|
||||
builder.with_msg_hook(|msg| {
|
||||
// Alt + keypad codes (#5108), which winit drops; neither hook consumes a message.
|
||||
windows_alt_codes::msg_hook(msg);
|
||||
windows_stylus::msg_hook(msg)
|
||||
});
|
||||
}));
|
||||
options
|
||||
};
|
||||
@@ -388,10 +454,15 @@ fn main() -> std::process::ExitCode {
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
// Before any plug-in loads: this launch's marker in the Launch folder next to the
|
||||
// preferences (none for runs without preferences), and Safe Mode.
|
||||
let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch")));
|
||||
let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag);
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app, saved);
|
||||
safe_mode::after_start(&mut app, stopped);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
@@ -458,7 +529,9 @@ fn main() -> std::process::ExitCode {
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
|
||||
}
|
||||
app.titlebar_from_env = titlebar_from_env();
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
app.desktop_frame = !custom_titlebar;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
@@ -483,7 +556,7 @@ fn main() -> std::process::ExitCode {
|
||||
single_instance::set_ui(&cc.egui_ctx);
|
||||
}
|
||||
app.services.native_input_hook = native_input_hook(&app);
|
||||
file_access::unconfined(|| open_files(&mut app, files));
|
||||
let held_files = file_access::unconfined(|| open_or_hold(&mut app, files));
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
#[cfg(feature = "wgpu")]
|
||||
created.ready();
|
||||
@@ -499,6 +572,8 @@ fn main() -> std::process::ExitCode {
|
||||
winit_window,
|
||||
pending_maximize,
|
||||
maximize_tries: 8,
|
||||
held_files,
|
||||
monitors_listed: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
@@ -506,7 +581,9 @@ 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);
|
||||
single_instance::resign();
|
||||
// The process ends normally from here: this launch didn't stop while a plug-in was loading.
|
||||
safe_mode::end_launch();
|
||||
#[cfg(feature = "wgpu")]
|
||||
let outcome = gpu::finish(outcome, &startup);
|
||||
#[cfg(not(feature = "wgpu"))]
|
||||
|
||||
@@ -335,13 +335,11 @@ mod tests {
|
||||
#[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();
|
||||
let temp = tempfile::Builder::new().prefix("vc-prefs-copy-").tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
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]
|
||||
|
||||
@@ -192,7 +192,22 @@ fn copy_dir_into(from: &Path, to: &Path, skip: &mut impl FnMut(String)) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct TestDir(tempfile::TempDir);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for TestDir {
|
||||
fn as_ref(&self) -> &Path {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
@@ -287,12 +302,8 @@ mod tests {
|
||||
assert_eq!(install_dir("macos", mounted, &appimage), Some("/tmp/.mount_vc/usr/bin".into()));
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-profile-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
fn temp(tag: &str) -> TestDir {
|
||||
TestDir(tempfile::Builder::new().prefix(&format!("vectorcraft-profile-{tag}-")).tempdir().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
//! 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.
|
||||
//! nor listen, and neither does a `--safe-mode` launch, which opens its files in an app of its own
|
||||
//! and leaves later launches to start theirs with plug-ins. `--new-instance` opens a launch's files
|
||||
//! in an app of its own. An app that stays in Safe Mode after the choice about a stopped plug-in
|
||||
//! stops listening ([`resign`]), and files later launches handed it before then open in a new app
|
||||
//! with plug-ins ([`take_for_frame`]). 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};
|
||||
@@ -43,6 +47,8 @@ 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());
|
||||
/// This app while it is the one later launches hand their files to ([`hold`]).
|
||||
static HELD: Mutex<Option<Primary>> = Mutex::new(None);
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
@@ -56,23 +62,46 @@ pub enum Start {
|
||||
|
||||
/// 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 {
|
||||
pub fn start(automation: bool, new_instance: bool, safe_mode: 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) };
|
||||
start_at(&path, new_instance, safe_mode, files)
|
||||
}
|
||||
|
||||
/// [`start`] with the endpoint file `path`. A launch with `--new-instance` or `--safe-mode` keeps
|
||||
/// its files and runs as its own app: a running app would open them with its plug-ins loaded. A
|
||||
/// `--safe-mode` launch never becomes the app later launches hand their files to, which would open
|
||||
/// them without plug-ins.
|
||||
fn start_at(path: &Path, new_instance: bool, safe_mode: bool, files: &[String]) -> Start {
|
||||
if safe_mode {
|
||||
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) {
|
||||
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))
|
||||
Start::Running(listen(path, queue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `primary` ([`start`]'s) until [`resign`].
|
||||
pub fn hold(primary: Option<Primary>) {
|
||||
*HELD.lock().unwrap_or_else(PoisonError::into_inner) = primary;
|
||||
}
|
||||
|
||||
/// Later launches start their own app from now on: the endpoint file goes. The app calls it when
|
||||
/// its window is gone, and when it stays in Safe Mode after the choice about a stopped plug-in.
|
||||
pub fn resign() {
|
||||
let primary = HELD.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
drop(primary);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -84,6 +113,43 @@ pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// The paths later launches handed over since the last call, to open in this app. An app that
|
||||
/// stays in Safe Mode (`safe_mode`) resigns and hands them to a new app with plug-ins (`launch`,
|
||||
/// [`launch_with_plug_ins`] in the app) instead: they arrived while it was the primary, before the
|
||||
/// choice about a stopped plug-in ended in Safe Mode, or just before it resigned. When `launch`
|
||||
/// fails (logged), they open here.
|
||||
pub fn take_for_frame(safe_mode: bool, launch: impl FnOnce(&[String]) -> io::Result<()>) -> Vec<String> {
|
||||
let files = take();
|
||||
if !safe_mode {
|
||||
return files;
|
||||
}
|
||||
// First, so the new app becomes the primary instead of handing the files back.
|
||||
resign();
|
||||
if files.is_empty() {
|
||||
return files;
|
||||
}
|
||||
match launch(&files) {
|
||||
Ok(()) => Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't start VectorCraft with plug-ins for the files another launch opened: {e}");
|
||||
files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start this program again, with plug-ins, on `files`.
|
||||
pub fn launch_with_plug_ins(files: &[String]) -> io::Result<()> {
|
||||
app_with_plug_ins(&std::env::current_exe()?, files).spawn().map(drop)
|
||||
}
|
||||
|
||||
/// The command [`launch_with_plug_ins`] runs: `exe -- files…`, so a file whose name starts with '-'
|
||||
/// stays a file.
|
||||
fn app_with_plug_ins(exe: &Path, files: &[String]) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.arg("--").args(files);
|
||||
command
|
||||
}
|
||||
|
||||
fn queue(files: Vec<String>) {
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files);
|
||||
if let Some(ctx) = UI.get() {
|
||||
@@ -263,15 +329,30 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Held by the tests that use the process-wide [`HELD`] and [`PENDING`], one at a time.
|
||||
static GLOBALS: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn globals() -> std::sync::MutexGuard<'static, ()> {
|
||||
GLOBALS.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
struct TestDir(tempfile::TempDir);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = Path;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.0.path()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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");
|
||||
fn folder(name: &str) -> (PathBuf, TestDir, String) {
|
||||
let dir = tempfile::Builder::new().prefix(&format!("vc-single-instance-{name}-")).tempdir().unwrap();
|
||||
let file = dir.path().join("a b.svg");
|
||||
fs::write(&file, "<svg/>").unwrap();
|
||||
let file = file.to_string_lossy().into_owned();
|
||||
(dir.join(FILE_NAME), dir, file)
|
||||
(dir.path().join(FILE_NAME), TestDir(dir), file)
|
||||
}
|
||||
|
||||
fn primary(path: &Path) -> (Primary, mpsc::Receiver<Vec<String>>) {
|
||||
@@ -303,12 +384,77 @@ mod tests {
|
||||
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_new_instance_or_safe_mode_launch_keeps_its_files() {
|
||||
let (path, _dir, file) = folder("own");
|
||||
let (primary, rx) = primary(&path);
|
||||
for (new_instance, safe_mode) in [(true, false), (false, true)] {
|
||||
let start = start_at(&path, new_instance, safe_mode, std::slice::from_ref(&file));
|
||||
assert!(matches!(start, Start::Running(None)), "runs as its own app (new instance {new_instance}, Safe Mode {safe_mode})");
|
||||
}
|
||||
assert!(matches!(start_at(&path, false, false, std::slice::from_ref(&file)), Start::Forwarded));
|
||||
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file));
|
||||
assert!(rx.try_recv().is_err(), "only the plain launch handed its file over");
|
||||
drop(primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_safe_mode_launch_leaves_later_launches_their_own_app() {
|
||||
let _globals = globals();
|
||||
let (path, _dir, file) = folder("safe-mode-alone");
|
||||
// With no app running, a Safe Mode launch runs without listening.
|
||||
assert!(matches!(start_at(&path, false, true, std::slice::from_ref(&file)), Start::Running(None)));
|
||||
assert!(!path.exists(), "a Safe Mode launch records no endpoint");
|
||||
// A plain launch after it starts its own app and takes later launches' files.
|
||||
let Start::Running(Some(primary)) = start_at(&path, false, false, std::slice::from_ref(&file)) else {
|
||||
panic!("the plain launch runs and listens")
|
||||
};
|
||||
assert!(path.exists());
|
||||
// An app that stays in Safe Mode after the choice gives that up.
|
||||
hold(Some(primary));
|
||||
resign();
|
||||
assert!(!path.exists(), "the endpoint file goes with resign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_handed_to_an_app_staying_in_safe_mode_go_to_a_new_app_with_plug_ins() {
|
||||
let _globals = globals();
|
||||
// Nothing left over from a test that failed.
|
||||
resign();
|
||||
drop(take());
|
||||
let (path, _dir, file) = folder("safe-mode-later");
|
||||
let Start::Running(Some(primary)) = start_at(&path, false, false, &[]) else { panic!("the first launch listens") };
|
||||
hold(Some(primary));
|
||||
// A later launch handed its file over while the choice about a stopped plug-in was up.
|
||||
queue(vec![file.clone()]);
|
||||
let mut launched = Vec::new();
|
||||
let here = take_for_frame(true, |files| {
|
||||
launched = files.to_vec();
|
||||
Ok(())
|
||||
});
|
||||
assert!(here.is_empty(), "nothing opens in the Safe Mode app");
|
||||
assert_eq!(launched, std::slice::from_ref(&file), "a new app with plug-ins opens it");
|
||||
assert!(!path.exists(), "the app resigned before starting the new one");
|
||||
// When the new app can't start, the file opens here rather than being lost.
|
||||
queue(vec![file.clone()]);
|
||||
assert_eq!(take_for_frame(true, |_| Err(io::Error::other("no program"))), std::slice::from_ref(&file));
|
||||
// An app with plug-ins opens what it is handed.
|
||||
queue(vec![file.clone()]);
|
||||
assert_eq!(take_for_frame(false, |_| panic!("no new app")), [file]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_app_takes_every_name_as_a_file() {
|
||||
let command = app_with_plug_ins(Path::new("vectorcraft"), &["-a.svg".to_owned(), "b.svg".to_owned()]);
|
||||
let args: Vec<_> = command.get_args().collect();
|
||||
assert_eq!(args, ["--", "-a.svg", "b.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_garbage_and_oversized_messages_are_refused() {
|
||||
let (path, dir, file) = folder("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] }));
|
||||
@@ -323,12 +469,11 @@ mod tests {
|
||||
// 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");
|
||||
let (path, _dir, file) = folder("stale");
|
||||
// The recorded app is gone: whatever holds the port closes without a greeting. (A freed
|
||||
// port can be taken by another test's listener meanwhile, which would then accept this
|
||||
// connection in place of its own client's.)
|
||||
@@ -357,18 +502,16 @@ mod tests {
|
||||
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 (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]
|
||||
@@ -383,9 +526,8 @@ mod tests {
|
||||
#[test]
|
||||
fn the_endpoint_file_is_the_users_alone() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let (path, dir, _) = folder("mode");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@ mod tests {
|
||||
#[test]
|
||||
fn fonts_only_gdi_has_loaded_are_listed_by_their_files() {
|
||||
use windows::Win32::Graphics::Gdi::{AddFontResourceExW, FR_PRIVATE, RemoveFontResourceExW};
|
||||
let dir = std::env::temp_dir().join(format!("vc-gdi-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let temp = tempfile::Builder::new().prefix("vc-gdi-fonts-").tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
let file = dir.join("29457");
|
||||
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
let mut data = std::fs::read(source).unwrap();
|
||||
@@ -303,7 +303,6 @@ mod tests {
|
||||
assert_eq!(unsafe { AddFontResourceExW(&path, FR_PRIVATE, None) }, 1);
|
||||
let files = super::font_files();
|
||||
let _ = unsafe { RemoveFontResourceExW(&path, FR_PRIVATE.0, None) };
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let wanted = file.to_string_lossy().to_lowercase();
|
||||
assert!(files.iter().any(|f| f.to_lowercase() == wanted), "{wanted} not in {} files", files.len());
|
||||
}
|
||||
|
||||
@@ -36,6 +36,33 @@ fn shortcut_overrides_from_an_earlier_version_load() {
|
||||
assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported");
|
||||
}
|
||||
|
||||
/// The command line's files wait for the choice about a stopped plug-in, so they open with the
|
||||
/// plug-ins it loads; with no choice up they open at once.
|
||||
#[test]
|
||||
fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() {
|
||||
let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap();
|
||||
let file = temp.path().join("a.svg");
|
||||
std::fs::write(&file, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#).unwrap();
|
||||
let files = vec![file.to_string_lossy().into_owned()];
|
||||
// The files the app reads to open them.
|
||||
let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let reads = read.clone();
|
||||
let services = vectorcraft_ui_egui::Services {
|
||||
read: Some(Box::new(move |p: &str| {
|
||||
reads.borrow_mut().push(p.to_owned());
|
||||
std::fs::read(p).map_err(|e| e.to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services);
|
||||
app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"}));
|
||||
assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up");
|
||||
assert!(read.borrow().is_empty(), "nothing opened before the choice");
|
||||
app.ui.stopped_plugin = None;
|
||||
assert!(open_or_hold(&mut app, files.clone()).is_empty());
|
||||
assert_eq!(*read.borrow(), files, "opened with no choice up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_files_unminimizes_and_focuses_the_window() {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -99,9 +126,8 @@ fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_ui_json_is_kept_beside_it() {
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-ui-prefs-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let temp = tempfile::Builder::new().prefix("vectorcraft-ui-prefs-").tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
let path = dir.join("ui.json");
|
||||
super::keep_prefs_copy(&path, b"{\"docking\": {\"root\": {\"FutureNode\": {}}}}", "ui.unreadable");
|
||||
super::keep_prefs_copy(&path, b"not json", "ui.unreadable");
|
||||
@@ -109,7 +135,6 @@ fn an_unreadable_ui_json_is_kept_beside_it() {
|
||||
let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap();
|
||||
let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap();
|
||||
let before = std::fs::read(dir.join("ui.before-layout-migration.json")).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced");
|
||||
assert_eq!(second, b"not json");
|
||||
assert_eq!(before, b"{}");
|
||||
@@ -166,6 +191,60 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where
|
||||
/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe
|
||||
/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both.
|
||||
#[test]
|
||||
fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() {
|
||||
assert!(!super::opted_into_app_title_bar(None), "no preferences: the default");
|
||||
let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false}));
|
||||
assert!(!super::opted_into_app_title_bar(Some(&off)));
|
||||
let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true}));
|
||||
assert!(super::opted_into_app_title_bar(Some(&on)));
|
||||
|
||||
// (probed_app, overridden, forced_to_x11): without the preference, then with it.
|
||||
let cases = [
|
||||
((false, None, false), false, true), // KDE, Sway, X11: the desktop frame
|
||||
((true, None, false), true, true), // GNOME on Wayland: the app frame either way
|
||||
((true, None, true), false, true), // GNOME, the window opened through XWayland
|
||||
((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app
|
||||
((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland
|
||||
((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference
|
||||
((false, Some(false), true), false, false),
|
||||
];
|
||||
for ((probed_app, overridden, forced_to_x11), without, with) in cases {
|
||||
assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}");
|
||||
assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame
|
||||
/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame
|
||||
/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference.
|
||||
#[test]
|
||||
fn the_start_frame_applies_the_app_title_bar_preference() {
|
||||
use craft_window::{Button, Frame, Layout};
|
||||
let gnome = Layout { left: vec![Button::Close], right: vec![] };
|
||||
let cases = [
|
||||
// (probed, overridden, forced_to_x11, opted_into_app), expected
|
||||
((Frame::Desktop, None, false, false), Frame::Desktop),
|
||||
((Frame::Desktop, None, false, true), Frame::App(Layout::default())),
|
||||
((Frame::Desktop, None, true, true), Frame::App(Layout::default())),
|
||||
((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())),
|
||||
((Frame::App(gnome.clone()), None, true, false), Frame::Desktop),
|
||||
((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())),
|
||||
((Frame::Desktop, Some(false), false, true), Frame::Desktop),
|
||||
((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())),
|
||||
];
|
||||
for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases {
|
||||
let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}");
|
||||
assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}");
|
||||
}
|
||||
// With both preferences on, System Title Bar takes precedence: the desktop draws the frame.
|
||||
let both = super::start_frame(Frame::Desktop, None, false, true);
|
||||
assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference");
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
||||
@@ -46,6 +46,53 @@ pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::Ui
|
||||
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the user asked for VectorCraft's own title bar in Preferences › User Interface ›
|
||||
/// VectorCraft Title Bar on Every Desktop (Linux and the BSDs). Read like
|
||||
/// [`opted_into_system_title_bar`]; a missing or unreadable value is `false` (the default).
|
||||
pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
saved.map(|ui| ui.engine_prefs.get("appTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the app draws the frame on this start, before the System Title Bar preference is
|
||||
/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and
|
||||
/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is
|
||||
/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window
|
||||
/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe
|
||||
/// decides.
|
||||
pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option<bool>, forced_to_x11: bool, opted_into_app: bool) -> bool {
|
||||
match overridden {
|
||||
Some(app) => app,
|
||||
None => opted_into_app || (probed_app && !forced_to_x11),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame this start uses, before the System Title Bar preference is applied
|
||||
/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed
|
||||
/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default
|
||||
/// layout, since `custom_titlebar_from` reads only which frame it is.
|
||||
pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option<bool>, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame {
|
||||
let probed_app = matches!(probed, craft_window::Frame::App(_));
|
||||
match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) {
|
||||
(true, frame @ craft_window::Frame::App(_)) => frame,
|
||||
(true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()),
|
||||
(false, _) => craft_window::Frame::Desktop,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `craft-window` probe's answer for this process (its override variable is
|
||||
/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection.
|
||||
fn probed_choice() -> &'static craft_window::Choice {
|
||||
static CHOICE: std::sync::OnceLock<craft_window::Choice> = std::sync::OnceLock::new();
|
||||
CHOICE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")))
|
||||
}
|
||||
|
||||
/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences ›
|
||||
/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always
|
||||
/// `false` on macOS and Windows.
|
||||
pub(crate) fn titlebar_from_env() -> bool {
|
||||
cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some()
|
||||
}
|
||||
|
||||
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
|
||||
/// decided and the user's System Title Bar preference.
|
||||
///
|
||||
@@ -82,24 +129,27 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
|
||||
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
|
||||
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
|
||||
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
|
||||
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
|
||||
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins,
|
||||
/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]).
|
||||
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
|
||||
let choice = probed_choice();
|
||||
let opted_into_system = opted_into_system_title_bar(saved);
|
||||
// macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from`
|
||||
// ignores the frame there).
|
||||
let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// When the window will actually open on X11 (XWayland) — the user asked for it *and* the X11
|
||||
// libraries are present, the same two conditions the launch path checks below — that run is
|
||||
// effectively X11, so keep the native frame. If the libraries are missing, startup stays on
|
||||
// Wayland, so the probe's own decision (app-drawn on GNOME) must stand.
|
||||
let forced_to_x11 = {
|
||||
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
|
||||
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
|
||||
// present, the same two conditions the launch path checks below — that run is effectively
|
||||
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
|
||||
// the probe's own decision (app-drawn on GNOME) must stand.
|
||||
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
|
||||
if choice.overridden().is_none() && forced_to_x11 {
|
||||
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
|
||||
}
|
||||
}
|
||||
custom_titlebar_from(choice.frame(), opted_into_system)
|
||||
xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found()
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let forced_to_x11 = false;
|
||||
let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app);
|
||||
custom_titlebar_from(&frame, opted_into_system)
|
||||
}
|
||||
|
||||
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! the size, position and maximized state saved with the UI preferences (`UiState::window`), moved
|
||||
//! onto a monitor that is still connected.
|
||||
|
||||
use vectorcraft_ui_egui::DockMonitor;
|
||||
use vectorcraft_ui_egui::state::WindowGeometry;
|
||||
use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize};
|
||||
|
||||
@@ -14,6 +15,9 @@ pub const MIN_SIZE: [f32; 2] = [800.0, 500.0];
|
||||
/// say how much of the screen they take): a monitor with less room opens it maximized.
|
||||
const SCREEN_ROOM: [f32; 2] = [64.0, 128.0];
|
||||
|
||||
/// Seconds between readings of the monitor list, which picks up a monitor connected while the app runs.
|
||||
const MONITORS_EVERY: f64 = 1.0;
|
||||
|
||||
/// A monitor: its top-left corner and size in physical pixels, and its scale factor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Monitor {
|
||||
@@ -67,6 +71,15 @@ impl Monitor {
|
||||
i32::try_from(want.max(lo).min(hi)).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// This monitor for floating panels, in the points egui reports for a window on it: physical
|
||||
/// pixels divided by the monitor's scale factor and the UI zoom.
|
||||
fn docking(&self, name: Option<String>, zoom: f32) -> DockMonitor {
|
||||
let scale = self.scale * zoom;
|
||||
let [x, y] = self.pos;
|
||||
let [w, h] = self.size;
|
||||
DockMonitor { name, rect: [x, y, w, h].map(|v| v as f32 / scale), scale }
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the window opens: `saved` (the last session's geometry) kept on a connected monitor, else
|
||||
@@ -109,6 +122,33 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
|
||||
std::array::from_fn(|i| size[i].min(room[i]).max(MIN_SIZE[i].min(room[i])).max(1.0))
|
||||
}
|
||||
|
||||
/// Whether to list the monitors again: on the first frame, when the UI zoom changed, and at most
|
||||
/// every [`MONITORS_EVERY`] seconds while the app draws. `listed` holds the last list's time and zoom.
|
||||
pub fn monitors_due(listed: Option<(f64, f32)>, now: f64, zoom: f32) -> bool {
|
||||
listed.is_none_or(|(at, listed_zoom)| listed_zoom != zoom || now - at >= MONITORS_EVERY)
|
||||
}
|
||||
|
||||
/// The connected monitors for floating panels (`Services::dock_monitors`).
|
||||
pub fn dock_monitors(window: &winit::window::Window, zoom: f32) -> Vec<DockMonitor> {
|
||||
let monitors: Vec<_> = window.available_monitors().map(|m| (Monitor::new(m.position(), m.size(), m.scale_factor()), m.name())).collect();
|
||||
docking_list(&monitors, zoom, cfg!(target_os = "macos"))
|
||||
}
|
||||
|
||||
/// The list for floating panels, empty when `monitors` have different scales and `shared_points` is
|
||||
/// false. macOS has one desktop of points. Elsewhere a new window multiplies its saved position by the
|
||||
/// scale of the monitor it is created on, which can put a window saved on another scale off every
|
||||
/// monitor. With an empty list craft-ui opens the window on the main window's monitor.
|
||||
///
|
||||
/// A name that two monitors share is left out, so craft-ui finds those monitors by their rects: macOS
|
||||
/// can give two displays of one model the same name.
|
||||
fn docking_list(monitors: &[(Monitor, Option<String>)], zoom: f32, shared_points: bool) -> Vec<DockMonitor> {
|
||||
if !shared_points && monitors.windows(2).any(|pair| pair[0].0.scale != pair[1].0.scale) {
|
||||
return Vec::new();
|
||||
}
|
||||
let unique = |name: &String| monitors.iter().filter(|(_, other)| other.as_ref() == Some(name)).count() == 1;
|
||||
monitors.iter().map(|(monitor, name)| monitor.docking(name.clone().filter(unique), zoom)).collect()
|
||||
}
|
||||
|
||||
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
|
||||
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
|
||||
/// until the host reports maximized (fixes #3635).
|
||||
@@ -204,6 +244,51 @@ mod tests {
|
||||
assert_eq!(place(saved(Some([9000, 9000]), [1e9, 1e9], true), &[], None).size, DEFAULT_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_panels_get_monitors_in_the_points_a_window_on_them_reports() {
|
||||
let dock = |m: Monitor, zoom| m.docking(Some("m".into()), zoom);
|
||||
let plain = dock(monitor(1920, 0, 1920, 1080, 1.0), 1.0);
|
||||
assert_eq!(plain, DockMonitor { name: Some("m".into()), rect: [1920.0, 0.0, 1920.0, 1080.0], scale: 1.0 });
|
||||
// A window at (3000, 200) on this monitor reports (1500, 100).
|
||||
let scaled = dock(monitor(1920, -200, 3840, 2160, 2.0), 1.0);
|
||||
assert_eq!(scaled, DockMonitor { name: Some("m".into()), rect: [960.0, -100.0, 1920.0, 1080.0], scale: 2.0 });
|
||||
// The UI zoom divides what egui reports as well.
|
||||
let zoomed = dock(monitor(-2560, 0, 2560, 1440, 1.0), 1.25);
|
||||
assert_eq!(zoomed, DockMonitor { name: Some("m".into()), rect: [-2048.0, 0.0, 2048.0, 1152.0], scale: 1.25 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitors_of_different_scales_are_listed_only_on_a_desktop_of_shared_points() {
|
||||
let named = |m: Monitor, name: &str| (m, Some(name.to_string()));
|
||||
let same = [named(monitor(0, 0, 1920, 1080, 1.5), "a"), named(monitor(1920, 0, 1920, 1080, 1.5), "b")];
|
||||
assert_eq!(docking_list(&same, 1.0, false).len(), 2);
|
||||
let mixed = [named(monitor(0, 0, 3840, 2160, 2.0), "a"), named(monitor(3840, 0, 1920, 1080, 1.0), "b")];
|
||||
assert_eq!(docking_list(&mixed, 1.0, false), Vec::new());
|
||||
assert_eq!(docking_list(&mixed, 1.0, true).len(), 2);
|
||||
assert_eq!(docking_list(&[], 1.0, false), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_two_monitors_share_is_left_out_of_the_list() {
|
||||
let list = [
|
||||
(monitor(0, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
|
||||
(monitor(1920, 0, 1920, 1080, 1.0), Some("Monitor #1".to_string())),
|
||||
(monitor(3840, 0, 1920, 1080, 1.0), Some("Monitor #2".to_string())),
|
||||
(monitor(5760, 0, 1920, 1080, 1.0), None),
|
||||
];
|
||||
let names: Vec<_> = docking_list(&list, 1.0, false).into_iter().map(|m| m.name).collect();
|
||||
assert_eq!(names, [None, None, Some("Monitor #2".to_string()), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_monitor_list_is_read_on_the_first_frame_then_once_a_second_or_on_a_zoom_change() {
|
||||
assert!(monitors_due(None, 0.0, 1.0));
|
||||
assert!(!monitors_due(Some((5.0, 1.0)), 5.0, 1.0));
|
||||
assert!(!monitors_due(Some((5.0, 1.0)), 5.5, 1.0));
|
||||
assert!(monitors_due(Some((5.0, 1.0)), 6.0, 1.0));
|
||||
assert!(monitors_due(Some((5.0, 1.0)), 5.5, 1.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_saved_window_comes_back_where_it_was() {
|
||||
let two = [monitor(0, 0, 2560, 1440, 1.0), monitor(2560, 0, 1920, 1080, 1.0)];
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
//! Alt + numeric-keypad codes on Windows (#5108): holding Alt, typing a decimal code on the
|
||||
//! numeric keypad (NumLock on) and releasing Alt types that character (`Alt+0176` → `°`).
|
||||
//!
|
||||
//! Windows does the work: when Alt comes up after keypad digits, `TranslateMessage` posts a
|
||||
//! `WM_CHAR` holding the character the code stands for (in the system's ANSI code page for a code
|
||||
//! with a leading 0, else its OEM code page), the convention every Windows text field follows
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input>). winit
|
||||
//! 0.30 reads a `WM_CHAR` only as part of the key press before it, so it drops this one, which
|
||||
//! follows a key release, and egui never sees the text. winit's message hook ([`msg_hook`]) sees
|
||||
//! the message before winit handles it: [`AltCodes`] notes keypad digits typed with Alt held, takes
|
||||
//! the character that follows Alt's release, and [`raw_input_hook`] hands it to the frame as typed
|
||||
//! text, so the Type tool (or a panel's text field) types it like any other character, in the
|
||||
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
|
||||
//! so they don't run a shortcut bound to Alt and a digit.
|
||||
//!
|
||||
//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right,
|
||||
//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the
|
||||
//! separate navigation keys by the extended-key flag, which only the separate keys set
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#extended-key-flag>),
|
||||
//! so with Alt held they count as digits and are taken out of the frame too, rather than moving
|
||||
//! the caret before the character arrives.
|
||||
//!
|
||||
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
|
||||
//! platforms, whose systems have no such convention, can't tell them apart either.
|
||||
|
||||
#![cfg_attr(not(windows), allow(dead_code))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Event, Key};
|
||||
|
||||
const WM_KEYDOWN: u32 = 0x0100;
|
||||
const WM_KEYUP: u32 = 0x0101;
|
||||
const WM_CHAR: u32 = 0x0102;
|
||||
const WM_SYSKEYDOWN: u32 = 0x0104;
|
||||
const WM_SYSKEYUP: u32 = 0x0105;
|
||||
const WM_SYSCHAR: u32 = 0x0106;
|
||||
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on.
|
||||
const VK_NUMPAD0: usize = 0x60;
|
||||
const VK_NUMPAD9: usize = 0x69;
|
||||
/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`,
|
||||
/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`.
|
||||
const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21];
|
||||
/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it
|
||||
/// comes as the top-row digit key, `Num5`).
|
||||
const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] =
|
||||
[Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp];
|
||||
const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
|
||||
/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the
|
||||
/// keypad (the keypad's own keys clear it).
|
||||
const EXTENDED_KEY: isize = 1 << 24;
|
||||
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
|
||||
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
|
||||
|
||||
thread_local! {
|
||||
/// The UI thread's sequence: [`msg_hook`] and [`raw_input_hook`] both run on it.
|
||||
static CODES: RefCell<AltCodes> = RefCell::new(AltCodes::default());
|
||||
}
|
||||
|
||||
/// Where an Alt + keypad code stands, from the keyboard messages of the thread's queue.
|
||||
#[derive(Default)]
|
||||
struct AltCodes {
|
||||
/// Keypad digits typed while Alt has been held.
|
||||
digits: usize,
|
||||
/// Alt came up after keypad digits: the next character message is the code's character.
|
||||
armed: bool,
|
||||
/// A high surrogate waiting for its low half.
|
||||
high: Option<u16>,
|
||||
/// Characters the codes typed, for the next frame.
|
||||
typed: String,
|
||||
/// The digits of the keypad presses and releases with Alt held since the last frame, which
|
||||
/// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken
|
||||
/// out of the frame.
|
||||
keypad_events: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and
|
||||
/// `lparam` the message's flags.
|
||||
fn keypad_digit(wparam: usize, lparam: isize) -> Option<usize> {
|
||||
if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) {
|
||||
return Some(wparam - VK_NUMPAD0);
|
||||
}
|
||||
if lparam & EXTENDED_KEY != 0 {
|
||||
return None;
|
||||
}
|
||||
NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam)
|
||||
}
|
||||
|
||||
impl AltCodes {
|
||||
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit)
|
||||
/// and `lparam` (its flags).
|
||||
fn message(&mut self, message: u32, wparam: usize, lparam: isize) {
|
||||
let keypad = match message {
|
||||
WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = keypad {
|
||||
// Windows sends a key pressed with Alt held as a system key.
|
||||
if message == WM_SYSKEYDOWN {
|
||||
self.digits += 1;
|
||||
self.armed = false;
|
||||
}
|
||||
self.keypad_events.push(d);
|
||||
return;
|
||||
}
|
||||
match message {
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
|
||||
// Any other key ends the sequence and keeps its own meaning.
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
|
||||
WM_KEYUP | WM_SYSKEYUP if ALT_KEYS.contains(&wparam) => {
|
||||
self.armed = self.digits > 0;
|
||||
self.digits = 0;
|
||||
}
|
||||
WM_CHAR | WM_SYSCHAR if self.armed => {
|
||||
let unit = (wparam & 0xffff) as u16;
|
||||
if (0xd800..0xdc00).contains(&unit) {
|
||||
self.high = Some(unit);
|
||||
return;
|
||||
}
|
||||
let units: Vec<u16> = self.high.take().into_iter().chain([unit]).collect();
|
||||
self.typed.extend(char::decode_utf16(units).filter_map(Result::ok).filter(|c| !c.is_control()));
|
||||
self.armed = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.digits = 0;
|
||||
self.armed = false;
|
||||
self.high = None;
|
||||
}
|
||||
|
||||
/// Hand the frame `events` the characters typed since the last frame, as typed text, and take
|
||||
/// out the keypad digits typed with Alt held.
|
||||
fn drain(&mut self, events: &mut Vec<Event>) {
|
||||
let mut keypad = std::mem::take(&mut self.keypad_events);
|
||||
if !keypad.is_empty() {
|
||||
events.retain(|e| {
|
||||
let Event::Key { key, modifiers, .. } = e else {
|
||||
return true;
|
||||
};
|
||||
if !modifiers.alt {
|
||||
return true;
|
||||
}
|
||||
// The first keypad key still to take out that egui reports as `key`.
|
||||
let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key);
|
||||
match keypad.iter().position(stands_for) {
|
||||
Some(i) => {
|
||||
keypad.remove(i);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
}
|
||||
if !self.typed.is_empty() {
|
||||
events.push(Event::Text(std::mem::take(&mut self.typed)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// winit's message hook for the keyboard: notes Alt + keypad codes and the characters they type.
|
||||
/// It never consumes the message.
|
||||
#[cfg(windows)]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn msg_hook(msg: *const std::ffi::c_void) {
|
||||
use windows::Win32::UI::WindowsAndMessaging::MSG;
|
||||
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
|
||||
// call; `as_ref` gives `None` for a null pointer.
|
||||
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
|
||||
return;
|
||||
};
|
||||
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
|
||||
CODES.with(|c| {
|
||||
if let Ok(mut c) = c.try_borrow_mut() {
|
||||
c.message(msg.message, msg.wParam.0, msg.lParam.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Before egui reads the main window's frame: the characters Alt + keypad codes typed, as text.
|
||||
pub fn raw_input_hook(raw: &mut egui::RawInput) {
|
||||
CODES.with(|c| {
|
||||
if let Ok(mut c) = c.try_borrow_mut() {
|
||||
c.drain(&mut raw.events);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ALT: usize = 0x12;
|
||||
|
||||
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
|
||||
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
for d in code.bytes() {
|
||||
let vk = VK_NUMPAD0 + usize::from(d - b'0');
|
||||
c.message(WM_SYSKEYDOWN, vk, 0);
|
||||
c.message(WM_SYSKEYUP, vk, 0);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(posted), 0);
|
||||
}
|
||||
|
||||
fn key(key: Key, alt: bool, pressed: bool) -> Event {
|
||||
let modifiers = egui::Modifiers { alt, ..Default::default() };
|
||||
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_code_types_its_character_when_alt_comes_up() {
|
||||
let mut c = AltCodes::default();
|
||||
alt_code(&mut c, "0176", 0xB0);
|
||||
alt_code(&mut c, "0151", 0x2014);
|
||||
let mut events = vec![key(Key::Num0, true, true), key(Key::Num0, true, false), key(Key::Num1, false, true)];
|
||||
c.drain(&mut events);
|
||||
// The keypad digits typed with Alt are gone (eight presses and eight releases, two here);
|
||||
// the digit typed without Alt stays, and the characters follow.
|
||||
assert_eq!(events, vec![key(Key::Num1, false, true), Event::Text("°—".into())]);
|
||||
let mut events = vec![key(Key::Num0, true, true)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events.len(), 1, "counts don't carry over to a later frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_character_without_a_code_before_it_is_left_to_winit() {
|
||||
let mut c = AltCodes::default();
|
||||
// A plain key press's character (winit types it), and Alt pressed and released alone.
|
||||
c.message(WM_KEYDOWN, 0x41, 0);
|
||||
c.message(WM_CHAR, usize::from(b'a'), 0);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(b'b'), 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_key_while_alt_is_held_cancels_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
|
||||
// Alt+F: a shortcut, not a code.
|
||||
c.message(WM_SYSKEYDOWN, 0x46, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0x41, 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0xD83D, 0);
|
||||
c.message(WM_CHAR, 0xDE00, 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("😀".into())]);
|
||||
}
|
||||
|
||||
/// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the
|
||||
/// extended-key bit), Alt released, and the character Windows then posts.
|
||||
fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
for d in code.bytes() {
|
||||
let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')];
|
||||
c.message(WM_SYSKEYDOWN, vk, 0);
|
||||
c.message(WM_SYSKEYUP, vk, 0);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(posted), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() {
|
||||
let mut c = AltCodes::default();
|
||||
alt_code_numlock_off(&mut c, "0176", 0xB0);
|
||||
// What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a
|
||||
// release each, then a plain Home typed after Alt came up.
|
||||
let mut events = vec![];
|
||||
for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] {
|
||||
events.push(key(k, true, true));
|
||||
events.push(key(k, true, false));
|
||||
}
|
||||
events.push(key(Key::Home, false, true));
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_numlock_off_keypad_five_counts_too() {
|
||||
let mut c = AltCodes::default();
|
||||
// Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear;
|
||||
// 0234 uses Insert, Down, Page Down and Left.
|
||||
alt_code_numlock_off(&mut c, "0151", 0x2014);
|
||||
alt_code_numlock_off(&mut c, "0234", 0xEA);
|
||||
let mut events =
|
||||
vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("—ê".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0);
|
||||
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0);
|
||||
// Alt+Home on the separate Home key (extended): a shortcut, not a digit.
|
||||
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
|
||||
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0x41, 0);
|
||||
let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numlock_off_keys_without_alt_are_left_alone() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0);
|
||||
c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0);
|
||||
let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@
|
||||
//! mouse message that Windows did not make from a pen or a touch
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
|
||||
//! clears the feed.
|
||||
//!
|
||||
//! The same `POINTER_PEN_INFO` gives the pen's tilt and barrel rotation (#372: a 6D Art Pen turns a
|
||||
//! Calligraphic brush's nib with its barrel), each only where its `PEN_MASK_*` bit says the pen
|
||||
//! reports it (<https://learn.microsoft.com/en-us/windows/win32/inputmsg/pen-mask-constants>):
|
||||
//! `tiltX` and `tiltY` in degrees −90..90, positive to the right and toward the user, and
|
||||
//! `rotation` in degrees 0..359 clockwise
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-pointer_pen_info>).
|
||||
//! Toward the user is down the screen, so they are [`PenPose`]'s axes and direction as they come.
|
||||
#![cfg_attr(not(windows), allow(dead_code))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
@@ -37,6 +45,11 @@ const PEN_FLAG_INVERTED: u32 = 0x0002;
|
||||
const PEN_FLAG_ERASER: u32 = 0x0004;
|
||||
/// `PEN_MASK_PRESSURE` (`winuser.h`), in `POINTER_PEN_INFO::penMask`: the pen reports pressure.
|
||||
const PEN_MASK_PRESSURE: u32 = 0x0001;
|
||||
/// `PEN_MASK_ROTATION`, `PEN_MASK_TILT_X` and `PEN_MASK_TILT_Y` (`winuser.h`): the pen reports its
|
||||
/// barrel rotation, its tilt along x and its tilt along y.
|
||||
const PEN_MASK_ROTATION: u32 = 0x0002;
|
||||
const PEN_MASK_TILT_X: u32 = 0x0004;
|
||||
const PEN_MASK_TILT_Y: u32 = 0x0008;
|
||||
/// `MI_WP_SIGNATURE` and `SIGNATURE_MASK`: the extra information of a mouse message that Windows
|
||||
/// made from a pen or a touch.
|
||||
const MI_WP_SIGNATURE: u32 = 0xFF51_5700;
|
||||
@@ -94,7 +107,8 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
|
||||
// pointer that is not a pen.
|
||||
unsafe { GetPointerPenInfo((msg.wParam.0 & 0xffff) as u32, &mut info) }.ok()?;
|
||||
let in_contact = info.pointerInfo.pointerFlags.0 & POINTER_FLAG_INCONTACT.0 != 0;
|
||||
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact))
|
||||
let pose = pen_pose(info.penMask, info.tiltX, info.tiltY, info.rotation);
|
||||
Some(pen_sample(info.pressure, info.penMask, info.penFlags, in_contact, pose))
|
||||
};
|
||||
// SAFETY: `GetMessageExtraInfo` reads the extra information of the thread's last message.
|
||||
let from_mouse = || is_mouse(unsafe { GetMessageExtraInfo() }.0 as u32);
|
||||
@@ -106,15 +120,25 @@ pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
|
||||
|
||||
/// What a pen whose `POINTER_PEN_INFO` holds `pressure` (0..1024), `pen_mask` and `pen_flags`
|
||||
/// reports: the pressure as winit gives it to a touch, 0 while it hovers, a full press for a pen
|
||||
/// in contact that reports none; and the eraser end while it is inverted or its eraser button is
|
||||
/// pressed.
|
||||
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) -> PenSample {
|
||||
/// in contact that reports none; the eraser end while it is inverted or its eraser button is
|
||||
/// pressed; and how it is held, `pose` ([`pen_pose`]).
|
||||
fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool, pose: PenPose) -> PenSample {
|
||||
let pressure = match pressure {
|
||||
1..=1024 if pen_mask & PEN_MASK_PRESSURE != 0 => pressure as f32 / 1024.0,
|
||||
_ if in_contact => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() }
|
||||
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose }
|
||||
}
|
||||
|
||||
/// How a pen whose `POINTER_PEN_INFO` holds `pen_mask`, `tilt_x`, `tilt_y` and `rotation` is
|
||||
/// held: the tilt when the pen reports it along either axis (0 along an axis it doesn't), the
|
||||
/// rotation when it reports one, each kept within [`PenPose`]'s ranges.
|
||||
fn pen_pose(pen_mask: u32, tilt_x: i32, tilt_y: i32, rotation: u32) -> PenPose {
|
||||
let along = |mask: u32, t: i32| if pen_mask & mask != 0 { t as f32 } else { 0.0 };
|
||||
let tilt = (pen_mask & (PEN_MASK_TILT_X | PEN_MASK_TILT_Y) != 0).then(|| [along(PEN_MASK_TILT_X, tilt_x), along(PEN_MASK_TILT_Y, tilt_y)]);
|
||||
let rotation = (pen_mask & PEN_MASK_ROTATION != 0).then_some(rotation as f32);
|
||||
PenPose { tilt, rotation }.sanitized()
|
||||
}
|
||||
|
||||
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
|
||||
@@ -390,7 +414,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn an_inverted_pen_or_its_eraser_button_is_the_eraser_end() {
|
||||
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true).eraser;
|
||||
let eraser = |flags| pen_sample(512, PEN_MASK_PRESSURE, flags, true, PenPose::default()).eraser;
|
||||
assert!(!eraser(0), "the tip");
|
||||
assert!(!eraser(PEN_FLAG_BARREL), "the barrel button");
|
||||
assert!(eraser(PEN_FLAG_INVERTED), "turned over");
|
||||
@@ -399,19 +423,32 @@ mod tests {
|
||||
assert!(eraser(PEN_FLAG_INVERTED | PEN_FLAG_BARREL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pens_tilt_and_rotation_come_where_it_reports_them() {
|
||||
let all = PEN_MASK_TILT_X | PEN_MASK_TILT_Y | PEN_MASK_ROTATION;
|
||||
assert_eq!(pen_pose(all, 30, -45, 90), PenPose { tilt: Some([30.0, -45.0]), rotation: Some(90.0) });
|
||||
assert_eq!(pen_pose(PEN_MASK_PRESSURE, 30, -45, 90), PenPose::default(), "a pen that reports neither");
|
||||
assert_eq!(pen_pose(PEN_MASK_TILT_X, 30, -45, 90), PenPose { tilt: Some([30.0, 0.0]), rotation: None }, "one axis");
|
||||
assert_eq!(pen_pose(PEN_MASK_ROTATION, 0, 0, 359), PenPose { tilt: None, rotation: Some(359.0) }, "a 6D Art Pen's barrel");
|
||||
let wild = pen_pose(all, 400, i32::MIN, 720);
|
||||
assert_eq!(wild, PenPose { tilt: Some([90.0, -90.0]), rotation: Some(0.0) }, "kept within the ranges");
|
||||
let pose = pen_pose(all, 10, 20, 180);
|
||||
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE | all, 0, true, pose).pose, pose);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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(512, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 0.5);
|
||||
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0);
|
||||
assert_eq!(
|
||||
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),
|
||||
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false, PenPose::default()),
|
||||
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");
|
||||
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false).pressure, 0.0);
|
||||
assert_eq!(pen_sample(0, 0, 0, true, PenPose::default()).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
|
||||
assert_eq!(pen_sample(300, 0, 0, true, PenPose::default()).pressure, 1.0, "a pressure without the pressure mask is not one");
|
||||
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true, PenPose::default()).pressure, 1.0, "as winit, which gives no force for 0");
|
||||
assert_eq!(pen_sample(2000, PEN_MASK_PRESSURE, 0, false, PenPose::default()).pressure, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,7 +54,7 @@ fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--safe-mode", "--version", "--help"] {
|
||||
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
|
||||
}
|
||||
// Headless work is the CLI's: the usage says where to go.
|
||||
@@ -74,3 +74,27 @@ fn help_prints_usage_and_exits_before_startup() {
|
||||
fn help_wins_over_a_bad_control_port_variable() {
|
||||
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
|
||||
}
|
||||
|
||||
/// An option the app doesn't know (a typo of `--safe-mode`) is an error, not a file to open with
|
||||
/// plug-ins; a later `--help` still wins.
|
||||
#[test]
|
||||
fn an_unknown_option_is_an_error_and_not_a_file() {
|
||||
for args in [&["--safe-mod"][..], &["drawing.svg", "--safe-mod"], &["-x"]] {
|
||||
let out = run(args, &[]);
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}");
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
let unknown = args.iter().find(|a| a.starts_with('-')).unwrap();
|
||||
assert_eq!(
|
||||
stderr,
|
||||
format!("vectorcraft: unknown option {unknown}; see vectorcraft --help, or put -- before a file whose name starts with '-'\n"),
|
||||
"{args:?}"
|
||||
);
|
||||
}
|
||||
prints_usage(&["--safe-mod", "--help"], &[]);
|
||||
// After `--`, a name starting with '-' is a file: the run goes on to the control port, which
|
||||
// `run` makes fail.
|
||||
let out = run(&["--", "-x.svg"], &[]);
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains("VECTORCRAFT_CONTROL_PORT") && !stderr.contains("unknown option"), "{stderr}");
|
||||
}
|
||||
|
||||
@@ -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="7" y="7" width="10" height="10" rx="1.5"/><rect x="3.5" y="3.5" width="17" height="17" rx="5" stroke-dasharray="7.854 7" stroke-dashoffset="7.854"/></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
@@ -208,4 +208,15 @@ sync_github_name = true
|
||||
[people.sadkotheguest]
|
||||
real_name = "Alexander Buraga"
|
||||
display_name = "Alexander Buraga"
|
||||
sync_github_name = false
|
||||
sync_github_name = false
|
||||
|
||||
[people.adikshan11]
|
||||
real_name = "Adithya Shankaran"
|
||||
sync_github_name = true
|
||||
|
||||
[people.slafebre]
|
||||
real_name = "Sven Lafebre"
|
||||
|
||||
[people.kylekanouse]
|
||||
real_name = "Kyle Kanouse"
|
||||
sync_github_name = true
|
||||
|
||||
@@ -29,6 +29,14 @@ 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;
|
||||
|
||||
/// Child references inspected, page rectangles produced, and outline segments retained, in all
|
||||
/// (shared objects are visited once per link, so each visit is charged). Real documents stay
|
||||
/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that.
|
||||
const MAX_DOCUMENT_WORK: usize = 1 << 24;
|
||||
|
||||
/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy).
|
||||
const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 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.
|
||||
@@ -44,6 +52,10 @@ pub(crate) struct Budget {
|
||||
pub(crate) cells: usize,
|
||||
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
|
||||
pub(crate) pressure: usize,
|
||||
/// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all).
|
||||
pub(crate) work: usize,
|
||||
/// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all).
|
||||
pub(crate) name_chars: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
@@ -53,7 +65,15 @@ impl Budget {
|
||||
chars: MAX_DOCUMENT_CHARS,
|
||||
cells: MAX_DOCUMENT_CELLS,
|
||||
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
|
||||
work: MAX_DOCUMENT_WORK,
|
||||
name_chars: MAX_DOCUMENT_NAME_CHARS,
|
||||
};
|
||||
|
||||
/// Take `n` units of work, or fail the read.
|
||||
pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
@@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
return Ok(None);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
self.budget.spend(pages.len() + 1)?;
|
||||
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
@@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
let kids = s.objs(parent, b"Chld");
|
||||
// Invisible masks and skipped children cost no node, so the visit is charged here.
|
||||
self.budget.spend(kids.len())?;
|
||||
for c in kids {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
if s.bool(c, b"Visi") == Some(false) {
|
||||
@@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect();
|
||||
self.budget.name_chars -= name.chars().count();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
@@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.warn(format!("a {} that could not be read", describe(class)));
|
||||
return Ok(Kind::Unsupported);
|
||||
};
|
||||
self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?;
|
||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||
}
|
||||
@@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_work_is_bounded() {
|
||||
let mut b = Budget::FULL;
|
||||
assert!(b.spend(MAX_DOCUMENT_WORK).is_ok());
|
||||
assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
};
|
||||
let grad = s.obj(fill, b"Grad")?;
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
|
||||
_ => Vec::new(),
|
||||
// Convert only the positions that pair with a colour, as shared attributes repeat this per run.
|
||||
let posn: &[Value] = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
|
||||
for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) {
|
||||
let color = color(r, *c)?;
|
||||
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
|
||||
@@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg") {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
// The whole source is decoded for every layer that reads it, so charge it all, not only the crop.
|
||||
let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else {
|
||||
return Err(Error::Limit("embedded image pixels"));
|
||||
};
|
||||
r.budget.pixels = left;
|
||||
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
|
||||
Ok(pixels) => Some((width as usize, pixels)),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
@@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
// A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference.
|
||||
let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(tw, th, states)
|
||||
|
||||
@@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096;
|
||||
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
|
||||
const MAX_TABLE_CELLS: usize = 1 << 16;
|
||||
|
||||
/// Work charged for each copy of a run's formatting a table cell keeps.
|
||||
const TABLE_RUN_COST: usize = 16;
|
||||
|
||||
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
|
||||
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let s = r.s;
|
||||
@@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let mut parts = run.text.split(CELL_BREAK).peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !part.is_empty() {
|
||||
// Each cell keeps its own copy of the run's formatting, so each copy is charged.
|
||||
match target.and_then(|i| cells.get_mut(i)) {
|
||||
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
None => extra = true,
|
||||
Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
_ => extra = true,
|
||||
}
|
||||
}
|
||||
if parts.peek().is_some() {
|
||||
@@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
// Shared blocks and run lists are visited once per link, so every visit is charged, empty or not.
|
||||
let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default();
|
||||
let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default();
|
||||
if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() {
|
||||
r.warn("text beyond the work one document may hold (truncated)");
|
||||
break;
|
||||
}
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
@@ -209,7 +220,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
// Paragraph formats by end index; a run without an index covers the rest of the block.
|
||||
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
|
||||
let mut open: Option<Paragraph> = None;
|
||||
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
for run in paragraph_runs {
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
let a = paragraph(r, attrs);
|
||||
first_align.get_or_insert(a.align);
|
||||
@@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
}
|
||||
};
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
for run in attribute_runs {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
|
||||
@@ -13,15 +13,26 @@ fn luminance(c: &Color) -> f32 {
|
||||
}
|
||||
|
||||
fn map_color(c: &Color, mode: Colorization, key: &Color) -> Color {
|
||||
let lum = luminance(c);
|
||||
// Black ink alone is its own tint: its amount is the tint, not how light it displays (100% K
|
||||
// displays lighter than black).
|
||||
let light = match *c {
|
||||
Color::Cmyk { c, m, y, k } if c == 0.0 && m == 0.0 && y == 0.0 => 1.0 - k,
|
||||
_ => luminance(c),
|
||||
};
|
||||
// A process or gray stroke color's tints and shades stay in its model.
|
||||
let (white, black) = match key {
|
||||
Color::Cmyk { .. } => (Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.0, 1.0)),
|
||||
Color::Gray { .. } => (Color::gray(0.0), Color::gray(1.0)),
|
||||
_ => (Color::WHITE, Color::BLACK),
|
||||
};
|
||||
match mode {
|
||||
Colorization::None => *c,
|
||||
Colorization::Tints => key.lerp(&Color::WHITE, lum),
|
||||
Colorization::Tints => key.lerp_keep_model(&white, light),
|
||||
Colorization::TintsAndShades => {
|
||||
if lum < 0.5 {
|
||||
Color::BLACK.lerp(key, lum * 2.0)
|
||||
if light < 0.5 {
|
||||
key.lerp_keep_model(&black, 1.0 - light * 2.0)
|
||||
} else {
|
||||
key.lerp(&Color::WHITE, (lum - 0.5) * 2.0)
|
||||
key.lerp_keep_model(&white, (light - 0.5) * 2.0)
|
||||
}
|
||||
}
|
||||
Colorization::HueShift { key: art_key } => {
|
||||
@@ -132,9 +143,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
|
||||
}
|
||||
|
||||
/// The stain instance `inst` puts on its symbol's art: its visible fill colour and that fill's
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance.
|
||||
/// opacity (how far the art mixes towards it). `None` for an unstained instance, including one
|
||||
/// whose fill has an opacity of 0, so its art keeps its paints and their swatch links.
|
||||
pub fn stain(inst: &Node) -> Option<(Color, f32)> {
|
||||
let f = inst.appearance.fill().filter(|f| f.visible)?;
|
||||
let f = inst.appearance.fill().filter(|f| f.visible && f.opacity > 0.0)?;
|
||||
Some((f.paint.color()?, f.opacity))
|
||||
}
|
||||
|
||||
|
||||
@@ -475,6 +475,10 @@ pub struct PatternBrush {
|
||||
pub side_frame: Option<Rect>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub side_swatch: Option<String>,
|
||||
/// Art for the corners where the path turns towards the side tile's top (`-y`); `inner_corner`
|
||||
/// is the art for the corners where it turns the other way. Both are drawn as the corner that
|
||||
/// joins a side tile on its right to one below it, and each corner tile is turned so that
|
||||
/// those two arms meet the sides.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outer_corner: Option<Node>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
+165
-1
@@ -1,6 +1,6 @@
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -299,6 +299,101 @@ fn pattern_places_corner_tiles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern brush whose corner tiles have a 4 × 4 pt frame and, as art, a 2 × 2 pt square in the
|
||||
/// top-left quarter of the frame. Corner art joins a side tile on its right to one below it, so
|
||||
/// that quarter is on the outside of the turn. `inner` and `outer` say which kinds of corner have
|
||||
/// art; `flips` are the brush's Flip Along and Flip Across.
|
||||
fn marked_corner_brush(inner: bool, outer: bool, (flip_along, flip_across): (bool, bool)) -> Brush {
|
||||
let marker =
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -2.0, 2.0, 0.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let mut b = pattern_brush(PatternFit::Stretch, false);
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
let frame = Some(Rect::new(0.0, -2.0, 4.0, 2.0));
|
||||
if inner {
|
||||
(p.inner_corner, p.inner_corner_frame) = (Some(marker.clone()), frame);
|
||||
}
|
||||
if outer {
|
||||
(p.outer_corner, p.outer_corner_frame) = (Some(marker), frame);
|
||||
}
|
||||
(p.flip_along, p.flip_across) = (flip_along, flip_across);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// Each combination of corner art (both kinds, inner only, outer only) and of the two flips.
|
||||
fn marked_corner_brushes() -> Vec<(String, Brush)> {
|
||||
let mut v = vec![];
|
||||
for (inner, outer) in [(true, true), (true, false), (false, true)] {
|
||||
for flips in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
v.push((format!("inner {inner}, outer {outer}, flips {flips:?}"), marked_corner_brush(inner, outer, flips)));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The bounds of the 2 × 2 pt corner markers among `out`, turned or not.
|
||||
fn marker_bounds(out: &[Node]) -> Vec<Rect> {
|
||||
out.iter().filter_map(Node::geometric_bounds).filter(|r| r.width() < 3.0 && r.height() < 3.0).collect()
|
||||
}
|
||||
|
||||
/// Corner tiles are square to the sides at a right angle and turn 90° with each turn of the path:
|
||||
/// on a square drawn either way round, each corner's marker fills the quarter of the band's corner
|
||||
/// outside the turn, with the brush flipped or not and with art for one kind of corner only
|
||||
/// (#6110).
|
||||
#[test]
|
||||
fn pattern_corner_tiles_are_square_to_the_sides() {
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let mut reversed = BezPath::new();
|
||||
reversed.move_to((0.0, 0.0));
|
||||
for (x, y) in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] {
|
||||
reversed.line_to((x, y));
|
||||
}
|
||||
reversed.close_path();
|
||||
// The square turns the same way at all four corners, and the reversed square the other way,
|
||||
// so one draws only inner corner tiles and the other only outer ones.
|
||||
for (name, brush) in marked_corner_brushes() {
|
||||
for path in [&sq, &reversed] {
|
||||
let out = stroke_pieces(&doc, &brush, path, &stroke(1.0));
|
||||
let marks = marker_bounds(&out);
|
||||
assert_eq!(marks.len(), 4, "{name}: one marker a corner");
|
||||
for (corner, outside) in SQUARE_CORNERS {
|
||||
let want = Rect::from_points(corner, outside);
|
||||
assert!(
|
||||
marks
|
||||
.iter()
|
||||
.any(|r| near(r.x0, want.x0, 1e-6) && near(r.y0, want.y0, 1e-6) && near(r.x1, want.x1, 1e-6) && near(r.y1, want.y1, 1e-6)),
|
||||
"{name}: the marker at {corner:?} fills {want:?}: {marks:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// At a corner that is not a right angle, a corner tile splits the difference between the two
|
||||
/// sides: its marker lies on the line that halves the angle, outside the turn, for sharp and
|
||||
/// shallow turns either way. Near a hairpin the marker moves smoothly towards the line the path
|
||||
/// arrives on.
|
||||
#[test]
|
||||
fn pattern_corner_tiles_sit_on_the_corners_diagonal() {
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
for (name, brush) in marked_corner_brushes() {
|
||||
for turn in [-179.9_f64, -170.0, -135.0, -60.0, -45.0, 45.0, 60.0, 135.0, 170.0, 179.9] {
|
||||
let (a, b) = (Vec2::new(1.0, 0.0), Vec2::new(turn.to_radians().cos(), turn.to_radians().sin()));
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((50.0, 0.0));
|
||||
bp.line_to(Point::new(50.0, 0.0) + b * 50.0);
|
||||
let out = stroke_pieces(&doc, &brush, &bp, &stroke(1.0));
|
||||
let marks = marker_bounds(&out);
|
||||
assert_eq!(marks.len(), 1, "{name}, {turn}°: one corner marker");
|
||||
let away = a - b;
|
||||
let want = Point::new(50.0, 0.0) + away / away.hypot() * 2.0_f64.sqrt();
|
||||
assert!(marks[0].center().distance(want) < 1e-6, "{name}, {turn}°: the marker is at {:?}, not {want:?}", marks[0].center());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
|
||||
fn auto_corner_brush(outer: Option<CornerSource>, inner: Option<CornerSource>) -> Brush {
|
||||
let mut b = pattern_brush(PatternFit::Stretch, false);
|
||||
@@ -701,6 +796,55 @@ fn tints_colorization_uses_stroke_colour() {
|
||||
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// Black ink alone is a tint by its amount: a process black stroke leaves it as it is, and another
|
||||
/// process stroke color takes that share of its inks. Tints of a process color stay process.
|
||||
#[test]
|
||||
fn tints_of_black_ink_are_its_amount_of_the_stroke_color() {
|
||||
let tinted = |art: Color, stroke: Color| {
|
||||
let mut n = rect_art(1.0, 1.0);
|
||||
n.appearance.set_fill(Paint::solid(art));
|
||||
colorize(&mut n, Colorization::Tints, &Paint::solid(stroke));
|
||||
n.appearance.fill_paint().color().unwrap()
|
||||
};
|
||||
let ink = Color::cmyk(0.0, 0.0, 0.0, 0.6);
|
||||
let close = |a: Color, b: Color| {
|
||||
let (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) = (a, b) else { panic!("{a:?} {b:?}") };
|
||||
(c - c2).abs() < 1e-5 && (m - m2).abs() < 1e-5 && (y - y2).abs() < 1e-5 && (k - k2).abs() < 1e-5
|
||||
};
|
||||
assert!(close(tinted(ink, Color::cmyk(0.0, 0.0, 0.0, 1.0)), ink));
|
||||
assert!(close(tinted(ink, Color::cmyk(0.0, 1.0, 1.0, 0.0)), Color::cmyk(0.0, 0.6, 0.6, 0.0)));
|
||||
let [r, g, b] = tinted(ink, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
|
||||
assert!((r - 1.0).abs() < 1e-5 && (g - 0.4).abs() < 1e-5 && (b - 0.4).abs() < 1e-5, "{r} {g} {b}");
|
||||
let Color::Gray { k } = tinted(ink, Color::gray(0.5)) else { panic!("tints of a gray stroke color stay gray") };
|
||||
assert!((k - 0.3).abs() < 1e-5, "{k}");
|
||||
}
|
||||
|
||||
/// Under Tints and Shades black ink stays black, however light 100% K displays, and a band of black
|
||||
/// ink is its amount: K 0.5 is the stroke color. A process or gray stroke color stays in its model.
|
||||
#[test]
|
||||
fn tints_and_shades_of_black_ink_go_by_its_amount() {
|
||||
let mapped = |k: f32, stroke: Color| {
|
||||
let mut n = rect_art(1.0, 1.0);
|
||||
n.appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, k)));
|
||||
colorize(&mut n, Colorization::TintsAndShades, &Paint::solid(stroke));
|
||||
n.appearance.fill_paint().color().unwrap()
|
||||
};
|
||||
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-5);
|
||||
let inks = |c: Color| {
|
||||
let Color::Cmyk { c, m, y, k } = c else { panic!("a process color: {c:?}") };
|
||||
[c, m, y, k]
|
||||
};
|
||||
let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0);
|
||||
assert!(close(inks(mapped(1.0, orange)), [0.0, 0.0, 0.0, 1.0]), "black stays black");
|
||||
assert!(close(inks(mapped(0.5, orange)), [0.0, 0.6, 1.0, 0.0]), "the stroke color");
|
||||
assert!(close(inks(mapped(0.25, orange)), [0.0, 0.3, 0.5, 0.0]), "a tint");
|
||||
assert!(close(inks(mapped(0.75, orange)), [0.0, 0.3, 0.5, 0.5]), "a shade");
|
||||
let Color::Gray { k } = mapped(0.75, Color::gray(0.4)) else { panic!("a gray stroke color stays gray") };
|
||||
assert!((k - 0.7).abs() < 1e-5, "{k}");
|
||||
let [r, g, b] = mapped(1.0, Color::rgb(1.0, 0.0, 0.0)).to_rgb();
|
||||
assert!(r < 1e-5 && g < 1e-5 && b < 1e-5, "black stays black: {r} {g} {b}");
|
||||
}
|
||||
|
||||
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
|
||||
fn type_art(fill: Paint, stroke: Paint) -> Node {
|
||||
use vectorcraft_doc::{CharStyle, TextObject};
|
||||
@@ -764,6 +908,26 @@ fn stain_mixes_type_strokes_too() {
|
||||
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
|
||||
}
|
||||
|
||||
/// An instance whose fill has an opacity of 0 does not stain its symbol's art, so the art keeps its
|
||||
/// swatch links.
|
||||
#[test]
|
||||
fn a_fill_with_no_opacity_leaves_the_symbol_art_linked() {
|
||||
let spot = Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4), swatch: Some("Spot".into()), tint: 1.0 };
|
||||
let art = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(spot.clone(), Paint::None, 0.0));
|
||||
let mut inst = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::IDENTITY });
|
||||
inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into();
|
||||
if let Some(f) = inst.appearance.fill_mut() {
|
||||
f.opacity = 0.0;
|
||||
}
|
||||
assert_eq!(stain(&inst), None);
|
||||
assert_eq!(instance_art(&art, &inst).appearance.fill_paint(), spot);
|
||||
// Any opacity above 0 still stains.
|
||||
if let Some(f) = inst.appearance.fill_mut() {
|
||||
f.opacity = 0.01;
|
||||
}
|
||||
assert_ne!(instance_art(&art, &inst).appearance.fill_paint(), spot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_defaults_store_and_find() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -879,12 +879,16 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
};
|
||||
trims.push(None);
|
||||
corner_half = corner_half.max(bb.width() * k / 2.0);
|
||||
let bis = a + b;
|
||||
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
|
||||
let xf = Affine::translate(t.verts[i].to_vec2())
|
||||
* Affine::rotate(bis.y.atan2(bis.x))
|
||||
* Affine::scale(k)
|
||||
* Affine::translate(-bb.center().to_vec2());
|
||||
// Inner and outer corner art is the corner that joins a side tile on its right to one
|
||||
// below it, so its arms point along +x and +y (as the flips leave them). The tile is
|
||||
// turned so that the line halving its arms lies on the line halving the sides at the
|
||||
// corner, back along the incoming side and on along the outgoing one: at a right angle
|
||||
// the arms meet the side tiles, and at other angles the difference is split between
|
||||
// the two sides (#6110).
|
||||
let arms = Vec2::new(if pb.flip_along { -1.0 } else { 1.0 }, if pb.flip_across { -1.0 } else { 1.0 });
|
||||
let sides = b - a;
|
||||
let angle = sides.y.atan2(sides.x) - arms.y.atan2(arms.x);
|
||||
let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(angle) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2());
|
||||
let mut n = art.clone();
|
||||
n.transform(xf, true);
|
||||
out.take(Some(n));
|
||||
|
||||
@@ -308,6 +308,78 @@ impl Default for DxfOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of
|
||||
/// options matches.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DxfPreset {
|
||||
/// The options as chosen (the export's defaults for those left out).
|
||||
#[default]
|
||||
Custom,
|
||||
/// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight
|
||||
/// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize
|
||||
/// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths),
|
||||
/// and placed images as PNG.
|
||||
Cnc,
|
||||
}
|
||||
|
||||
impl DxfPreset {
|
||||
pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc];
|
||||
|
||||
/// The `dxfPreset` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Custom => "custom",
|
||||
Self::Cnc => "cnc",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Custom => "Custom",
|
||||
Self::Cnc => "CNC / CAM",
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset by id (`custom`, `cnc`), any case.
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
|
||||
}
|
||||
|
||||
/// The options the preset sets (Custom: the defaults); the region and crop are the defaults.
|
||||
pub fn options(self) -> DxfOptions {
|
||||
match self {
|
||||
Self::Custom => DxfOptions::default(),
|
||||
Self::Cnc => DxfOptions {
|
||||
version: DxfVersion::R2000,
|
||||
unit: Unit::Millimeters,
|
||||
scale: 1.0,
|
||||
scale_lineweights: false,
|
||||
colors: ColorDepth::Aci256,
|
||||
raster: RasterFormat::Png,
|
||||
preserve: Preserve::Editability,
|
||||
alter_paths: false,
|
||||
outline_text: true,
|
||||
..DxfOptions::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom.
|
||||
pub fn of(o: &DxfOptions) -> Self {
|
||||
let c = Self::Cnc.options();
|
||||
let same = o.version == c.version
|
||||
&& o.unit == c.unit
|
||||
&& o.scale == c.scale
|
||||
&& o.scale_lineweights == c.scale_lineweights
|
||||
&& o.colors == c.colors
|
||||
&& o.raster == c.raster
|
||||
&& o.preserve == c.preserve
|
||||
&& o.alter_paths == c.alter_paths
|
||||
&& o.outline_text == c.outline_text;
|
||||
if same { Self::Cnc } else { Self::Custom }
|
||||
}
|
||||
}
|
||||
|
||||
/// An image file a drawing links to, to be written next to it under `name`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DxfImage {
|
||||
|
||||
@@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() {
|
||||
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() {
|
||||
let mut d = text_doc("Cut", 20.0);
|
||||
let layer = d.layers[0].id;
|
||||
let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||||
d.insert(Some(layer), usize::MAX, frame).unwrap();
|
||||
let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() };
|
||||
let out = export(&d, &o).unwrap();
|
||||
let p = pairs(&out.bytes);
|
||||
assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015"));
|
||||
assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4"));
|
||||
let k = kinds(&out);
|
||||
assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}");
|
||||
assert!(k.len() > 2, "the glyphs' outlines: {k:?}");
|
||||
// The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline.
|
||||
assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}");
|
||||
assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}");
|
||||
assert!(out.images.is_empty());
|
||||
assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_become_dxf_layers() {
|
||||
let mut d = shapes_doc();
|
||||
|
||||
@@ -231,6 +231,21 @@ impl GenericCmyk {
|
||||
}
|
||||
g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::<f32>();
|
||||
}
|
||||
// An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks.
|
||||
for p in 0..3 {
|
||||
if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) {
|
||||
a[p] = [0.0; 3];
|
||||
for row in &mut a {
|
||||
row[p] = 0.0;
|
||||
}
|
||||
a[p][p] = 1.0;
|
||||
g[p] = 0.0;
|
||||
}
|
||||
}
|
||||
// Every ink is held at a bound, or the gradient is zero: no step can lower the error.
|
||||
if g == [0.0; 3] {
|
||||
break;
|
||||
}
|
||||
let mut improved = false;
|
||||
for _ in 0..8 {
|
||||
let mut al = a;
|
||||
|
||||
@@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
|
||||
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
|
||||
/// in all) stays within 0.001 on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
|
||||
let c = cms();
|
||||
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(lo[0], 0.0, "{lo:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
|
||||
/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values
|
||||
/// stays within 0.001 on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() {
|
||||
let c = cms();
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -60i32..=60 {
|
||||
let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_colours_round_trip_through_the_cms() {
|
||||
let c = cms_no_bpc();
|
||||
|
||||
@@ -151,16 +151,23 @@ pub struct GradientStop {
|
||||
/// Tint of the linked swatch (0..1; 1 without a link), as on a solid paint.
|
||||
#[serde(default = "crate::full_tint", skip_serializing_if = "crate::is_full_tint")]
|
||||
pub tint: f32,
|
||||
/// Process inks (C, M, Y, K, 0..1) printed together with the linked spot swatch's tint: a
|
||||
/// DeviceN colour of a file that mixes a spot ink with process plates. `color` is how the mix
|
||||
/// looks; separations print the spot ink at `tint` and these plates. `None`: the ink alone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plates: Option<[f32; 4]>,
|
||||
}
|
||||
|
||||
impl GradientStop {
|
||||
/// An opaque, unlinked stop of `color` at `offset` with a centred midpoint.
|
||||
pub fn new(offset: f32, color: Color) -> Self {
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0 }
|
||||
Self { offset, color, opacity: 1.0, midpoint: 0.5, swatch: None, tint: 1.0, plates: None }
|
||||
}
|
||||
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked.
|
||||
/// Give the stop `color`, linked to `link` (a global swatch and tint) or unlinked, without
|
||||
/// process plates.
|
||||
pub fn set_color(&mut self, color: Color, link: Option<(String, f32)>) {
|
||||
self.color = color;
|
||||
self.plates = None;
|
||||
(self.swatch, self.tint) = match link {
|
||||
Some((n, t)) => (Some(n), t),
|
||||
None => (None, 1.0),
|
||||
|
||||
@@ -296,6 +296,28 @@ impl Appearance {
|
||||
scale(e, s);
|
||||
}
|
||||
}
|
||||
/// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document.
|
||||
pub const SVG_FILTER: &str = "svg.filter";
|
||||
|
||||
/// Does any effect (the object's or a fill's or stroke's) hold an SVG filter?
|
||||
pub fn has_svg_filter(&self) -> bool {
|
||||
self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER)
|
||||
}
|
||||
|
||||
/// Move the `origin` of every SVG filter effect (where its user space starts in the document)
|
||||
/// by `a`, as the object it is on moves.
|
||||
pub fn transform_svg_filters(&mut self, a: kurbo::Affine) {
|
||||
let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
|
||||
for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) {
|
||||
let at =
|
||||
|i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let p = a * kurbo::Point::new(at(0), at(1));
|
||||
if p.x.is_finite() && p.y.is_finite() && e.params.is_object() {
|
||||
e.params["origin"] = serde_json::json!([p.x, p.y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient paints of the fills and strokes.
|
||||
fn gradients_mut(&mut self) -> impl Iterator<Item = &mut vectorcraft_color::GradientPaint> {
|
||||
self.items.iter_mut().filter_map(|i| match i {
|
||||
|
||||
@@ -22,11 +22,15 @@ pub struct ProfilePreset {
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// (left, right) width factors at `t`, linear between points.
|
||||
/// (left, right) width factors at `t`: a smooth curve through the points ([`Self::at_points`]).
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's). Each side runs through
|
||||
/// its points on a monotone cubic curve (Fritsch–Carlson): it flows through a width point
|
||||
/// without a kink, turns flat at a point wider or narrower than both neighbours, and never
|
||||
/// goes past either point of the span it is in (#5714). Points on one straight line stay on
|
||||
/// it. A step (two points at one position, [`Self::around`]) ends the curve on each side of it.
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
@@ -36,9 +40,21 @@ impl WidthProfile {
|
||||
}
|
||||
// (Positions go forward; found in log time, so a long profile read from a file is cheap to ask.)
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
if let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
let u = (t - a.0) / (b.0 - a.0).max(1e-9);
|
||||
return (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u);
|
||||
if let (Some(j), Some(b)) = (i.checked_sub(1), p.get(i))
|
||||
&& let Some(a) = p.get(j)
|
||||
{
|
||||
let h = b.0 - a.0;
|
||||
let u = (t - a.0) / h.max(1e-9);
|
||||
let linear = (a.1 + (b.1 - a.1) * u, a.2 + (b.2 - a.2) * u);
|
||||
if h <= STEP {
|
||||
return linear;
|
||||
}
|
||||
let side = |v: fn(&(f64, f64, f64)) -> f64, straight: f64| {
|
||||
let y = hermite(v(a), v(b), tangent(p, j, v) * h, tangent(p, i, v) * h, u);
|
||||
// Widths read from a file may be huge: where the curve overflows, the straight line.
|
||||
if y.is_finite() { y } else { straight }
|
||||
};
|
||||
return (side(|q| q.1, linear.0), side(|q| q.2, linear.1));
|
||||
}
|
||||
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
|
||||
}
|
||||
@@ -55,14 +71,17 @@ 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.
|
||||
/// The widest the stroke gets, as a multiple of its weight, 1 for a profile without points. Each
|
||||
/// side stays between the widths of the two points of each span ([`Self::at_points`]), but the
|
||||
/// two sides needn't peak together, so a span counts its wider left and its wider right.
|
||||
pub fn widest(&self) -> f64 {
|
||||
if self.points.is_empty() {
|
||||
let p = &self.points;
|
||||
if p.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)
|
||||
let total = |l: f64, r: f64| (l.max(0.0) + r.max(0.0)) / 2.0;
|
||||
let spans = p.windows(2).map(|w| total(w[0].1.max(w[1].1), w[0].2.max(w[1].2)));
|
||||
p.iter().map(|&(_, l, r)| total(l, r)).chain(spans).fold(0.0, f64::max)
|
||||
}
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
@@ -227,6 +246,41 @@ impl AngleProfile {
|
||||
}
|
||||
}
|
||||
|
||||
/// How close two width points' positions are to make a step ([`WidthProfile::around`]).
|
||||
const STEP: f64 = 1e-7;
|
||||
|
||||
/// The slope of one side (`v` reads it from a point) of width profile `p` at its point `j`, in
|
||||
/// width per unit of position: Fritsch–Carlson's choice for a monotone cubic curve. It is 0 where
|
||||
/// the point is wider or narrower than both neighbours, the weighted harmonic mean of the slopes
|
||||
/// of the spans on either side elsewhere (never more than three times either, so no span
|
||||
/// overshoots its points), and the span's own slope at an end of the profile or beside a step.
|
||||
fn tangent(p: &[(f64, f64, f64)], j: usize, v: fn(&(f64, f64, f64)) -> f64) -> f64 {
|
||||
let Some(q) = p.get(j) else { return 0.0 };
|
||||
let before = j.checked_sub(1).and_then(|k| p.get(k)).filter(|a| q.0 - a.0 > STEP);
|
||||
let after = p.get(j + 1).filter(|b| b.0 - q.0 > STEP);
|
||||
let slope = |a: &(f64, f64, f64), b: &(f64, f64, f64)| (v(b) - v(a)) / (b.0 - a.0);
|
||||
match (before, after) {
|
||||
(Some(a), Some(b)) => {
|
||||
let (d0, d1) = (slope(a, q), slope(q, b));
|
||||
if d0 * d1 <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (h0, h1) = (q.0 - a.0, b.0 - q.0);
|
||||
3.0 * (h0 + h1) / ((2.0 * h1 + h0) / d0 + (h1 + 2.0 * h0) / d1)
|
||||
}
|
||||
(Some(a), None) => slope(a, q),
|
||||
(None, Some(b)) => slope(q, b),
|
||||
(None, None) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cubic Hermite curve from `y0` to `y1` at `u` (0..1), with end slopes `m0` and `m1` per
|
||||
/// whole span.
|
||||
fn hermite(y0: f64, y1: f64, m0: f64, m1: f64, u: f64) -> f64 {
|
||||
let (u2, u3) = (u * u, u * u * u);
|
||||
(2.0 * u3 - 3.0 * u2 + 1.0) * y0 + (u3 - 2.0 * u2 + u) * m0 + (-2.0 * u3 + 3.0 * u2) * y1 + (u3 - u2) * m1
|
||||
}
|
||||
|
||||
/// 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()?);
|
||||
@@ -341,6 +395,7 @@ pub struct SavedProfile {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Widths and steps are found by search, at the ends, between points and at a step.
|
||||
#[test]
|
||||
fn width_profile_lookups_by_search() {
|
||||
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25), (1.0, 0.0, 0.0)] };
|
||||
@@ -358,4 +413,93 @@ mod tests {
|
||||
let near = WidthProfile { points: vec![(0.4999999, 2.0, 2.0), (0.5, 1.0, 1.0), (0.5, 0.5, 0.25)] };
|
||||
assert_eq!(near.around(0.5), ((1.0, 1.0), (0.5, 0.25)));
|
||||
}
|
||||
|
||||
/// The slope of one side of `p` just before and just after `t`.
|
||||
fn slopes(p: &WidthProfile, t: f64) -> (f64, f64) {
|
||||
let e = 1e-6;
|
||||
((p.at(t).0 - p.at(t - e).0) / e, (p.at(t + e).0 - p.at(t).0) / e)
|
||||
}
|
||||
|
||||
/// #5714: the width flows through a width point instead of turning a corner there.
|
||||
#[test]
|
||||
fn widths_run_smoothly_through_width_points() {
|
||||
// A swell in the middle: flat at its widest, and a quarter of the way along above the
|
||||
// straight ramp from 1 to 3 (2).
|
||||
let swell = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 2.0), (1.0, 1.0, 1.0)] };
|
||||
let (before, after) = slopes(&swell, 0.5);
|
||||
assert!(before.abs() < 1e-4 && after.abs() < 1e-4, "{before} {after}");
|
||||
let (l, r) = swell.at(0.25);
|
||||
assert!((l - 2.25).abs() < 1e-12 && (r - 1.625).abs() < 1e-12, "{l} {r}");
|
||||
// Rising on both sides of a point: the slope is the same either side of it.
|
||||
let rise = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.3, 1.0, 1.0), (1.0, 4.0, 4.0)] };
|
||||
let (before, after) = slopes(&rise, 0.3);
|
||||
assert!(before > 0.0 && (before - after).abs() < 1e-3 * before, "{before} {after}");
|
||||
// Points on one line stay on it.
|
||||
let straight = WidthProfile { points: vec![(0.0, 0.0, 1.0), (0.25, 0.5, 1.0), (1.0, 2.0, 1.0)] };
|
||||
for t in [0.1, 0.25, 0.6, 0.9] {
|
||||
let (l, r) = straight.at(t);
|
||||
assert!((l - 2.0 * t).abs() < 1e-12 && (r - 1.0).abs() < 1e-12, "{t}: {l} {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Neither side of a profile goes wider or narrower than the two points of the span it is in.
|
||||
#[test]
|
||||
fn widths_never_pass_the_points_either_side() {
|
||||
for preset in WidthProfile::PRESETS {
|
||||
let p = WidthProfile { points: preset.points.to_vec() };
|
||||
for w in p.points.windows(2) {
|
||||
let (lo, hi) = (w[0].1.min(w[1].1), w[0].1.max(w[1].1));
|
||||
for k in 0..=100 {
|
||||
let t = w[0].0 + (w[1].0 - w[0].0) * k as f64 / 100.0;
|
||||
let l = p.at(t).0;
|
||||
assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{}: {l} at {t} outside {lo}..{hi}", preset.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uneven spacing and a steep jump next to a gentle rise.
|
||||
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.05, 1.0, 1.0), (0.9, 1.1, 1.1), (1.0, 5.0, 5.0)] };
|
||||
for k in 0..=1000 {
|
||||
let t = k as f64 / 1000.0;
|
||||
let l = p.at(t).0;
|
||||
let span = p.points.windows(2).find(|w| t >= w[0].0 && t <= w[1].0).map(|w| (w[0].1.min(w[1].1), w[0].1.max(w[1].1)));
|
||||
let (lo, hi) = span.unwrap();
|
||||
assert!(l >= lo - 1e-12 && l <= hi + 1e-12, "{l} at {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A step still breaks the width sharply, and the curve on either side ends at it.
|
||||
#[test]
|
||||
fn a_step_ends_the_curve_on_each_side() {
|
||||
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (0.5, 1.0, 1.0), (1.0, 2.0, 2.0)] };
|
||||
assert_eq!(p.around(0.5), ((3.0, 3.0), (1.0, 1.0)));
|
||||
// Two points between ends: each side of the step is a straight ramp.
|
||||
assert!((p.at(0.25).0 - 2.0).abs() < 1e-12 && (p.at(0.75).0 - 1.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// `widest()` is at least the stroke's width anywhere along the profile, even where the sides peak apart.
|
||||
#[test]
|
||||
fn widest_covers_every_width_along_the_profile() {
|
||||
let profiles = [
|
||||
vec![(0.0, 1.0, 0.0), (0.5, 0.2, 1.0), (1.0, 1.0, 0.0)],
|
||||
vec![(0.0, 0.0, 1.0), (0.3, 1.0, 0.0), (0.6, 0.0, 1.0), (1.0, 1.0, 1.0)],
|
||||
vec![(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)],
|
||||
];
|
||||
for points in profiles {
|
||||
let p = WidthProfile { points };
|
||||
let most = (0..=1000).map(|k| p.at(k as f64 / 1000.0)).map(|(l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max);
|
||||
assert!(p.widest() >= most - 1e-12, "{} < {most}: {:?}", p.widest(), p.points);
|
||||
}
|
||||
assert_eq!(WidthProfile::lens().widest(), 1.0);
|
||||
assert_eq!(WidthProfile::default().widest(), 1.0);
|
||||
}
|
||||
|
||||
/// Widths read from a file may be absurd: the curve stays finite or falls back to the line.
|
||||
#[test]
|
||||
fn huge_widths_stay_finite() {
|
||||
let p = WidthProfile { points: vec![(0.0, 0.0, 0.0), (0.5, f64::MAX, 1.0), (1.0, -f64::MAX, 0.0)] };
|
||||
for k in 0..=10 {
|
||||
let (l, r) = p.at(k as f64 / 10.0);
|
||||
assert!(!l.is_nan() && r.is_finite(), "{l} {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,11 +192,14 @@ fn strokes_without_pressure_round_trip_unchanged() {
|
||||
assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
|
||||
}
|
||||
|
||||
/// The Lens profile's widths at its middle, a quarter of the way along and past its end.
|
||||
#[test]
|
||||
fn profile_interp() {
|
||||
let p = WidthProfile::lens();
|
||||
assert_eq!(p.at(0.5), (1.0, 1.0));
|
||||
assert_eq!(p.at(0.25), (0.5, 0.5));
|
||||
// Half way up the lens the width curves above the straight ramp (0.5), flattening into its
|
||||
// widest point (#5714).
|
||||
assert_eq!(p.at(0.25), (0.625, 0.625));
|
||||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,60 @@ impl super::Document {
|
||||
d.layers.insert(0, Arc::new(layer));
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away,
|
||||
/// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and
|
||||
/// no other artboard overlapping it (touching edges don't overlap). `None` when there is no
|
||||
/// such artboard or no room.
|
||||
pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option<Rect> {
|
||||
let src = self.artboards.get(i)?.rect;
|
||||
let r = src + side.offset(src);
|
||||
let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD);
|
||||
let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1));
|
||||
(in_canvas && clear).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value,
|
||||
/// the space New Artboard and Duplicate Artboards leave right of the last artboard).
|
||||
pub const ARTBOARD_GAP: f64 = 20.0;
|
||||
|
||||
/// A side of an artboard, where an artboard added or duplicated beside it goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArtboardSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl ArtboardSide {
|
||||
pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left];
|
||||
|
||||
/// The side a command's `side` param names: "left", "right", "top" or "bottom".
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|s| s.name() == name)
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Left => "left",
|
||||
Self::Right => "right",
|
||||
Self::Top => "top",
|
||||
Self::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down).
|
||||
pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 {
|
||||
let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP);
|
||||
match self {
|
||||
Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0),
|
||||
Self::Right => vectorcraft_geom::Vec2::new(w, 0.0),
|
||||
Self::Top => vectorcraft_geom::Vec2::new(0.0, -h),
|
||||
Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document's locked artboards ([`Document::locked_artboards`]).
|
||||
|
||||
@@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{Document, NodeId};
|
||||
|
||||
/// One asset: what it is called (its files' names) and the objects it exports, cropped to their
|
||||
/// visual bounds.
|
||||
/// visual bounds, or for a 3D asset its object's solids as a mesh file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportAsset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub nodes: Vec<NodeId>,
|
||||
/// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D
|
||||
/// file. `None` for art exported as pictures.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format_3d: Option<String>,
|
||||
}
|
||||
|
||||
/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`…
|
||||
@@ -30,7 +34,16 @@ impl Document {
|
||||
|
||||
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
|
||||
pub fn next_asset_name(&self) -> String {
|
||||
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
|
||||
let n = self
|
||||
.assets
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let digits = a.name.strip_prefix(ASSET)?;
|
||||
// `parse` alone would accept a leading `+`.
|
||||
digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::<u64>().ok()).flatten()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
format!("{ASSET}{}", n.saturating_add(1))
|
||||
}
|
||||
|
||||
@@ -57,3 +70,23 @@ impl Document {
|
||||
v.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn name_after(names: &[&str]) -> String {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None });
|
||||
}
|
||||
doc.next_asset_name()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_asset_name_ignores_non_digit_suffixes() {
|
||||
assert_eq!(name_after(&["Asset +5"]), "Asset 1");
|
||||
assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1");
|
||||
assert_eq!(name_after(&["Asset 5"]), "Asset 6");
|
||||
}
|
||||
}
|
||||
|
||||
+337
-61
@@ -699,6 +699,34 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
|
||||
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
|
||||
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
|
||||
/// the same place (by character) of both.
|
||||
/// The run of `of` at fraction `u` of its characters.
|
||||
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
|
||||
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let target = (u * total as f64) as usize;
|
||||
let mut seen = 0;
|
||||
for r in &of.runs {
|
||||
seen += r.text.chars().count();
|
||||
if seen > target {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
of.runs.last()
|
||||
}
|
||||
|
||||
/// The fraction of the characters of `runs` at the middle of each run.
|
||||
fn run_middles(runs: &[crate::text::TextRun]) -> Vec<f64> {
|
||||
let total: usize = runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
runs.iter()
|
||||
.map(|r| {
|
||||
let len = r.text.chars().count();
|
||||
let u = (start as f64 + len as f64 / 2.0) / total as f64;
|
||||
start += len;
|
||||
u
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
|
||||
o.xf = lerp_affine(x.xf, y.xf, t);
|
||||
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
|
||||
@@ -706,27 +734,10 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64
|
||||
{
|
||||
*f = lerp_path(fx, fy, t);
|
||||
}
|
||||
// The run of `of` at fraction `u` of its characters.
|
||||
fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> {
|
||||
let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum();
|
||||
let target = (u * total as f64) as usize;
|
||||
let mut seen = 0;
|
||||
for r in &of.runs {
|
||||
seen += r.text.chars().count();
|
||||
if seen > target {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
of.runs.last()
|
||||
}
|
||||
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
|
||||
let mut start = 0usize;
|
||||
let tf = tc as f32;
|
||||
let mut resized = false;
|
||||
for r in &mut o.runs {
|
||||
let len = r.text.chars().count();
|
||||
let u = (start as f64 + len as f64 / 2.0) / total as f64;
|
||||
start += len;
|
||||
let middles = run_middles(&o.runs);
|
||||
for (r, u) in o.runs.iter_mut().zip(middles) {
|
||||
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
|
||||
let (sx, sy) = (&rx.style, &ry.style);
|
||||
r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf);
|
||||
@@ -752,33 +763,115 @@ pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node {
|
||||
// Step counts
|
||||
// =====================================================================================
|
||||
|
||||
/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke.
|
||||
fn color_distance(a: &Node, b: &Node) -> f32 {
|
||||
let mut d = 0.0f32;
|
||||
let mut cmp = |x: &Paint, y: &Paint| {
|
||||
let cols = |p: &Paint| -> Vec<Color> {
|
||||
match p {
|
||||
Paint::Solid { color, .. } => vec![*color],
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
let (cx, cy) = (cols(x), cols(y));
|
||||
for (i, c) in cx.iter().enumerate() {
|
||||
if let Some(o) = cy.get(i).or(cy.first()) {
|
||||
let (p, q) = (c.to_rgb(), o.to_rgb());
|
||||
for k in 0..3 {
|
||||
d = d.max((p[k] - q[k]).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Largest channel difference between two solid colors, as a fraction of the channel's range: two
|
||||
/// tints of the same swatch by their tints, RGB by its 0 to 255 values over 256, CMYK and gray by
|
||||
/// their percentages, and colors in different spaces (or Lab) by the difference of their RGB
|
||||
/// values over 255. A solid paint against no paint, or anything that isn't solid, differs by 0:
|
||||
/// [`lerp_paint`] switches such paints halfway rather than mixing them.
|
||||
fn paint_difference(x: &Paint, y: &Paint) -> f64 {
|
||||
let (Paint::Solid { color: p, swatch: sp, tint: tp }, Paint::Solid { color: q, swatch: sq, tint: tq }) = (x, y) else {
|
||||
return 0.0;
|
||||
};
|
||||
cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint());
|
||||
cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint());
|
||||
d
|
||||
if sp.is_some() && sp == sq && tp != tq {
|
||||
return f64::from((tp - tq).abs());
|
||||
}
|
||||
let most = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0.0f64, |m, (u, v)| m.max(f64::from((u - v).abs())));
|
||||
match (p, q) {
|
||||
(Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => most(&[*r, *g, *b], &[*r2, *g2, *b2]) * 255.0 / 256.0,
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => most(&[*c, *m, *y, *k], &[*c2, *m2, *y2, *k2]),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => most(&[*k], &[*k2]),
|
||||
_ => most(&p.to_rgb(), &q.to_rgb()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of intermediate steps between two keys `len` apart (along the spine).
|
||||
/// Largest difference (see [`paint_difference`]) between pairs of fills and strokes, or `None`
|
||||
/// when any of them is a gradient or a pattern.
|
||||
fn paints_difference(pairs: &[(&Paint, &Paint)]) -> Option<f64> {
|
||||
let mut d = 0.0f64;
|
||||
for (x, y) in pairs {
|
||||
if [x, y].iter().any(|p| matches!(p, Paint::Gradient(_) | Paint::Pattern { .. })) {
|
||||
return None;
|
||||
}
|
||||
d = d.max(paint_difference(x, y));
|
||||
}
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Largest channel difference (0..1, see [`paint_difference`]) between the solid colors of two
|
||||
/// keys: their top fill and stroke, the runs of two text objects paired as [`lerp_text`] pairs
|
||||
/// them, and the members of two groups paired as [`Lerp::new`] pairs them. `None` when any of
|
||||
/// these holds a gradient or a pattern, whose steps Smooth Color counts by distance.
|
||||
fn color_distance(a: &Node, b: &Node) -> Option<f64> {
|
||||
let mut d =
|
||||
paints_difference(&[(&a.appearance.fill_paint(), &b.appearance.fill_paint()), (&a.appearance.stroke_paint(), &b.appearance.stroke_paint())])?;
|
||||
match (&a.kind, &b.kind) {
|
||||
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if k1 == k2 && !(*k1 && ca.len() != cb.len()) => {
|
||||
for (x, y) in ca.iter().zip(cb) {
|
||||
d = d.max(color_distance(x, y)?);
|
||||
}
|
||||
}
|
||||
(NodeKind::Text(x), NodeKind::Text(y)) => {
|
||||
// The runs `lerp_text` pairs: those of `x` and of `y` at the middle of each run of either.
|
||||
for u in run_middles(&x.runs).into_iter().chain(run_middles(&y.runs)) {
|
||||
let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue };
|
||||
d = d.max(paints_difference(&[(&rx.style.fill, &ry.style.fill), (&rx.style.stroke, &ry.style.stroke)])?);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
d.is_finite().then_some(d)
|
||||
}
|
||||
|
||||
/// Smooth Color's steps between two keys by distance: the largest distance between their
|
||||
/// corresponding visible bounding-box edges, in mean key widths (left and right edges) or heights
|
||||
/// (top and bottom), rounded, less one, from 1 to 1000. Keys with no width are measured in their
|
||||
/// mean height across as well, keys with no height in their mean width, and keys with neither in
|
||||
/// points.
|
||||
fn spatial_steps(ba: Rect, bb: Rect) -> usize {
|
||||
let (w, h) = ((ba.width() + bb.width()) / 2.0, (ba.height() + bb.height()) / 2.0);
|
||||
let size = |s: f64, other: f64| {
|
||||
if s > 1e-9 {
|
||||
s
|
||||
} else if other > 1e-9 {
|
||||
other
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
let (w, h) = (size(w, h), size(h, w));
|
||||
let q = ((ba.x0 - bb.x0).abs().max((ba.x1 - bb.x1).abs()) / w).max((ba.y0 - bb.y0).abs().max((ba.y1 - bb.y1).abs()) / h);
|
||||
if !q.is_finite() {
|
||||
return 1;
|
||||
}
|
||||
((q + 0.5).floor() - 1.0).clamp(1.0, 1000.0) as usize
|
||||
}
|
||||
|
||||
/// `255 × d` steps for a color difference `d` (see [`color_distance`]), a half step rounding down,
|
||||
/// from 1 to 254.
|
||||
fn level_steps(d: f64) -> f64 {
|
||||
// The tolerance keeps a difference that lands on a half step (128 of 256 RGB values gives
|
||||
// 127.5), stored as an `f32`, from rounding up.
|
||||
(255.0 * d - 0.5 - 1e-4).ceil().clamp(1.0, 254.0)
|
||||
}
|
||||
|
||||
/// Smooth Color's steps between two keys whose colors differ by `d` (see [`color_distance`]):
|
||||
/// [`level_steps`], and at most eight per point of the largest of the keys' widths and heights
|
||||
/// and the distance between their centers along either axis.
|
||||
fn color_steps(d: f64, ba: Rect, bb: Rect) -> usize {
|
||||
let by_color = level_steps(d);
|
||||
let (ca, cb) = (ba.center(), bb.center());
|
||||
let reach = [(ca.x - cb.x).abs(), (ca.y - cb.y).abs(), ba.width(), ba.height(), bb.width(), bb.height()].into_iter().fold(0.0f64, f64::max);
|
||||
let by_density = (8.0 * reach + 0.5).floor().clamp(1.0, 254.0);
|
||||
if !by_density.is_finite() {
|
||||
return by_color as usize;
|
||||
}
|
||||
by_color.min(by_density) as usize
|
||||
}
|
||||
|
||||
/// Number of intermediate steps between two keys. Specified Distance counts along the spine, `len`
|
||||
/// long between them. Smooth Color counts from the keys' colors and visible bounds, whatever the
|
||||
/// spine: keys whose colors differ make [`color_steps`] ([`level_steps`] when either key has no
|
||||
/// bounds), and others make [`spatial_steps`] (1 when either key has no bounds).
|
||||
pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize {
|
||||
match spacing {
|
||||
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
||||
@@ -787,17 +880,14 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
|
||||
((len / d).round() as i64 - 1).clamp(0, 1000) as usize
|
||||
}
|
||||
BlendSpacing::SmoothColor => {
|
||||
let cd = color_distance(a, b);
|
||||
if cd > 1.0 / 255.0 {
|
||||
((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256)
|
||||
} else {
|
||||
// Same colours: base the count on the distance between the objects.
|
||||
let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds());
|
||||
let dist = match (ba, bb) {
|
||||
(Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()),
|
||||
_ => len,
|
||||
};
|
||||
((dist / 2.0).ceil() as usize).clamp(1, 256)
|
||||
let bounds = a.visual_bounds().zip(b.visual_bounds());
|
||||
match (color_distance(a, b), bounds) {
|
||||
// Colors that differ by less than a thousandth of a step count as the same.
|
||||
(Some(d), Some((ba, bb))) if d * 255.0 > 1e-3 => color_steps(d, ba, bb),
|
||||
(Some(d), None) if d * 255.0 > 1e-3 => level_steps(d) as usize,
|
||||
// Same colors, gradients and patterns: the distance between the objects.
|
||||
(_, Some((ba, bb))) => spatial_steps(ba, bb),
|
||||
(_, None) => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1148,13 +1238,19 @@ fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) ->
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Most steps `spacing` makes between two keys, whatever their size and distance
|
||||
/// ([`blend_step_count`]'s clamps).
|
||||
fn most_steps(spacing: BlendSpacing) -> usize {
|
||||
/// Most steps `spacing` makes between keys `a` and `b`, whatever their size and distance
|
||||
/// ([`blend_step_count`]'s clamps). A copy of a blend is moved and scaled, never recolored
|
||||
/// ([`Shape::Other`]), so Smooth Color between colors that differ makes no more than their color
|
||||
/// change asks for, whatever the copy's scale or rotation. By distance, a copy turned by Align to
|
||||
/// Path can measure its edges differently, so it counts as up to 1000.
|
||||
fn most_steps(a: &Node, b: &Node, spacing: BlendSpacing) -> usize {
|
||||
match spacing {
|
||||
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
||||
BlendSpacing::Distance(_) => 1000,
|
||||
BlendSpacing::SmoothColor => 256,
|
||||
BlendSpacing::SmoothColor => match color_distance(a, b) {
|
||||
Some(d) if d * 255.0 > 1e-3 => level_steps(d) as usize,
|
||||
_ => 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1182,8 +1278,8 @@ struct StepCount {
|
||||
impl StepCount {
|
||||
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
|
||||
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
|
||||
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
|
||||
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
|
||||
/// by distance, or by Smooth Color, can make more steps scaled up, so a copy counts the most
|
||||
/// its spacing can make between each pair ([`most_steps`]), an upper bound for any scale.
|
||||
fn node(&mut self, n: &Node, copy: bool) -> usize {
|
||||
let at = (std::ptr::from_ref(n) as usize, copy);
|
||||
if let Some(&known) = self.known.get(&at) {
|
||||
@@ -1249,7 +1345,11 @@ impl StepCount {
|
||||
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
|
||||
let inside = self.nodes(keys, true);
|
||||
let each = self.steps(keys);
|
||||
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
|
||||
let most = keys.windows(2).map(|w| match w {
|
||||
[a, b] => most_steps(a, b, spec.spacing),
|
||||
_ => 0,
|
||||
});
|
||||
let demand = each.iter().zip(most).map(|(&c, m)| c.saturating_mul(m)).fold(0, usize::saturating_add);
|
||||
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
|
||||
}
|
||||
}
|
||||
@@ -1634,4 +1734,180 @@ mod tests {
|
||||
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
|
||||
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
|
||||
}
|
||||
|
||||
/// A key for the Smooth Color tests: a rectangle of `size` (width, height) centered on `c`,
|
||||
/// with `fill`, and `stroke` at `width`.
|
||||
fn smooth_key(c: (f64, f64), size: (f64, f64), fill: Paint, stroke: Paint, width: f64) -> Node {
|
||||
let ((cx, cy), (w, h)) = (c, size);
|
||||
let rect = Rect::new(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0);
|
||||
Node::path(NodeId(1), shapes::rectangle(rect), Appearance::basic(fill, stroke, width))
|
||||
}
|
||||
|
||||
/// Smooth Color's steps between two filled `size` rectangles centered on `a` and `b`.
|
||||
fn smooth(a: (f64, f64), b: (f64, f64), size: (f64, f64), fill_a: Color, fill_b: Color) -> usize {
|
||||
let key = |c, fill| smooth_key(c, size, Paint::solid(fill), Paint::None, 0.0);
|
||||
blend_step_count(&key(a, fill_a), &key(b, fill_b), BlendSpacing::SmoothColor, a.0.max(b.0) - a.0.min(b.0))
|
||||
}
|
||||
|
||||
/// Smooth Color counts 255 steps for a full change of the channel that changes most, a half
|
||||
/// step rounding down: RGB values over 256, CMYK and gray in percent, a spot color by its
|
||||
/// tint, colors in different spaces by their RGB values over 255, a fill or a stroke,
|
||||
/// whichever changes more.
|
||||
#[test]
|
||||
fn smooth_color_counts_a_step_per_level_of_color_change() {
|
||||
let (a, b, size) = ((50.0, 150.0), (250.0, 150.0), (20.0, 20.0));
|
||||
let black = Color::rgb8(0, 0, 0);
|
||||
for (level, steps) in [(1, 1), (2, 2), (5, 5), (64, 64), (127, 127), (128, 127), (129, 128), (200, 199), (254, 253), (255, 254)] {
|
||||
assert_eq!(smooth(a, b, size, black, Color::rgb8(level, 0, 0)), steps, "black to red {level}");
|
||||
assert_eq!(smooth(a, b, size, black, Color::rgb8(level, level, level)), steps, "black to gray {level}");
|
||||
}
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)), 254);
|
||||
assert_eq!(smooth(a, b, size, black, Color::rgb8(64, 128, 0)), 127, "the channel that changes most");
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(17, 42, 93), Color::rgb8(91, 147, 220)), 127);
|
||||
for (percent, steps) in [(0.2, 1), (1.0, 3), (5.0, 13), (10.0, 25), (25.0, 64), (50.0, 127), (100.0, 254)] {
|
||||
let p = percent / 100.0;
|
||||
assert_eq!(smooth(a, b, size, Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(p, 0.0, 0.0, 0.0)), steps, "cyan {percent}%");
|
||||
assert_eq!(smooth(a, b, size, Color::gray(0.0), Color::gray(p)), steps, "gray {percent}%");
|
||||
}
|
||||
let spot = |tint: f32| Paint::Solid { color: Color::rgb8(255, 0, 0).tinted(tint), swatch: Some("Spot".into()), tint };
|
||||
let key = |c, fill| smooth_key(c, size, fill, Paint::None, 0.0);
|
||||
assert_eq!(blend_step_count(&key(a, spot(1.0)), &key(b, spot(0.5)), BlendSpacing::SmoothColor, 200.0), 127, "a tint of 50%");
|
||||
let stroked = |c, fill: Color, stroke: Color| smooth_key(c, size, Paint::solid(fill), Paint::solid(stroke), 1.0);
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
assert_eq!(blend_step_count(&stroked(a, red, black), &stroked(b, red, blue), BlendSpacing::SmoothColor, 200.0), 254, "the stroke changes");
|
||||
let outline = |c, stroke: Color| smooth_key(c, size, Paint::None, Paint::solid(stroke), 1.0);
|
||||
assert_eq!(blend_step_count(&outline(a, red), &outline(b, blue), BlendSpacing::SmoothColor, 200.0), 254, "strokes alone");
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::cmyk(0.0, 0.0, 0.0, 0.0)), 128, "RGB against CMYK white");
|
||||
assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::rgb8(255, 255, 255)), 127, "RGB against RGB white");
|
||||
}
|
||||
|
||||
/// Smooth Color reads the colors of group members paired in order and of text runs, and
|
||||
/// counts a solid paint against no paint by distance, since such a paint switches halfway
|
||||
/// instead of mixing.
|
||||
#[test]
|
||||
fn smooth_color_reads_the_colors_inside_groups_and_text() {
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
let square = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0));
|
||||
let group = |x: f64, top: Color| Node::new(NodeId(1), NodeKind::Group { children: vec![square(x, red), square(x, top)], clip: false });
|
||||
let steps = |a: &Node, b: &Node| blend_step_count(a, b, BlendSpacing::SmoothColor, 200.0);
|
||||
assert_eq!(steps(&group(50.0, red), &group(250.0, blue)), 254, "groups whose members change color");
|
||||
assert_eq!(steps(&group(50.0, red), &group(250.0, red)), 9, "groups of the same colors");
|
||||
let text = |x: f64, c: Color| {
|
||||
let style = crate::CharStyle { fill: Paint::solid(c), ..Default::default() };
|
||||
Node::new(NodeId(1), NodeKind::Text(Box::new(crate::TextObject::point(Point::new(x, 150.0), "Hi", style))))
|
||||
};
|
||||
assert_eq!(steps(&text(50.0, red), &text(250.0, blue)), 254, "text whose runs change color");
|
||||
let mut two_runs = text(250.0, red);
|
||||
if let NodeKind::Text(t) = &mut two_runs.kind {
|
||||
let style = crate::CharStyle { fill: Paint::solid(blue), ..t.runs[0].style.clone() };
|
||||
t.runs.push(crate::text::TextRun::new("Hi", style));
|
||||
}
|
||||
assert_eq!(steps(&text(50.0, red), &two_runs), 254, "a run that turns blue against one red run");
|
||||
let empty = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::None, Paint::None, 0.0);
|
||||
assert_eq!(steps(&smooth_key((50.0, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0), &empty(250.0)), 9, "a fill against none");
|
||||
let nothing = |c: Color| Node::path(NodeId(1), PathData::new(vec![]), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
|
||||
assert_eq!(steps(¬hing(red), ¬hing(blue)), 254, "keys without bounds whose colors differ");
|
||||
assert_eq!(steps(¬hing(red), ¬hing(red)), 1, "keys without bounds of the same color");
|
||||
}
|
||||
|
||||
/// Smooth Color makes at most eight steps per point of the largest of the keys' widths and
|
||||
/// heights and the distance between their centers along either axis.
|
||||
#[test]
|
||||
fn smooth_color_makes_at_most_eight_steps_a_point() {
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
for (dx, steps) in [(0.0, 160), (20.0, 160), (20.1, 161), (25.0, 200), (31.5, 252), (40.0, 254)] {
|
||||
assert_eq!(smooth((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0), red, blue), steps, "{dx} pt apart");
|
||||
}
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 175.0), (20.0, 20.0), red, blue), 200, "25 pt apart vertically");
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (5.0, 5.0), red, blue), 40, "5 pt squares on each other");
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (25.0, 5.0), red, blue), 200, "the longer side counts");
|
||||
assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (1.0, 1.0), red, blue), 8);
|
||||
}
|
||||
|
||||
/// Smooth Color between keys of the same colors counts the largest distance between their
|
||||
/// corresponding visible edges in mean key widths or heights, rounded, less one; so do keys
|
||||
/// with gradients, whatever their colors.
|
||||
#[test]
|
||||
fn smooth_color_between_the_same_colors_counts_by_distance() {
|
||||
let red = Color::rgb8(255, 0, 0);
|
||||
let same = |a, b, size| smooth(a, b, size, red, red);
|
||||
for (dx, steps) in [(25.0, 1), (50.0, 2), (100.0, 4), (200.0, 9), (400.0, 19), (800.0, 39)] {
|
||||
assert_eq!(same((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0)), steps, "20 pt squares {dx} pt apart");
|
||||
}
|
||||
for (side, steps) in [(5.0, 39), (10.0, 19), (40.0, 4), (80.0, 2)] {
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (side, side)), steps, "{side} pt squares");
|
||||
}
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (80.0, 10.0)), 2, "wide");
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (10.0, 80.0)), 19, "tall");
|
||||
assert_eq!(same((50.0, 250.0), (250.0, 50.0), (20.0, 20.0)), 9, "diagonal");
|
||||
assert_eq!(same((50.0, 250.0), (250.0, 50.0), (80.0, 10.0)), 19, "wide and diagonal");
|
||||
assert_eq!(same((50.0, 150.0), (1050.0, 150.0), (1.0, 1.0)), 999);
|
||||
assert_eq!(same((50.0, 150.0), (1052.0, 150.0), (1.0, 1.0)), 1000, "at most 1000");
|
||||
assert_eq!(same((50.0, 150.0), (50.0, 150.0), (20.0, 20.0)), 1, "at least one");
|
||||
assert_eq!(same((50.0, 150.0), (250.0, 150.0), (0.0, 20.0)), 9, "vertical lines, measured in their height");
|
||||
assert_eq!(same((50.0, 150.0), (50.0, 350.0), (20.0, 0.0)), 9, "horizontal lines, measured in their width");
|
||||
assert_eq!(same((50.0, 150.0), (60.0, 150.0), (0.0, 0.0)), 9, "points, measured in points");
|
||||
let key = |c, size, stroke: f64| smooth_key(c, size, Paint::solid(red), Paint::solid(Color::BLACK), stroke);
|
||||
let wide =
|
||||
blend_step_count(&key((50.0, 150.0), (10.0, 20.0), 0.0), &key((250.0, 150.0), (80.0, 20.0), 0.0), BlendSpacing::SmoothColor, 200.0);
|
||||
assert_eq!(wide, 4, "keys of different widths");
|
||||
let strokes =
|
||||
blend_step_count(&key((50.0, 150.0), (20.0, 20.0), 1.0), &key((250.0, 150.0), (20.0, 20.0), 10.0), BlendSpacing::SmoothColor, 200.0);
|
||||
assert_eq!(strokes, 7, "strokes widen the visible edges");
|
||||
let gradient = |a: Color, b: Color| {
|
||||
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
Default::default(),
|
||||
vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)],
|
||||
))))
|
||||
};
|
||||
let shaded = |c, fill| smooth_key(c, (20.0, 20.0), fill, Paint::None, 0.0);
|
||||
let blue = Color::rgb8(0, 0, 255);
|
||||
for (from, to) in [(gradient(red, blue), gradient(red, blue)), (gradient(red, blue), gradient(blue, red))] {
|
||||
assert_eq!(blend_step_count(&shaded((50.0, 150.0), from), &shaded((250.0, 150.0), to), BlendSpacing::SmoothColor, 200.0), 9);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Smooth Color blend counts each pair of keys on its own.
|
||||
#[test]
|
||||
fn smooth_color_counts_each_pair_of_keys() {
|
||||
let key = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0));
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
let spec = BlendSpec::default();
|
||||
assert_eq!(blend_steps(&[key(50.0, red), key(150.0, red), key(250.0, blue)], &spec), vec![4, 254]);
|
||||
assert_eq!(blend_steps(&[key(50.0, red), key(150.0, blue), key(250.0, red)], &spec), vec![254, 254]);
|
||||
}
|
||||
|
||||
/// A Smooth Color blend between different colors inside another blend's keys counts as the
|
||||
/// steps its colors ask for, so the outer blend keeps the steps it asks for within
|
||||
/// [`MAX_BLEND_STEPS`]: 200 steps, each a copy making 254 more. The copies keep their keys'
|
||||
/// colors, also halfway between a red-to-blue and a blue-to-red blend.
|
||||
#[test]
|
||||
fn a_smooth_color_blend_in_a_key_counts_its_color_steps() {
|
||||
let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255));
|
||||
let key = |x: f64, c: Color| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0);
|
||||
let inner = |x: f64, from: Color, to: Color| blend_of(key(x, from), key(x + 100.0, to), BlendSpacing::SmoothColor);
|
||||
let outer = blend_of(inner(0.0, red, blue), inner(500.0, red, blue), BlendSpacing::Steps(200));
|
||||
let (keys, spec) = blend_parts(&outer).unwrap();
|
||||
assert_eq!(blend_steps(keys, spec), vec![200]);
|
||||
let crossed = blend_of(inner(0.0, red, blue), inner(500.0, blue, red), BlendSpacing::Steps(199));
|
||||
let (keys, spec) = blend_parts(&crossed).unwrap();
|
||||
assert_eq!(blend_steps(keys, spec), vec![199]);
|
||||
let drawn = blend_expand(keys, spec);
|
||||
let Some(NodeKind::Blend { children, .. }) = drawn.get(100).map(|n| &n.kind) else { panic!("a copy of a blend halfway") };
|
||||
let fills: Vec<_> = children.iter().map(|k| k.appearance.fill_paint().color()).collect();
|
||||
assert!(fills == [Some(red), Some(blue)] || fills == [Some(blue), Some(red)], "{fills:?}");
|
||||
}
|
||||
|
||||
/// A Smooth Color blend between the same colors inside another blend's keys counts as up to
|
||||
/// 1000 steps, which a copy turned to a path can ask for: 200 steps of copies of it come
|
||||
/// down to 99.
|
||||
#[test]
|
||||
fn a_smooth_color_blend_of_the_same_colors_in_a_key_counts_up_to_1000_steps() {
|
||||
let red = Color::rgb8(255, 0, 0);
|
||||
let key = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0);
|
||||
let inner = |x: f64| blend_of(key(x), key(x + 100.0), BlendSpacing::SmoothColor);
|
||||
let outer = blend_of(inner(0.0), inner(500.0), BlendSpacing::Steps(200));
|
||||
let (keys, spec) = blend_parts(&outer).unwrap();
|
||||
assert_eq!(blend_steps(keys, spec), vec![99]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,8 +313,8 @@ mod tests {
|
||||
d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }];
|
||||
d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into();
|
||||
d.assets = vec![
|
||||
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] },
|
||||
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] },
|
||||
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None },
|
||||
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None },
|
||||
];
|
||||
d.variables.bindings.insert(NodeId(11), ["v".to_string()].into());
|
||||
d.variables.bindings.insert(NodeId(10), ["w".to_string()].into());
|
||||
|
||||
@@ -195,6 +195,24 @@ impl ImageBlob {
|
||||
jpeg_components(&self.bytes) == Some(4) || tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(8 | 16)))
|
||||
}
|
||||
|
||||
/// Does the image hold CMYK samples of 16 bits (a CMYK TIFF), which [`Self::cmyk`] gives at 8
|
||||
/// bits? Reads only its header.
|
||||
pub fn is_cmyk16(&self) -> bool {
|
||||
tiff(&self.bytes).is_some_and(|(t, _)| matches!(t, tiff::ColorType::CMYK(16)))
|
||||
}
|
||||
|
||||
/// The width and height a JPEG or TIFF says it has, for any color type. Reads only its header.
|
||||
pub fn header_size(&self) -> Option<(u32, u32)> {
|
||||
let bytes = self.bytes.as_slice();
|
||||
if bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default());
|
||||
d.decode_headers().ok()?;
|
||||
let (w, h) = d.dimensions()?;
|
||||
return Some((u32::try_from(w).ok()?, u32::try_from(h).ok()?));
|
||||
}
|
||||
tiff(bytes).and_then(|(_, mut d)| d.dimensions().ok())
|
||||
}
|
||||
|
||||
/// The ink amounts of a CMYK image; `None` for other images, and ones that can't be decoded
|
||||
/// or are larger than [`MAX_CMYK_PIXELS`].
|
||||
pub fn cmyk(&self) -> Option<Inks> {
|
||||
@@ -633,6 +651,38 @@ mod tests {
|
||||
assert_eq!(rgb.get_pixel(1, 0).0, [0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_gives_the_size_without_the_samples() {
|
||||
assert_eq!(ImageBlob::cmyk_tiff(&inks()).unwrap().header_size(), Some((3, 2)));
|
||||
// A CMYK TIFF of 6000 × 6000 pixels whose samples aren't there.
|
||||
let mut t = b"II*\0\x08\0\0\0\x07\0".to_vec();
|
||||
// Bits per sample: four values, after the entries (8 + 2 + 7 × 12 + 4 = 98).
|
||||
let entries = [
|
||||
(256u16, 4u16, 1u32, 6000u32),
|
||||
(257, 4, 1, 6000),
|
||||
(258, 3, 4, 98),
|
||||
(262, 3, 1, 5),
|
||||
(273, 4, 1, 106),
|
||||
(277, 3, 1, 4),
|
||||
(279, 4, 1, 144_000_000),
|
||||
];
|
||||
for (tag, kind, count, value) in entries {
|
||||
t.extend_from_slice(&tag.to_le_bytes());
|
||||
t.extend_from_slice(&kind.to_le_bytes());
|
||||
t.extend_from_slice(&count.to_le_bytes());
|
||||
t.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
t.extend_from_slice(&0u32.to_le_bytes());
|
||||
t.extend_from_slice(&[8, 0, 8, 0, 8, 0, 8, 0]);
|
||||
let big = ImageBlob::new("image/tiff", t);
|
||||
assert!(big.is_cmyk());
|
||||
assert_eq!(big.header_size(), Some((6000, 6000)));
|
||||
assert_eq!(big.cmyk(), None);
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(2, 2).write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
assert_eq!(ImageBlob::png(png).header_size(), None, "not a JPEG or TIFF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_image_samples_its_ink_amounts() {
|
||||
let b = ImageBlob::cmyk_tiff(&inks()).unwrap();
|
||||
|
||||
@@ -68,10 +68,12 @@ impl Document {
|
||||
print_setup,
|
||||
assets,
|
||||
svg_scripts,
|
||||
svg_filters,
|
||||
puppet,
|
||||
new_art,
|
||||
dimensions,
|
||||
retired_artboard_id: _,
|
||||
opened_artboard: _,
|
||||
index: _,
|
||||
} = self;
|
||||
*version == other.version
|
||||
@@ -134,6 +136,7 @@ impl Document {
|
||||
&& *print_setup == other.print_setup
|
||||
&& *assets == other.assets
|
||||
&& *svg_scripts == other.svg_scripts
|
||||
&& *svg_filters == other.svg_filters
|
||||
&& match (puppet, &other.puppet) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
@@ -244,10 +247,12 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
svg_filters: vec![],
|
||||
puppet: None,
|
||||
new_art: Default::default(),
|
||||
dimensions: false,
|
||||
retired_artboard_id: Default::default(),
|
||||
opened_artboard: None,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
@@ -350,6 +355,7 @@ impl Document {
|
||||
}
|
||||
false
|
||||
}
|
||||
crate::id_index::note_tree_walk();
|
||||
let mut p = vec![];
|
||||
find(&self.layers, id, &mut p).then_some(p)
|
||||
}
|
||||
@@ -391,6 +397,7 @@ impl Document {
|
||||
});
|
||||
}
|
||||
if !want.is_empty() {
|
||||
crate::id_index::note_tree_walk();
|
||||
let mut rest = HashMap::with_capacity(want.len());
|
||||
walk(&self.layers, &want, &mut Vec::new(), &mut rest);
|
||||
out.extend(rest);
|
||||
|
||||
@@ -123,6 +123,39 @@ impl Document {
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions,
|
||||
/// graph designs, pattern swatches (the one being edited included) and their opacity masks?
|
||||
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
|
||||
let roots = self
|
||||
.layers
|
||||
.iter()
|
||||
.chain(self.symbols.iter().map(|s| &s.art))
|
||||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
|
||||
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()));
|
||||
let mut any = false;
|
||||
for root in roots {
|
||||
crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n));
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// Change with `f` every node for which `wants` holds, anywhere in the document's art (as
|
||||
/// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art
|
||||
/// they share.
|
||||
pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) {
|
||||
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 {
|
||||
edit_branches_where(root, &wants, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -164,7 +197,7 @@ impl Document {
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { kind, .. } => {
|
||||
if let EnvelopeKind::TopObject { path } = kind.as_mut() {
|
||||
if let EnvelopeKind::TopObject { path, .. } = kind.as_mut() {
|
||||
round_path(path);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +227,26 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds,
|
||||
/// copying out of the shared art only the branches that hold one.
|
||||
fn edit_branches_where(n: &mut Arc<Node>, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) {
|
||||
let mut any = false;
|
||||
crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c));
|
||||
if !any {
|
||||
return;
|
||||
}
|
||||
let node = Arc::make_mut(n);
|
||||
if wants(node) {
|
||||
f(node);
|
||||
}
|
||||
if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
edit_branches_where(&mut m.art, wants, f);
|
||||
}
|
||||
for c in node.children_mut().into_iter().flatten() {
|
||||
edit_branches_where(c, wants, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -6,9 +6,15 @@ impl Document {
|
||||
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
|
||||
/// instances measured by their art ([`Node::geometric_bounds_with`]).
|
||||
pub fn bounds_of(&self, ids: &[NodeId], visual: bool) -> Option<Rect> {
|
||||
let symbols = self.symbol_boxes();
|
||||
self.bounds_of_with(ids, visual, &self.symbol_boxes())
|
||||
}
|
||||
|
||||
/// [`bounds_of`](Document::bounds_of) measuring symbol instances from `symbols`, a cache shared
|
||||
/// across calls: measuring many instances one by one otherwise measures their art again for
|
||||
/// each.
|
||||
pub fn bounds_of_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option<Rect> {
|
||||
ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| {
|
||||
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(&symbols) } else { n.geometric_bounds_with(&symbols) })
|
||||
vectorcraft_geom::union_opt(acc, if visual { n.visual_bounds_with(symbols) } else { n.geometric_bounds_with(symbols) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,31 +85,77 @@ impl Document {
|
||||
/// by its clipping path where that paints). Guides (paths made into guides too) aren't artwork
|
||||
/// (Delete Empty Artboards).
|
||||
pub fn artboard_has_art(&self, rect: Rect) -> bool {
|
||||
self.art_meets(&self.symbol_boxes(), rect, true)
|
||||
}
|
||||
|
||||
/// Is there a rect in `rects` (artboards) with no artwork on it, by [`Document::artboard_has_art`]?
|
||||
/// The symbols are measured once for all of them. A first pass rules art out by a quick box
|
||||
/// (paths and compound paths: their control points grown by the farthest their strokes reach),
|
||||
/// so a rect away from every path is found without measuring along any; only when every rect
|
||||
/// may hold art is each one measured.
|
||||
pub fn any_artboard_without_art(&self, rects: impl IntoIterator<Item = Rect>) -> bool {
|
||||
let rects: Vec<Rect> = rects.into_iter().collect();
|
||||
let symbols = self.symbol_boxes();
|
||||
rects.iter().any(|r| !self.art_meets(&symbols, *r, false)) || rects.iter().any(|r| !self.art_meets(&symbols, *r, true))
|
||||
}
|
||||
|
||||
/// [`Document::artboard_has_art`] of `rect` when `exact`. Otherwise whether art may lie on it:
|
||||
/// paths and compound paths count when their quick box meets it, so a false answer is exact and
|
||||
/// a true one may not be.
|
||||
fn art_meets(&self, symbols: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
|
||||
// As in `art_on_artboard`: a millionth of a point covers rounding at the edges.
|
||||
let rect = rect.inflate(1e-6, 1e-6);
|
||||
fn meets(b: Option<Rect>, r: Rect) -> bool {
|
||||
b.is_some_and(|b| b.x0 <= r.x1 && r.x0 <= b.x1 && b.y0 <= r.y1 && r.y0 <= b.y1)
|
||||
}
|
||||
fn has(c: &Node, s: &SymbolBoxes, rect: Rect) -> bool {
|
||||
/// A box that holds the visual bounds of a path, or of a compound path of paths, found
|
||||
/// without measuring along its shape: its control points' box grown by the farthest its
|
||||
/// strokes reach ([`crate::Appearance::outset`], as in [`Node::reach_bounds`]), with a
|
||||
/// margin for rounding. None for other objects, and when the reach is not finite.
|
||||
fn quick_box(c: &Node) -> Option<Rect> {
|
||||
let b = match &c.kind {
|
||||
NodeKind::Path { path, .. } => path.control_bounds()?,
|
||||
NodeKind::Compound { children, .. } => children.iter().try_fold(None, |acc, m| {
|
||||
let NodeKind::Path { path, .. } = &m.kind else { return None };
|
||||
Some(vectorcraft_geom::union_opt(acc, path.control_bounds()))
|
||||
})??,
|
||||
_ => return None,
|
||||
};
|
||||
let o = c.appearance.outset();
|
||||
let o = o * (1.0 + 1e-9) + 1e-6;
|
||||
o.is_finite().then(|| b.inflate(o, o))
|
||||
}
|
||||
/// Does `c`'s visual box meet `rect`? Without `exact`, a quick box that meets it answers yes.
|
||||
fn art_box_meets(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
|
||||
match quick_box(c).map(|b| meets(Some(b), rect)) {
|
||||
Some(false) => false,
|
||||
Some(true) if !exact => true,
|
||||
_ => meets(c.visual_bounds_with(s), rect),
|
||||
}
|
||||
}
|
||||
fn has(c: &Node, s: &SymbolBoxes, rect: Rect, exact: bool) -> bool {
|
||||
match &c.kind {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
let Some((clip, rest)) = children.split_first() else { return false };
|
||||
let paint = clip.clip_paint();
|
||||
if (paint.fill.is_some() || paint.stroke.is_some()) && meets(clip.visual_bounds_with(s), rect) {
|
||||
if (paint.fill.is_some() || paint.stroke.is_some()) && art_box_meets(clip, s, rect, exact) {
|
||||
return true;
|
||||
}
|
||||
if !exact {
|
||||
// The part of `rect` inside the clip's box lies inside `rect`.
|
||||
return rest.iter().any(|m| has(m, s, rect, false));
|
||||
}
|
||||
// The members show only inside the clip's box.
|
||||
let Some(b) = c.visual_bounds_with(s).filter(|b| meets(Some(*b), rect)) else { return false };
|
||||
let inside = Rect::new(b.x0.max(rect.x0), b.y0.max(rect.y0), b.x1.min(rect.x1), b.y1.min(rect.y1));
|
||||
rest.iter().any(|m| has(m, s, inside))
|
||||
rest.iter().any(|m| has(m, s, inside, true))
|
||||
}
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect)),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)),
|
||||
NodeKind::Path { guide: true, .. } => false,
|
||||
_ => meets(c.visual_bounds_with(s), rect),
|
||||
_ => art_box_meets(c, s, rect, exact),
|
||||
}
|
||||
}
|
||||
let symbols = self.symbol_boxes();
|
||||
self.layers.iter().any(|l| has(l, &symbols, rect))
|
||||
self.layers.iter().any(|l| has(l, symbols, rect, exact))
|
||||
}
|
||||
|
||||
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
|
||||
@@ -296,11 +348,14 @@ impl Document {
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
}
|
||||
let on_path = n.extras().objects_on_path.is_some();
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old: Vec<Arc<Node>> = std::mem::take(ch);
|
||||
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
if on_path {
|
||||
renamed.extend(old.iter().zip(ch.iter()).map(|(o, c)| (o.id, c.id)));
|
||||
}
|
||||
}
|
||||
// An Objects on Path group names its path and its objects by id: the copy names the copies.
|
||||
if let Some(e) = n.extras_if_any()
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Color Halftone's Max Radius was a distance in points until documents of version 3; since then
|
||||
//! it is in pixels of the Document Raster Effects resolution, as public documentation gives it
|
||||
//! (#3260). [`Document::migrate_halftone_radii`] converts the radii of older documents, and
|
||||
//! [`Document::halftone_radii_to_points`] converts them back for a file saved for an older app.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::limits::FORMAT_VERSION;
|
||||
use crate::{Appearance, Document, Effect, Node};
|
||||
|
||||
/// The first document version whose Color Halftone radii are in pixels.
|
||||
const PIXEL_RADII_SINCE: u32 = 3;
|
||||
const HALFTONE: &str = "pixelate.colorHalftone";
|
||||
const RADIUS: &str = "maxRadius";
|
||||
/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s
|
||||
/// `DOC_KEY`), whose art can carry effects.
|
||||
const BRUSHES: &str = "brushes";
|
||||
/// The radius (points) an older document's halftone without one drew with.
|
||||
const OLD_DEFAULT: f64 = 8.0;
|
||||
/// The range the radius was kept within in points, and is kept within in pixels.
|
||||
const RANGE: std::ops::RangeInclusive<f64> = 4.0..=127.0;
|
||||
|
||||
impl Document {
|
||||
/// Saved before version 3: each Color Halftone's Max Radius in points becomes the pixels of
|
||||
/// the document's raster effects resolution that draw the same dots, within the 4 to 127 px
|
||||
/// the effect takes (a radius over 127 px, from a large radius at a high resolution, draws
|
||||
/// smaller dots than before). In the art, symbols, patterns, graph designs, opacity masks and
|
||||
/// graphic styles.
|
||||
pub fn migrate_halftone_radii(&mut self) {
|
||||
if self.version >= PIXEL_RADII_SINCE {
|
||||
return;
|
||||
}
|
||||
self.version = FORMAT_VERSION;
|
||||
let k = self.raster_ppi() / 72.0;
|
||||
self.each_halftone(&mut |e| rescale(e, k));
|
||||
}
|
||||
|
||||
/// For a file saved for an app before version 3 (every native file version before today's):
|
||||
/// each Color Halftone's Max Radius in pixels becomes the points that draw the same dots,
|
||||
/// within the 4 to 127 pt that app takes (a radius under 4 pt, from a fine screen at a high
|
||||
/// resolution, draws bigger dots there), and the document becomes version 2, so reopening the
|
||||
/// file here converts them back.
|
||||
pub fn halftone_radii_to_points(&mut self) {
|
||||
if self.version < PIXEL_RADII_SINCE {
|
||||
return;
|
||||
}
|
||||
self.version = PIXEL_RADII_SINCE - 1;
|
||||
let k = 72.0 / self.raster_ppi();
|
||||
self.each_halftone(&mut |e| rescale(e, k));
|
||||
}
|
||||
|
||||
/// `fix` each effect in the art, symbols, patterns (and the one being edited as it was), graph
|
||||
/// designs, opacity masks, Shaper edits, graphic styles and brush definitions that use a Color
|
||||
/// Halftone.
|
||||
fn each_halftone(&mut self, fix: &mut dyn FnMut(&mut Effect)) {
|
||||
for l in &mut self.layers {
|
||||
node(l, fix);
|
||||
}
|
||||
for s in &mut self.symbols {
|
||||
node(&mut s.art, fix);
|
||||
}
|
||||
// Pattern editing is saved with the pattern as it was, which Cancel puts back.
|
||||
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
|
||||
for a in &mut p.art {
|
||||
node(a, fix);
|
||||
}
|
||||
}
|
||||
for g in &mut self.graph_designs {
|
||||
node(&mut g.art, fix);
|
||||
}
|
||||
if self.graphic_styles.iter().any(|s| has_halftone(&s.appearance)) {
|
||||
for s in self.graphic_styles.iter_mut() {
|
||||
appearance(&mut s.appearance, fix);
|
||||
}
|
||||
}
|
||||
if let Some(b) = self.unknown.get_mut(BRUSHES) {
|
||||
brushes(b, fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fix` each Color Halftone in JSON `v` (brush definitions): every object whose `id` is
|
||||
/// [`HALFTONE`] is a serialized [`Effect`] (object ids are numbers).
|
||||
fn brushes(v: &mut Value, fix: &mut dyn FnMut(&mut Effect)) {
|
||||
match v {
|
||||
Value::Object(m) if m.get("id").and_then(Value::as_str) == Some(HALFTONE) => {
|
||||
let params = m.get("params").cloned().unwrap_or(Value::Null);
|
||||
let mut e = Effect { id: HALFTONE.into(), params, visible: true };
|
||||
fix(&mut e);
|
||||
m.insert("params".into(), e.params);
|
||||
}
|
||||
Value::Object(m) => m.values_mut().for_each(|v| brushes(v, fix)),
|
||||
Value::Array(a) => a.iter_mut().for_each(|v| brushes(v, fix)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `e`'s radius times `k` (pixels a point, or points a pixel), within the effect's range, when it
|
||||
/// is a Color Halftone. Its default is 8 in either unit.
|
||||
fn rescale(e: &mut Effect, k: f64) {
|
||||
if e.id != HALFTONE {
|
||||
return;
|
||||
}
|
||||
let pt = e.params.get(RADIUS).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(OLD_DEFAULT);
|
||||
let px = pt.clamp(*RANGE.start(), *RANGE.end()) * k;
|
||||
if !e.params.is_object() {
|
||||
e.params = Value::Object(Default::default());
|
||||
}
|
||||
e.params[RADIUS] = json!(px.clamp(*RANGE.start(), *RANGE.end()));
|
||||
}
|
||||
|
||||
fn has_halftone(a: &Appearance) -> bool {
|
||||
a.effects.iter().chain(a.items.iter().flat_map(|i| i.effects())).any(|e| e.id == HALFTONE)
|
||||
}
|
||||
|
||||
fn appearance(a: &mut Appearance, fix: &mut dyn FnMut(&mut Effect)) {
|
||||
for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) {
|
||||
fix(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix the effects of `n`'s subtree, its opacity masks and its Shaper edits (copying only what
|
||||
/// changes).
|
||||
fn node(n: &mut Arc<Node>, fix: &mut dyn FnMut(&mut Effect)) {
|
||||
let n = Arc::make_mut(n);
|
||||
if has_halftone(&n.appearance) {
|
||||
appearance(&mut n.appearance, fix);
|
||||
}
|
||||
if let Some(x) = n.extras_if_any() {
|
||||
if let Some(m) = x.mask.as_mut() {
|
||||
node(&mut m.art, fix);
|
||||
}
|
||||
// A Shaper Group's face and edge edits paint with appearances of their own.
|
||||
if let Some(s) = x.shaper.as_mut() {
|
||||
let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut());
|
||||
for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) {
|
||||
appearance(p, fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch {
|
||||
node(c, fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn halftone(radius: Option<f64>) -> Effect {
|
||||
let params = radius.map_or_else(|| json!({"channel1": 108.0}), |r| json!({"maxRadius": r, "channel1": 108.0}));
|
||||
Effect { id: HALFTONE.into(), params, visible: true }
|
||||
}
|
||||
|
||||
fn radius(e: &Effect) -> f64 {
|
||||
e.params[RADIUS].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_radii_become_the_pixels_that_draw_the_same_dots() {
|
||||
for (ppi, pt, px) in [(72.0, 8.0, 8.0), (300.0, 8.0, 33.333333333333336), (300.0, 4.0, 16.666666666666668), (150.0, 127.0, 127.0)] {
|
||||
let mut e = halftone(Some(pt));
|
||||
rescale(&mut e, ppi / 72.0);
|
||||
assert!((radius(&e) - px).abs() < 1e-9, "{pt} pt at {ppi} ppi: {}", radius(&e));
|
||||
assert_eq!(e.params["channel1"], json!(108.0), "the angles stay");
|
||||
}
|
||||
// A radius left out drew at the old default, 8 pt.
|
||||
let mut e = halftone(None);
|
||||
rescale(&mut e, 300.0 / 72.0);
|
||||
assert!((radius(&e) - 33.333333333333336).abs() < 1e-9);
|
||||
// Other effects keep their parameters.
|
||||
let mut blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 8.0}), visible: true };
|
||||
rescale(&mut blur, 300.0 / 72.0);
|
||||
assert_eq!(blur.params, json!({"radius": 8.0}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_documents_before_version_3_are_converted_once() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.raster_effects_ppi = 300.0;
|
||||
let mut style = d.graphic_styles.first().cloned().unwrap();
|
||||
style.appearance.effects.push(halftone(Some(8.0)));
|
||||
d.graphic_styles.push(style);
|
||||
let fresh = d.clone();
|
||||
d.migrate_halftone_radii();
|
||||
assert_eq!(d, fresh, "a new document's radii are already pixels");
|
||||
d.version = 1;
|
||||
d.migrate_halftone_radii();
|
||||
assert_eq!(d.version, FORMAT_VERSION);
|
||||
let r = d.graphic_styles.last().map(|s| radius(&s.appearance.effects[0])).unwrap();
|
||||
assert!((r - 33.333333333333336).abs() < 1e-9, "{r}");
|
||||
let once = d.clone();
|
||||
d.migrate_halftone_radii();
|
||||
assert_eq!(d, once, "converted documents are left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radii_go_back_to_points_for_an_older_app_and_return_unchanged() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.raster_effects_ppi = 300.0;
|
||||
let mut style = d.graphic_styles.first().cloned().unwrap();
|
||||
style.appearance.effects.push(halftone(Some(100.0)));
|
||||
style.appearance.effects.push(halftone(Some(4.0)));
|
||||
d.graphic_styles.push(style);
|
||||
let r = |d: &Document, i: usize| d.graphic_styles.last().map(|s| radius(&s.appearance.effects[i])).unwrap();
|
||||
d.halftone_radii_to_points();
|
||||
assert_eq!(d.version, 2, "reopened here, it converts back");
|
||||
assert!((r(&d, 0) - 24.0).abs() < 1e-9, "100 px at 300 ppi is 24 pt: {}", r(&d, 0));
|
||||
assert_eq!(r(&d, 1), 4.0, "0.96 pt is under the 4 pt the older app takes");
|
||||
let points = d.clone();
|
||||
d.halftone_radii_to_points();
|
||||
assert_eq!(d, points, "already in points");
|
||||
d.migrate_halftone_radii();
|
||||
assert!((r(&d, 0) - 100.0).abs() < 1e-9, "{}", r(&d, 0));
|
||||
}
|
||||
|
||||
/// Shaper face and edge edits paint with appearances of their own, and brush definitions keep
|
||||
/// their art as JSON: their radii convert both ways too.
|
||||
#[test]
|
||||
fn shaper_edits_and_brushes_are_converted_both_ways() {
|
||||
use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec};
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.raster_effects_ppi = 300.0;
|
||||
d.version = 1;
|
||||
let mut look = Appearance::default_art();
|
||||
look.effects = vec![halftone(Some(8.0))];
|
||||
let face = FaceEdit {
|
||||
selector: FaceSelector { sources: vec![0], anchor: vectorcraft_geom::Point::ZERO },
|
||||
erase: false,
|
||||
erase_edges: false,
|
||||
paint_source: None,
|
||||
merge: None,
|
||||
paint: Some(look),
|
||||
};
|
||||
let mut shaper = Node::group(d.alloc_id(), vec![]);
|
||||
shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] }));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, shaper).unwrap();
|
||||
let effect = serde_json::to_value(halftone(Some(8.0))).unwrap();
|
||||
d.unknown.insert(BRUSHES.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [effect]}}}]));
|
||||
let radii = |d: &Document| {
|
||||
let shaper = d.layers[0].children().and_then(|c| c.first()).cloned().unwrap();
|
||||
let paint = shaper.extras().shaper.as_ref().and_then(|s| s.faces[0].paint.clone()).unwrap();
|
||||
let brush = d.unknown[BRUSHES][0]["art"]["appearance"]["effects"][0]["params"][RADIUS].as_f64().unwrap();
|
||||
(radius(&paint.effects[0]), brush)
|
||||
};
|
||||
d.migrate_halftone_radii();
|
||||
let (face, brush) = radii(&d);
|
||||
assert!((face - 33.333333333333336).abs() < 1e-9, "the face edit: {face}");
|
||||
assert!((brush - 33.333333333333336).abs() < 1e-9, "the brush: {brush}");
|
||||
assert_eq!(d.unknown[BRUSHES][0]["art"]["appearance"]["effects"][0]["params"]["channel1"], json!(108.0));
|
||||
d.halftone_radii_to_points();
|
||||
let (face, brush) = radii(&d);
|
||||
assert!((face - 8.0).abs() < 1e-9 && (brush - 8.0).abs() < 1e-9, "back in points: {face}, {brush}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pattern_being_edited_is_converted_as_it_was() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.raster_effects_ppi = 300.0;
|
||||
let id = d.alloc_id();
|
||||
let mut look = Appearance::default_art();
|
||||
look.effects = vec![halftone(Some(8.0))];
|
||||
let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let tile = Arc::new(Node::path(id, square, look));
|
||||
let original = Some(crate::PatternDef::new("Tile", vec![tile]));
|
||||
d.pattern_edit = Some(crate::PatternEdit { pattern: "Tile".into(), layer: crate::NodeId(0), original });
|
||||
d.version = 1;
|
||||
d.migrate_halftone_radii();
|
||||
let art = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0];
|
||||
assert!((radius(&art.appearance.effects[0]) - 33.333333333333336).abs() < 1e-9, "the tile Cancel puts back");
|
||||
}
|
||||
}
|
||||
+334
-4
@@ -6,9 +6,10 @@ use std::rc::Rc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
|
||||
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, PathSeg, Point, Rect};
|
||||
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes};
|
||||
use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, SymbolBoxes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HitKind {
|
||||
@@ -508,8 +509,9 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
&& let Some(mut chain) = doc.ancestry(scope)
|
||||
{
|
||||
// Isolated before its artboard was locked: nothing in it hits.
|
||||
if locks.holds_id(scope) {
|
||||
// Isolated before its artboard, or its layer or a group around it, was locked or hidden:
|
||||
// nothing in it hits (#5309).
|
||||
if !doc.is_editable_with(&locks, scope) {
|
||||
return None;
|
||||
}
|
||||
if isolated.is_container() {
|
||||
@@ -723,6 +725,43 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
|
||||
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||||
}
|
||||
|
||||
/// Check the body without assembling a `BezPath`. A miss near a joint or cap may still hit a
|
||||
/// miter or square cap, so the caller falls back to the full stroke check there.
|
||||
fn near_path_body(path: &PathData, p: Point, radius: f64, decoration_reach: f64) -> Option<(bool, bool)> {
|
||||
if !radius.is_finite() || radius < 0.0 || !decoration_reach.is_finite() || decoration_reach < 0.0 {
|
||||
return None;
|
||||
}
|
||||
let radius_sq = radius * radius;
|
||||
let decoration_sq = decoration_reach * decoration_reach;
|
||||
let mut near_decoration = false;
|
||||
for sp in &path.subpaths {
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
if decoration_reach > 0.0 && !near_decoration && ([c.p0, c.p3].iter().any(|q| (*q - p).hypot2() <= decoration_sq)) {
|
||||
near_decoration = true;
|
||||
}
|
||||
let controls = [c.p0, c.p1, c.p2, c.p3];
|
||||
if controls.iter().all(|q| q.x < p.x - radius)
|
||||
|| controls.iter().all(|q| q.x > p.x + radius)
|
||||
|| controls.iter().all(|q| q.y < p.y - radius)
|
||||
|| controls.iter().all(|q| q.y > p.y + radius)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let seg = if sp.segment_is_line(i) { PathSeg::Line(vectorcraft_geom::kurbo::Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) };
|
||||
if seg.nearest(p, 1e-9).distance_sq <= radius_sq {
|
||||
if vectorcraft_geom::bez::has_length(&seg) {
|
||||
return Some((true, near_decoration));
|
||||
}
|
||||
// A zero-length segment can still hit as an outline, though a painted stroke
|
||||
// needs a segment with direction. Let the existing check decide its hit kind.
|
||||
near_decoration = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((false, near_decoration))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many objects [`hit_leaf`] tested on this thread.
|
||||
@@ -734,6 +773,43 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
if opt.outline
|
||||
&& let Some((hit, needs_full_check)) = near_path_body(path, p, opt.tol, 0.0)
|
||||
{
|
||||
if hit {
|
||||
return Some(HitKind::Outline);
|
||||
}
|
||||
if !needs_full_check {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut strokes = n.appearance.painted_strokes();
|
||||
if !opt.outline
|
||||
&& n.appearance.fill().is_none_or(|f| f.paint.is_none())
|
||||
&& let Some(s) = strokes.next()
|
||||
&& strokes.next().is_none()
|
||||
&& s.align == StrokeAlign::Center
|
||||
&& s.width.is_finite()
|
||||
&& s.width > 0.0
|
||||
&& s.dash.as_ref().is_none_or(|d| !d.is_dashed())
|
||||
&& s.profile.is_none()
|
||||
&& s.start_arrow.is_none()
|
||||
&& s.end_arrow.is_none()
|
||||
&& s.brush.is_none()
|
||||
&& let Some((hit, near_decoration)) = near_path_body(
|
||||
path,
|
||||
p,
|
||||
s.width / 2.0 + opt.tol,
|
||||
if s.join == crate::LineJoin::Miter || s.cap == crate::LineCap::Square { s.reach() + opt.tol } else { 0.0 },
|
||||
)
|
||||
{
|
||||
if hit {
|
||||
return Some(HitKind::Stroke);
|
||||
}
|
||||
if !near_decoration {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let bp = path.to_bezpath();
|
||||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||||
return Some(k);
|
||||
@@ -860,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and
|
||||
/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance
|
||||
/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform.
|
||||
struct InstanceArt {
|
||||
art: Node,
|
||||
map: Option<crate::NineSliceMap>,
|
||||
xf: vectorcraft_geom::Affine,
|
||||
}
|
||||
|
||||
impl InstanceArt {
|
||||
/// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that
|
||||
/// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of,
|
||||
/// which [`Document::place_symbol_art`] does not place and the canvas does not draw.
|
||||
fn of(doc: &Document, inst: &Node) -> Option<Self> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = crate::symbol_overrides::dynamic_art(&sym.art, inst);
|
||||
let map = doc.symbol_slice_map(symbol, *xf);
|
||||
if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { art, map, xf: *xf })
|
||||
}
|
||||
|
||||
/// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the
|
||||
/// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell
|
||||
/// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides
|
||||
/// object by object.
|
||||
fn whole(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> {
|
||||
whole.or_else(|| self.map.as_ref()?.one_cell(n, doc))
|
||||
}
|
||||
|
||||
/// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by
|
||||
/// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when
|
||||
/// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as
|
||||
/// [`Document::place_symbol_art`] refuses it.
|
||||
fn place(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
match (&self.map, whole) {
|
||||
(_, Some(a)) => Some(moved(n, a)),
|
||||
(Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)),
|
||||
(None, None) => Some(moved(n, self.xf)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Object `n` of the art placed on the canvas without clip groups
|
||||
/// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the
|
||||
/// same limit ([`Document::place_symbol_art_unclipped`]).
|
||||
fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
match (&self.map, whole) {
|
||||
(_, Some(a)) => Some(moved(n, a)),
|
||||
(Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)),
|
||||
(None, None) => Some(moved(n, self.xf)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` transformed by `xf`, its strokes at their own weight.
|
||||
fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node {
|
||||
let mut out = n.clone();
|
||||
out.transform(xf, false);
|
||||
out
|
||||
}
|
||||
|
||||
/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's
|
||||
/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the
|
||||
/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the
|
||||
/// outline of the clipping path of a clip group or a clipping layer picks that path before the art
|
||||
/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed
|
||||
/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper
|
||||
/// group's original shapes, which the canvas does not draw, and guide paths with
|
||||
/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice
|
||||
/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not
|
||||
/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`.
|
||||
pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option<Vec<u32>> {
|
||||
struct Find<'a> {
|
||||
doc: &'a Document,
|
||||
art: &'a InstanceArt,
|
||||
p: Point,
|
||||
opt: HitOptions,
|
||||
symbols: &'a SymbolBoxes<'a>,
|
||||
}
|
||||
impl Find<'_> {
|
||||
fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> bool {
|
||||
self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some())
|
||||
}
|
||||
fn skips_guide(&self, n: &Node) -> bool {
|
||||
self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
}
|
||||
fn find(&self, n: &Node, path: &mut Vec<u32>, whole: Option<vectorcraft_geom::Affine>) -> bool {
|
||||
if !n.visible || n.locked || n.is_template() || self.skips_guide(n) {
|
||||
return false;
|
||||
}
|
||||
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) };
|
||||
if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH {
|
||||
return self.hit(n, whole);
|
||||
}
|
||||
let whole = self.art.whole(n, self.doc, whole);
|
||||
if n.clips()
|
||||
&& let Some(first) = children.first()
|
||||
{
|
||||
let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false };
|
||||
if self.opt.clip_outlines
|
||||
&& first.visible
|
||||
&& !first.locked
|
||||
&& !self.skips_guide(first)
|
||||
&& clip_outline(&clip, self.p, self.opt).is_some()
|
||||
{
|
||||
path.push(0);
|
||||
return true;
|
||||
}
|
||||
if !clip_contains(&clip, self.p) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
// A Shaper group's original shapes are not drawn; symbol art has no isolation scope.
|
||||
if shaper_hides(n, i, None) {
|
||||
continue;
|
||||
}
|
||||
let Ok(i) = u32::try_from(i) else { continue };
|
||||
path.push(i);
|
||||
if self.find(c, path, whole) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
let art = InstanceArt::of(doc, inst)?;
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut path = vec![];
|
||||
Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path)
|
||||
}
|
||||
|
||||
/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]),
|
||||
/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a
|
||||
/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip
|
||||
/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An
|
||||
/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut
|
||||
/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`]
|
||||
/// picks it.
|
||||
/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies.
|
||||
pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option<BezPath> {
|
||||
let art = InstanceArt::of(doc, inst)?;
|
||||
let part = crate::symbol_overrides::part(&art.art, path)?;
|
||||
let mut out = BezPath::new();
|
||||
let mut limit = None;
|
||||
let mut whole = None;
|
||||
let mut n = &art.art;
|
||||
for &i in path {
|
||||
let children = n.children()?;
|
||||
whole = art.whole(n, doc, whole);
|
||||
if i != 0
|
||||
&& n.clips()
|
||||
&& let Some(first) = children.first()
|
||||
&& let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds()
|
||||
{
|
||||
match meet(Some(clip), limit) {
|
||||
Some(r) => limit = Some(r),
|
||||
None => return Some(out),
|
||||
}
|
||||
}
|
||||
n = children.get(usize::try_from(i).ok()?)?;
|
||||
}
|
||||
outline_within(&art.place(part, doc, whole)?, limit, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one.
|
||||
fn outline_within(n: &Node, limit: Option<Rect>, out: &mut BezPath) {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
if let Some(pd) = n.path_data() {
|
||||
match limit {
|
||||
Some(r) => push_cut(out, &pd.to_bezpath(), r),
|
||||
None => out.extend(pd.to_bezpath()),
|
||||
}
|
||||
} else if n.children().is_none()
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
&& let Some(b) = meet(Some(b), limit)
|
||||
{
|
||||
out.extend(b.to_path(0.1));
|
||||
}
|
||||
let Some(children) = n.children() else { return };
|
||||
if n.clips()
|
||||
&& let Some((first, rest)) = children.split_first()
|
||||
{
|
||||
let inner = match first.geometric_bounds() {
|
||||
Some(c) => match meet(Some(c), limit) {
|
||||
Some(r) => Some(r),
|
||||
None => return,
|
||||
},
|
||||
None => limit,
|
||||
};
|
||||
rest.iter().for_each(|c| outline_within(c, inner, out));
|
||||
return;
|
||||
}
|
||||
children.iter().for_each(|c| outline_within(c, limit, out));
|
||||
}
|
||||
|
||||
/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap.
|
||||
fn meet(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
|
||||
let a = a?;
|
||||
let Some(b) = b else { return Some(a) };
|
||||
let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1));
|
||||
(r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
|
||||
}
|
||||
|
||||
/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`.
|
||||
fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) {
|
||||
use vectorcraft_geom::kurbo::PathEl;
|
||||
let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::<Point>);
|
||||
vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| {
|
||||
let to = match el {
|
||||
PathEl::MoveTo(q) => {
|
||||
(at, start, last) = (q, q, None);
|
||||
return;
|
||||
}
|
||||
PathEl::LineTo(q) => q,
|
||||
PathEl::ClosePath => start,
|
||||
_ => return,
|
||||
};
|
||||
if let Some((a, b)) = cut_segment(at, to, r) {
|
||||
if last != Some(a) {
|
||||
out.move_to(a);
|
||||
}
|
||||
out.line_to(b);
|
||||
last = Some(b);
|
||||
}
|
||||
at = to;
|
||||
});
|
||||
}
|
||||
|
||||
/// The part of segment `a` to `b` inside `r`, `None` when none of it is.
|
||||
fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> {
|
||||
let d = b - a;
|
||||
let (mut t0, mut t1) = (0.0_f64, 1.0_f64);
|
||||
for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] {
|
||||
if p.abs() < 1e-12 {
|
||||
if q < 0.0 {
|
||||
return None;
|
||||
}
|
||||
} else if p < 0.0 {
|
||||
t0 = t0.max(q / p);
|
||||
} else {
|
||||
t1 = t1.min(q / p);
|
||||
}
|
||||
}
|
||||
(t0 <= t1).then(|| (a + d * t0, a + d * t1))
|
||||
}
|
||||
|
||||
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
|
||||
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
|
||||
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
|
||||
@@ -893,6 +1220,9 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, wh
|
||||
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)) {
|
||||
// Isolated art whose layer, or a group around it, was locked or hidden afterwards: the
|
||||
// marquee touches none of it, as clicks don't (#5309).
|
||||
Some(s) if !doc.is_editable(s.id) => vec![],
|
||||
Some(s) => match (s.children(), marquee_in(s, r)) {
|
||||
(Some(c), Some(r)) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(),
|
||||
(Some(_), None) => vec![],
|
||||
|
||||
@@ -38,6 +38,39 @@ fn unfilled_interior_misses() {
|
||||
assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_plain_stroke_hits_its_middle_and_curved_end() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let mut sp = vectorcraft_geom::SubPath::polyline(&(0..=4097).map(|i| Point::new(i as f64 * 10.0, 0.0)).collect::<Vec<_>>(), false);
|
||||
sp.anchors[4096].h_out = Point::new(40960.0, 100.0);
|
||||
sp.anchors[4097].h_in = Point::new(40970.0, 100.0);
|
||||
sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(40970.0, 100.0)));
|
||||
sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(0.0, 100.0)));
|
||||
let path = vectorcraft_geom::PathData::single(sp);
|
||||
let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
d.insert(Some(l), 0, Node::path(id, path, look)).unwrap();
|
||||
let outline = HitOptions { outline: true, ..Default::default() };
|
||||
for point in [Point::new(20485.0, 0.0), Point::new(40965.0, 75.0)] {
|
||||
assert_eq!(hit_test(&d, point, HitOptions::default()).map(|h| h.kind), Some(HitKind::Stroke));
|
||||
assert_eq!(hit_test(&d, point, outline).map(|h| h.kind), Some(HitKind::Outline));
|
||||
}
|
||||
assert!(hit_test(&d, Point::new(20485.0, 50.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_anchors_remain_hittable_as_an_outline() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let id = d.alloc_id();
|
||||
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[Point::new(50.0, 50.0); 2], false));
|
||||
let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
d.insert(d.layers.first().map(|l| l.id), 0, Node::path(id, path, look)).unwrap();
|
||||
let at = Point::new(50.0, 50.0);
|
||||
assert_eq!(hit_test(&d, at, HitOptions::default()).map(|h| h.kind), Some(HitKind::Outline));
|
||||
assert_eq!(hit_test(&d, at, HitOptions { outline: true, ..Default::default() }).map(|h| h.kind), Some(HitKind::Outline));
|
||||
}
|
||||
|
||||
/// #1103: a marquee drawn over a background picks what is on it, not the background, whose
|
||||
/// outline it doesn't touch.
|
||||
#[test]
|
||||
@@ -1340,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() {
|
||||
assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}");
|
||||
assert_eq!(checked, 201);
|
||||
}
|
||||
|
||||
/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does:
|
||||
/// Direct Selection picks an object only where the mask shows it, and outlines it there.
|
||||
#[test]
|
||||
fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2));
|
||||
if let NodeKind::Layer { children, .. } = &mut layer.kind {
|
||||
*children = vec![Arc::new(mask), Arc::new(art)];
|
||||
}
|
||||
layer.set_clips(true);
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) });
|
||||
let inst = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
let n = d.node(inst).unwrap();
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art");
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it");
|
||||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap());
|
||||
assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}");
|
||||
// Its outline inside the group around the layer is cut too.
|
||||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap());
|
||||
assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}");
|
||||
}
|
||||
|
||||
/// In a dynamic symbol's art that placing through its 9-slice guides could make more than
|
||||
/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the
|
||||
/// canvas draws nothing of it. At the art's natural size the same art is picked.
|
||||
#[test]
|
||||
fn symbol_art_past_the_object_limit_picks_nothing() {
|
||||
use vectorcraft_color::{Gradient, GradientPaint};
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
|
||||
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
|
||||
mask.linked = false;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
// Drawn in six cells, each with its own copy of the mask's 3,240,000 squares.
|
||||
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
big.extras_mut().mask = Some(Box::new(mask));
|
||||
let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) });
|
||||
d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default()));
|
||||
let scaled = d.alloc_id();
|
||||
let natural = d.alloc_id();
|
||||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||||
d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap();
|
||||
d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit");
|
||||
let opt = HitOptions::default();
|
||||
let n = d.node(scaled).unwrap();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square");
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path");
|
||||
assert!(symbol_part_outline(&d, n, &[0]).is_none());
|
||||
assert!(symbol_part_outline(&d, n, &[1]).is_none());
|
||||
let n = d.node(natural).unwrap();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0]));
|
||||
assert!(symbol_part_outline(&d, n, &[0]).is_some());
|
||||
}
|
||||
|
||||
/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it
|
||||
/// included, so an object in it is picked and outlined there even when placing that object through
|
||||
/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects.
|
||||
#[test]
|
||||
fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() {
|
||||
use vectorcraft_color::{Gradient, GradientPaint};
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
|
||||
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
|
||||
mask.linked = false;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
big.extras_mut().mask = Some(Box::new(mask));
|
||||
let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]);
|
||||
group.set_clips(true);
|
||||
let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) });
|
||||
d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default()));
|
||||
let inst = d.alloc_id();
|
||||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap();
|
||||
let m = d.symbol_slice_map("Corner", xf).unwrap();
|
||||
assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit");
|
||||
assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn");
|
||||
let n = d.node(inst).unwrap();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1]));
|
||||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap());
|
||||
assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}");
|
||||
}
|
||||
|
||||
/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes,
|
||||
/// which the canvas does not draw: a click over an original shape whose face the group erased
|
||||
/// picks nothing, and a click over the face it shows picks the face.
|
||||
#[test]
|
||||
fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(crate::shaper::SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(group)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) });
|
||||
let inst = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
let n = d.node(inst).unwrap();
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape");
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face");
|
||||
}
|
||||
|
||||
@@ -90,6 +90,24 @@ impl Paths {
|
||||
/// object and look the next one up, again and again).
|
||||
const MISSES_PER_REBUILD: u32 = 4;
|
||||
|
||||
thread_local! {
|
||||
/// [`tree_walks`] of this thread.
|
||||
static TREE_WALKS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times lookups by id on this thread have walked a document's tree, reading up to all of
|
||||
/// its nodes: one count per search of the tree and per rebuild of the cache. An edit that looks its
|
||||
/// objects up one at a time counts about one walk per object; one that looks them all up at once
|
||||
/// counts a few.
|
||||
pub fn tree_walks() -> u64 {
|
||||
TREE_WALKS.get()
|
||||
}
|
||||
|
||||
/// Adds one to [`tree_walks`].
|
||||
pub(crate) fn note_tree_walk() {
|
||||
TREE_WALKS.set(TREE_WALKS.get().saturating_add(1));
|
||||
}
|
||||
|
||||
/// The cache, shared by clones of a document (each checks every path it uses against its own tree).
|
||||
#[derive(Default)]
|
||||
pub struct IdIndex(RwLock<Option<Arc<Paths>>>, std::sync::atomic::AtomicU32);
|
||||
@@ -125,6 +143,7 @@ impl IdIndex {
|
||||
}
|
||||
}
|
||||
}
|
||||
note_tree_walk();
|
||||
let mut out = Paths::default();
|
||||
walk(layers, u32::MAX, &mut out);
|
||||
let out = Arc::new(out);
|
||||
|
||||
+14
-3
@@ -16,8 +16,10 @@ pub mod corners;
|
||||
pub mod depth;
|
||||
pub mod dimension;
|
||||
pub mod graph;
|
||||
mod halftone_radii;
|
||||
pub mod hit;
|
||||
mod id_index;
|
||||
pub use id_index::tree_walks;
|
||||
pub mod inks;
|
||||
mod inline;
|
||||
pub mod intertwine;
|
||||
@@ -107,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument};
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, SegmentRef, Selection};
|
||||
pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
@@ -139,7 +141,7 @@ mod limits;
|
||||
mod records;
|
||||
mod units;
|
||||
|
||||
pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
|
||||
pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
|
||||
pub use canvas_range::BeyondCanvas;
|
||||
#[cfg(test)]
|
||||
use document_ext::free_id;
|
||||
@@ -148,7 +150,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, SymbolEdit};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit};
|
||||
pub use units::{ColorMode, Unit, parse_number};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -308,6 +310,10 @@ pub struct Document {
|
||||
/// given) SVG export links with `<script>` elements, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_scripts: Vec<String>,
|
||||
/// Effect › SVG Filters: the SVG filters the document holds (at most [`SvgFilter::MAX`]), in
|
||||
/// the order the submenu lists them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_filters: Vec<SvgFilter>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
@@ -324,6 +330,11 @@ pub struct Document {
|
||||
/// Never saved.
|
||||
#[serde(skip)]
|
||||
pub retired_artboard_id: RetiredArtboardId,
|
||||
/// The id of the artboard an imported file names as its active one (a `.ai` file's
|
||||
/// `CropAreaActive`): the document opens with that artboard active. A `.ai` save or export
|
||||
/// names it as the file's active artboard. `None`: the first. Native files do not keep it.
|
||||
#[serde(skip)]
|
||||
pub opened_artboard: Option<u32>,
|
||||
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
|
||||
/// is set, so documents without any pay nothing for them.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
|
||||
@@ -14,8 +14,10 @@ pub enum DocError {
|
||||
}
|
||||
|
||||
/// The document version new documents get. Version 2: a positive Twist angle turns clockwise
|
||||
/// ([`crate::twist`]).
|
||||
pub const FORMAT_VERSION: u32 = 2;
|
||||
/// ([`crate::twist`]). Version 3: Color Halftone's Max Radius is in pixels of the raster effects
|
||||
/// resolution (older documents are converted on opening,
|
||||
/// [`crate::Document::migrate_halftone_radii`]).
|
||||
pub const FORMAT_VERSION: u32 = 3;
|
||||
|
||||
/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so
|
||||
/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718).
|
||||
|
||||
+174
-2
@@ -46,10 +46,26 @@ impl LinkInfo {
|
||||
Self { path: path.into(), ..Self::default() }
|
||||
}
|
||||
|
||||
/// The file name: the path's last component, whichever system's separators it uses.
|
||||
/// The file name: the path's last component, whichever system's separators it uses
|
||||
/// ([`path_separators`]).
|
||||
pub fn name(&self) -> &str {
|
||||
self.path.rsplit(['/', '\\']).next().unwrap_or(&self.path)
|
||||
self.path.rsplit(path_separators(&self.path)).next().unwrap_or(&self.path)
|
||||
}
|
||||
|
||||
/// The folder the file is in: the path without its name and the separators before it.
|
||||
pub fn folder(&self) -> &str {
|
||||
let folder = self.path.get(..self.path.len().saturating_sub(self.name().len())).unwrap_or_default();
|
||||
folder.trim_end_matches(path_separators(&self.path))
|
||||
}
|
||||
}
|
||||
|
||||
/// The characters that separate the folders of `path`. A path with a colon and no `/` or `\` is a
|
||||
/// classic Mac OS path (`Disk:Folder:file.tif`, as older documents store links), whose folders are
|
||||
/// separated by colons. Any other path is separated by `/` and `\`: its file name may hold a colon
|
||||
/// (`/Users/a/x:y.png`) and never holds a `/` or `\`.
|
||||
pub fn path_separators(path: &str) -> &'static [char] {
|
||||
const SLASHES: &[char] = &['/', '\\'];
|
||||
if path.contains(':') && !path.contains(SLASHES) { &[':'] } else { SLASHES }
|
||||
}
|
||||
|
||||
impl From<&str> for LinkInfo {
|
||||
@@ -164,6 +180,31 @@ impl ImageBlob {
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
|
||||
}
|
||||
|
||||
/// Does a colorize paint ([`crate::NodeExtras::colorize`]) show on these bytes? Not while
|
||||
/// they are a preview whose pixels aren't grayscale, such as [`missing_placeholder`]: until
|
||||
/// the file is relinked the image shows the preview as it is, and keeps the paint.
|
||||
pub fn takes_colorize(&self) -> bool {
|
||||
!self.is_proxy() || self.gray_source().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// The side of [`missing_placeholder`]'s image, in pixels.
|
||||
pub const MISSING_SIDE: u32 = 64;
|
||||
|
||||
/// What a linked image shows when an importer has no pixels for it (its file is missing or isn't
|
||||
/// in the data): a light tile, crossed out, [`MISSING_SIDE`] pixels a side, as a PNG. `None` when
|
||||
/// it can't be encoded.
|
||||
pub fn missing_placeholder() -> Option<Vec<u8>> {
|
||||
let last = MISSING_SIDE - 1;
|
||||
let img = image::RgbaImage::from_fn(MISSING_SIDE, MISSING_SIDE, |x, y| {
|
||||
let edge = x == 0 || y == 0 || x == last || y == last;
|
||||
let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1;
|
||||
image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] })
|
||||
});
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(png)
|
||||
}
|
||||
|
||||
/// `bytes` decoded and shrunk to fit [`PROXY_SIZE`] as a PNG; `None` when small enough already or
|
||||
@@ -374,6 +415,68 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the
|
||||
/// way to one are copied.
|
||||
pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) {
|
||||
fn link_of(n: &Node) -> Option<&LinkInfo> {
|
||||
match &n.kind {
|
||||
NodeKind::Image(image) => image.link.as_ref(),
|
||||
NodeKind::PlacedDocument(placed) => Some(&placed.link),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link.
|
||||
fn update(n: &mut Arc<Node>, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) {
|
||||
// Each child is updated through a second handle, so an updated child is a new node and a new
|
||||
// pointer marks a change.
|
||||
let mask = n.extras().mask.as_ref().and_then(|m| {
|
||||
let mut art = Arc::clone(&m.art);
|
||||
update(&mut art, pick, f);
|
||||
(!Arc::ptr_eq(&art, &m.art)).then_some(art)
|
||||
});
|
||||
let mut children = vec![];
|
||||
for (i, c) in n.children().into_iter().flatten().enumerate() {
|
||||
let mut new = Arc::clone(c);
|
||||
update(&mut new, pick, f);
|
||||
if !Arc::ptr_eq(&new, c) {
|
||||
children.push((i, new));
|
||||
}
|
||||
}
|
||||
let own = link_of(n).is_some_and(pick);
|
||||
if !own && mask.is_none() && children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = Arc::make_mut(n);
|
||||
let link = match &mut n.kind {
|
||||
NodeKind::Image(image) => image.link.as_mut(),
|
||||
NodeKind::PlacedDocument(placed) => Some(&mut placed.link),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(link) = link.filter(|_| own) {
|
||||
f(link);
|
||||
}
|
||||
if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) {
|
||||
m.art = art;
|
||||
}
|
||||
if let Some(slots) = n.children_mut() {
|
||||
for (i, c) in children {
|
||||
if let Some(slot) = slots.get_mut(i) {
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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| &mut p.art));
|
||||
for root in roots {
|
||||
update(root, &pick, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask
|
||||
/// art included) for which `pick` holds; only the subtrees holding one are copied.
|
||||
pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) {
|
||||
@@ -445,6 +548,46 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them;
|
||||
/// the other nodes stay shared with the document it was cloned from.
|
||||
#[test]
|
||||
fn update_links_where_copies_only_the_nodes_it_changes() {
|
||||
let mut d = Document::new(10.0, 10.0);
|
||||
image(&mut d, "a", Some(LinkInfo::new("/art/a.png")));
|
||||
image(&mut d, "b", Some(LinkInfo::new("/art/b.png")));
|
||||
image(&mut d, "c", None);
|
||||
let mut copy = d.clone();
|
||||
let mut calls = 0;
|
||||
copy.update_links_where(
|
||||
|l| l.path == "/art/a.png",
|
||||
|l| {
|
||||
calls += 1;
|
||||
l.relative = Some("art/a.png".into());
|
||||
},
|
||||
);
|
||||
assert_eq!(calls, 1);
|
||||
let node = |d: &Document, key: &str| {
|
||||
d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone()
|
||||
};
|
||||
let relative = |n: &Node| match &n.kind {
|
||||
NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!((relative(&node(©, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None));
|
||||
assert!(Arc::ptr_eq(&node(©, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(©, "c"), &node(&d, "c")));
|
||||
assert!(!Arc::ptr_eq(©.layers[0], &d.layers[0]));
|
||||
// Nothing picked: nothing is copied.
|
||||
let mut same = d.clone();
|
||||
same.update_links_where(|_| false, |_| {});
|
||||
assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_placeholder_is_a_png() {
|
||||
let png = missing_placeholder().unwrap();
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), MISSING_SIDE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_string_from_older_files_reads_as_a_link() {
|
||||
let old: ImageObject = serde_json::from_str(r#"{"key":"k","width":2,"height":1,"xf":[1,0,0,1,0,0],"link":"C:\\art\\photo.png"}"#).unwrap();
|
||||
@@ -460,6 +603,35 @@ mod tests {
|
||||
assert_eq!(v["link"], serde_json::json!({"path": "/a/b.png", "size": 9, "page": 2}));
|
||||
}
|
||||
|
||||
/// A classic Mac OS path is named by its last colon-separated component, and its folder is
|
||||
/// what comes before; other paths keep their `/` and `\` separators, and a name never holds
|
||||
/// one.
|
||||
#[test]
|
||||
fn a_classic_mac_path_is_named_by_its_last_component() {
|
||||
let parts = |path: &str| {
|
||||
let l = LinkInfo::new(path);
|
||||
(l.name().to_string(), l.folder().to_string())
|
||||
};
|
||||
assert_eq!(parts("Macintosh HD:Images:photo.tif"), ("photo.tif".into(), "Macintosh HD:Images".into()));
|
||||
assert_eq!(parts("Disk:photo.tif"), ("photo.tif".into(), "Disk".into()));
|
||||
assert_eq!(parts("Disk:Art/2024:a\\b.tif"), ("b.tif".into(), "Disk:Art/2024:a".into()));
|
||||
assert_eq!(parts("Disk:Images:../other.png"), ("other.png".into(), "Disk:Images:..".into()), "a name reaches no other folder");
|
||||
assert_eq!(parts("C:\\art\\photo.png"), ("photo.png".into(), "C:\\art".into()));
|
||||
assert_eq!(parts("c:/art/photo.png"), ("photo.png".into(), "c:/art".into()));
|
||||
assert_eq!(parts("/Users/a/x:y.png"), ("x:y.png".into(), "/Users/a".into()), "a POSIX name may hold a colon");
|
||||
assert_eq!(parts("./art/x:y.png"), ("x:y.png".into(), "./art".into()));
|
||||
assert_eq!(parts("..\\art\\x:y.png"), ("x:y.png".into(), "..\\art".into()));
|
||||
assert_eq!(parts("\\\\?\\C:\\art\\photo.png"), ("photo.png".into(), "\\\\?\\C:\\art".into()));
|
||||
assert_eq!(parts("file:///art/photo.png"), ("photo.png".into(), "file:///art".into()));
|
||||
assert_eq!(parts("file:/art/photo.png"), ("photo.png".into(), "file:/art".into()));
|
||||
assert_eq!(parts("assets/icon:dark.png"), ("icon:dark.png".into(), "assets".into()), "a relative path with slashes");
|
||||
assert_eq!(parts(":Images:photo.tif"), ("photo.tif".into(), ":Images".into()), "a classic path relative to its folder");
|
||||
assert_eq!(parts("/Users/a//photo.png").1, "/Users/a", "repeated separators");
|
||||
assert_eq!(parts("photo.png"), ("photo.png".into(), String::new()));
|
||||
assert_eq!(parts("C:photo.png"), ("photo.png".into(), "C".into()), "a drive-relative path has the same shape");
|
||||
assert_eq!(LinkInfo::new("Macintosh HD:Images:photo.tif").path, "Macintosh HD:Images:photo.tif", "the stored path is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reader_hashes_as_its_bytes_do() {
|
||||
// Several 64 KB reads, and none.
|
||||
|
||||
+358
-2
@@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation,
|
||||
#[cfg(test)]
|
||||
use blend_spec::{blend_color_shift, blend_ramp};
|
||||
pub use envelope::{
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_expand, envelope_frame,
|
||||
envelope_grid, envelope_mapper, envelope_of, envelope_overlay, smooth_handles,
|
||||
EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds,
|
||||
envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin,
|
||||
set_type_ink, smooth_handles,
|
||||
};
|
||||
pub(crate) use expand::max_outset;
|
||||
pub use expand::{
|
||||
@@ -173,6 +174,303 @@ mod tests {
|
||||
assert!((c.x - 105.0).abs() < 1e-6 && (c.y + 10.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// A 100 × 50 outline whose top side runs (0,0) → (30,0) → (100,0) as two straight segments.
|
||||
fn top_split_at_30() -> PathData {
|
||||
let a = |x: f64, y: f64| Anchor::corner(Point::new(x, y));
|
||||
PathData::single(SubPath::new(vec![a(0.0, 0.0), a(30.0, 0.0), a(100.0, 0.0), a(100.0, 50.0), a(0.0, 50.0)], true))
|
||||
}
|
||||
|
||||
/// The length of `c`'s control polygon.
|
||||
fn polygon(c: CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// An anchor and its incoming and outgoing handles.
|
||||
type Knot = ((f64, f64), (f64, f64), (f64, f64));
|
||||
|
||||
/// A closed path through `pts`.
|
||||
fn outline(pts: &[Knot]) -> PathData {
|
||||
let a = |&(p, i, o): &Knot| Anchor { p: p.into(), h_in: i.into(), h_out: o.into(), ..Anchor::corner(p.into()) };
|
||||
PathData::single(SubPath::new(pts.iter().map(a).collect(), true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_straight_segments_spreads_by_length() {
|
||||
// The top side runs (0,0) → (30,0) → (100,0): each straight piece takes its length's share.
|
||||
let kind = EnvelopeKind::TopObject { path: top_split_at_30(), corners: None };
|
||||
let map = envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
for (q, want) in [((50.0, 0.0), (50.0, 0.0)), ((15.0, 0.0), (15.0, 0.0)), ((80.0, 0.0), (80.0, 0.0)), ((50.0, 25.0), (50.0, 25.0))] {
|
||||
let p = map(Point::new(q.0, q.1));
|
||||
assert!(p.distance(Point::new(want.0, want.1)) < 1e-6, "{q:?} → {p:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_shares_a_side_by_control_polygon_length() {
|
||||
// Two curves of unequal polygons make the top side; the anchor between them lands at the
|
||||
// first one's share, and inside each curve the content follows its parameter.
|
||||
let first = CubicBez::new((0.0, 0.0), (10.0, -20.0), (30.0, -20.0), (40.0, 0.0));
|
||||
let second = CubicBez::new((40.0, 0.0), (60.0, 10.0), (90.0, 10.0), (100.0, 0.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), (10.0, -20.0)),
|
||||
((40.0, 0.0), (30.0, -20.0), (60.0, 10.0)),
|
||||
((100.0, 0.0), (90.0, 10.0), (100.0, 0.0)),
|
||||
((100.0, 80.0), (100.0, 80.0), (100.0, 80.0)),
|
||||
((0.0, 80.0), (0.0, 80.0), (0.0, 80.0)),
|
||||
]);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let share = polygon(first) / (polygon(first) + polygon(second));
|
||||
assert!(c.eval(share, 0.0).distance(Point::new(40.0, 0.0)) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
for f in [0.25, 0.5, 0.75] {
|
||||
let p = c.eval(share * f, 0.0);
|
||||
assert!(p.distance(first.eval(f)) < 1e-9, "first curve at {f}: {p:?}");
|
||||
let p = c.eval(share + (1.0 - share) * f, 0.0);
|
||||
assert!(p.distance(second.eval(f)) < 1e-9, "second curve at {f}: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_rectangle_top_object_stays_close_to_the_identity() {
|
||||
// The corners fall at the middle of each corner arc. Each half arc takes the share of its
|
||||
// control polygon, so the straight edges stay close to where the content was.
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let path = shapes::rounded_rectangle(r, 10.0);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let [tl, tr, br, bl] = c.corners();
|
||||
let k = 10.0 * (1.0 - std::f64::consts::FRAC_1_SQRT_2);
|
||||
for (p, want) in [(tl, (k, k)), (tr, (200.0 - k, k)), (br, (200.0 - k, 100.0 - k)), (bl, (k, 100.0 - k))] {
|
||||
assert!(p.distance(Point::from(want)) < 0.01, "corner {p:?}, want {want:?}");
|
||||
}
|
||||
for (u, v) in [(0.25, 0.0), (0.5, 0.0), (0.8, 1.0), (0.0, 0.5), (0.5, 0.5), (1.0, 0.3)] {
|
||||
let p = c.eval(u, v);
|
||||
assert!(p.distance(Point::new(200.0 * u, 100.0 * v)) < 3.0, "({u}, {v}) → {p:?}");
|
||||
}
|
||||
// The top edge starts after the first half arc's share.
|
||||
let sp = &path.subpaths[0];
|
||||
let edge = Point::new(10.0, 0.0);
|
||||
let arc = (0..sp.segment_count())
|
||||
.filter(|i| !sp.segment_is_line(*i))
|
||||
.map(|i| sp.segment(i))
|
||||
.find(|s| s.p0.distance(edge) < 1e-9 || s.p3.distance(edge) < 1e-9)
|
||||
.unwrap();
|
||||
let half = polygon(if arc.p3.distance(edge) < 1e-9 { arc.subsegment(0.5..1.0) } else { arc.subsegment(0.0..0.5) });
|
||||
let p = c.eval(half / (2.0 * half + 180.0), 0.0);
|
||||
assert!(p.distance(Point::new(10.0, 0.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_inside_a_bulging_curve_is_the_nearest_outline_point() {
|
||||
// The top left anchor's handle bulges out past the corner, so the anchor turns inward
|
||||
// there and the corner falls inside the curve.
|
||||
// The outline's bounding box starts at (-7.957, -22.222); the top curve's point nearest
|
||||
// that corner, found by sampling the curve 2,000,000 times, is (-5.072093, -18.127254).
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), (-50.0, -50.0)),
|
||||
((200.0, 0.0), (150.0, 0.0), (200.0, 0.0)),
|
||||
((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)),
|
||||
((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)),
|
||||
]);
|
||||
let want = Point::new(-5.072093, -18.127254);
|
||||
let tl = Coons::from_top_object(&path, None).unwrap().corners()[0];
|
||||
assert!(tl.distance(want) < 1e-5, "{tl:?}, want {want:?}");
|
||||
assert!(tl.distance(Point::ZERO) > 1.0, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_takes_an_anchor_only_near_its_box_corner() {
|
||||
// A chamfered top left corner of a 200 × 100 outline. With both chamfer anchors within
|
||||
// 47.5% of the box from its corner, the nearer anchor is the corner.
|
||||
let chamfer = |a: (f64, f64), b: (f64, f64)| {
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
outline(&[c(a), c(b), c((200.0, 0.0)), c((200.0, 100.0)), c((0.0, 100.0))])
|
||||
};
|
||||
let tl = Coons::from_top_object(&chamfer((0.0, 46.0), (94.0, 0.0)), None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(0.0, 46.0)) < 1e-9, "{tl:?}");
|
||||
// Farther out, neither anchor counts: the corner is the chamfer's point nearest the box corner.
|
||||
let tl = Coons::from_top_object(&chamfer((0.0, 50.0), (100.0, 0.0)), None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(20.0, 40.0)) < 1e-6, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_skips_smooth_and_inward_anchors() {
|
||||
// A top left anchor whose outgoing handle turns from its incoming one by `deg` degrees
|
||||
// (positive turns out of the outline). The top side bulges up past it.
|
||||
let shape = |deg: f64| {
|
||||
let a = deg.to_radians();
|
||||
let (hx, hy) = (50.0 * a.cos() + 40.0 * a.sin(), 40.0 * a.cos() - 50.0 * a.sin());
|
||||
outline(&[
|
||||
((50.0, -300.0), (25.0, -280.0), (50.0 + hx, -300.0 - hy)),
|
||||
((250.0, -300.0), (200.0, -340.0), (250.0, -300.0)),
|
||||
((250.0, -200.0), (250.0, -200.0), (250.0, -200.0)),
|
||||
((50.0, -200.0), (50.0, -200.0), (50.0, -230.0)),
|
||||
])
|
||||
};
|
||||
let corner = |deg: f64| Coons::from_top_object(&shape(deg), None).unwrap().corners()[0];
|
||||
let anchor = Point::new(50.0, -300.0);
|
||||
for deg in [0.0, 0.4, 0.6, -1.5] {
|
||||
assert!(corner(deg).distance(anchor) > 1.0, "{deg}°: {:?}", corner(deg));
|
||||
}
|
||||
for deg in [0.9, 1.5, 10.0] {
|
||||
assert!(corner(deg).distance(anchor) < 1e-9, "{deg}°: {:?}", corner(deg));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_skips_an_inward_anchor_between_straight_segments() {
|
||||
// A 200 × 100 outline of straight segments with a notch at its top left: (40,40) is the
|
||||
// anchor nearest the box corner, but it turns into the outline, so (60,0) is the corner.
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
let path = outline(&[c((60.0, 0.0)), c((200.0, 0.0)), c((200.0, 100.0)), c((0.0, 100.0)), c((0.0, 60.0)), c((40.0, 40.0))]);
|
||||
let tl = Coons::from_top_object(&path, None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(60.0, 0.0)) < 1e-9, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_tie_goes_to_the_first_anchor() {
|
||||
// Both chamfer anchors lie 30 from the box corner: the first one along the outline, in
|
||||
// the order it was drawn, wins.
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
let tl = |pts: &[Knot]| Coons::from_top_object(&outline(pts), None).unwrap().corners()[0];
|
||||
let from_left = tl(&[c((0.0, 30.0)), c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0))]);
|
||||
assert!(from_left.distance(Point::new(0.0, 30.0)) < 1e-9, "{from_left:?}");
|
||||
let from_top = tl(&[c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0)), c((0.0, 30.0))]);
|
||||
assert!(from_top.distance(Point::new(30.0, 0.0)) < 1e-9, "{from_top:?}");
|
||||
// Drawn counter-clockwise, the anchor met first in that order wins.
|
||||
let back = tl(&[c((0.0, 100.0)), c((100.0, 100.0)), c((100.0, 0.0)), c((30.0, 0.0)), c((0.0, 30.0))]);
|
||||
assert!(back.distance(Point::new(30.0, 0.0)) < 1e-9, "{back:?}");
|
||||
let back = tl(&[c((0.0, 30.0)), c((0.0, 100.0)), c((100.0, 100.0)), c((100.0, 0.0)), c((30.0, 0.0))]);
|
||||
assert!(back.distance(Point::new(0.0, 30.0)) < 1e-9, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reflected_top_object_keeps_the_corners_a_file_gives() {
|
||||
// Reflecting the outline across x = 0 keeps its anchors in the same order, now running
|
||||
// counter-clockwise: the corners stay on the same anchors, so the content is reflected too.
|
||||
let mut path = top_split_at_30();
|
||||
path.transform(Affine::FLIP_X);
|
||||
let c = Coons::from_top_object(&path, Some([1, 2, 3, 4])).unwrap();
|
||||
let want = [(-30.0, 0.0), (-100.0, 0.0), (-100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
assert!(c.corners().iter().zip(want).all(|(p, q)| p.distance(q) < 1e-9), "{:?}", c.corners());
|
||||
let p = c.eval(0.5, 0.5);
|
||||
assert!(p.distance(Point::new(-50.0, 17.5)) < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_keeps_the_corners_a_file_gives() {
|
||||
let same = |a: [Point; 4], b: [Point; 4]| a.iter().zip(b).all(|(p, q)| p.distance(q) < 1e-9);
|
||||
let want = [(30.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
let c = Coons::from_top_object(&top_split_at_30(), Some([1, 2, 3, 4])).unwrap();
|
||||
assert!(same(c.corners(), want), "{:?}", c.corners());
|
||||
// Drawn the other way round, with the corners counted along that order.
|
||||
let mut back = top_split_at_30();
|
||||
back.subpaths[0].reverse();
|
||||
let c = Coons::from_top_object(&back, Some([3, 2, 1, 0])).unwrap();
|
||||
assert!(same(c.corners(), want), "{:?}", c.corners());
|
||||
// Corners that repeat, run out of order or go past the outline are found from it instead.
|
||||
let found = [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
for bad in [[0, 0, 3, 4], [2, 1, 3, 4], [1, 2, 3, 9]] {
|
||||
let c = Coons::from_top_object(&top_split_at_30(), Some(bad)).unwrap();
|
||||
assert!(same(c.corners(), found), "{bad:?}: {:?}", c.corners());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_curve_moves_the_top_object_share_to_its_new_anchor() {
|
||||
// Splitting the top curve at t = 0.3 keeps its shape but changes the polygon shares: the
|
||||
// new anchor lands at the first piece's share, not at u = 0.3.
|
||||
let top = CubicBez::new((0.0, 0.0), (40.0, -40.0), (160.0, -40.0), (200.0, 0.0));
|
||||
let (a, b) = (top.subsegment(0.0..0.3), top.subsegment(0.3..1.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), a.p1.into()),
|
||||
(a.p3.into(), a.p2.into(), b.p1.into()),
|
||||
((200.0, 0.0), b.p2.into(), (200.0, 0.0)),
|
||||
((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)),
|
||||
((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)),
|
||||
]);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let share = polygon(a) / (polygon(a) + polygon(b));
|
||||
assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
assert!((share - 0.3).abs() > 0.01, "{share}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_top_object_is_the_identity() {
|
||||
let r = Rect::new(10.0, 20.0, 110.0, 70.0);
|
||||
let c = Coons::from_top_object(&shapes::rectangle(r), None).unwrap();
|
||||
for (u, v) in [(0.125, 0.3), (0.9, 0.05), (0.37, 0.81)] {
|
||||
let p = c.eval(u, v);
|
||||
assert!(p.distance(Point::new(10.0 + 100.0 * u, 20.0 + 50.0 * v)) < 1e-9, "({u}, {v}) → {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_line_top_object_maps_onto_its_bounding_box() {
|
||||
let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(100.0, 40.0))], false));
|
||||
let p = Coons::from_top_object(&line, None).unwrap().eval(0.25, 0.75);
|
||||
assert!(p.distance(Point::new(25.0, 30.0)) < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_spreads_a_segment_by_its_parameter() {
|
||||
// The top side is one straight segment whose handles sit at 60 and 80: its parameter
|
||||
// reaches the middle at x = 63.75, not at 45.
|
||||
let mut top = vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(90.0, 0.0))];
|
||||
top[0].h_out = Point::new(60.0, 0.0);
|
||||
top[1].h_in = Point::new(80.0, 0.0);
|
||||
top.extend([Anchor::corner(Point::new(90.0, 40.0)), Anchor::corner(Point::new(0.0, 40.0))]);
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::single(SubPath::new(top, true)), corners: None };
|
||||
let map = envelope_mapper(&kind, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let p = map(Point::new(5.0, 0.0));
|
||||
assert!(p.distance(Point::new(63.75, 0.0)) < 1e-6, "{p:?}");
|
||||
let p = map(Point::new(5.0, 10.0));
|
||||
assert!(p.distance(Point::new(45.0, 40.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_follows_a_curved_side_by_its_parameter() {
|
||||
// The top side bends down from (0,0) to (100,0) through handles at (20,30) and (90,30):
|
||||
// halfway along its parameter it passes (53.75, 22.5).
|
||||
let mut top = vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(100.0, 0.0))];
|
||||
top[0].h_out = Point::new(20.0, 30.0);
|
||||
top[1].h_in = Point::new(90.0, 30.0);
|
||||
top.extend([Anchor::corner(Point::new(100.0, 80.0)), Anchor::corner(Point::new(0.0, 80.0))]);
|
||||
let path = PathData::single(SubPath::new(top, true));
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
for (u, t) in [(0.5, 0.5), (0.25, 0.25), (0.8125, 0.8125)] {
|
||||
let want = CubicBez::new((0.0, 0.0), (20.0, 30.0), (90.0, 30.0), (100.0, 0.0)).eval(t);
|
||||
let p = c.eval(u, 0.0);
|
||||
assert!(p.distance(want) < 1e-6, "u {u}: {p:?} vs {want:?}");
|
||||
}
|
||||
// Drawn the other way round, the outline gives the same patch.
|
||||
let mut back = path.clone();
|
||||
back.subpaths[0].reverse();
|
||||
let p = Coons::from_top_object(&back, None).unwrap().eval(0.5, 0.0);
|
||||
assert!(p.distance(Point::new(53.75, 22.5)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_many_segments_keeps_each_corner() {
|
||||
// The top side zigzags through `n` straight segments, between y = 0 and y = 2.
|
||||
for n in [3, 100] {
|
||||
let w = f64::from(n);
|
||||
let zig = |i: i32| Point::new(f64::from(i) * 100.0 / w, if i % 2 == 1 { 2.0 } else { 0.0 });
|
||||
let mut pts: Vec<Anchor> = (0..=n).map(|i| Anchor::corner(zig(i))).collect();
|
||||
pts.extend([Anchor::corner(Point::new(100.0, 50.0)), Anchor::corner(Point::new(0.0, 50.0))]);
|
||||
let c = Coons::from_top_object(&PathData::single(SubPath::new(pts, true)), None).unwrap();
|
||||
for i in (1..n).filter(|i| n < 10 || [1, 37, 38, 99].contains(i)) {
|
||||
let p = c.eval(f64::from(i) / w, 0.0);
|
||||
assert!(p.distance(zig(i)) < 1e-9, "{n} segments, anchor {i}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_outline_keeps_its_sides_measured_by_length() {
|
||||
let c = Coons::from_path(&top_split_at_30()).unwrap();
|
||||
let p = c.eval(0.5, 0.0);
|
||||
assert!(p.distance(Point::new(50.0, 0.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_identity() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
@@ -233,6 +531,64 @@ mod tests {
|
||||
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
|
||||
}
|
||||
|
||||
/// `bounds_margin` grows the box an envelope maps from, only while Distort Appearance is on. It
|
||||
/// holds shares of the box, and an envelope with an uneven frame maps from the same box in that
|
||||
/// frame. A native file writes it only when it isn't zero.
|
||||
#[test]
|
||||
fn an_envelope_maps_from_its_box_with_its_margins() {
|
||||
let rect = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 110.0, 70.0)), crate::Appearance::default_art());
|
||||
let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 0.0, h: 0.0, v: 0.0, horizontal: true };
|
||||
let content = [Arc::new(rect)];
|
||||
let src = |content: &[Arc<Node>], frame: Affine, options: EnvelopeOptions| EnvelopeMap::new(content, &kind, frame, options).unwrap().src();
|
||||
// Top, bottom, left and right.
|
||||
let on = EnvelopeOptions { bounds_margin: [0.125, 0.25, 0.0625, 0.5], ..EnvelopeOptions::NEW };
|
||||
let geometric = Rect::new(10.0, 20.0, 110.0, 70.0);
|
||||
let grown = Rect::new(3.75, 13.75, 160.0, 82.5);
|
||||
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions::default()), geometric);
|
||||
assert_eq!(src(&content, Affine::IDENTITY, on), grown);
|
||||
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { distort_appearance: false, ..on }), geometric);
|
||||
for bad in [f32::NAN, f32::INFINITY, -0.05] {
|
||||
assert_eq!(src(&content, Affine::IDENTITY, EnvelopeOptions { bounds_margin: [bad, 0.25, 0.0625, 0.5], ..on }), geometric);
|
||||
}
|
||||
// A finite share of a box too wide to grow leaves the box as it is.
|
||||
let wide = Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 1e308, 10.0)), crate::Appearance::default_art());
|
||||
let wide = [Arc::new(wide)];
|
||||
let huge = EnvelopeOptions { bounds_margin: [0.0, 0.0, f32::MAX, 0.0], ..on };
|
||||
assert_eq!(src(&wide, Affine::IDENTITY, huge), src(&wide, Affine::IDENTITY, EnvelopeOptions::NEW));
|
||||
// Bounds follow the box: a warp that doesn't bend covers it.
|
||||
let b = envelope_bounds(&content, &kind, Affine::IDENTITY, on).unwrap();
|
||||
assert!((b.x0 - 3.75).abs() < 1e-6 && (b.y1 - 82.5).abs() < 1e-6, "{b:?}");
|
||||
// Art taken through an uneven frame maps from the same box in that frame.
|
||||
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale_non_uniform(2.0, 0.5);
|
||||
let mut placed = (*content[0]).clone();
|
||||
placed.transform(frame, true);
|
||||
let framed = src(&[Arc::new(placed)], frame, on);
|
||||
assert!((framed.x0 - 3.75).abs() + (framed.y0 - 13.75).abs() + (framed.x1 - 160.0).abs() + (framed.y1 - 82.5).abs() < 1e-6, "{framed:?}");
|
||||
|
||||
// A frame that turns and scales gives the same margins as the art had before it took them.
|
||||
let mut stroked = (*content[0]).clone();
|
||||
if let Some(s) = stroked.appearance.stroke_mut() {
|
||||
s.width = 10.0;
|
||||
}
|
||||
let margin = |n: &Node, frame: Affine| {
|
||||
let (with, plain) = envelope_content_boxes(&[Arc::new(n.clone())], frame).unwrap();
|
||||
envelope_stroke_margin(with, plain).unwrap()
|
||||
};
|
||||
let before = margin(&stroked, Affine::IDENTITY);
|
||||
assert_eq!(before.map(|m| (m * 1000.0).round()), [100.0, 100.0, 50.0, 50.0]);
|
||||
let frame = Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::scale(2.0);
|
||||
let mut turned = stroked.clone();
|
||||
turned.transform(frame, true);
|
||||
assert_eq!(margin(&turned, frame).map(|m| (m * 1000.0).round()), before.map(|m| (m * 1000.0).round()));
|
||||
// Margins an `f32` can't hold are none.
|
||||
assert_eq!(envelope_stroke_margin(Rect::new(-5e41, 0.0, 100.0, 100.0), Rect::new(0.0, 0.0, 100.0, 100.0)), None);
|
||||
|
||||
assert!(serde_json::to_value(EnvelopeOptions::default()).unwrap().get("boundsMargin").is_none());
|
||||
let saved = serde_json::to_value(on).unwrap();
|
||||
assert_eq!(saved["boundsMargin"], serde_json::json!([0.125, 0.25, 0.0625, 0.5]));
|
||||
assert_eq!(serde_json::from_value::<EnvelopeOptions>(saved).unwrap(), on);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
+452
-20
@@ -1,6 +1,7 @@
|
||||
use super::expand::{Hooks, Outliner, Warper, map_node};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of};
|
||||
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
|
||||
use vectorcraft_geom::Homography;
|
||||
|
||||
/// How an envelope distorts its content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -31,7 +32,14 @@ pub enum EnvelopeKind {
|
||||
handles: Vec<[Vec2; 4]>,
|
||||
},
|
||||
/// The content's bounding box maps onto the outline of this path.
|
||||
TopObject { path: PathData },
|
||||
TopObject {
|
||||
path: PathData,
|
||||
/// The anchors (indices into the outline, the path's largest subpath, in its own order)
|
||||
/// at the top left, top right, bottom right and bottom left corners, as a saved file gives
|
||||
/// them. `None`: the corners are found from the outline.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corners: Option<[u32; 4]>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape.
|
||||
@@ -76,6 +84,12 @@ pub struct EnvelopeOptions {
|
||||
pub distort_linear_gradients: bool,
|
||||
/// Distort Pattern Fills (with Distort Appearance): pattern tiles bend with the art.
|
||||
pub distort_pattern_fills: bool,
|
||||
/// With Distort Appearance, how far the box the content maps from reaches past its geometric
|
||||
/// bounds at the top, bottom, left and right, as shares of their height and width. An envelope
|
||||
/// opened from a `.ai` whose saved size includes its strokes has these margins. New envelopes
|
||||
/// have none. Leaving Edit Contents measures them again when the strokes reach differently.
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub bounds_margin: [f32; 4],
|
||||
}
|
||||
|
||||
impl Default for EnvelopeOptions {
|
||||
@@ -86,6 +100,7 @@ impl Default for EnvelopeOptions {
|
||||
distort_appearance: false,
|
||||
distort_linear_gradients: false,
|
||||
distort_pattern_fills: false,
|
||||
bounds_margin: [0.0; 4],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +113,7 @@ impl EnvelopeOptions {
|
||||
distort_appearance: true,
|
||||
distort_linear_gradients: false,
|
||||
distort_pattern_fills: false,
|
||||
bounds_margin: [0.0; 4],
|
||||
};
|
||||
/// Do gradients bend with the art?
|
||||
pub fn gradients(&self) -> bool {
|
||||
@@ -107,6 +123,319 @@ impl EnvelopeOptions {
|
||||
pub fn patterns(&self) -> bool {
|
||||
self.distort_appearance && self.distort_pattern_fills
|
||||
}
|
||||
/// The box content with geometric bounds `r` maps from. Margins that aren't finite, are
|
||||
/// negative or give a box that isn't finite count as none.
|
||||
fn source_box(&self, r: Rect) -> Rect {
|
||||
let [top, bottom, left, right] = self.bounds_margin.map(f64::from);
|
||||
if !self.distort_appearance || !self.bounds_margin.iter().all(|m| m.is_finite() && *m >= 0.0) {
|
||||
return r;
|
||||
}
|
||||
let grown = crate::marks::outset(r, [top * r.height(), bottom * r.height(), left * r.width(), right * r.width()]);
|
||||
if grown.is_finite() { grown } else { r }
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by
|
||||
/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as
|
||||
/// [`Node::transform`] scales them.
|
||||
pub fn envelope_content_boxes(content: &[Arc<Node>], frame: Affine) -> Option<(Rect, Rect)> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| {
|
||||
(vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame)))
|
||||
});
|
||||
Some((stroked?, plain?))
|
||||
}
|
||||
|
||||
/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or
|
||||
/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead
|
||||
/// of its laid-out box. Type keeps its laid-out box while nothing is registered with
|
||||
/// [`set_type_ink`], and when it draws nothing.
|
||||
pub fn envelope_content_bounds(content: &[Arc<Node>]) -> Option<Rect> {
|
||||
content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY)))
|
||||
}
|
||||
|
||||
/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by
|
||||
/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn
|
||||
/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is
|
||||
/// laid out above this crate, so the text crate registers one with [`set_type_ink`].
|
||||
pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option<Rect>;
|
||||
|
||||
/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform
|
||||
/// after it.
|
||||
type TypeMap = (Affine, Option<(Homography, Affine)>);
|
||||
|
||||
static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new();
|
||||
|
||||
/// Register how envelopes measure type, and a number that changes whenever what it reads besides
|
||||
/// the type changes, such as the installed fonts (the first registration stays).
|
||||
pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) {
|
||||
// Already set: the same measure, registered by another session, an import or a renderer.
|
||||
let _ = TYPE_INK.set((f, inputs));
|
||||
}
|
||||
|
||||
/// How many entries each table of [`TypeInks`] holds before it is emptied.
|
||||
const TYPE_INK_KEPT: usize = 1024;
|
||||
|
||||
/// `map`, bit for bit, for a table key.
|
||||
fn map_bits(map: &TypeMap) -> [u64; 21] {
|
||||
let mut bits = [0; 21];
|
||||
let (m, projection) = map;
|
||||
for (b, v) in bits.iter_mut().zip(m.as_coeffs()) {
|
||||
*b = v.to_bits();
|
||||
}
|
||||
if let Some((h, after)) = projection {
|
||||
for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) {
|
||||
*b = v.to_bits();
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an
|
||||
/// affine map without its move) and the inputs number registered with [`set_type_ink`].
|
||||
type NodeKey = (usize, [u64; 21], u64);
|
||||
|
||||
/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and
|
||||
/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number.
|
||||
type ContentKey = ([u64; 2], [u64; 21], u64);
|
||||
|
||||
/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs
|
||||
/// number: by the object (an unchanged object is found without being read),
|
||||
/// and by its content (a moved or copied object is found without laying it out again). Neither
|
||||
/// table keeps a copy of the type.
|
||||
#[derive(Default)]
|
||||
struct TypeInks {
|
||||
/// By the object's address, with a weak pointer that keeps the address from being reused.
|
||||
nodes: std::collections::HashMap<NodeKey, (std::sync::Weak<Node>, Option<Rect>)>,
|
||||
/// By the type's content.
|
||||
content: std::collections::HashMap<ContentKey, Option<Rect>>,
|
||||
}
|
||||
|
||||
/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type
|
||||
/// that could not be hashed whole).
|
||||
struct Missing {
|
||||
by_node: NodeKey,
|
||||
content: Option<ContentKey>,
|
||||
}
|
||||
|
||||
impl TypeInks {
|
||||
/// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under
|
||||
/// once it is measured.
|
||||
fn find(&mut self, n: &Arc<Node>, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result<Option<Rect>, Box<Missing>> {
|
||||
// While the table holds a weak pointer to `n`'s allocation, no other object can be at that
|
||||
// address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut`
|
||||
// moves it).
|
||||
let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs);
|
||||
if let Some((_, ink)) = self.nodes.get(&by_node) {
|
||||
return Ok(*ink);
|
||||
}
|
||||
// Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout.
|
||||
let [a, b, c, d, _, _] = t.xf.as_coeffs();
|
||||
let mut text = t.clone();
|
||||
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
|
||||
text.cached_bounds = None;
|
||||
text.cached_baselines.clear();
|
||||
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
|
||||
match content.and_then(|key| self.content.get(&key)) {
|
||||
Some(ink) => {
|
||||
let ink = *ink;
|
||||
self.keep_node(n, by_node, ink);
|
||||
Ok(ink)
|
||||
}
|
||||
None => Err(Box::new(Missing { by_node, content })),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the box `ink` measured for type node `n` under the keys `missing`.
|
||||
fn keep(&mut self, n: &Arc<Node>, missing: Missing, ink: Option<Rect>) {
|
||||
if let Some(key) = missing.content {
|
||||
if self.content.len() >= TYPE_INK_KEPT {
|
||||
self.content.clear();
|
||||
}
|
||||
self.content.insert(key, ink);
|
||||
}
|
||||
self.keep_node(n, missing.by_node, ink);
|
||||
}
|
||||
|
||||
fn keep_node(&mut self, n: &Arc<Node>, by_node: NodeKey, ink: Option<Rect>) {
|
||||
if self.nodes.len() >= TYPE_INK_KEPT {
|
||||
self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0);
|
||||
if self.nodes.len() >= TYPE_INK_KEPT {
|
||||
self.nodes.clear();
|
||||
}
|
||||
}
|
||||
self.nodes.insert(by_node, (Arc::downgrade(n), ink));
|
||||
}
|
||||
}
|
||||
|
||||
/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what
|
||||
/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds
|
||||
/// of its inline graphics. `None` when it can't be written whole.
|
||||
fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> {
|
||||
use std::hash::Hasher;
|
||||
/// Feeds what is written to two hashers.
|
||||
struct Feed([std::collections::hash_map::DefaultHasher; 2]);
|
||||
impl std::io::Write for Feed {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
for h in &mut self.0 {
|
||||
h.write(buf);
|
||||
}
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut feed = Feed(Default::default());
|
||||
// The second hasher starts from another state.
|
||||
feed.0[1].write_u8(1);
|
||||
serde_json::to_writer(&mut feed, text).ok()?;
|
||||
let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits)));
|
||||
for h in &mut feed.0 {
|
||||
h.write_u8(u8::from(text.first_para_continues));
|
||||
for b in inline_bounds.clone() {
|
||||
match b {
|
||||
None => h.write_u8(0),
|
||||
Some(bits) => {
|
||||
h.write_u8(1);
|
||||
bits.into_iter().for_each(|v| h.write_u64(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let [a, b] = feed.0;
|
||||
Some([a.finish(), b.finish()])
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// The type [`type_ink`] measured on this thread.
|
||||
static TYPE_INKS: std::cell::RefCell<TypeInks> = std::cell::RefCell::new(TypeInks::default());
|
||||
}
|
||||
|
||||
/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m`
|
||||
/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its
|
||||
/// second transform; measured again when `t`, the maps (without a move of `m` when there is no
|
||||
/// projection) or the registered inputs number change. `None` while nothing is registered.
|
||||
fn type_ink(n: &Arc<Node>, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option<Option<Rect>> {
|
||||
let (f, inputs) = TYPE_INK.get()?;
|
||||
let inputs = inputs();
|
||||
// Without a projection the type is measured without the move, which is added to the box.
|
||||
let [a, b, c, d, e, g] = m.as_coeffs();
|
||||
let (map, offset) = match projection {
|
||||
None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)),
|
||||
Some(p) => ((m, Some(p)), Vec2::ZERO),
|
||||
};
|
||||
let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) {
|
||||
Ok(ink) => ink,
|
||||
Err(missing) => {
|
||||
// Measured outside the borrow: laying type out may measure other type.
|
||||
let ink = f(t, map.0, map.1);
|
||||
TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink));
|
||||
ink
|
||||
}
|
||||
};
|
||||
Some(ink.map(|r| r + offset))
|
||||
}
|
||||
|
||||
/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which
|
||||
/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree.
|
||||
fn find_type(n: &Node, typed: &mut std::collections::HashSet<usize>) -> bool {
|
||||
let holds = match &n.kind {
|
||||
NodeKind::Text(_) => true,
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds),
|
||||
_ => false,
|
||||
};
|
||||
if holds {
|
||||
typed.insert(n as *const Node as usize);
|
||||
}
|
||||
holds
|
||||
}
|
||||
|
||||
/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds,
|
||||
/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them),
|
||||
/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups
|
||||
/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into
|
||||
/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box.
|
||||
fn content_box(n: &Arc<Node>, stroked: bool, to: Affine) -> Option<Rect> {
|
||||
let mut typed = std::collections::HashSet::new();
|
||||
find_type(n, &mut typed);
|
||||
content_box_in(n, stroked, to, &typed)
|
||||
}
|
||||
|
||||
/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]).
|
||||
fn content_box_in(n: &Arc<Node>, stroked: bool, to: Affine, typed: &std::collections::HashSet<usize>) -> Option<Rect> {
|
||||
// `n` in the axes of `to`.
|
||||
let moved = || {
|
||||
if to == Affine::IDENTITY {
|
||||
return std::borrow::Cow::Borrowed(&**n);
|
||||
}
|
||||
let mut m = (**n).clone();
|
||||
m.transform(to, true);
|
||||
std::borrow::Cow::Owned(m)
|
||||
};
|
||||
let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() };
|
||||
if !typed.contains(&(Arc::as_ptr(n) as usize)) {
|
||||
return own(&moved());
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
// Type in perspective: its outlines as its projection draws them.
|
||||
let ink = match n.projection() {
|
||||
None => type_ink(n, t, to * t.xf, None),
|
||||
Some(h) => type_ink(n, t, t.xf, Some((h, to))),
|
||||
}
|
||||
.flatten();
|
||||
let m = moved();
|
||||
let Some(ink) = ink else { return own(&m) };
|
||||
if !stroked {
|
||||
return Some(ink);
|
||||
}
|
||||
let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) };
|
||||
Some(Rect::new(
|
||||
ink.x0 - (g.x0 - v.x0).max(0.0),
|
||||
ink.y0 - (g.y0 - v.y0).max(0.0),
|
||||
ink.x1 + (v.x1 - g.x1).max(0.0),
|
||||
ink.y1 + (v.y1 - g.y1).max(0.0),
|
||||
))
|
||||
}
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
// A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`.
|
||||
let clip = children.first()?;
|
||||
content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed)
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
|
||||
let layer = matches!(n.kind, NodeKind::Layer { .. });
|
||||
let members = n.editing_children().iter();
|
||||
if stroked {
|
||||
// The container's own strokes, scaled by `to` as `Node::transform` scales them,
|
||||
// without copying its members.
|
||||
let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false });
|
||||
own_strokes.appearance = n.appearance.clone();
|
||||
own_strokes.transform(to, true);
|
||||
own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed)))
|
||||
} else {
|
||||
members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed)))
|
||||
}
|
||||
}
|
||||
_ => own(&moved()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for
|
||||
/// content with no width or no height, or for margins an `f32` can't hold.
|
||||
pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> {
|
||||
if plain.width() < 1e-6 || plain.height() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let margin = [
|
||||
(plain.y0 - stroked.y0) / plain.height(),
|
||||
(stroked.y1 - plain.y1) / plain.height(),
|
||||
(plain.x0 - stroked.x0) / plain.width(),
|
||||
(stroked.x1 - plain.x1) / plain.width(),
|
||||
]
|
||||
.map(|m| m as f32);
|
||||
margin.iter().all(|m| m.is_finite()).then_some(margin)
|
||||
}
|
||||
|
||||
/// Handle slots of a [`MeshPoint`]: towards the next column, previous column, next row, previous row.
|
||||
@@ -196,7 +525,7 @@ pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
|
||||
Some((grid.lines(), Some(grid)))
|
||||
}
|
||||
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
|
||||
EnvelopeKind::TopObject { path } => Some((path.clone(), None)),
|
||||
EnvelopeKind::TopObject { path, .. } => Some((path.clone(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,27 +547,21 @@ pub struct EnvelopeMap<'a> {
|
||||
}
|
||||
|
||||
impl<'a> EnvelopeMap<'a> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame`. `None` when the content
|
||||
/// has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine) -> Option<Self> {
|
||||
/// The map of an envelope of `kind` around `content` with `frame` and `options`, from the
|
||||
/// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]).
|
||||
/// `None` when the content has no bounds.
|
||||
pub fn new(content: &[Arc<Node>], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Self> {
|
||||
let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY };
|
||||
let to_frame = frame.inverse();
|
||||
let src = if frame == Affine::IDENTITY {
|
||||
nodes_bounds(content)?
|
||||
} else {
|
||||
content.iter().try_fold(None, |acc, c| {
|
||||
let mut m = (**c).clone();
|
||||
m.transform(to_frame, false);
|
||||
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
|
||||
})??
|
||||
};
|
||||
let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?;
|
||||
let src = options.source_box(src);
|
||||
Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) })
|
||||
}
|
||||
|
||||
/// The map of envelope node `n` (`None` for other nodes).
|
||||
pub fn of(n: &'a Node) -> Option<Self> {
|
||||
match &n.kind {
|
||||
NodeKind::Envelope { content, kind, frame, .. } => Self::new(content, kind, **frame),
|
||||
NodeKind::Envelope { content, kind, frame, options, .. } => Self::new(content, kind, **frame, *options),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -302,7 +625,7 @@ pub(super) fn expand_envelope(
|
||||
frame: Affine,
|
||||
hooks: Hooks,
|
||||
) -> Vec<Node> {
|
||||
let Some(map) = EnvelopeMap::new(content, kind, frame) else { return content.iter().map(|c| (**c).clone()).collect() };
|
||||
let Some(map) = EnvelopeMap::new(content, kind, frame, options) else { return content.iter().map(|c| (**c).clone()).collect() };
|
||||
let src = map.src();
|
||||
let fid = if fidelity.is_finite() { fidelity.clamp(0.0, 100.0) / 100.0 } else { 0.5 };
|
||||
let f = |p: Point| map.map(p);
|
||||
@@ -319,14 +642,123 @@ pub(super) fn expand_envelope(
|
||||
}
|
||||
|
||||
/// Envelope bounds: a mesh's points, a top object's path, the warp's surface (sampled).
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine) -> Option<Rect> {
|
||||
pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option<Rect> {
|
||||
match kind {
|
||||
EnvelopeKind::Mesh { rows, cols, points, handles } if !handles.is_empty() => envelope_grid(*rows, *cols, points, handles)?.outline().bounds(),
|
||||
EnvelopeKind::Mesh { points, .. } => {
|
||||
let first = *points.first()?;
|
||||
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.bounds(),
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame)?.bounds(),
|
||||
EnvelopeKind::TopObject { path, .. } => path.bounds(),
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{CharStyle, TextObject};
|
||||
|
||||
/// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there.
|
||||
fn look_up(kept: &mut TypeInks, n: &Arc<Node>, linear: Affine, measured: &mut usize) -> Option<Rect> {
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("type") };
|
||||
match kept.find(n, t, &(linear, None), 0) {
|
||||
Ok(ink) => ink,
|
||||
Err(missing) => {
|
||||
*measured += 1;
|
||||
let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0));
|
||||
kept.keep(n, *missing, ink);
|
||||
ink
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_node(i: usize, text: &str) -> Arc<Node> {
|
||||
let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default());
|
||||
Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t))))
|
||||
}
|
||||
|
||||
/// Many type objects are each measured once however often they are looked up; a moved or copied
|
||||
/// object is found by its content, and a changed one or another transform is measured again.
|
||||
#[test]
|
||||
fn measured_type_is_kept_for_many_objects() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect();
|
||||
for _ in 0..5 {
|
||||
for n in &nodes {
|
||||
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
|
||||
}
|
||||
}
|
||||
assert_eq!(measured, 200);
|
||||
let mut moved = (*nodes[7]).clone();
|
||||
moved.transform(Affine::translate((300.0, 40.0)), true);
|
||||
let moved = Arc::new(moved);
|
||||
assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured));
|
||||
assert_eq!(measured, 200, "a moved object is found by its content");
|
||||
let changed = type_node(7, "Changed");
|
||||
look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 201, "a changed object is measured again");
|
||||
look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured);
|
||||
assert_eq!(measured, 202, "another transform is measured again");
|
||||
}
|
||||
|
||||
/// Type that differs only in what layout reads but files don't save (a first paragraph that
|
||||
/// continues a thread, the resolved bounds of an inline graphic) is measured again.
|
||||
#[test]
|
||||
fn type_that_differs_in_what_is_not_saved_is_measured_again() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default());
|
||||
t.runs.push(crate::TextRun::new("", CharStyle::default()));
|
||||
t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None });
|
||||
let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone()))));
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 1, "a copy is found by its content");
|
||||
t.first_para_continues = true;
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 2, "a first paragraph that continues a thread");
|
||||
if let Some(inline) = &mut t.runs[1].inline {
|
||||
inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0));
|
||||
}
|
||||
look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured);
|
||||
assert_eq!(measured, 3, "an inline graphic's bounds");
|
||||
}
|
||||
|
||||
/// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled
|
||||
/// with the frame, as the group moved into the frame does; deeply nested groups are measured in
|
||||
/// one walk each.
|
||||
#[test]
|
||||
fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() {
|
||||
let text = type_node(1, "Group");
|
||||
let mut group = Node::group(NodeId(2), vec![text.clone()]);
|
||||
group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into();
|
||||
let group = Arc::new(group);
|
||||
let to = Affine::scale(2.0);
|
||||
let mut moved = (*group).clone();
|
||||
moved.transform(to, true);
|
||||
let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to)));
|
||||
assert!(want.is_some());
|
||||
assert_eq!(content_box(&group, true, to), want);
|
||||
let mut nested = group;
|
||||
for i in 0..300 {
|
||||
nested = Arc::new(Node::group(NodeId(10 + i), vec![nested]));
|
||||
}
|
||||
assert!(content_box(&nested, false, Affine::IDENTITY).is_some());
|
||||
}
|
||||
|
||||
/// A table that fills up is emptied instead of growing, and what is looked up after that is
|
||||
/// measured again.
|
||||
#[test]
|
||||
fn the_tables_stay_bounded() {
|
||||
let mut kept = TypeInks::default();
|
||||
let mut measured = 0;
|
||||
let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect();
|
||||
for n in &nodes {
|
||||
look_up(&mut kept, n, Affine::IDENTITY, &mut measured);
|
||||
}
|
||||
assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len());
|
||||
assert_eq!(measured, TYPE_INK_KEPT + 10);
|
||||
}
|
||||
}
|
||||
|
||||
+262
-7
@@ -73,13 +73,16 @@ const SIDE: usize = 65;
|
||||
|
||||
/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to
|
||||
/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding
|
||||
/// box corners.
|
||||
/// box corners. [`Coons::from_top_object`] samples its sides at the places `knots` gives instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Coons {
|
||||
top: Vec<Point>,
|
||||
right: Vec<Point>,
|
||||
bottom: Vec<Point>,
|
||||
left: Vec<Point>,
|
||||
/// Where along its side (0 to 1, rising) each point of the top, right, bottom and left side
|
||||
/// lies; `None` when the points are evenly spaced.
|
||||
knots: Option<Box<[Vec<f64>; 4]>>,
|
||||
}
|
||||
|
||||
impl Coons {
|
||||
@@ -87,9 +90,11 @@ impl Coons {
|
||||
pub fn rect(r: Rect) -> Self {
|
||||
let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::<Vec<_>>();
|
||||
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
|
||||
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) }
|
||||
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl), knots: None }
|
||||
}
|
||||
|
||||
/// The patch of `path`'s outline, each side measured by its length: equal steps in `u` or `v`
|
||||
/// cover equal lengths of the side.
|
||||
pub fn from_path(path: &PathData) -> Option<Self> {
|
||||
// The largest subpath is the outline.
|
||||
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
|
||||
@@ -136,7 +141,145 @@ impl Coons {
|
||||
bottom.reverse();
|
||||
let mut left = resample(&arc(bl, tl), SIDE);
|
||||
left.reverse();
|
||||
Some(Self { top, right, bottom, left })
|
||||
Some(Self { top, right, bottom, left, knots: None })
|
||||
}
|
||||
|
||||
/// The patch of a top object's outline (the largest subpath of `path`), split into four sides
|
||||
/// at its corners. Each piece of a side between two anchors or corners takes a share of `u` or
|
||||
/// `v` in proportion to the length of its control polygon, spread along the piece by its own
|
||||
/// curve parameter. A segment without handles counts as a cubic with its handles a third of
|
||||
/// the way along, so the content spreads evenly along it. `corners` are the anchors (indices
|
||||
/// into the outline, in its own order) at the top left, top right, bottom right and bottom
|
||||
/// left, as a saved file gives them. They stay on those anchors when they run the way the
|
||||
/// outline does or the opposite way, so a reflected outline keeps them; without them, or when
|
||||
/// they don't fit the outline, each corner is found from the outline ([`top_object_corner`]).
|
||||
/// A straight segment closes an open subpath, and segments of no length are left out. A top
|
||||
/// object's envelope maps onto this patch.
|
||||
pub fn from_top_object(path: &PathData, corners: Option<[u32; 4]>) -> Option<Self> {
|
||||
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
|
||||
let mut sp = sp.clone();
|
||||
let count = sp.anchors.len();
|
||||
// Stored corners count only when they are distinct anchors met in order around the outline.
|
||||
let fits = |c: &[usize; 4]| {
|
||||
if !c.iter().all(|i| *i < count) {
|
||||
return false;
|
||||
}
|
||||
let gaps: Vec<usize> = (0..4).map(|k| (c[(k + 1) % 4] + count - c[k]) % count).collect();
|
||||
gaps.iter().all(|g| *g > 0) && gaps.iter().sum::<usize>() == count
|
||||
};
|
||||
let given = corners.map(|c| c.map(|i| i as usize));
|
||||
// Stored corners met in the outline's own order keep it as drawn, even counter-clockwise
|
||||
// (a reflected outline). Otherwise orient it clockwise in y-down (TL → TR → BR → BL).
|
||||
let mut corners = given.filter(fits);
|
||||
let reversed = corners.is_none() && sp.signed_area() < 0.0;
|
||||
if reversed {
|
||||
sp.reverse();
|
||||
corners = given.map(|c| c.map(|i| count.saturating_sub(1).saturating_sub(i))).filter(fits);
|
||||
}
|
||||
// Each segment, and whether it is straight: a straight one's parameter runs evenly along it.
|
||||
let line = |a: Point, b: Point| (CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b), true);
|
||||
let mut segs: Vec<(usize, (CubicBez, bool))> = (0..sp.segment_count())
|
||||
.map(|i| {
|
||||
let c = sp.segment(i);
|
||||
(i, if sp.segment_is_line(i) { line(c.p0, c.p3) } else { (c, false) })
|
||||
})
|
||||
.collect();
|
||||
if !sp.closed
|
||||
&& let (Some(a), Some(b)) = (sp.anchors.last(), sp.anchors.first())
|
||||
{
|
||||
segs.push((count.saturating_sub(1), line(a.p, b.p)));
|
||||
}
|
||||
segs.retain(|(_, (c, _))| [c.p1, c.p2, c.p3].iter().any(|p| p.distance(c.p0) > 1e-9));
|
||||
// The kept segment that anchor `k` starts (a segment of no length starts where the next begins).
|
||||
let starts: Vec<usize> = segs.iter().map(|(i, _)| *i).collect();
|
||||
// Where each kept segment and the anchor it starts at come in the outline as drawn: a tie
|
||||
// between corners goes to the one met first there.
|
||||
let order: Vec<(usize, usize)> = starts
|
||||
.iter()
|
||||
.map(|&i| if reversed { (count.saturating_sub(1).saturating_sub(i), (2 * count).saturating_sub(2 + i) % count.max(1)) } else { (i, i) })
|
||||
.collect();
|
||||
let segs: Vec<(CubicBez, bool)> = segs.into_iter().map(|(_, s)| s).collect();
|
||||
let r = segs.iter().map(|(c, _)| c.bounding_box()).reduce(|a, b| a.union(b)).or_else(|| path.bounds())?;
|
||||
// An outline of fewer than three points (a straight line there and back) maps onto its
|
||||
// bounding box, as in `from_path`.
|
||||
if segs.len() < 2 || (segs.len() == 2 && segs.iter().all(|(_, straight)| *straight)) {
|
||||
return Some(Self::rect(r));
|
||||
}
|
||||
let n = segs.len() as f64;
|
||||
let at = |s: f64| {
|
||||
let s = s.rem_euclid(n);
|
||||
let i = (s.floor() as usize).min(segs.len() - 1);
|
||||
segs.get(i).map_or(Point::ZERO, |(c, _)| c.eval((s - i as f64).clamp(0.0, 1.0)))
|
||||
};
|
||||
// The side from `a` to `b`: its points, and where along it (0 to 1) each lies. It runs
|
||||
// through pieces of segments, split at `a` and `b`. Each piece takes its share of the side
|
||||
// by the length of its control polygon and holds points at even steps of its parameter
|
||||
// (at least 16, and at least `SIDE` points on the side), its ends included. A side of
|
||||
// many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` points,
|
||||
// or one for each piece when it has more pieces than that.
|
||||
let side = |a: f64, b: f64| {
|
||||
let span = (b - a).rem_euclid(n);
|
||||
if !(span.is_finite() && span > 0.0) {
|
||||
return (vec![at(a), at(a)], vec![0.0, 1.0]);
|
||||
}
|
||||
let mut pieces: Vec<CubicBez> = vec![];
|
||||
let (mut s, end) = (a, a + span);
|
||||
while s < end - 1e-12 && pieces.len() <= segs.len() {
|
||||
let i = s.floor();
|
||||
let next = (i + 1.0).min(end);
|
||||
if let Some((c, _)) = segs.get((i as usize) % segs.len()) {
|
||||
pieces.push(c.subsegment((s - i).clamp(0.0, 1.0)..(next - i).clamp(0.0, 1.0)));
|
||||
}
|
||||
s = next;
|
||||
}
|
||||
let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3);
|
||||
let mut weights: Vec<f64> = pieces.iter().map(polygon).collect();
|
||||
let mut total: f64 = weights.iter().sum();
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
weights.iter_mut().for_each(|w| *w = 1.0);
|
||||
total = weights.len().max(1) as f64;
|
||||
}
|
||||
let count = pieces.len().max(1);
|
||||
let per = 16usize.max((SIDE - 1).div_ceil(count));
|
||||
let per = if count * per < MAX_SIDE_POINTS { per } else { (MAX_SIDE_POINTS / count).max(1) };
|
||||
let mut pts = vec![pieces.first().map_or_else(|| at(a), |c| c.p0)];
|
||||
let mut knots = vec![0.0];
|
||||
let mut before = 0.0;
|
||||
for (c, w) in pieces.iter().zip(&weights) {
|
||||
for k in 1..=per {
|
||||
let f = k as f64 / per as f64;
|
||||
pts.push(c.eval(f));
|
||||
knots.push((before + w * f) / total);
|
||||
}
|
||||
before += w;
|
||||
}
|
||||
if let Some(last) = knots.last_mut() {
|
||||
*last = 1.0;
|
||||
}
|
||||
(pts, knots)
|
||||
};
|
||||
let (tl, tr, br, bl) = match corners {
|
||||
// Anchor `k` sits at the start of the first kept segment from it on.
|
||||
Some(c) => {
|
||||
let place = |k: usize| starts.iter().position(|i| *i >= k).unwrap_or(0) as f64;
|
||||
(place(c[0]), place(c[1]), place(c[2]), place(c[3]))
|
||||
}
|
||||
None => {
|
||||
let corner = |q: Point| top_object_corner(&segs, &order, r, q);
|
||||
(corner(Point::new(r.x0, r.y0)), corner(Point::new(r.x1, r.y0)), corner(Point::new(r.x1, r.y1)), corner(Point::new(r.x0, r.y1)))
|
||||
}
|
||||
};
|
||||
let flip = |(mut p, mut u): (Vec<Point>, Vec<f64>)| {
|
||||
p.reverse();
|
||||
u.reverse();
|
||||
u.iter_mut().for_each(|x| *x = 1.0 - *x);
|
||||
(p, u)
|
||||
};
|
||||
let (top, top_u) = side(tl, tr);
|
||||
let (right, right_u) = side(tr, br);
|
||||
let (bottom, bottom_u) = flip(side(br, bl));
|
||||
let (left, left_u) = flip(side(bl, tl));
|
||||
Some(Self { top, right, bottom, left, knots: Some(Box::new([top_u, right_u, bottom_u, left_u])) })
|
||||
}
|
||||
|
||||
/// The point at `u` along a side; past its ends the end segments go on straight (art an
|
||||
@@ -147,10 +290,36 @@ impl Coons {
|
||||
side[i].lerp(side[i + 1], s - i as f64)
|
||||
}
|
||||
|
||||
/// The point at `u` along a side whose points lie at `knots` (rising, 0 to 1); past its ends
|
||||
/// the end segments go on straight, as in [`Self::sample`].
|
||||
fn sample_at(side: &[Point], knots: &[f64], u: f64) -> Point {
|
||||
if side.len() < 2 || side.len() != knots.len() {
|
||||
return side.first().copied().unwrap_or_default();
|
||||
}
|
||||
let i = knots.partition_point(|k| *k <= u).clamp(1, side.len() - 1) - 1;
|
||||
let (k0, k1) = (knots[i], knots[i + 1]);
|
||||
let f = if k1 > k0 { (u - k0) / (k1 - k0) } else { 0.0 };
|
||||
side[i].lerp(side[i + 1], f)
|
||||
}
|
||||
|
||||
/// The point at `u` along side `which` (0 top, 1 right, 2 bottom, 3 left).
|
||||
fn side_at(&self, which: usize, u: f64) -> Point {
|
||||
let side = match which {
|
||||
0 => &self.top,
|
||||
1 => &self.right,
|
||||
2 => &self.bottom,
|
||||
_ => &self.left,
|
||||
};
|
||||
match self.knots.as_deref().and_then(|k| k.get(which)) {
|
||||
Some(knots) => Self::sample_at(side, knots, u),
|
||||
None => Self::sample(side, u),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface point at (u, v) ∈ [0,1]².
|
||||
pub fn eval(&self, u: f64, v: f64) -> Point {
|
||||
let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v));
|
||||
let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]);
|
||||
let (t, b, l, r) = (self.side_at(0, u), self.side_at(2, u), self.side_at(3, v), self.side_at(1, v));
|
||||
let [p00, p10, p11, p01] = self.corners();
|
||||
let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2()
|
||||
- ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2());
|
||||
c.to_point()
|
||||
@@ -158,7 +327,93 @@ impl Coons {
|
||||
|
||||
/// The four corners TL, TR, BR, BL.
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
[self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]]
|
||||
let end = |side: &[Point]| side.last().copied().unwrap_or_default();
|
||||
[self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]]
|
||||
}
|
||||
}
|
||||
|
||||
/// The most points one side of [`Coons::from_top_object`] holds while it adds points between its
|
||||
/// anchors.
|
||||
const MAX_SIDE_POINTS: usize = 1 << 16;
|
||||
|
||||
/// How far into the outline's bounding box, as a share of its width and of its height, a corner
|
||||
/// of a top object looks for an anchor.
|
||||
const CORNER_REACH: f64 = 0.475;
|
||||
|
||||
/// A handle this long (in points) or shorter makes its anchor a corner of a top object.
|
||||
const SHORT_HANDLE: f64 = 0.01;
|
||||
|
||||
/// An anchor whose tangent directions, as unit vectors, differ by less than this in x and in y is
|
||||
/// smooth, and is not a corner of a top object.
|
||||
const SMOOTH_TURN: f64 = 0.01;
|
||||
|
||||
/// A top object's corner this close (in curve parameter) to either end of its segment moves onto
|
||||
/// the anchor there.
|
||||
const CORNER_END_SNAP: f64 = 0.005;
|
||||
|
||||
/// Is the anchor at `a`, reached by handle `h_in` and left by handle `h_out` on an outline that
|
||||
/// runs clockwise on screen, a corner of a top object? A handle of [`SHORT_HANDLE`] or shorter
|
||||
/// makes it one. Otherwise its tangent directions must differ by [`SMOOTH_TURN`] or more and turn
|
||||
/// clockwise, so it points out of the outline. A straight segment's handles here are the ones
|
||||
/// [`Coons::from_top_object`] gives it, a third of the way along, so an anchor between two
|
||||
/// straight segments longer than three times [`SHORT_HANDLE`] is a corner only when it turns out
|
||||
/// of the outline.
|
||||
fn top_object_corner_anchor(h_in: Point, a: Point, h_out: Point) -> bool {
|
||||
let (vin, vout) = (a - h_in, h_out - a);
|
||||
let (len_in, len_out) = (vin.hypot(), vout.hypot());
|
||||
if len_in <= SHORT_HANDLE || len_out <= SHORT_HANDLE {
|
||||
return true;
|
||||
}
|
||||
let (ui, uo) = (vin / len_in, vout / len_out);
|
||||
if (ui.x - uo.x).abs() < SMOOTH_TURN && (ui.y - uo.y).abs() < SMOOTH_TURN {
|
||||
return false;
|
||||
}
|
||||
ui.cross(uo) >= 0.0
|
||||
}
|
||||
|
||||
/// Where along `segs` (segment index + parameter, in [0, n)) the corner of a top object nearest
|
||||
/// corner `q` of its bounding box `r` lies, for an outline that runs clockwise on screen. It is
|
||||
/// the nearest anchor to `q` that is a corner ([`top_object_corner_anchor`]) and lies within
|
||||
/// [`CORNER_REACH`] of the box's width and height from `q`. Without one, it is the nearest point
|
||||
/// of the outline to `q`, moved onto its segment's anchor when within [`CORNER_END_SNAP`] of
|
||||
/// either end. `order` gives where each segment's first anchor and the segment itself come in the
|
||||
/// outline as drawn, which may run the other way; on a tie the one met first there wins.
|
||||
fn top_object_corner(segs: &[(CubicBez, bool)], order: &[(usize, usize)], r: Rect, q: Point) -> f64 {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let n = segs.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let rank = |i: usize| order.get(i).copied().unwrap_or((i, i));
|
||||
let (reach_x, reach_y) = (r.width() * CORNER_REACH, r.height() * CORNER_REACH);
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for i in 0..n {
|
||||
let (Some((prev, _)), Some((cur, _))) = (segs.get((i + n - 1) % n), segs.get(i)) else { continue };
|
||||
let a = cur.p0;
|
||||
if (a.x - q.x).abs() > reach_x || (a.y - q.y).abs() > reach_y || !top_object_corner_anchor(prev.p2, a, cur.p1) {
|
||||
continue;
|
||||
}
|
||||
let (d, k) = (a.distance_squared(q), rank(i).0);
|
||||
if best.is_none_or(|(b, bk, _)| d < b || (d == b && k < bk)) {
|
||||
best = Some((d, k, i));
|
||||
}
|
||||
}
|
||||
if let Some((_, _, i)) = best {
|
||||
return i as f64;
|
||||
}
|
||||
let tol = (r.width() + r.height()).max(1e-6) * 1e-9;
|
||||
let nearest = segs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (c, _))| (c.nearest(q, tol), i))
|
||||
.min_by(|a, b| a.0.distance_sq.total_cmp(&b.0.distance_sq).then(rank(a.1).1.cmp(&rank(b.1).1)));
|
||||
let Some((near, i)) = nearest else { return 0.0 };
|
||||
if near.t > CORNER_END_SNAP && near.t < 1.0 - CORNER_END_SNAP {
|
||||
i as f64 + near.t
|
||||
} else if near.t < 0.5 {
|
||||
i as f64
|
||||
} else {
|
||||
((i + 1) % n) as f64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,7 +1288,7 @@ pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
|
||||
EnvelopeKind::TopObject { path, corners } => match Coons::from_top_object(path, *corners) {
|
||||
Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)),
|
||||
None => Box::new(flat),
|
||||
},
|
||||
|
||||
@@ -27,6 +27,10 @@ pub struct ObjectAttributes {
|
||||
/// the element's event attributes; nothing in VectorCraft runs them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub events: Vec<(String, String)>,
|
||||
/// The CSS classes the object had in an imported SVG, written back to `class` on export
|
||||
/// (beside the generated `cls-N` classes).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub classes: Vec<String>,
|
||||
/// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG
|
||||
/// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives
|
||||
/// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's,
|
||||
|
||||
@@ -66,6 +66,12 @@ pub struct NodeExtras {
|
||||
/// 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>,
|
||||
/// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents
|
||||
/// began ([`crate::live::EnvelopeOptions::bounds_margin`]).
|
||||
pub envelope_edit_margin: Option<[f32; 4]>,
|
||||
/// Transform panel › Align to Pixel Grid: the path's or compound path's horizontal and vertical
|
||||
/// segments stay on the pixel grid when it is transformed.
|
||||
pub pixel_aligned: bool,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -91,6 +97,8 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
compound_shape: false,
|
||||
shape_mode: crate::ShapeMode::Add,
|
||||
symbol_overrides: Vec::new(),
|
||||
envelope_edit_margin: None,
|
||||
pixel_aligned: false,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -212,7 +212,10 @@ impl Node {
|
||||
/// [`Node::reach_bounds`] with symbol instances measured by their symbol's art box from
|
||||
/// `symbols`. An instance that only moves its art is measured as the art's visual box moved,
|
||||
/// and any other instance is measured as its geometric box ([`Node::geometric_bounds_with`])
|
||||
/// grown by the farthest a stroke in the art can reach.
|
||||
/// grown by the farthest a stroke in the art can reach. For an instance placed through 9-slice
|
||||
/// guides, that box also holds the art box placed by each cell's map
|
||||
/// ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an instance of a
|
||||
/// symbol with 9-slice guides at any depth is measured as its art placed by its transform.
|
||||
pub fn reach_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
|
||||
symbols.quickly(|| self.reach(Some(symbols)))
|
||||
}
|
||||
@@ -297,7 +300,7 @@ impl Node {
|
||||
self.projected(Some(xf.transform_rect_bbox(art)))
|
||||
}
|
||||
NodeKind::Blend { children, spec } => vectorcraft_geom::union_opt(union(children), spec.spine.as_ref().and_then(|s| s.bounds())),
|
||||
NodeKind::Envelope { content, kind, frame, .. } => crate::live::envelope_bounds(content, kind, **frame),
|
||||
NodeKind::Envelope { content, kind, frame, options, .. } => crate::live::envelope_bounds(content, kind, **frame, *options),
|
||||
NodeKind::Mesh(m) => m.bounds(),
|
||||
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
|
||||
// copies out by the source's bounds measured without the document.
|
||||
@@ -329,6 +332,14 @@ impl Node {
|
||||
self.visual(Some(symbols))
|
||||
}
|
||||
|
||||
/// The visual box of a group or layer without a clipping path, from the visual boxes of its
|
||||
/// [`Node::editing_children`]: for a walk that measures the members as well.
|
||||
pub fn visual_of_members(&self, members: impl Iterator<Item = Option<Rect>>) -> Option<Rect> {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
members.fold(None, vectorcraft_geom::union_opt).map(|b| b.inflate(o, o))
|
||||
}
|
||||
|
||||
/// The box of [`Node::visual_bounds`], or of [`Node::visual_bounds_with`] when `symbols` is
|
||||
/// given.
|
||||
pub(crate) fn visual(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
|
||||
@@ -343,11 +354,7 @@ impl Node {
|
||||
}
|
||||
}
|
||||
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
|
||||
// The container's own strokes paint around its members.
|
||||
let o = self.appearance.outset();
|
||||
self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Layer { .. } | NodeKind::Group { .. } => self.visual_of_members(self.editing_children().iter().map(|c| c.visual(symbols))),
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
|
||||
let o = crate::live::max_outset(children, symbols);
|
||||
@@ -367,7 +374,7 @@ impl Node {
|
||||
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
|
||||
Some((s, art)) => {
|
||||
let placed = xf.transform_rect_bbox(art.geometric);
|
||||
match s.placed_visual(symbol, *xf) {
|
||||
match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) {
|
||||
Some(v) => v.union(placed),
|
||||
// Art placed through 9-slice guides can draw outside `placed`.
|
||||
None => s
|
||||
@@ -441,6 +448,9 @@ impl Node {
|
||||
if self.appearance.gradients().next().is_some() {
|
||||
self.appearance.transform_gradients(a);
|
||||
}
|
||||
if self.appearance.has_svg_filter() {
|
||||
self.appearance.transform_svg_filters(a);
|
||||
}
|
||||
if sc.patterns {
|
||||
crate::pattern::transform_pattern_paints(self, a);
|
||||
}
|
||||
@@ -501,7 +511,7 @@ impl Node {
|
||||
*h = (lin * h.to_point()).to_vec2();
|
||||
}
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||||
EnvelopeKind::TopObject { path, .. } => path.transform(a),
|
||||
EnvelopeKind::Warp { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -603,6 +613,25 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// The paint behind the Fill (or Stroke) proxy, the one [`Node::proxy_paint`] gives, without its
|
||||
/// map and box: the object is not measured, so a long path costs no more than a short one.
|
||||
pub fn proxy_paint_only(&self, stroke: bool, item: Option<usize>) -> Option<&vectorcraft_color::Paint> {
|
||||
let item = self.appearance.item_of_kind(item, !stroke);
|
||||
if item.is_none()
|
||||
&& !stroke
|
||||
&& let Some(c) = self.extras().colorize.as_deref()
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
if item.is_none()
|
||||
&& let NodeKind::Text(t) = &self.kind
|
||||
{
|
||||
let st = &t.runs.first()?.style;
|
||||
return Some(if stroke { &st.stroke } else { &st.fill });
|
||||
}
|
||||
if stroke { self.appearance.stroke_at(item).map(|s| &s.paint) } else { self.appearance.fill_at(item).map(|f| &f.paint) }
|
||||
}
|
||||
|
||||
/// The gradient behind the Fill (or Stroke) proxy and its placement in document coordinates
|
||||
/// (see [`Node::proxy_paint`]): what the gradient annotator shows and edits.
|
||||
pub fn proxy_gradient(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::GradientPaint, vectorcraft_color::GradientGeom)> {
|
||||
|
||||
@@ -21,6 +21,8 @@ fn extras_are_written_flat_in_order() {
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
e.compound_shape = true;
|
||||
e.shape_mode = crate::ShapeMode::Exclude;
|
||||
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
|
||||
e.pixel_aligned = true;
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -65,9 +67,12 @@ fn extras_are_written_flat_in_order() {
|
||||
"live_paint_gaps",
|
||||
"compound_shape",
|
||||
"shape_mode",
|
||||
"envelope_edit_margin",
|
||||
"pixel_aligned",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(v["shape_mode"], "exclude");
|
||||
assert_eq!(v["pixel_aligned"], true);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
|
||||
@@ -443,6 +448,20 @@ fn visual_bounds_include_stroke() {
|
||||
assert_eq!(p.visual_bounds(), Some(Rect::new(-0.5, -0.5, 10.5, 10.5)));
|
||||
}
|
||||
|
||||
/// A group's box from its members' boxes grows by the group's own strokes, leaves out members
|
||||
/// without a box, and is `None` when none has one.
|
||||
#[test]
|
||||
fn visual_of_members_unions_the_members_and_grows_by_the_group_stroke() {
|
||||
let mut g = Node::group(NodeId(1), vec![]);
|
||||
g.appearance = Appearance::default_art().into();
|
||||
let (a, b) = (Rect::new(0.0, 0.0, 10.0, 10.0), Rect::new(20.0, 5.0, 30.0, 8.0));
|
||||
let o = g.appearance.outset();
|
||||
assert!(o > 0.0);
|
||||
assert_eq!(g.visual_of_members([Some(a), None, Some(b)].into_iter()), Some(Rect::new(0.0, 0.0, 30.0, 10.0).inflate(o, o)));
|
||||
assert_eq!(g.visual_of_members([None, None].into_iter()), None);
|
||||
assert_eq!(g.visual_of_members([].into_iter()), None);
|
||||
}
|
||||
|
||||
/// Type's visual bounds grow by how far its character strokes paint past the glyphs: half the
|
||||
/// weight centred, the whole weight outside, nothing inside; scaled with the type.
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,8 @@ impl Serialize for Node {
|
||||
is_default(&e.compound_shape),
|
||||
is_default(&e.shape_mode),
|
||||
e.symbol_overrides.is_empty(),
|
||||
e.envelope_edit_margin.is_none(),
|
||||
is_default(&e.pixel_aligned),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -94,6 +96,8 @@ impl Serialize for Node {
|
||||
field!("compound_shape", &e.compound_shape);
|
||||
field!("shape_mode", &e.shape_mode);
|
||||
field!("symbol_overrides", &e.symbol_overrides);
|
||||
field!("envelope_edit_margin", &e.envelope_edit_margin);
|
||||
field!("pixel_aligned", &e.pixel_aligned);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -232,6 +236,10 @@ pub(super) struct NodeWire {
|
||||
shape_mode: crate::ShapeMode,
|
||||
#[serde(default, deserialize_with = "counted_list")]
|
||||
symbol_overrides: Vec<crate::SymbolOverride>,
|
||||
#[serde(default)]
|
||||
envelope_edit_margin: Option<[f32; 4]>,
|
||||
#[serde(default)]
|
||||
pixel_aligned: bool,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -258,6 +266,8 @@ impl From<NodeWire> for Node {
|
||||
compound_shape: w.compound_shape,
|
||||
shape_mode: w.shape_mode,
|
||||
symbol_overrides: w.symbol_overrides,
|
||||
envelope_edit_margin: w.envelope_edit_margin,
|
||||
pixel_aligned: w.pixel_aligned,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
@@ -125,9 +125,19 @@ impl Document {
|
||||
if angle == 0.0 {
|
||||
return self.bounds_of(ids, visual).map(OrientedBox::aligned);
|
||||
}
|
||||
self.oriented_bounds_with(ids, visual, &self.symbol_boxes())
|
||||
}
|
||||
|
||||
/// [`oriented_bounds`](Document::oriented_bounds) measuring symbol instances from `symbols`,
|
||||
/// a cache shared across calls: measuring many instances one by one otherwise measures their
|
||||
/// art again for each.
|
||||
pub fn oriented_bounds_with(&self, ids: &[NodeId], visual: bool, symbols: &crate::SymbolBoxes) -> Option<OrientedBox> {
|
||||
let angle = self.bbox_angle(ids);
|
||||
if angle == 0.0 {
|
||||
return self.bounds_of_with(ids, visual, symbols).map(OrientedBox::aligned);
|
||||
}
|
||||
let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse();
|
||||
let symbols = self.symbol_boxes();
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(&symbols, to_local, visual)))?;
|
||||
let rect = ids.iter().filter_map(|id| self.node(*id)).fold(None, |acc, n| union_opt(acc, n.bounds_in(symbols, to_local, visual)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
}
|
||||
|
||||
+53
-9
@@ -2,7 +2,7 @@
|
||||
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
|
||||
//! up to the miter limit, projecting caps and arrowheads.
|
||||
|
||||
use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit};
|
||||
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
|
||||
@@ -16,12 +16,7 @@ const ARCLEN_ACCURACY: f64 = 1e-3;
|
||||
/// The segments of a subpath that have length (zero-length ones, all of whose points coincide,
|
||||
/// have no direction).
|
||||
fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec<PathSeg> {
|
||||
let moves = |s: &PathSeg| match s {
|
||||
PathSeg::Line(l) => l.p1 != l.p0,
|
||||
PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0,
|
||||
PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0,
|
||||
};
|
||||
segments(els).into_iter().filter(moves).collect()
|
||||
segments(els).into_iter().filter(has_length).collect()
|
||||
}
|
||||
|
||||
/// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end.
|
||||
@@ -166,6 +161,47 @@ impl StrokeLayer {
|
||||
out.union(bounds.inflate(side_max, side_max))
|
||||
}
|
||||
|
||||
/// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a
|
||||
/// subpath of its path (`closed`: a closed one), both with length ([`has_length`]), or `None`
|
||||
/// when the join is not a miter, the path does not turn there or the miter limit bevels it.
|
||||
pub fn miter_tip(&self, closed: bool, a: &PathSeg, b: &PathSeg) -> Option<Point> {
|
||||
let side = self.side_reach(closed);
|
||||
if self.join != LineJoin::Miter || side <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
miter(a.end(), tangent(a, 1.0), tangent(b, 0.0), side, self.miter_limit).map(|[_, tip, _]| tip)
|
||||
}
|
||||
|
||||
/// [`Self::bounds_on`] with the miter spikes' tips taken from `tips`, the box of the tips of
|
||||
/// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured
|
||||
/// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does.
|
||||
pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>) -> Rect {
|
||||
let els = bp.elements();
|
||||
let mut out = tips.map_or(bounds, |t| bounds.union(t));
|
||||
let mut side_max: f64 = 0.0;
|
||||
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
|
||||
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
|
||||
for (r, closed) in subpaths(bp) {
|
||||
let side = self.side_reach(closed);
|
||||
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
|
||||
if side <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if self.cap == LineCap::Square && !closed {
|
||||
let segs = drawn(&els[r]);
|
||||
for (at, dir) in ends(&segs).into_iter().flatten() {
|
||||
let n = Vec2::new(-dir.y, dir.x) * side;
|
||||
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (at, _, end) in head_ends(self, bp) {
|
||||
let r = self.head_reach(end);
|
||||
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
|
||||
}
|
||||
out.union(bounds.inflate(side_max, side_max))
|
||||
}
|
||||
|
||||
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
|
||||
/// outside strokes of closed paths count on their own side only (and within `tol` of the
|
||||
/// path); a width profile counts at its width where `p` is nearest.
|
||||
@@ -271,13 +307,21 @@ impl Appearance {
|
||||
pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect {
|
||||
let mut path = Some(bp);
|
||||
let mut shape = None;
|
||||
self.stroked_bounds_by(bounds, |s| {
|
||||
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
|
||||
s.bounds_on(bp, bounds)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
|
||||
/// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`.
|
||||
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
|
||||
self.painted_strokes().fold(bounds, |acc, s| {
|
||||
if !s.needs_shape() {
|
||||
let o = s.side_reach(false);
|
||||
return acc.union(bounds.inflate(o, o));
|
||||
}
|
||||
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
|
||||
acc.union(s.bounds_on(bp, bounds))
|
||||
acc.union(shaped(s))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,23 @@ impl SavedSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG filter the document holds (Effect › SVG Filters): a `<filter>` element named by its
|
||||
/// `id`, listed in the SVG Filters submenu and in Apply SVG Filter.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SvgFilter {
|
||||
/// The filter element's `id`.
|
||||
pub name: String,
|
||||
/// The `<filter>` element's XML, as it was written.
|
||||
pub xml: String,
|
||||
}
|
||||
|
||||
impl SvgFilter {
|
||||
/// Most SVG filters a document holds.
|
||||
pub const MAX: usize = 64;
|
||||
/// Longest filter element, in bytes.
|
||||
pub const MAX_XML: usize = 64 * 1024;
|
||||
}
|
||||
|
||||
/// A symbol definition.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Symbol {
|
||||
|
||||
@@ -54,6 +54,20 @@ pub struct Selection {
|
||||
/// the engine then sets the document's from it. Editing state, never saved.
|
||||
#[serde(skip)]
|
||||
pub new_art: Arc<HashSet<NodeId>>,
|
||||
/// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance
|
||||
/// is the one selected object, and solid fill and stroke changes go to its own appearance for
|
||||
/// that object ([`crate::symbol_overrides`]). Any other selection change clears it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub symbol_part: Option<SymbolPart>,
|
||||
}
|
||||
|
||||
/// See [`Selection::symbol_part`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SymbolPart {
|
||||
/// The symbol instance.
|
||||
pub instance: NodeId,
|
||||
/// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it.
|
||||
pub path: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
@@ -79,6 +93,13 @@ impl Selection {
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.key_guide = None;
|
||||
self.symbol_part = None;
|
||||
}
|
||||
/// Select only instance `instance`, with the object at `path` of its symbol's art (see
|
||||
/// [`Selection::symbol_part`]).
|
||||
pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec<u32>) {
|
||||
self.set([instance]);
|
||||
self.symbol_part = Some(SymbolPart { instance, path });
|
||||
}
|
||||
/// Is there a key for Align: an object or a guide?
|
||||
pub fn has_key(&self) -> bool {
|
||||
@@ -93,6 +114,7 @@ impl Selection {
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.symbol_part = None;
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -100,6 +122,7 @@ impl Selection {
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.symbol_part = None;
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
self.segments.remove(&id);
|
||||
@@ -128,6 +151,7 @@ impl Selection {
|
||||
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
|
||||
/// Shift-click on a guide).
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
self.symbol_part = None;
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
if self.key_guide == Some(i) {
|
||||
@@ -207,6 +231,27 @@ impl Selection {
|
||||
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
|
||||
self.key_guide = None;
|
||||
}
|
||||
// A selected part needs its instance selected alone, with no guides, its symbol dynamic
|
||||
// and the object still in the symbol's art as a path, compound path or type, where neither
|
||||
// it nor a group or layer around it is hidden, locked or a template.
|
||||
let editable_part = |art: &crate::Node, path: &[u32]| {
|
||||
crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint)
|
||||
&& (0..=path.len())
|
||||
.all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template()))
|
||||
};
|
||||
let part_kept = self.symbol_part.as_ref().is_some_and(|sp| {
|
||||
self.objects == [sp.instance]
|
||||
&& self.guides.is_empty()
|
||||
&& found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path))
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
if !part_kept {
|
||||
self.symbol_part = None;
|
||||
}
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
@@ -238,6 +283,9 @@ impl Selection {
|
||||
}
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) {
|
||||
self.symbol_part = None;
|
||||
}
|
||||
self.objects.retain(|o| !gone.contains(o));
|
||||
self.anchors.retain(|id, _| !gone.contains(id));
|
||||
self.segments.retain(|id, _| !gone.contains(id));
|
||||
|
||||
@@ -41,6 +41,82 @@ fn guides_are_selected_on_their_own() {
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
/// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers
|
||||
/// around it in the symbol's art are visible, unlocked and not a template. An art edit that hides
|
||||
/// or locks it, or one around it, or makes a layer around it a template, drops the pick.
|
||||
#[test]
|
||||
fn a_picked_symbol_part_is_dropped_once_it_cannot_be_edited() {
|
||||
let rect = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let leaf = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art());
|
||||
let group = Node::group(d.alloc_id(), vec![Arc::new(leaf)]);
|
||||
let layer = Node::layer(d.alloc_id(), "Inner", crate::LayerColor::Preset(2));
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(group), Arc::new(layer)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Parts".into(), art: Arc::new(root.clone()) });
|
||||
let inst = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Parts".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
// The layer in the art holds a path too.
|
||||
let in_layer = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art());
|
||||
let mut art = root;
|
||||
if let Some(NodeKind::Layer { children, .. }) = art.children_mut().and_then(|c| c.get_mut(1)).map(|c| &mut Arc::make_mut(c).kind) {
|
||||
children.push(Arc::new(in_layer));
|
||||
}
|
||||
d.symbols[0].art = Arc::new(art.clone());
|
||||
let picked = |d: &Document, path: &[u32]| {
|
||||
let mut s = Selection::default();
|
||||
s.set_symbol_part(inst, path.to_vec());
|
||||
s.prune(d);
|
||||
s.symbol_part.is_some()
|
||||
};
|
||||
assert!(picked(&d, &[0, 0]) && picked(&d, &[1, 0]), "editable parts stay picked");
|
||||
assert!(!picked(&d, &[0]) && !picked(&d, &[1]) && !picked(&d, &[]), "a group or layer does not stay picked");
|
||||
let edited = |change: &dyn Fn(&mut Node)| {
|
||||
let mut d = d.clone();
|
||||
let mut art = art.clone();
|
||||
change(&mut art);
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
d
|
||||
};
|
||||
let child = |art: &mut Node, path: &[usize]| -> Node {
|
||||
let mut n = &*art;
|
||||
for &i in path {
|
||||
n = &n.children().unwrap()[i];
|
||||
}
|
||||
n.clone()
|
||||
};
|
||||
let set = |art: &mut Node, path: &[usize], n: Node| {
|
||||
let mut at = art;
|
||||
for &i in path {
|
||||
at = Arc::make_mut(&mut at.children_mut().unwrap()[i]);
|
||||
}
|
||||
*at = n;
|
||||
};
|
||||
let hidden = edited(&|art| {
|
||||
let mut n = child(art, &[0, 0]);
|
||||
n.visible = false;
|
||||
set(art, &[0, 0], n);
|
||||
});
|
||||
assert!(!picked(&hidden, &[0, 0]), "the part is hidden");
|
||||
let locked_group = edited(&|art| {
|
||||
let mut n = child(art, &[0]);
|
||||
n.locked = true;
|
||||
set(art, &[0], n);
|
||||
});
|
||||
assert!(!picked(&locked_group, &[0, 0]), "the group around it is locked");
|
||||
assert!(picked(&locked_group, &[1, 0]), "a part outside the locked group stays");
|
||||
let template = edited(&|art| {
|
||||
let mut n = child(art, &[1]);
|
||||
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
||||
*template = true;
|
||||
}
|
||||
set(art, &[1], n);
|
||||
});
|
||||
assert!(!picked(&template, &[1, 0]), "the layer around it is a template");
|
||||
let hidden_root = edited(&|art| art.visible = false);
|
||||
assert!(!picked(&hidden_root, &[0, 0]), "the art is hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -120,6 +120,21 @@ impl Quotes {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
|
||||
}
|
||||
|
||||
/// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else.
|
||||
/// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with
|
||||
/// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening
|
||||
/// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single
|
||||
/// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian
|
||||
/// wants, and `’90s` keeps its apostrophe.
|
||||
pub fn typed_as_straight(c: char) -> Option<char> {
|
||||
(c == '”').then_some('"')
|
||||
}
|
||||
|
||||
/// Does a quote typed after `prev` (the character before it, `None` at the start) open?
|
||||
pub fn opens_after(&self, prev: Option<char>) -> bool {
|
||||
self.opens(prev, None)
|
||||
}
|
||||
|
||||
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
|
||||
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
|
||||
/// and is updated to its last character, so text can be converted in pieces.
|
||||
@@ -164,6 +179,9 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
|
||||
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
|
||||
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
|
||||
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
|
||||
// „…“ at either level: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/bg.xml,
|
||||
// `delimiters`). Shaped as `bg`, fonts draw their Bulgarian forms (`locl` for `BGR`, #5945).
|
||||
("Bulgarian", "bg", 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`).
|
||||
@@ -185,6 +203,8 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
|
||||
("Kannada", "kn", Quotes::ENGLISH),
|
||||
("Malayalam", "ml", Quotes::ENGLISH),
|
||||
("Odia", "or", Quotes::ENGLISH),
|
||||
("Assamese", "as", Quotes::ENGLISH),
|
||||
("Sanskrit", "sa", Quotes::ENGLISH),
|
||||
("Urdu", "ur", Quotes::ENGLISH),
|
||||
];
|
||||
|
||||
@@ -374,6 +394,8 @@ mod tests {
|
||||
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("Assamese"), Ok(Some("as")));
|
||||
assert_eq!(char_language("Sanskrit"), Ok(Some("sa")));
|
||||
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());
|
||||
@@ -385,6 +407,10 @@ mod tests {
|
||||
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")));
|
||||
// Bulgarian (#5945), by name or tag.
|
||||
assert_eq!(char_language("Bulgarian"), Ok(Some("bg")));
|
||||
assert_eq!(char_language("BG"), Ok(Some("bg")));
|
||||
assert_eq!(char_language_name(Some("bg")), Some("Bulgarian"));
|
||||
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() {
|
||||
@@ -409,6 +435,15 @@ mod tests {
|
||||
}
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
|
||||
// Only a typed `”` stands for a straight quote; the other curly quotes stay as typed.
|
||||
assert_eq!(Quotes::typed_as_straight('”'), Some('"'));
|
||||
assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none()));
|
||||
// A quote opens at the start and after a space, and closes after a letter.
|
||||
assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' ')));
|
||||
assert!(!Quotes::ENGLISH.opens_after(Some('a')));
|
||||
// Bulgarian: „…“ at either level, as CLDR has it.
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("bulgarian").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);
|
||||
|
||||
+127
-3
@@ -7,6 +7,7 @@
|
||||
//! those links (and colours) wherever art and swatches hold them, copying only the nodes that
|
||||
//! change.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::swatch::REGISTRATION;
|
||||
@@ -85,6 +86,12 @@ impl Document {
|
||||
fn numbered_swatch_name(&self, base: &str, from: usize) -> String {
|
||||
(from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
/// Names for several new swatches: [`FreeSwatchNames::take`] gives what
|
||||
/// [`Document::free_swatch_name`] gives once the swatches named before it are added.
|
||||
pub fn free_swatch_names(&self) -> FreeSwatchNames {
|
||||
let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone()));
|
||||
FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() }
|
||||
}
|
||||
/// The name a new swatch of `paint` gets by default, unused in the document: a colour's values
|
||||
/// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1",
|
||||
/// "New Pattern Swatch 1"…
|
||||
@@ -169,7 +176,7 @@ impl Document {
|
||||
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
|
||||
let mut tree = |n: &Node| {
|
||||
walk_with_masks(n, &mut |m| {
|
||||
node_paints(m).for_each(&mut *f);
|
||||
node_paints(m).chain(override_paints(m)).for_each(&mut *f);
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
|
||||
}
|
||||
@@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names
|
||||
/// handed out since, for naming many new swatches without searching the document for each name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FreeSwatchNames {
|
||||
taken: HashSet<String>,
|
||||
/// For each base and first number already numbered, the number its next numbered name is
|
||||
/// looked for from: names are only ever added, so the numbers below it stay taken.
|
||||
next: HashMap<(String, usize), usize>,
|
||||
}
|
||||
|
||||
impl FreeSwatchNames {
|
||||
/// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`]
|
||||
/// gives it; the name is taken from then on.
|
||||
pub fn take(&mut self, base: &str) -> String {
|
||||
let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() };
|
||||
self.taken.insert(name.clone());
|
||||
name
|
||||
}
|
||||
/// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken
|
||||
/// from then on.
|
||||
pub fn take_new(&mut self, paint: &Paint) -> String {
|
||||
let base = match paint {
|
||||
Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))),
|
||||
Paint::Solid { color, .. } => return self.take(&color_name(*color)),
|
||||
Paint::Gradient(_) => "New Gradient Swatch",
|
||||
_ => "New Pattern Swatch",
|
||||
};
|
||||
let name = self.numbered(base, 1);
|
||||
self.taken.insert(name.clone());
|
||||
name
|
||||
}
|
||||
/// Count `name` as taken (a swatch or color group added another way).
|
||||
pub fn insert(&mut self, name: &str) {
|
||||
self.taken.insert(name.to_string());
|
||||
}
|
||||
/// The first of "base `from`", "base `from + 1`"… not taken.
|
||||
fn numbered(&mut self, base: &str, from: usize) -> String {
|
||||
let key = (base.to_string(), from);
|
||||
let start = self.next.get(&key).copied().unwrap_or(from);
|
||||
let (i, name) =
|
||||
(start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string()));
|
||||
self.next.insert(key, i + 1);
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`,
|
||||
/// true when it changed them.
|
||||
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
|
||||
@@ -222,10 +275,11 @@ fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key:
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// and gradient stops (with their link), a dynamic symbol instance's own paints
|
||||
/// ([`override_paints`]), then mesh points (unlinked), in paint order.
|
||||
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
|
||||
n.walk(&mut |m| {
|
||||
for p in node_paints(m) {
|
||||
for p in node_paints(m).chain(override_paints(m)) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())),
|
||||
@@ -262,6 +316,12 @@ pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
|
||||
}
|
||||
|
||||
/// The paints a dynamic symbol instance keeps for objects of its art
|
||||
/// ([`crate::symbol_overrides`]), which [`node_paints`] leaves out.
|
||||
pub fn override_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten()
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
let runs = match &mut n.kind {
|
||||
@@ -300,6 +360,20 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
}
|
||||
out = Some(m);
|
||||
}
|
||||
// A dynamic symbol instance's own paints for objects of its art (`symbol_overrides`).
|
||||
if !n.extras().symbol_overrides.is_empty() {
|
||||
let mut overrides = n.extras().symbol_overrides.clone();
|
||||
let mut k = 0;
|
||||
for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() {
|
||||
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
if k > 0 {
|
||||
out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides;
|
||||
count += k;
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
for (i, c) in ch.iter().enumerate() {
|
||||
if let Some((new, k)) = map_node(c, f) {
|
||||
@@ -419,6 +493,56 @@ mod tests {
|
||||
assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_swatch_names_match_naming_each_swatch_as_it_is_added() {
|
||||
let (mut d, _, _) = doc();
|
||||
let mut names = d.free_swatch_names();
|
||||
for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] {
|
||||
let want = d.free_swatch_name(base);
|
||||
assert_eq!(names.take(base), want, "{base}");
|
||||
d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false });
|
||||
}
|
||||
let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
added,
|
||||
["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() {
|
||||
let (mut d, _, _) = doc();
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false });
|
||||
let mut names = d.free_swatch_names();
|
||||
// A name taken another way (a group made alongside) counts for both.
|
||||
names.insert("R=255 G=0 B=0 3");
|
||||
d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] });
|
||||
let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 };
|
||||
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default())));
|
||||
let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient];
|
||||
let mut added = vec![];
|
||||
for paint in paints {
|
||||
let want = d.new_swatch_name(&paint);
|
||||
assert_eq!(names.take_new(&paint), want);
|
||||
d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false });
|
||||
added.push(want);
|
||||
}
|
||||
assert_eq!(
|
||||
added,
|
||||
[
|
||||
"R=255 G=0 B=0",
|
||||
"R=255 G=0 B=0 2",
|
||||
"New Gradient Swatch 1",
|
||||
"Brand 40%",
|
||||
"R=255 G=0 B=0 4",
|
||||
"New Gradient Swatch 3",
|
||||
"Brand 40% 2",
|
||||
"New Gradient Swatch 4"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_copies_only_changed_nodes() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -451,6 +451,18 @@ impl NineSliceMap {
|
||||
self.most_at(art, &boxes, 0)
|
||||
}
|
||||
|
||||
/// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one
|
||||
/// piece, with everything in it: the map of the cell its box lies in, or of the cell at the
|
||||
/// origin when it has no box. `None` when its box crosses a guide.
|
||||
pub fn one_cell(&self, n: &Node, doc: &Document) -> Option<Affine> {
|
||||
let boxes = doc.symbol_boxes().quick();
|
||||
let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
|
||||
return Some(self.cell_at(Point::ZERO));
|
||||
};
|
||||
let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1));
|
||||
(cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0]))
|
||||
}
|
||||
|
||||
/// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`.
|
||||
pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 {
|
||||
Self { clips: false, ..*self }.most_placed(art, doc)
|
||||
|
||||
@@ -49,6 +49,10 @@ impl SymbolOverride {
|
||||
r.style.fill = p.clone();
|
||||
}
|
||||
if let Some(p) = &self.stroke {
|
||||
// A painted stroke on characters without a weight gets 1 pt, as a path's does.
|
||||
if !p.is_none() && r.style.stroke_width == 0.0 {
|
||||
r.style.stroke_width = 1.0;
|
||||
}
|
||||
r.style.stroke = p.clone();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +61,14 @@ impl SymbolOverride {
|
||||
n.appearance.set_fill(p.clone());
|
||||
}
|
||||
if let Some(p) = &self.stroke {
|
||||
// A stroke without paint has no weight; painting it gives it 1 pt.
|
||||
if !p.is_none()
|
||||
&& let Some(s) = n.appearance.stroke_mut()
|
||||
&& s.paint.is_none()
|
||||
&& s.width == 0.0
|
||||
{
|
||||
s.width = 1.0;
|
||||
}
|
||||
n.appearance.set_stroke(p.clone());
|
||||
}
|
||||
}
|
||||
@@ -69,6 +81,13 @@ impl SymbolOverride {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can Direct Selection select `n`, an object of a dynamic symbol's art, and change its fill and
|
||||
/// stroke for one instance? Paths, compound paths and type can; groups, images and every other
|
||||
/// object cannot.
|
||||
pub fn takes_part_paint(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))
|
||||
}
|
||||
|
||||
/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file
|
||||
/// of one endless path stays small once read.
|
||||
fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
|
||||
@@ -180,6 +199,27 @@ mod tests {
|
||||
assert_eq!(kept.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_on_type_without_a_weight_gets_one_point() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let style = crate::CharStyle { stroke: Paint::None, stroke_width: 0.0, ..crate::CharStyle::default() };
|
||||
let text = Node::new(NodeId(2), NodeKind::Text(Box::new(crate::TextObject::point(vectorcraft_geom::Point::ZERO, "Hi", style))));
|
||||
let mut a = Node::group(NodeId(1), vec![Arc::new(text)]);
|
||||
apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]);
|
||||
let Some(NodeKind::Text(t)) = part(&a, &[0]).map(|n| &n.kind) else { panic!("type") };
|
||||
assert!(t.runs.iter().all(|r| r.style.stroke == red && r.style.stroke_width == 1.0), "the stroke draws at 1 pt");
|
||||
|
||||
// A path whose stroke has neither paint nor weight gets 1 pt too.
|
||||
let mut ap = Appearance::default_art();
|
||||
if let Some(s) = ap.stroke_mut() {
|
||||
(s.paint, s.width) = (Paint::None, 0.0);
|
||||
}
|
||||
let path = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap);
|
||||
let mut a = Node::group(NodeId(1), vec![Arc::new(path)]);
|
||||
apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]);
|
||||
assert_eq!(part(&a, &[0]).unwrap().appearance.stroke_width(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_deeper_than_the_cap_does_not_read() {
|
||||
let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]);
|
||||
|
||||
+150
-3
@@ -23,6 +23,9 @@ const MAX_DEPTH: usize = 32;
|
||||
/// holding instances: past it, an estimate stands in ([`SymbolBoxes::effects_reach`]).
|
||||
const MAX_REACHES: usize = 4096;
|
||||
|
||||
/// A symbol's name and the bits of the linear part of a transform that places its art.
|
||||
type Placing = (String, [u64; 4]);
|
||||
|
||||
/// A symbol's art measured in symbol space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct ArtBox {
|
||||
@@ -37,6 +40,9 @@ pub(crate) struct ArtBox {
|
||||
/// The widest stroke of the objects with a visible brushed stroke in the art, or in the art
|
||||
/// of a symbol an instance in it shows: `None` when no object there has one.
|
||||
pub(crate) brush: Option<f64>,
|
||||
/// Whether the art holds an instance of a symbol with 9-slice guides, or of a symbol whose art
|
||||
/// holds one at any depth.
|
||||
pub(crate) sliced: bool,
|
||||
}
|
||||
|
||||
/// The art boxes of a document's symbols, which [`Node::geometric_bounds_with`],
|
||||
@@ -73,6 +79,14 @@ pub struct SymbolBoxes<'a> {
|
||||
unplaced: RefCell<HashMap<String, f64>>,
|
||||
/// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]).
|
||||
runs: Cell<usize>,
|
||||
/// The boxes [`Self::sliced_visual`] has measured, by symbol name and the bits of the linear
|
||||
/// part of the transform that placed the art.
|
||||
sliced: RefCell<HashMap<Placing, Option<Rect>>>,
|
||||
/// How many objects of symbol art [`Self::sliced_visual`] has placed, the art of opacity masks
|
||||
/// included.
|
||||
sliced_placed: Cell<u64>,
|
||||
/// The most objects of symbol art [`Self::sliced_visual`] places: [`MAX_SYMBOL_NODES`].
|
||||
sliced_most: u64,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -90,6 +104,9 @@ impl Document {
|
||||
farthest: RefCell::default(),
|
||||
unplaced: RefCell::default(),
|
||||
runs: Cell::new(0),
|
||||
sliced: RefCell::default(),
|
||||
sliced_placed: Cell::new(0),
|
||||
sliced_most: MAX_SYMBOL_NODES,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +121,9 @@ impl<'a> SymbolBoxes<'a> {
|
||||
/// art box alone. An instance that scales or turns stroked art then measures a little larger
|
||||
/// than with [`Node::visual_bounds_with`], and its art is not transformed to measure it. The
|
||||
/// visual box of an instance placed through 9-slice guides holds the art box placed by each
|
||||
/// cell's map ([`crate::NineSliceMap::placement_bounds`]).
|
||||
/// cell's map ([`crate::NineSliceMap::placement_bounds`]). An instance whose art holds an
|
||||
/// instance of a symbol with 9-slice guides at any depth is measured as its art placed by its
|
||||
/// transform ([`Self::sliced_visual`]).
|
||||
pub fn quick(self) -> Self {
|
||||
self.quick.set(true);
|
||||
self
|
||||
@@ -321,13 +340,64 @@ impl<'a> SymbolBoxes<'a> {
|
||||
if self.quick.get() {
|
||||
return None;
|
||||
}
|
||||
self.place_and_measure(name, xf, most)
|
||||
}
|
||||
|
||||
/// [`Self::placed_visual`] in boxes made by [`Self::quick`] and while symbol art is measured
|
||||
/// too, for a symbol whose art holds an instance of a symbol with 9-slice guides at any depth:
|
||||
/// where that instance draws its art depends on the transform of each instance above it. The
|
||||
/// art is placed once for each symbol and linear part of `xf`, and the box is moved by the rest
|
||||
/// of `xf`. `None` for other symbols, when [`Self::placed_visual`] is `None` outside quick
|
||||
/// boxes, and when placing the art would take the objects of the art these boxes have placed
|
||||
/// for it past [`MAX_SYMBOL_NODES`], the art placed to measure the instances in placed art
|
||||
/// included. The renderer draws no more objects of symbol art than that in one frame.
|
||||
pub(crate) fn sliced_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
|
||||
if !self.art(name)?.sliced {
|
||||
return None;
|
||||
}
|
||||
let [a, b, c, d, e, f] = xf.as_coeffs();
|
||||
let (linear, moved) = (Affine::new([a, b, c, d, 0.0, 0.0]), Affine::translate((e, f)));
|
||||
let key = (name.to_string(), [a, b, c, d].map(f64::to_bits));
|
||||
let cached = self.sliced.borrow().get(&key).copied();
|
||||
let v = match cached {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// The most objects placing makes, the art of opacity masks included, is set aside
|
||||
// while the art is placed and measured, and what placing did not make is given back.
|
||||
let art = &self.doc.symbols.iter().find(|s| s.name == name)?.art;
|
||||
let most = match self.doc.symbol_slice_map(name, linear) {
|
||||
Some(m) => m.most_placed(art, self.doc),
|
||||
None => art.count_with_masks() as u64,
|
||||
};
|
||||
let used = self.sliced_placed.get();
|
||||
if used.saturating_add(most) > self.sliced_most {
|
||||
return None;
|
||||
}
|
||||
self.sliced_placed.set(used + most);
|
||||
let (v, placed) = self.place_and_count(name, linear, MAX_SYMBOL_NODES).unwrap_or((None, 0));
|
||||
self.sliced_placed.set(self.sliced_placed.get().saturating_sub(most.saturating_sub(placed)));
|
||||
self.sliced.borrow_mut().insert(key, v);
|
||||
v
|
||||
}
|
||||
};
|
||||
v.map(|v| moved.transform_rect_bbox(v))
|
||||
}
|
||||
|
||||
/// The box [`Self::placed_visual_within`] returns outside quick boxes.
|
||||
fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option<Rect> {
|
||||
self.place_and_count(name, xf, most).and_then(|(v, _)| v)
|
||||
}
|
||||
|
||||
/// [`Self::place_and_measure`], with the number of objects of the placed art, the art of
|
||||
/// opacity masks included. `None` when the art is not placed.
|
||||
fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option<Rect>, u64)> {
|
||||
self.measure_art(name, |art| {
|
||||
let art = match self.doc.symbol_slice_map(name, xf) {
|
||||
Some(m) if m.most_placed(art, self.doc) > most => return None,
|
||||
Some(m) => m.place(art, self.doc),
|
||||
None => self.doc.place_symbol_art(name, art, xf)?,
|
||||
};
|
||||
self.in_art(|| art.visual(Some(self)))
|
||||
Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64))
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
@@ -385,6 +455,7 @@ fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
|
||||
let reach = crate::live::max_outset(std::slice::from_ref(art), Some(symbols));
|
||||
let mut effects = false;
|
||||
let mut brush: Option<f64> = None;
|
||||
let mut sliced = false;
|
||||
art.walk(&mut |n| {
|
||||
let mut own = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects().iter()));
|
||||
effects |= own.any(|e| e.visible);
|
||||
@@ -397,9 +468,10 @@ fn art_box(art: &Arc<Node>, symbols: &SymbolBoxes) -> Option<ArtBox> {
|
||||
if let Some(w) = symbols.brush_width(symbol) {
|
||||
brush = Some(brush.map_or(w, |b| b.max(w)));
|
||||
}
|
||||
sliced |= symbols.doc.symbol_nine_slice(symbol).is_some() || symbols.art(symbol).is_some_and(|a| a.sliced);
|
||||
}
|
||||
});
|
||||
Some(ArtBox { geometric, visual, reach, effects, brush })
|
||||
Some(ArtBox { geometric, visual, reach, effects, brush, sliced })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -468,6 +540,81 @@ mod tests {
|
||||
assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_boxes_hold_art_that_nested_9_slice_guides_move_past_the_instance() {
|
||||
// The panel of the test above, in a symbol without guides: once at its natural size and
|
||||
// once scaled 2 times across. Where the bar draws depends on the transform the holder's
|
||||
// instance adds to the panel's.
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let bar = rect(&mut d, Rect::new(10.0, 40.0, 70.0, 60.0), 0.0);
|
||||
symbol(&mut d, "Bar", vec![bar]);
|
||||
let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
|
||||
let inner = instance(&mut d, "Bar", Affine::IDENTITY);
|
||||
symbol(&mut d, "Panel", vec![frame, inner]);
|
||||
d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default()));
|
||||
let natural = instance(&mut d, "Panel", Affine::IDENTITY);
|
||||
symbol(&mut d, "Holder", vec![natural]);
|
||||
let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
|
||||
symbol(&mut d, "Wide holder", vec![wide]);
|
||||
// The holder scaled 2 times across scales the panel 2 times across: the bar draws from x
|
||||
// −20 to 160, as in the test above.
|
||||
let holder = instance(&mut d, "Holder", Affine::scale_non_uniform(2.0, 1.0));
|
||||
let quick = holder.visual_bounds_with(&d.symbol_boxes().quick()).unwrap();
|
||||
let drawn = Rect::new(-20.0, 40.0, 160.0, 60.0);
|
||||
assert_eq!(quick.union(drawn), quick, "{quick:?} holds {drawn:?}");
|
||||
let wide_holder = instance(&mut d, "Wide holder", Affine::scale(2.0));
|
||||
for n in [&holder, &wide_holder] {
|
||||
let exact = n.visual_bounds_with(&d.symbol_boxes()).unwrap();
|
||||
assert!(exact.x0 < 0.0, "{exact:?} reaches past the frame's left edge");
|
||||
let quick = n.visual_bounds_with(&d.symbol_boxes().quick()).unwrap();
|
||||
assert_eq!(quick.union(exact), quick, "{quick:?} holds {exact:?}");
|
||||
let reach = n.reach_bounds_with(&d.symbol_boxes()).unwrap();
|
||||
assert_eq!(reach.union(exact), reach, "{reach:?} holds {exact:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_boxes_place_art_that_holds_9_slice_instances_a_bounded_number_of_times() {
|
||||
// A frame with guides, and 16 levels of symbols that each hold an instance of the level
|
||||
// below a little taller and one turned a little: the transforms that place the frame differ
|
||||
// for each of the 2^16 ways down, so measuring every one would place art 2^17 times.
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
|
||||
symbol(&mut d, "Level 0", vec![frame]);
|
||||
d.set_symbol_nine_slice("Level 0", Some(crate::SymbolNineSlice::default()));
|
||||
let (taller, turned) = (Affine::scale_non_uniform(1.0, 1.01), Affine::rotate(0.1));
|
||||
for i in 1..=16 {
|
||||
let below = format!("Level {}", i - 1);
|
||||
let pair = vec![instance(&mut d, &below, taller), instance(&mut d, &below, turned)];
|
||||
symbol(&mut d, &format!("Level {i}"), pair);
|
||||
}
|
||||
let top = instance(&mut d, "Level 16", taller);
|
||||
// With room for 5,000 objects: each placement places at least one.
|
||||
let s = SymbolBoxes { sliced_most: 5000, ..d.symbol_boxes().quick() };
|
||||
assert!(top.visual_bounds_with(&s).is_some());
|
||||
assert!(s.sliced_placed.get() <= 5000, "{} objects", s.sliced_placed.get());
|
||||
assert!(s.art_measured() <= 5000 + d.symbols.len(), "{} measures", s.art_measured());
|
||||
// Instances whose transforms differ only by a move place the art once.
|
||||
let level = instance(&mut d, "Level 1", taller);
|
||||
let moved = instance(&mut d, "Level 1", Affine::translate((5.0, 7.0)) * taller);
|
||||
let s = d.symbol_boxes().quick();
|
||||
let b = level.visual_bounds_with(&s).unwrap();
|
||||
let measured = s.art_measured();
|
||||
assert_eq!(moved.visual_bounds_with(&s), Some(Affine::translate((5.0, 7.0)).transform_rect_bbox(b)));
|
||||
assert_eq!(s.art_measured(), measured);
|
||||
// The art of an opacity mask counts toward the limit: a group holding the framed
|
||||
// instance, masked by a group of 10 rectangles, is 13 objects.
|
||||
let mut framed = instance(&mut d, "Level 0", Affine::IDENTITY);
|
||||
let squares = (0..10).map(|_| Arc::new(rect(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0))).collect();
|
||||
let mask = Node::group(d.alloc_id(), squares);
|
||||
framed.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(mask, true)));
|
||||
symbol(&mut d, "Masked", vec![framed]);
|
||||
let masked = instance(&mut d, "Masked", taller);
|
||||
let s = d.symbol_boxes().quick();
|
||||
assert!(masked.visual_bounds_with(&s).is_some());
|
||||
assert_eq!(s.sliced_placed.get(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -143,8 +143,11 @@ pub struct CharStyle {
|
||||
pub no_break: bool,
|
||||
/// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these
|
||||
/// characters are shaped in, so that the font uses that language's forms (OpenType `locl`).
|
||||
/// `None`: the default language, English: USA, shaped without a language. New type takes the
|
||||
/// document's language (Document Setup). Saved only when set.
|
||||
/// `None`: the default language, English: USA, shaped without a language, which is what text
|
||||
/// saved without one reads as. A tag the list doesn't have, from a file, is kept and has no
|
||||
/// hyphenation. New type takes the document's language (Document Setup), else the default
|
||||
/// language of Preferences › Hyphenation. Saved only when set. Each run hyphenates in its own
|
||||
/// language (`vectorcraft_text::hyphen`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<String>,
|
||||
/// Another glyph of the font for the run's first character (Glyphs panel › Show › Alternates
|
||||
|
||||
@@ -228,7 +228,8 @@ pub struct ParaStyle {
|
||||
pub leading_model: LeadingModel,
|
||||
/// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line
|
||||
/// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before
|
||||
/// it and imported text keep [`Burasagari::None`].
|
||||
/// it and imported text keep [`Burasagari::None`], except legacy `.ai` type with binding
|
||||
/// punctuation on, which opens with [`Burasagari::Forced`].
|
||||
pub burasagari: Burasagari,
|
||||
/// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a
|
||||
/// line.
|
||||
|
||||
@@ -233,13 +233,16 @@ impl ParaList {
|
||||
|
||||
/// The list that the Type tool starts when `typed` and a space are typed at the start of a
|
||||
/// paragraph: "1." or "1)" starts a numbered list in 1, 2, 3, "A." or "A)" starts one in A, B,
|
||||
/// C, and "*", "#", ">" or "+" starts a bulleted list with the filled dot. Any other text starts
|
||||
/// none.
|
||||
/// C, and "*" starts a bulleted list with the filled dot, "+" one with the hollow circle, "#"
|
||||
/// one with the filled square and ">" one with the dash. Any other text starts none.
|
||||
pub fn typed(typed: &str) -> Option<ParaList> {
|
||||
match typed {
|
||||
"1." | "1)" => Some(Self::numbered(NumberStyle::Arabic)),
|
||||
"A." | "A)" => Some(Self::numbered(NumberStyle::UpperLetters)),
|
||||
"*" | "#" | ">" | "+" => Some(Self::bulleted(Bullet::Dot)),
|
||||
"*" => Some(Self::bulleted(Bullet::Dot)),
|
||||
"+" => Some(Self::bulleted(Bullet::Circle)),
|
||||
"#" => Some(Self::bulleted(Bullet::Square)),
|
||||
">" => Some(Self::bulleted(Bullet::Dash)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,22 +212,23 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
}
|
||||
|
||||
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
|
||||
/// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the
|
||||
/// filled dot. Other text, the same characters later in a paragraph or without the space, and
|
||||
/// a paragraph already in a list start nothing.
|
||||
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
|
||||
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
|
||||
/// later in a paragraph or without the space, and a paragraph already in a list start nothing.
|
||||
#[test]
|
||||
fn typed_list_starts_are_found_at_a_paragraphs_start() {
|
||||
let (arabic, letters, dot) =
|
||||
(ParaList::numbered(NumberStyle::Arabic), ParaList::numbered(NumberStyle::UpperLetters), ParaList::bulleted(Bullet::Dot));
|
||||
let typed = ["1.", "1)", "A.", "A)", "*", "#", ">", "+"].map(ParaList::typed);
|
||||
assert_eq!(typed, [arabic, arabic, letters, letters, dot, dot, dot, dot].map(Some));
|
||||
let (circle, square, dash) = (ParaList::bulleted(Bullet::Circle), ParaList::bulleted(Bullet::Square), ParaList::bulleted(Bullet::Dash));
|
||||
let typed = ["1.", "1)", "A.", "A)", "*", "+", "#", ">"].map(ParaList::typed);
|
||||
assert_eq!(typed, [arabic, arabic, letters, letters, dot, circle, square, dash].map(Some));
|
||||
for other in ["", "2.", "a.", "a)", "I.", "1", "A", "1:", "-", "•", "1.)", " 1.", "*1", "1."] {
|
||||
assert_eq!(ParaList::typed(other), None, "{other:?}");
|
||||
}
|
||||
let mut t = TextObject::point(Point::ZERO, "Fruit\n1. Apples\nA) x * y\n+ ", CharStyle::default());
|
||||
assert_eq!(t.typed_list_at(9), Some((6, arabic)));
|
||||
assert_eq!(t.typed_list_at(19), Some((16, letters)));
|
||||
assert_eq!(t.typed_list_at(27), Some((25, dot)));
|
||||
assert_eq!(t.typed_list_at(27), Some((25, circle)));
|
||||
// Not at the paragraph's start, not right after the space, or past the end.
|
||||
for at in [0, 5, 8, 10, 15, 23, 25, 26, 99] {
|
||||
assert_eq!(t.typed_list_at(at), None, "{at}");
|
||||
@@ -245,7 +246,7 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
|
||||
#[test]
|
||||
fn typed_list_starts_skip_a_continued_paragraph() {
|
||||
let dot = ParaList::bulleted(Bullet::Dot);
|
||||
let mut t = TextObject::point(Point::ZERO, "* Pears\n# ", CharStyle::default());
|
||||
let mut t = TextObject::point(Point::ZERO, "* Pears\n* ", CharStyle::default());
|
||||
assert_eq!(t.typed_list_at(2), Some((0, dot)));
|
||||
t.first_para_continues = true;
|
||||
assert_eq!(t.typed_list_at(2), None, "a continued paragraph starts no list");
|
||||
|
||||
@@ -105,12 +105,11 @@ impl DataSet {
|
||||
/// The variable state of a document: definitions, datasets, bindings
|
||||
/// (object id → the variables bound to it) and which dataset is active.
|
||||
///
|
||||
/// An object may be driven by several variables — the reference app binds the selected object
|
||||
/// to the selected variable, one click at a time, and never says a second binding replaces the
|
||||
/// first — so a binding is a set. `dataset.select` applies the text values in the order the
|
||||
/// variables are defined, which is what makes two text variables on one object deterministic:
|
||||
/// the last one wins, and `applied` counts both. Of two visibility variables on one object, the
|
||||
/// last one defined wins too.
|
||||
/// An object may be driven by several variables, so a binding is a set. `variable.bind` and Make
|
||||
/// Text Dynamic refuse a second text variable on one type object, but a document saved before
|
||||
/// they did may hold two: `dataset.select` applies the text values in the order the variables
|
||||
/// are defined, so the last one wins, and `applied` counts both. Of two visibility variables on
|
||||
/// one object, the last one defined wins too.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Variables {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
|
||||
@@ -17,6 +17,11 @@ vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
# Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the
|
||||
# workspace reads SVG with; raster-images decodes the object's pixels it hands the filter as a PNG.
|
||||
resvg = { version = "0.48", default-features = false, features = ["raster-images"] }
|
||||
# The number lists of filter attributes, read as usvg reads them (MIT OR Apache-2.0; already used by usvg).
|
||||
svgtypes = "0.16"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user