Merge main into issue-5349 (#5349)

This commit is contained in:
MarekSpodBiedry
2026-11-07 13:44:25 +01:00
713 changed files with 79445 additions and 4439 deletions
+28
View File
@@ -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
+1
View File
@@ -82,6 +82,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 }}
+56 -5
View File
@@ -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.
+17
View File
@@ -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 | |
+17
View File
@@ -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
View 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
+15
View File
@@ -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"
@@ -6100,6 +6111,7 @@ dependencies = [
"serde_json",
"smithay-clipboard",
"socket2",
"tempfile",
"vectorcraft-engine",
"vectorcraft-render",
"vectorcraft-text",
@@ -6163,6 +6175,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-text",
"serde_json",
"tempfile",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-engine",
@@ -6469,6 +6482,7 @@ dependencies = [
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-pathops",
"vectorcraft-testkit",
"vectorcraft-text",
]
@@ -6591,6 +6605,7 @@ dependencies = [
name = "vectorcraft-web"
version = "0.21.0"
dependencies = [
"craft-web-api",
"eframe",
"egui",
"js-sys",
+5
View File
@@ -136,3 +136,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.
+1 -1
View File
@@ -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 leaves out live effects and writes symbols and 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);
+11 -4
View File
@@ -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 (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
leaves out live effects and writes symbols and 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
@@ -104,7 +107,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 +134,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 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`).
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
@@ -218,6 +223,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 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. |
+1
View File
@@ -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 }
+45 -19
View File
@@ -2,9 +2,9 @@
//!
//! ```text
//! 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]
@@ -81,11 +81,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 +95,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).
@@ -279,13 +280,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 +296,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 +337,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 +383,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 +501,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 +510,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 +548,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 +602,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}))?;
+44
View File
@@ -187,6 +187,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 +324,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.
{
+32
View File
@@ -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"]);
+2
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
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 }
+84 -60
View File
@@ -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(&params)))
.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(&params)}))
})
.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(&params))).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(())
}
+40
View File
@@ -27,6 +27,19 @@ pub(crate) fn kept_from_browser(key: &str, code: &str, modifiers: Modifiers, alt
vectorcraft_ui_egui::shortcuts::runs_a_shortcut(key, code, modifiers)
}
/// Leave a paste to its Paste event (#5555). eframe's web runner reports a Cmd+V press both as the
/// key press and, with the clipboard's text, as a Paste event (the browser's `paste` comes in the
/// same task as the `keydown`, so both are in one frame's input); the app pastes on each, as egui's
/// window integration sends only the Paste. With a Paste in `events`, the paste key's press is
/// dropped; its release stays, so the paste chord is still followed (`PasteChord`). A Cmd+V without
/// a Paste (a clipboard with no text) keeps its press.
pub(crate) fn drop_paste_key_presses(events: &mut Vec<egui::Event>) {
if !events.iter().any(|e| matches!(e, egui::Event::Paste(_))) {
return;
}
events.retain(|e| !matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command));
}
/// The modifiers of a key event as eframe reports them to egui (its `modifiers_from_kb_event`).
#[cfg(target_arch = "wasm32")]
fn modifiers(e: &web_sys::KeyboardEvent) -> Modifiers {
@@ -115,4 +128,31 @@ mod tests {
assert!(!kept_from_browser("ż", "KeyZ", ctrl_alt, true));
assert!(!kept_from_browser("}", "Digit0", ctrl_alt, true));
}
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
#[test]
fn a_paste_chord_pastes_once() {
// Cmd+V and Cmd+Shift+V (Paste in Place): the press goes, the Paste and the release stay.
for mods in [cmd(), cmd() | Modifiers::SHIFT] {
let mut events = vec![key(Key::V, true, mods), egui::Event::Paste("<svg/>".into()), key(Key::V, false, mods)];
drop_paste_key_presses(&mut events);
assert_eq!(events, vec![egui::Event::Paste("<svg/>".into()), key(Key::V, false, mods)], "{mods:?}");
}
}
#[test]
fn keys_without_a_paste_stay() {
// No text on the clipboard: no Paste event, so the press is the paste chord's only report.
let mut events = vec![key(Key::V, true, cmd()), key(Key::V, false, cmd())];
drop_paste_key_presses(&mut events);
assert_eq!(events.len(), 2);
// Typing V, and other chords, beside a paste.
let typed = vec![key(Key::V, true, Modifiers::NONE), key(Key::C, true, cmd()), egui::Event::Paste("a".into())];
let mut events = typed.clone();
drop_paste_key_presses(&mut events);
assert_eq!(events, typed);
}
}
+2 -2
View File
@@ -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);
}
@@ -378,6 +377,8 @@ impl eframe::App for WebShell {
/// shared input hook before egui runs.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
raw.events.extend(self.host.pointers.borrow_mut().take());
// A Cmd+V comes as its key and as a Paste: one paste (#5555).
crate::keys::drop_paste_key_presses(&mut raw.events);
if let Some(app) = &mut self.app {
app.raw_input_hook(ctx, raw);
}
@@ -421,7 +422,6 @@ impl eframe::App for WebShell {
}
app.ui(ui);
}
crate::api::poll_replies();
}
}
+1
View File
@@ -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.
+3
View File
@@ -51,6 +51,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.
+25 -20
View File
@@ -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(&notes, "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));
+8 -10
View File
@@ -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);
+28
View File
@@ -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.
+9 -5
View File
@@ -58,7 +58,12 @@ fn idle_control_socket_releases_its_worker() {
fn idle_expiry_closes_without_writing_a_line() {
let (mut client, done, worker) = socket_fixture(short_timeouts(), |_, _| panic!("no request expected"));
let mut got = Vec::new();
client.read_to_end(&mut got).unwrap();
match client.read_to_end(&mut got) {
// On Windows the server's close can reach the client as a reset.
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {}
Ok(_) => {}
Err(e) => panic!("reading the idle connection: {e}"),
}
assert_eq!(String::from_utf8_lossy(&got), "", "the app wrote to an idle connection");
expect_socket_deadline(client, done, worker);
}
@@ -329,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
+32 -7
View File
@@ -753,13 +753,22 @@ enum FirstFrame {
Shown,
/// Overdue.
Hung,
/// Overdue, but the UI is still building the first frame: the app's own work (on a slow
/// machine, or with antivirus reading every font file), not the adapter, which has been given
/// nothing to show yet.
Busy,
}
/// The window's start after `frames` frames run, `since` its first frame was due.
fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
/// The window's start after `frames` frames run, `since` its first frame was due and
/// `since_built` the first frame's UI pass ended (`None` while it runs). A frame counts once its
/// UI pass ends, before it is painted, so the adapter's time starts there: with no frame run, the
/// UI thread is in the app's code, and starting again without the adapter wouldn't help (#5160).
fn first_frame(frames: u64, since: Duration, since_built: Option<Duration>, minimized: bool) -> FirstFrame {
if frames >= 2 || minimized {
FirstFrame::Shown
} else if since >= FIRST_FRAME_TIMEOUT {
} else if frames == 0 {
if since >= FIRST_FRAME_TIMEOUT { FirstFrame::Busy } else { FirstFrame::Wait }
} else if since_built.unwrap_or(since) >= FIRST_FRAME_TIMEOUT {
FirstFrame::Hung
} else {
FirstFrame::Wait
@@ -767,18 +776,34 @@ fn first_frame(frames: u64, since: Duration, minimized: bool) -> FirstFrame {
}
/// Watch the window's first frame from another thread: once it reaches the screen, clear the start
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after the app was
/// created ([`Startup::ready`]), start the app again without the adapter, as [`finish`] does for
/// one that failed, and end this process, whose UI thread is stuck in the graphics driver.
/// from [`START_FILE`]; when it hasn't reached the screen [`FIRST_FRAME_TIMEOUT`] after its UI pass
/// ended, start the app again without the adapter, as [`finish`] does for one that failed, and end
/// this process, whose UI thread is stuck in the graphics driver. A first frame still being built
/// that long is waited for, with a note in the log.
pub fn watch_first_frame(startup: Arc<Startup>) {
let mut built: Option<Instant> = None;
let mut noted = false;
let watch = move || loop {
std::thread::sleep(Duration::from_millis(250));
let Some(due) = startup.ready.get() else { continue };
let ui = startup.ui.get();
let frames = ui.map_or(0, egui::Context::cumulative_frame_nr);
if frames >= 1 && built.is_none() {
built = Some(Instant::now());
}
let minimized = ui.and_then(|c| c.input(|i| i.viewport().minimized)).unwrap_or(false);
match first_frame(frames, due.elapsed(), minimized) {
match first_frame(frames, due.elapsed(), built.map(|t| t.elapsed()), minimized) {
FirstFrame::Wait => continue,
FirstFrame::Busy => {
if !noted {
log::warn!(
"the window's first frame is still being built after {}s: waiting for it (the app's work, not the graphics adapter)",
FIRST_FRAME_TIMEOUT.as_secs()
);
noted = true;
}
continue;
}
FirstFrame::Shown => {
startup.presented();
return;
+36 -21
View File
@@ -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]
@@ -366,14 +366,19 @@ fn gpus_without_pci_ids_are_told_apart_by_name() {
fn a_first_frame_that_never_reaches_the_screen_hangs_its_adapter() {
let s = Duration::from_secs;
// The second frame began: the first was presented.
assert_eq!(first_frame(2, s(60), false), FirstFrame::Shown);
// Still drawing (or presenting) the first frame: waited for, then given up on (#964).
for frames in [0, 1] {
assert_eq!(first_frame(frames, s(3), false), FirstFrame::Wait);
assert_eq!(first_frame(frames, FIRST_FRAME_TIMEOUT, false), FirstFrame::Hung);
}
assert_eq!(first_frame(2, s(60), Some(s(1)), false), FirstFrame::Shown);
// The first frame built and not yet presented: waited for, then given up on (#964), counting
// from when its UI pass ended.
assert_eq!(first_frame(1, s(3), Some(s(1)), false), FirstFrame::Wait);
assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, Some(FIRST_FRAME_TIMEOUT), false), FirstFrame::Hung);
assert_eq!(first_frame(1, s(45), Some(s(5)), false), FirstFrame::Wait, "a first frame built slowly gets its full time to show");
assert_eq!(first_frame(1, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Hung);
// The first frame still being built: the app's work, not the adapter's (#5160).
assert_eq!(first_frame(0, s(3), None, false), FirstFrame::Wait);
assert_eq!(first_frame(0, FIRST_FRAME_TIMEOUT, None, false), FirstFrame::Busy);
assert_eq!(first_frame(0, s(600), None, false), FirstFrame::Busy, "never a restart without an adapter");
// A window minimized at the start may not draw again until restored.
assert_eq!(first_frame(1, s(60), true), FirstFrame::Shown);
assert_eq!(first_frame(1, s(60), Some(s(60)), true), FirstFrame::Shown);
// A hung adapter is left out once, like one that failed.
let skip = vec!["Dx12:10de:28a0".to_string()];
assert_eq!(restart_without(true, 1, Some("Dx12:10de:28a0"), &skip), None);
@@ -611,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.
@@ -665,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,
@@ -688,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
@@ -716,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
@@ -737,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
@@ -772,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]
+149 -14
View File
@@ -44,19 +44,50 @@ macro_rules! dialog_settings {
dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog);
/// On Linux and the BSDs the dialog is GTK's or the desktop portal's, which match a file type's
/// `*.ext` patterns case-sensitively.
const CASE_SENSITIVE_FILTERS: bool =
cfg!(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"));
/// The extensions an open dialog's filter is given for `exts`: with `any_case`, each extension
/// that has a letter is followed by a pattern with each letter as a `[xX]` pair, so the filter for
/// `dib` also lists `image.DIB` and `image.Dib`.
fn open_extensions(exts: &[impl AsRef<str>], any_case: bool) -> Vec<String> {
let mut out = Vec::with_capacity(exts.len() * 2);
for e in exts {
let e = e.as_ref();
out.push(e.to_string());
if any_case && e.chars().any(|c| c.is_ascii_alphabetic()) {
out.push(
e.chars()
.map(|c| if c.is_ascii_alphabetic() { format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase()) } else { c.to_string() })
.collect(),
);
}
}
out
}
/// `d` set up for `request`: its title, file types, folder and suggested name.
fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => {
return fileio::place_filters().fold(d.title(vectorcraft_ui_egui::i18n::t("Place")), |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)
d.filter(
vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name),
&open_extensions(exts, CASE_SENSITIVE_FILTERS),
)
});
}
PickRequest::Folder => return d,
};
let any_case = CASE_SENSITIVE_FILTERS && matches!(request, PickRequest::Open(_));
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
d.filter(
vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()),
&open_extensions(exts, any_case),
)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
@@ -167,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())
@@ -294,6 +349,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.
@@ -538,22 +595,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");
@@ -569,19 +624,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();
@@ -605,7 +660,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
@@ -663,3 +717,84 @@ mod open_with_tests {
assert_eq!(super::registry_default_path(windows::Win32::System::Registry::HKEY_CURRENT_USER, &key), None, "deleted");
}
}
#[cfg(test)]
mod dialog_tests {
use super::{CASE_SENSITIVE_FILTERS, DialogSettings, FilePick, PickRequest, dialog_for, fileio, open_extensions};
/// The file types a dialog was given, by label.
#[derive(Default)]
struct Filters(Vec<(String, Vec<String>)>);
impl DialogSettings for Filters {
fn parent(self, _window: &winit::window::Window) -> Self {
self
}
fn title(self, _title: &str) -> Self {
self
}
fn filter(mut self, name: &str, exts: &[impl ToString]) -> Self {
self.0.push((name.to_string(), exts.iter().map(ToString::to_string).collect()));
self
}
fn folder(self, _folder: &str) -> Self {
self
}
fn file_name(self, _name: &str) -> Self {
self
}
}
/// Does the case-sensitive pattern `*.{ext}` match `name`? `ext` may hold literal characters,
/// `*` and `[..]` sets.
fn matches(ext: &str, name: &str) -> bool {
fn here(p: &[char], n: &[char]) -> bool {
match p.split_first() {
None => n.is_empty(),
Some(('*', rest)) => (0..=n.len()).any(|i| here(rest, &n[i..])),
Some(('[', rest)) => {
let Some(end) = rest.iter().position(|&c| c == ']') else { return false };
n.split_first().is_some_and(|(c, n)| rest[..end].contains(c) && here(&rest[end + 1..], n))
}
Some((c, rest)) => n.split_first().is_some_and(|(d, n)| d == c && here(rest, n)),
}
}
let pattern: Vec<char> = format!("*.{ext}").chars().collect();
here(&pattern, &name.chars().collect::<Vec<_>>())
}
/// Where filters match case-sensitively, every extension of File › Open and Place lists files
/// whose extension is in capitals or mixed case, `image.DIB` and `image.Pns` included.
#[test]
fn open_and_place_filters_list_extensions_in_any_case() {
let all = fileio::open_filters().chain(fileio::place_filters());
for (label, exts) in all {
let patterns = open_extensions(exts, true);
for ext in exts {
let mut mixed = ext.to_ascii_uppercase();
mixed.replace_range(..1, &ext[..1]);
for name in [format!("a.{ext}"), format!("a.{}", ext.to_ascii_uppercase()), format!("a.{mixed}")] {
assert!(patterns.iter().any(|p| matches(p, &name)), "{label}: {patterns:?} don't match {name}");
}
}
}
assert_eq!(open_extensions(&["dib", "jp2", "*"], true), ["dib", "[dD][iI][bB]", "jp2", "[jJ][pP]2", "*"]);
assert_eq!(open_extensions(&["dib", "PNS"], false), ["dib", "PNS"]);
}
/// Open and Place dialogs get the any-case patterns on Linux and the BSDs; a save dialog's file
/// types stay as listed.
#[test]
fn open_dialogs_get_any_case_patterns_where_filters_are_case_sensitive() {
let dib = vec![FilePick::filter("BMP", &["bmp", "dib"])];
let pick = FilePick { filters: dib.clone(), ..Default::default() };
let expected = |any_case: bool| open_extensions(&["bmp", "dib"], any_case);
let opened = dialog_for(Filters::default(), &PickRequest::Open(pick.clone())).0;
assert_eq!(opened[0].1, expected(CASE_SENSITIVE_FILTERS));
let saved = dialog_for(Filters::default(), &PickRequest::Save(pick)).0;
assert_eq!(saved, dib);
let placed = dialog_for(Filters::default(), &PickRequest::OpenMany).0;
let first = fileio::place_filters().next().map(|(_, exts)| open_extensions(exts, CASE_SENSITIVE_FILTERS));
assert_eq!(placed.first().map(|(_, exts)| exts.clone()), first);
}
}
+23 -6
View File
@@ -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 {
+2 -4
View File
@@ -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);
}
}
+8 -2
View File
@@ -56,6 +56,7 @@ mod system_fonts;
mod tablet;
mod window;
mod window_frame;
mod windows_alt_codes;
#[cfg(windows)]
mod windows_cores;
mod windows_stylus;
@@ -314,13 +315,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
};
+2 -4
View File
@@ -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]
+18 -7
View File
@@ -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]
+25 -17
View File
@@ -263,15 +263,23 @@ mod tests {
use super::*;
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 +311,11 @@ 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_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,16 +330,20 @@ 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");
// No listener on the recorded port any more.
let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
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.)
let gone = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = gone.local_addr().unwrap().port();
write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap();
let closed = std::thread::spawn(move || drop(gone.accept()));
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
closed.join().unwrap();
// A listener that doesn't know the greeting gets neither the token nor the paths.
let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = impostor.local_addr().unwrap().port();
@@ -352,18 +363,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]
@@ -378,9 +387,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);
}
}
+2 -3
View File
@@ -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());
}
+2 -4
View File
@@ -99,9 +99,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 +108,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"{}");
+225
View File
@@ -0,0 +1,225 @@
//! 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.
//!
//! 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. With it off the keys
/// are Insert, End, … and type no code.
const VK_NUMPAD0: usize = 0x60;
const VK_NUMPAD9: usize = 0x69;
/// `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,
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
/// as Alt and a digit: taken out of the frame.
keypad_events: usize,
}
impl AltCodes {
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
fn message(&mut self, message: u32, wparam: usize) {
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
match message {
// Windows sends a key pressed with Alt held as a system key.
WM_SYSKEYDOWN if keypad => {
self.digits += 1;
self.keypad_events += 1;
self.armed = false;
}
WM_SYSKEYUP if keypad => self.keypad_events += 1,
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 > 0 {
events.retain(|e| {
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
if digit && keypad > 0 {
keypad -= 1;
return false;
}
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);
}
});
}
}
/// 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);
for d in code.bytes() {
let vk = VK_NUMPAD0 + usize::from(d - b'0');
c.message(WM_SYSKEYDOWN, vk);
c.message(WM_SYSKEYUP, vk);
}
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(posted));
}
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);
c.message(WM_CHAR, usize::from(b'a'));
c.message(WM_SYSKEYDOWN, ALT);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, usize::from(b'b'));
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);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
// Alt+F: a shortcut, not a code.
c.message(WM_SYSKEYDOWN, 0x46);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0x41);
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);
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
c.message(WM_KEYUP, ALT);
c.message(WM_CHAR, 0xD83D);
c.message(WM_CHAR, 0xDE00);
let mut events = vec![];
c.drain(&mut events);
assert_eq!(events, vec![Event::Text("😀".into())]);
}
}
+50 -13
View File
@@ -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]
+1
View File
@@ -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

+19
View File
@@ -201,3 +201,22 @@ sync_github_name = false
[people.FrancoJacquet]
real_name = "Franco Jacquet"
[people.FractalEncrypt]
sync_github_name = true
[people.sadkotheguest]
real_name = "Alexander Buraga"
display_name = "Alexander Buraga"
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
+19 -7
View File
@@ -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))
}
+69
View File
@@ -701,6 +701,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 +813,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);
+26 -6
View File
@@ -86,6 +86,9 @@ const TINT_STEPS: u32 = 16;
const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0;
/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for.
const MAX_INK_TINTS: usize = 4096;
/// How many even steps [`Gradient::ink_mixes`] checks the lightness of two stops' display RGB mix
/// at, to tell whether it moves steadily from one stop's lightness to the other's.
const INK_MIX_STEPS: u32 = 64;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
@@ -148,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),
@@ -401,8 +411,10 @@ impl Gradient {
/// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
/// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
/// [`MIN_INK_LIGHTNESS_SPAN`], or when the display RGB mix's lightness turns back somewhere
/// on the way from one color's lightness to the other's (checked at [`INK_MIX_STEPS`] even
/// steps), as from red to blue. `None` for a Classic gradient, and when the curve has more
/// than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
if self.method.is_classic() || !self.fits_ink_tints() {
@@ -414,8 +426,16 @@ impl Gradient {
out.push((s.offset, i, 0.0, s.opacity));
let Some(n) = self.stops.get(i + 1) else { continue };
let (l0, l1) = (lightness(&s.color), lightness(&n.color));
let mixed_lightness = |w: f32| lightness(&GradientMethod::Classic.mix(&s.color, &n.color, w));
// The mix position itself when the lightness doesn't tell the mixes apart.
let by_position = (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN
|| !(0..=INK_MIX_STEPS)
.map(|k| mixed_lightness(k as f32 / INK_MIX_STEPS as f32))
.collect::<Vec<_>>()
.windows(2)
.all(|w| (w[1] - w[0]) * (l1 - l0) >= 0.0);
for v in perceptual_mixes(s, n) {
let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
let weight = if by_position {
v
} else {
let want = lightness(&self.method.mix(&s.color, &n.color, v));
@@ -423,7 +443,7 @@ impl Gradient {
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
// The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
if (mixed_lightness(mid) - want) * (l1 - l0) < 0.0 {
lo = mid;
} else {
hi = mid;
+19
View File
@@ -585,3 +585,22 @@ fn ink_mixes_follow_the_perceptual_curve() {
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect();
assert_eq!(g.ink_mixes(), None);
}
/// From red to blue the display RGB mix darkens past blue's lightness and comes back, so a
/// weight found by lightness near the blue stop would be a red-side one and the inks would jump
/// there. The weights are the mix positions instead, and reach the blue stop's inks steadily.
#[test]
fn ink_mixes_use_the_mix_position_when_the_lightness_turns_back() {
let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0));
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red), GradientStop::new(1.0, blue)]);
g.method = GradientMethod::Perceptual;
let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
assert_eq!(mixes[mixes.len() - 1], (1.0, 1, 0.0, 1.0), "the blue stop");
let between: Vec<_> = mixes.iter().filter(|m| m.1 == 0 && m.0 > 0.0).collect();
assert!(!between.is_empty(), "stops on the curve between red and blue: {mixes:?}");
// With the midpoint at ½, a stop's offset on the curve is its mix position.
for (t, _, w, _) in &between {
assert!((w - t).abs() < 1e-6, "at {t}: weight {w}");
}
assert!(mixes.windows(2).filter(|p| p[1].1 == 0).all(|p| p[0].2 <= p[1].2), "the weights rise: {mixes:?}");
}
+154 -10
View File
@@ -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}");
}
}
}
+4 -1
View File
@@ -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));
}
+50
View File
@@ -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();
+2
View File
@@ -72,6 +72,7 @@ impl Document {
new_art,
dimensions,
retired_artboard_id: _,
opened_artboard: _,
index: _,
} = self;
*version == other.version
@@ -248,6 +249,7 @@ impl Document {
new_art: Default::default(),
dimensions: false,
retired_artboard_id: Default::default(),
opened_artboard: None,
index: Default::default(),
};
let id = d.alloc_id();
+8 -6
View File
@@ -99,13 +99,15 @@ impl Document {
}
/// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit).
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The
/// layer is removed wherever it is, also as a sublayer of another layer; an id that names
/// anything other than a layer is left alone.
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
if let Some(se) = self.symbol_edit.take() {
self.layers.retain(|l| l.id != se.layer);
let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)];
for id in layers.into_iter().flatten() {
if self.node(id).is_some_and(Node::is_layer) {
let _ = self.remove(id);
}
}
}
+61 -9
View File
@@ -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`]
+277
View File
@@ -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");
}
}
+83 -4
View File
@@ -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);
@@ -893,6 +969,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![],
+33
View File
@@ -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]
+12 -2
View File
@@ -16,6 +16,7 @@ pub mod corners;
pub mod depth;
pub mod dimension;
pub mod graph;
mod halftone_radii;
pub mod hit;
mod id_index;
pub mod inks;
@@ -40,6 +41,7 @@ pub mod live;
pub mod live_paint;
pub mod marks;
mod mem;
pub use mem::{appearance_heap_bytes, paint_heap_bytes};
pub mod metadata;
pub mod node;
pub mod objects_on_path;
@@ -67,6 +69,7 @@ mod symbols;
pub use symbols::SymbolBoxes;
pub mod text;
pub mod trace;
pub mod twist;
pub mod variables;
pub mod xml_id;
@@ -110,6 +113,7 @@ pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::FontHeightReference;
pub use text::GlyphChoice;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
@@ -226,8 +230,9 @@ pub struct Document {
#[serde(default, skip_serializing)]
pub mask_edit: Option<MaskEdit>,
/// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the
/// symbol already holds the art of its editing layer.
#[serde(default, skip_serializing)]
/// symbol already holds the art of its editing layer. Never read either: a value in file data
/// is dropped, so the layer it names is kept and saving does not write the value again.
#[serde(default, skip_serializing, deserialize_with = "skip::read_as_none")]
pub symbol_edit: Option<SymbolEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
@@ -320,6 +325,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")]
+5 -1
View File
@@ -13,7 +13,11 @@ pub enum DocError {
Invalid(String),
}
pub const FORMAT_VERSION: u32 = 1;
/// The document version new documents get. Version 2: a positive Twist angle turns clockwise
/// ([`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).
+78 -2
View File
@@ -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
@@ -445,6 +486,12 @@ mod tests {
.unwrap()
}
#[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 +507,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.
+60 -2
View File
@@ -53,8 +53,8 @@ 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, envelope_bounds, envelope_content_boxes,
envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles,
};
pub(crate) use expand::max_outset;
pub use expand::{
@@ -233,6 +233,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 {
+55 -7
View File
@@ -76,6 +76,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 +92,7 @@ impl Default for EnvelopeOptions {
distort_appearance: false,
distort_linear_gradients: false,
distort_pattern_fills: false,
bounds_margin: [0.0; 4],
}
}
}
@@ -98,6 +105,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 +115,45 @@ 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. 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| {
let mut m = (**c).clone();
m.transform(to_frame, true);
(vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds()))
});
Some((stroked?, plain?))
}
/// 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.
@@ -218,9 +265,9 @@ 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`. `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 {
@@ -232,13 +279,14 @@ impl<'a> EnvelopeMap<'a> {
Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds()))
})??
};
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 +350,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,7 +367,7 @@ 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, .. } => {
@@ -327,6 +375,6 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
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::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
}
}
+33 -12
View File
@@ -98,7 +98,7 @@ impl Document {
vec_bytes(g) + g.iter().map(|g: &SwatchGroup| g.name.capacity() + swatches_bytes(&g.swatches)).sum::<usize>()
});
n += shared(&self.graphic_styles, &newer.graphic_styles, |g| {
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap(&g.appearance)).sum::<usize>()
vec_bytes(g) + g.iter().map(|g: &GraphicStyle| g.name.capacity() + appearance_heap_bytes(&g.appearance)).sum::<usize>()
});
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
n += shared(&self.char_styles, &newer.char_styles, styles);
@@ -245,7 +245,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
let mut b = ALLOC + ARC + size_of::<Node>();
b += n.name.as_ref().map_or(0, String::capacity);
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) {
b += ALLOC + ARC + appearance_heap(&n.appearance);
b += ALLOC + ARC + appearance_heap_bytes(&n.appearance);
}
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
@@ -293,32 +293,53 @@ fn text_bytes(t: &TextObject) -> usize {
+ vec_bytes(&t.cached_baselines)
}
/// The heap an appearance owns (not the appearance itself).
fn appearance_heap(a: &Appearance) -> usize {
/// Approximate retained memory of an appearance and its owned buffers, without serialization.
pub fn appearance_heap_bytes(a: &Appearance) -> usize {
size_of::<Appearance>()
+ vec_bytes(&a.items)
+ a.items
.iter()
.map(|i| match i {
AppearanceItem::Fill(f) => paint_heap(&f.paint),
AppearanceItem::Stroke(s) => paint_heap(&s.paint),
.map(|item| {
let paint = paint_heap_bytes(item.paint());
let effects = effects_heap(item.effects());
let stroke = match item {
AppearanceItem::Stroke(s) => {
s.dash.as_ref().map_or(0, |d| vec_bytes(&d.pattern))
+ s.profile.as_ref().map_or(0, |p| vec_bytes(&p.points))
+ s.pressure.as_ref().map_or(0, |p| vec_bytes(&p.points))
+ s.brush.as_ref().map_or(0, String::capacity)
}
_ => 0,
};
paint + effects + stroke
})
.sum::<usize>()
+ vec_bytes(&a.effects)
+ a.effects.iter().map(|e: &Effect| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
+ effects_heap(&a.effects)
}
fn paint_heap(p: &Paint) -> usize {
fn effects_heap(effects: &Vec<Effect>) -> usize {
vec_bytes(effects) + effects.iter().map(|e| e.id.capacity() + json_bytes(&e.params)).sum::<usize>()
}
/// Approximate heap retained by one paint, without serializing it.
pub fn paint_heap_bytes(p: &Paint) -> usize {
match p {
Paint::Solid { swatch, .. } => swatch.as_ref().map_or(0, String::capacity),
Paint::Gradient(g) => ALLOC + size_of_val(&**g) + vec_bytes(&g.gradient.stops),
Paint::Gradient(g) => {
ALLOC
+ size_of_val(&**g)
+ vec_bytes(&g.gradient.stops)
+ g.gradient.stops.iter().map(|s| s.swatch.as_ref().map_or(0, String::capacity)).sum::<usize>()
+ g.swatch.as_ref().map_or(0, String::capacity)
+ g.freeform.as_ref().map_or(0, |f| vec_bytes(&f.points) + vec_bytes(&f.lines) + f.lines.iter().map(vec_bytes).sum::<usize>())
}
Paint::Pattern { pattern, .. } => pattern.capacity(),
Paint::None => 0,
}
}
fn swatches_bytes(s: &[Swatch]) -> usize {
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap(&w.paint)).sum::<usize>()
ALLOC + std::mem::size_of_val(s) + s.iter().map(|w| w.name.capacity() + paint_heap_bytes(&w.paint)).sum::<usize>()
}
/// A vector's buffer (not what its elements own).
+4
View File
@@ -66,6 +66,9 @@ 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]>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -91,6 +94,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
envelope_edit_margin: None,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+40 -9
View File
@@ -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,9 +374,14 @@ 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),
None => placed.inflate(art.reach, art.reach),
// Art placed through 9-slice guides can draw outside `placed`.
None => s
.document()
.symbol_slice_map_of(symbol, art.geometric, *xf)
.map_or(placed, |m| m.placement_bounds(art.geometric).union(placed))
.inflate(art.reach, art.reach),
}
}
None => xf.transform_rect_bbox(SYMBOL_SQUARE),
@@ -598,6 +610,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)> {
+16
View File
@@ -21,6 +21,7 @@ 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]);
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -65,6 +66,7 @@ fn extras_are_written_flat_in_order() {
"live_paint_gaps",
"compound_shape",
"shape_mode",
"envelope_edit_margin",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
@@ -443,6 +445,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]
+5
View File
@@ -53,6 +53,7 @@ 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(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -94,6 +95,7 @@ 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);
st.end()
}
}
@@ -232,6 +234,8 @@ 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]>,
}
impl From<NodeWire> for Node {
@@ -258,6 +262,7 @@ 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,
};
Node {
id: w.id,
+12 -2
View File
@@ -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
View File
@@ -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))
})
}
+14
View File
@@ -164,6 +164,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 +188,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 +379,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 +392,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 +420,9 @@ mod tests {
}
let mut prev = None;
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
// 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);
+5
View File
@@ -13,3 +13,8 @@ pub fn is_empty_vec<T>(v: &[T]) -> bool {
pub fn is_empty_map(v: &serde_json::Map<String, serde_json::Value>) -> bool {
v.is_empty()
}
/// Read a field's value and drop it, giving `None`: for state that is never taken from a file.
pub fn read_as_none<'de, D: serde::Deserializer<'de>, T>(d: D) -> Result<Option<T>, D::Error> {
<serde::de::IgnoredAny as serde::Deserialize>::deserialize(d)?;
Ok(None)
}
+10 -2
View File
@@ -98,6 +98,10 @@ impl GraphicStyle {
/// The extension (and `format` header) of graphic style library files.
pub const STYLES_EXT: &str = "vcstyles";
/// The version of the `.vcstyles` files [`write`] writes. Version 2: a positive Twist angle turns
/// clockwise ([`crate::twist`]).
const STYLES_VERSION: u32 = crate::twist::TWIST_CLOCKWISE_SINCE;
/// The file: a header around the library.
#[derive(Serialize, Deserialize)]
struct NativeFile {
@@ -109,17 +113,21 @@ struct NativeFile {
/// `lib` as a `.vcstyles` file.
pub fn write(lib: &StyleLibrary) -> String {
let file = NativeFile { format: STYLES_EXT.into(), version: 1, library: lib.clone() };
let file = NativeFile { format: STYLES_EXT.into(), version: STYLES_VERSION, library: lib.clone() };
serde_json::to_string_pretty(&file).unwrap_or_default()
}
/// Read a `.vcstyles` file; an unnamed library is called `name`.
/// Read a `.vcstyles` file; an unnamed library is called `name`. A version 1 file was written by
/// an app that turned Twist counterclockwise: its Twist angles are negated.
pub fn read(text: &str, name: &str) -> Result<StyleLibrary, String> {
let f: NativeFile = serde_json::from_str(text.trim_start_matches('\u{feff}')).map_err(|e| format!("not a graphic style library: {e}"))?;
if f.format != STYLES_EXT {
return Err(format!("not a graphic style library (format `{}`)", f.format));
}
let mut lib = f.library;
if f.version < STYLES_VERSION {
crate::twist::negate_library(&mut lib);
}
if lib.name.trim().is_empty() {
lib.name = name.into();
}
+20
View File
@@ -343,6 +343,20 @@ impl NineSliceMap {
self.cell_xf(self.x.cell(q.x), self.y.cell(q.y))
}
/// A box that holds everything [`Self::place`] draws of art whose geometric box in symbol space
/// is `art`, before strokes and effects: `art` placed by each of the nine cells' maps. An object
/// that crosses a guide and draws symbol art is placed by the map of the cell its center is in,
/// so it can draw outside the box the corner cells place `art` in.
pub fn placement_bounds(&self, art: Rect) -> Rect {
let mut b = self.cell_xf(0, 0).transform_rect_bbox(art);
for j in 0..3 {
for i in 0..3 {
b = b.union(self.cell_xf(i, j).transform_rect_bbox(art));
}
}
b
}
/// The guides in symbol space: the two x values and the two y values.
fn cuts(&self) -> ([f64; 2], [f64; 2]) {
let inv = self.natural.inverse();
@@ -704,6 +718,12 @@ impl Document {
NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf)
}
/// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already
/// measured.
pub(crate) fn symbol_slice_map_of(&self, name: &str, art: Rect, xf: Affine) -> Option<NineSliceMap> {
NineSliceMap::new(self.symbol_nine_slice(name)?, art, self.symbol_natural_xf(name), xf)
}
/// Art `art` of symbol `name` (stained as the caller draws it) placed by an instance's
/// transform `xf`, with strokes at their own weight: through the symbol's 9-slice guides when
/// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through
+171 -3
View File
@@ -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,
}
}
}
@@ -102,7 +119,11 @@ impl<'a> SymbolBoxes<'a> {
/// Makes these boxes measure every symbol instance as [`Node::reach_bounds_with`] does, by its
/// 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.
/// 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`]). 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
@@ -319,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()
}
@@ -383,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);
@@ -395,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)]
@@ -447,6 +521,100 @@ mod tests {
assert_eq!(scaled.reach_bounds_with(&s), Some(Rect::new(0.0, 0.0, 60.0, 30.0).inflate(reach, reach)));
}
#[test]
fn quick_boxes_hold_art_that_9_slice_guides_move_past_the_instance() {
// A 100 × 100 frame with guides a quarter in, holding an instance of a bar from x 10 to 70.
// Scaled 2 times across, the bar crosses the left guide and is placed by the middle
// column's map, x → 3x − 50: it draws from x −20 to 160, past the frame's left edge at 0.
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 panel = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
let exact = panel.visual_bounds_with(&d.symbol_boxes()).unwrap();
assert_eq!(exact, Rect::new(-20.0, 0.0, 200.0, 100.0));
let quick = panel.visual_bounds_with(&d.symbol_boxes().quick()).unwrap();
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);
+61
View File
@@ -451,3 +451,64 @@ fn pica_form_rejects_exponent_notation() {
assert_eq!(Unit::Points.parse(s), None, "{s}");
}
}
/// Symbol editing of symbol "S" on `layer`.
fn symbol_edit_on(layer: NodeId) -> SymbolEdit {
SymbolEdit {
symbol: "S".into(),
layer,
instance: None,
xf: Affine::IDENTITY,
group: false,
changed: false,
back_isolation: None,
back_layer: None,
}
}
/// The opacity-mask and symbol editing layers are left out of the saved document wherever they
/// are, also when `layer.move` made them sublayers of another layer.
#[test]
fn edit_mode_layers_are_dropped_as_sublayers_too() {
let mut d = Document::new(612.0, 792.0);
let host = d.layers[0].id;
let mask = d.add_layer(Some("Mask"));
let symbol = d.add_layer(Some("Symbol"));
d.move_node(mask, Some(host), 0).unwrap();
d.move_node(symbol, Some(host), usize::MAX).unwrap();
let object = d.alloc_id();
d.mask_edit = Some(MaskEdit { object, layer: mask });
d.symbol_edit = Some(symbol_edit_on(symbol));
let saved = d.without_edit_modes();
assert!(saved.node(mask).is_none(), "the mask editing layer is left out");
assert!(saved.node(symbol).is_none(), "the symbol editing layer is left out");
assert!(saved.node(host).is_some_and(|l| l.children().is_some_and(|c| c.is_empty())));
assert!(saved.mask_edit.is_none() && saved.symbol_edit.is_none());
}
/// Symbol editing is never read from a file, so a layer that file data names as the editing layer
/// loads and stays in the document, and saving does not write the value again.
#[test]
fn symbol_edit_is_not_read_from_a_file() {
let mut d = Document::new(612.0, 792.0);
let layer = d.layers[0].id;
let mut v = serde_json::to_value(&d).unwrap();
v["symbol_edit"] = serde_json::to_value(symbol_edit_on(layer)).unwrap();
d = serde_json::from_value(v).unwrap();
assert_eq!(d.symbol_edit, None);
assert!(d.extra.is_empty(), "not kept as an unknown key");
d.drop_edit_modes();
assert!(d.node(layer).is_some(), "the layer is kept");
assert!(serde_json::to_value(&d).unwrap().get("symbol_edit").is_none(), "not saved again");
}
/// An opacity-mask editing id in file data that names art inside a layer, not a layer, leaves the
/// art in place.
#[test]
fn mask_edit_naming_art_leaves_it() {
let (mut d, a, b) = doc_with_rects();
d.mask_edit = Some(MaskEdit { object: b, layer: a });
d.drop_edit_modes();
assert!(d.node(a).is_some() && d.node(b).is_some());
assert_eq!(d.mask_edit, None);
}
+1 -1
View File
@@ -16,7 +16,7 @@ mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics};
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
+28 -2
View File
@@ -143,10 +143,35 @@ 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
/// for Current Selection). The text keeps the character; layout draws the chosen glyph while
/// the run starts with that character and is set in the face the choice names, and the
/// default glyph otherwise. Saved only when set.
#[serde(default, rename = "glyphAlternate", skip_serializing_if = "Option::is_none")]
pub glyph_alternate: Option<GlyphChoice>,
}
/// A glyph chosen for one character ([`CharStyle::glyph_alternate`]): the glyph id and the face it
/// belongs to, named by its PostScript name and version string, so that a glyph id is never used
/// in another font or another version of the font.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct GlyphChoice {
/// The character the glyph is drawn for.
pub ch: char,
/// The glyph id in the face.
pub gid: u32,
/// The face's PostScript name (`SourceSans3-Regular`).
pub font: String,
/// The face's version string (`Version 3.052`); empty when it has none.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub version: String,
}
/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square
@@ -420,6 +445,7 @@ impl Default for CharStyle {
font_height_reference: FontHeightReference::EmBox,
no_break: false,
language: None,
glyph_alternate: None,
}
}
}
+231
View File
@@ -0,0 +1,231 @@
//! The Twist effect's angle in saved documents. A positive angle turns clockwise on the page in
//! VectorCraft 0.21.0 and later; earlier releases turned it counterclockwise. Documents of version
//! 2 ([`TWIST_CLOCKWISE_SINCE`]) turn clockwise. Version 1 documents written before 0.21.0 have
//! their Twist angles negated when they open, so they draw as they were saved
//! ([`Document::migrate_twist_angles`], [`written_clockwise`]), and a save for an earlier app
//! negates them back ([`Document::store_legacy_twist_angles`]). Graphic style library files
//! (`.vcstyles`) of version 1 have their Twist angles negated when they are read
//! ([`crate::style_libs::read`]); [`crate::style_libs::write`] writes version 2.
use std::sync::Arc;
use serde_json::{Map, Value, json};
use crate::style_libs::StyleLibrary;
use crate::{Appearance, Document, Effect, Node};
/// The Twist effect's id.
pub const TWIST: &str = "distort.twist";
/// The first document version whose Twist angles turn clockwise for a positive value.
pub const TWIST_CLOCKWISE_SINCE: u32 = 2;
/// The first release (major, minor) whose Twist turned clockwise for a positive angle.
const CLOCKWISE_RELEASE: (u64, u64) = (0, 21);
/// Did the app named by a native file's `generator` (`"VectorCraft 0.21.0"`) turn Twist effects
/// clockwise for a positive angle? It did from 0.21.0. Any other value, or none, is an earlier
/// app (DrawCraft included).
pub fn written_clockwise(generator: Option<&str>) -> bool {
let Some(version) = generator.and_then(|g| g.strip_prefix("VectorCraft ")) else { return false };
let mut parts = version.split('.').map(|p| p.parse::<u64>().ok());
match (parts.next().flatten(), parts.next().flatten()) {
(Some(major), Some(minor)) => (major, minor) >= CLOCKWISE_RELEASE,
_ => false,
}
}
/// The angle a Twist effect without a readable one draws with (`vectorcraft-effects` reads a
/// missing or unreadable `angle` as this).
const DEFAULT_ANGLE: f64 = 10.0;
/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s
/// `DOC_KEY`), whose art can carry effects.
const BRUSHES_KEY: &str = "brushes";
/// The angle a Twist effect with `params` draws with, read as `vectorcraft-effects` reads it: a
/// number, the number a string starts with, or a boolean as 1 or 0; anything else (or a value
/// that is not finite) is [`DEFAULT_ANGLE`].
fn angle(params: &Value) -> f64 {
let v = match params.get("angle") {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => {
let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect();
t.parse().ok()
}
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
};
v.filter(|v| v.is_finite()).unwrap_or(DEFAULT_ANGLE)
}
/// Write the negated angle a Twist effect with `params` draws with into `params`, as a number.
fn negate_params(params: &mut Value) {
// `0.0 - a` rather than `-a`: an angle of 0 stays 0, not -0.
let turned = Value::from(0.0 - angle(params));
match params {
Value::Object(m) => {
m.insert("angle".into(), turned);
}
p => *p = json!({ "angle": turned }),
}
}
fn has_twist(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.id == TWIST)
}
fn appearance_has_twist(a: &Appearance) -> bool {
has_twist(&a.effects) || a.items.iter().any(|i| has_twist(i.effects()))
}
fn negate_appearance(a: &mut Appearance) {
for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) {
if e.id == TWIST {
negate_params(&mut e.params);
}
}
}
/// The appearances a Shaper Group's face and edge edits paint with.
fn shaper_appearances(n: &Node) -> impl Iterator<Item = &Appearance> {
let s = n.extras().shaper.as_deref();
let faces = s.into_iter().flat_map(|s| s.faces.iter().filter_map(|f| f.paint.as_ref()));
let edges = s.into_iter().flat_map(|s| s.edges.iter().filter_map(|e| e.paint.as_ref()));
faces.chain(edges)
}
/// Does `n`'s subtree (opacity-mask art and Shaper edits included) hold a Twist effect?
fn node_has_twist(n: &Node) -> bool {
appearance_has_twist(&n.appearance)
|| n.extras().mask.as_ref().is_some_and(|m| node_has_twist(&m.art))
|| shaper_appearances(n).any(appearance_has_twist)
|| n.children().is_some_and(|c| c.iter().any(|c| node_has_twist(c)))
}
/// Negate the Twist angles of `a`'s subtree, copying only the nodes on the way to them.
fn negate_node(a: &mut Arc<Node>) {
if !node_has_twist(a) {
return;
}
let n = Arc::make_mut(a);
if appearance_has_twist(&n.appearance) {
negate_appearance(&mut n.appearance);
}
if let Some(x) = n.extras_if_any() {
if let Some(m) = x.mask.as_mut() {
negate_node(&mut m.art);
}
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())) {
negate_appearance(p);
}
}
}
for c in n.children_mut().into_iter().flatten() {
negate_node(c);
}
}
fn is_twist_json(m: &Map<String, Value>) -> bool {
m.get("id").and_then(Value::as_str) == Some(TWIST)
}
/// Does JSON `v` (brush definitions) hold a Twist effect?
fn json_has_twist(v: &Value) -> bool {
match v {
Value::Object(m) => is_twist_json(m) || m.values().any(json_has_twist),
Value::Array(a) => a.iter().any(json_has_twist),
_ => false,
}
}
/// Negate the Twist angles in JSON `v`: every object whose `id` is [`TWIST`] is a serialized
/// [`Effect`] (object ids are numbers).
fn negate_json(v: &mut Value) {
match v {
Value::Object(m) if is_twist_json(m) => negate_params(m.entry("params").or_insert(Value::Null)),
Value::Object(m) => m.values_mut().for_each(negate_json),
Value::Array(a) => a.iter_mut().for_each(negate_json),
_ => {}
}
}
/// Negate every Twist angle of a graphic style library: its styles' and its patterns' art.
pub(crate) fn negate_library(lib: &mut StyleLibrary) {
for g in &mut lib.styles {
negate_appearance(&mut g.appearance);
}
for p in &mut lib.patterns {
p.art.iter_mut().for_each(negate_node);
}
}
impl Document {
/// A document before version [`TWIST_CLOCKWISE_SINCE`] that holds a Twist effect: when
/// `written_clockwise` (asked only then; see [`written_clockwise`]) says the app that saved it
/// turned Twist counterclockwise, negate its Twist angles, so it draws as it was saved. A later
/// version is left as it is, and the document's version is not changed. It recurses through
/// the art, once per level of nesting.
pub fn migrate_twist_angles(&mut self, written_clockwise: impl FnOnce() -> bool) {
if self.version >= TWIST_CLOCKWISE_SINCE || !self.has_twist() || written_clockwise() {
return;
}
self.negate_twist_angles();
}
/// The reverse of [`Document::migrate_twist_angles`], for saving for an app that turned Twist
/// counterclockwise: negate the Twist angles and mark the document version 1. Call it on a
/// document whose angles turn clockwise, as every opened or new document's do, whatever
/// version another conversion for that save has marked it already.
pub fn store_legacy_twist_angles(&mut self) {
self.version = TWIST_CLOCKWISE_SINCE - 1;
if self.has_twist() {
self.negate_twist_angles();
}
}
/// Does any Twist effect sit where [`Document::negate_twist_angles`] negates them?
fn has_twist(&self) -> bool {
let mut 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()));
roots.any(|r| node_has_twist(r))
|| self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance))
|| self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist)
}
/// Negate every Twist angle: in the art of the layers, symbols, graph designs, pattern
/// swatches and the pattern definition that pattern editing's Cancel puts back (opacity-mask
/// art and Shaper edits included), in the graphic styles and in the brush definitions.
fn negate_twist_angles(&mut self) {
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 {
negate_node(root);
}
if self.graphic_styles.iter().any(|g| appearance_has_twist(&g.appearance)) {
for g in self.graphic_styles.iter_mut() {
negate_appearance(&mut g.appearance);
}
}
if self.unknown.get(BRUSHES_KEY).is_some_and(json_has_twist)
&& let Some(b) = self.unknown.get_mut(BRUSHES_KEY)
{
negate_json(b);
}
}
}
#[cfg(test)]
mod tests;
+186
View File
@@ -0,0 +1,186 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect, shapes};
use super::*;
use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec};
use crate::{GraphicStyle, OpacityMask, PatternDef, Symbol};
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
/// An appearance with a Twist of `params` on the object and on its fill, and a Roughen.
fn twisted_appearance(params: &Value) -> Appearance {
let mut a = Appearance::default_art();
a.effects.push(fx(TWIST, params.clone()));
a.effects.push(fx("distort.roughen", json!({"size": 5})));
a.items[0].effects_mut().push(fx(TWIST, params.clone()));
a
}
fn twisted(d: &mut Document, params: &Value) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), twisted_appearance(params))
}
/// A version 1 document with Twist effects of `params` everywhere one is stored: on a path, in a
/// group, in an opacity mask's art, in a Shaper edit, in a symbol, a pattern swatch and a graphic
/// style, and `brush` in a brush definition. Its first layer holds the art; its last has no
/// Twist.
fn document(params: &Value, brush: &Value) -> Document {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let path = twisted(&mut d, params);
d.insert(Some(l), 0, path).unwrap();
let inner = twisted(&mut d, params);
let group = Node::group(d.alloc_id(), vec![Arc::new(inner)]);
d.insert(Some(l), 1, group).unwrap();
let mask_art = twisted(&mut d, params);
let mut masked = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art());
masked.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, true)));
d.insert(Some(l), 2, masked).unwrap();
let mut shaper = Node::group(d.alloc_id(), vec![]);
let face = FaceEdit {
selector: FaceSelector { sources: vec![0], anchor: Point::ZERO },
erase: false,
erase_edges: false,
paint_source: None,
merge: None,
paint: Some(twisted_appearance(params)),
};
shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] }));
d.insert(Some(l), 3, shaper).unwrap();
let art = twisted(&mut d, params);
d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art), dynamic: false });
let art = twisted(&mut d, params);
d.patterns.push(PatternDef::new("P", vec![Arc::new(art)]));
d.graphic_styles.push(GraphicStyle::new("G", twisted_appearance(params)));
d.unknown.insert(BRUSHES_KEY.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [brush]}}}]));
d.add_layer(None);
d.version = 1;
d
}
/// The angle each Twist effect of the saved document draws with, in the order the JSON lists them.
fn angles(d: &Document) -> Vec<f64> {
fn walk(v: &Value, out: &mut Vec<f64>) {
match v {
Value::Object(m) => {
if is_twist_json(m) {
out.push(angle(m.get("params").unwrap_or(&Value::Null)));
}
m.values().for_each(|c| walk(c, out));
}
Value::Array(a) => a.iter().for_each(|c| walk(c, out)),
_ => {}
}
}
let mut out = vec![];
walk(&serde_json::to_value(d).unwrap(), &mut out);
out
}
#[test]
fn a_version_1_document_from_before_0_21_has_its_twist_angles_negated() {
let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST}));
let before = angles(&d);
// Two per path, shaper edit and style; one in the brush.
assert_eq!(before.len(), 15, "{before:?}");
assert_eq!(before.iter().filter(|&&a| a == DEFAULT_ANGLE).count(), 1, "{before:?}");
d.migrate_twist_angles(|| false);
assert_eq!(d.version, 1, "the caller sets the version");
assert_eq!(angles(&d), before.iter().map(|a| -a).collect::<Vec<_>>());
let roughen = serde_json::to_string(&d).unwrap().matches(r#""id":"distort.roughen","params":{"size":5}"#).count();
assert_eq!(roughen, 7, "other effects are left alone");
}
#[test]
fn a_document_that_turned_clockwise_is_left_as_it_is() {
let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST, "params": {"angle": 30}}));
let before = d.clone();
d.migrate_twist_angles(|| true);
assert_eq!(d, before, "version 1, written by 0.21.0 or later");
d.version = 2;
let before = d.clone();
d.migrate_twist_angles(|| panic!("a version 2 document's writer is not asked about"));
assert_eq!(d, before, "version 2");
}
#[test]
fn the_writer_is_asked_about_only_when_a_twist_is_stored() {
let mut d = Document::new(10.0, 10.0);
d.version = 1;
let before = d.clone();
d.migrate_twist_angles(|| panic!("a document without a Twist needs no answer"));
assert_eq!(d, before);
}
#[test]
fn angles_are_read_as_the_effect_reads_them() {
let read = |params: Value| {
let mut e = fx(TWIST, params);
negate_params(&mut e.params);
e.params
};
assert_eq!(read(json!({"angle": 30, "x": 1})), json!({"angle": -30.0, "x": 1}));
assert_eq!(read(json!({"angle": "45 deg"})), json!({"angle": -45.0}));
assert_eq!(read(json!({"angle": true})), json!({"angle": -1.0}));
assert_eq!(read(json!({})), json!({"angle": -10.0}));
assert_eq!(read(Value::Null), json!({"angle": -10.0}));
let zero = read(json!({"angle": 0}));
assert!(zero["angle"].as_f64().is_some_and(|a| a == 0.0 && a.is_sign_positive()), "{zero}");
}
#[test]
fn only_the_art_holding_a_twist_is_copied() {
let mut d = document(&json!({"angle": 30}), &json!({"id": TWIST}));
let plain = d.layers.last().cloned().unwrap();
let twisted = d.layers[0].clone();
d.migrate_twist_angles(|| false);
assert!(Arc::ptr_eq(d.layers.last().unwrap(), &plain), "a layer without a Twist is not copied");
assert!(!Arc::ptr_eq(&d.layers[0], &twisted));
}
#[test]
fn storing_for_an_earlier_app_and_reading_back_round_trips() {
let original = document(&json!({"angle": 30.0}), &json!({"id": TWIST, "params": {"angle": -12.5}}));
let mut d = original.clone();
d.migrate_twist_angles(|| false);
d.version = 2;
let current = d.clone();
assert_eq!(angles(&current), angles(&original).iter().map(|a| -a).collect::<Vec<_>>());
d.store_legacy_twist_angles();
assert_eq!(d, original);
d.migrate_twist_angles(|| false);
d.version = 2;
assert_eq!(d, current);
// Another conversion for the same save may have marked the document version 1 already.
d.version = 1;
d.store_legacy_twist_angles();
assert_eq!(d, original);
}
#[test]
fn a_version_1_style_library_has_its_twist_angles_negated() {
let lib = StyleLibrary { name: "L".into(), styles: vec![GraphicStyle::new("G", twisted_appearance(&json!({"angle": 30.0})))], patterns: vec![] };
let text = crate::style_libs::write(&lib);
assert!(text.contains(r#""version": 2"#), "{text}");
assert_eq!(crate::style_libs::read(&text, "x").unwrap(), lib, "a version 2 file reads as written");
let old = crate::style_libs::read(&text.replacen(r#""version": 2"#, r#""version": 1"#, 1), "x").unwrap();
let fx = &old.styles[0].appearance.effects;
assert_eq!(fx[0].params, json!({"angle": -30.0}));
assert_eq!(fx[1], lib.styles[0].appearance.effects[1]);
assert_eq!(old.styles[0].appearance.items[0].effects()[0].params, json!({"angle": -30.0}));
}
#[test]
fn releases_from_0_21_turned_twist_clockwise() {
for g in ["VectorCraft 0.21.0", "VectorCraft 0.21.4", "VectorCraft 0.22.0-dev", "VectorCraft 1.0.0"] {
assert!(written_clockwise(Some(g)), "{g}");
}
for g in ["VectorCraft 0.20.0", "VectorCraft 0.9.12", "DrawCraft 0.1.0", "VectorCraft", "VectorCraft x.y", "vectorcraft 0.21.0"] {
assert!(!written_clockwise(Some(g)), "{g}");
}
assert!(!written_clockwise(None));
}
+22 -5
View File
@@ -43,13 +43,25 @@ pub fn is_length(id: &str, key: &str, relative: bool) -> bool {
}
/// The range in points that distance parameter `key` of effect `id` is kept within, as its
/// parameter documentation gives it (`{maxRadius: pt 4..127 (8), …}`); `None` when it gives none.
/// parameter documentation gives it (`{edgeWidth: pt 1..14 (2), …}`); `None` when it gives none.
pub fn length_range(id: &str, key: &str) -> Option<std::ops::RangeInclusive<f64>> {
documented_range(id, key, "pt")
}
/// The range of parameter `key` of effect `id` when it is a size in pixels of the Document Raster
/// Effects resolution (`{maxRadius: px 4..127 (8), …}`), which no unit setting changes and which
/// stays in pixels when the resolution does; `None` for other parameters.
pub fn raster_px_range(id: &str, key: &str) -> Option<std::ops::RangeInclusive<f64>> {
documented_range(id, key, "px")
}
/// The range parameter `key` of effect `id` is documented with in `unit` (`key: unit lo..hi`).
fn documented_range(id: &str, key: &str, unit: &str) -> Option<std::ops::RangeInclusive<f64>> {
let doc = catalog_index().get(id)?.params;
let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' ');
let rest = doc.match_indices(key).map(|(at, _)| at).filter(|at| starts(*at)).find_map(|at| {
let rest = doc.get(at + key.len()..)?;
rest.strip_prefix(": pt ").or_else(|| rest.strip_prefix("?: pt "))
rest.strip_prefix(": ").or_else(|| rest.strip_prefix("?: "))?.strip_prefix(unit)?.strip_prefix(' ')
})?;
let (lo, hi) = rest.split_whitespace().next()?.split_once("..")?;
let (lo, hi) = (lo.parse::<f64>().ok()?, hi.trim_end_matches(',').parse::<f64>().ok()?);
@@ -81,7 +93,6 @@ fn lengths_of(id: &str) -> Lengths {
"brushStrokes.spatter" => always(&["sprayRadius"]),
"brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]),
"brushStrokes.sumiE" => always(&["strokeWidth"]),
"pixelate.colorHalftone" => always(&["maxRadius"]),
"pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]),
"texture.craquelure" => always(&["crackSpacing"]),
"texture.mosaicTiles" => always(&["tileSize"]),
@@ -296,7 +307,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}),
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
r(
"blur.gaussian",
"Gaussian Blur…",
BLUR,
"{radius: pt (5), linear?: bool (false; blurs the colours in linear light, as an SVG filter with color-interpolation-filters=\"linearRGB\" does)}",
json!({"radius": 5.0}),
),
r(
"artistic.coloredPencil",
"Colored Pencil…",
@@ -633,7 +650,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"pixelate.colorHalftone",
"Color Halftone…",
PIXELATE,
"{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell",
"{maxRadius: px 4..127 (8; the radius of a dot at full strength, which fills its square cell, in pixels of the Document Raster Effects resolution), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell",
json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}),
),
r(
+4 -4
View File
@@ -77,10 +77,10 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects};
pub use reshape::{
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint,
needs_outline, outline_art, outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_copies, has_transform_options,
image_paint, needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
@@ -95,7 +95,7 @@ mod catalog;
mod geometry;
mod params;
pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects};
pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects, raster_px_range};
pub(crate) use geometry::{Budget, apply_one};
pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with};
#[cfg(test)]
+6 -3
View File
@@ -95,13 +95,16 @@ pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
}
/// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see
/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the
/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent.
/// [`EffectInfo::lengths`]), and its sizes in raster pixels ([`crate::raster_px_range`]), which
/// keep their share of the object so. A distance left at its default is written out scaled; one
/// the effect doesn't have (Outline Stroke's weight following its stroke) stays absent.
pub fn scale_effect(e: &mut Effect, s: f64) {
let Some(info) = catalog_index().get(e.id.as_str()) else { return };
let merged = merged_params(&e.id, &e.params);
let relative = util::flag(&merged, "relative", false);
for k in info.lengths.keys(relative) {
let mut keys: Vec<&str> = info.lengths.keys(relative).collect();
keys.extend(merged.as_object().into_iter().flat_map(|m| m.keys()).map(String::as_str).filter(|k| crate::raster_px_range(&e.id, k).is_some()));
for k in keys {
let v = util::num(&merged, k, f64::NAN);
if v.is_finite() {
if !e.params.is_object() {
+51 -5
View File
@@ -254,7 +254,8 @@ pub enum PixelFx {
/// Stylize › Glowing Edges: bright coloured Sobel outlines on black.
GlowingEdges { width: f64, brightness: f64, smoothness: f64 },
/// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels
/// 1 to 4) into dots of up to `max_radius`, whose area follows the channel's strength.
/// 1 to 4) into dots of up to `max_radius` pixels of the Document Raster Effects resolution
/// ([`PixelSpace::line`] each), whose area follows the channel's strength.
ColorHalftone { max_radius: f64, angles: [f64; 4] },
/// Pixelate › Crystallize: polygons of solid colour around random points about `cell` apart.
Crystallize { cell: f64 },
@@ -668,9 +669,10 @@ impl PixelFx {
}
}
/// How far (document units) from a pixel the content that decides it lies; `None` when that
/// is anywhere in the object (Radial Blur).
pub fn reach(&self) -> Option<f64> {
/// How far (document units) from a pixel the content that decides it lies, with lines of the
/// raster grid `line` apart ([`PixelSpace::line`]); `None` when that is anywhere in the object
/// (Radial Blur).
pub fn reach(&self, line: f64) -> Option<f64> {
match *self {
PixelFx::RadialBlur { .. } => None,
PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius),
@@ -703,7 +705,10 @@ impl PixelFx {
PixelFx::Stamp { smoothness, .. } => Some(sketch_ink::stamp_reach(smoothness)),
PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)),
// The cells around the pixel's, and the cells around theirs.
PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius),
// (Without a usable grid there are no dots: see `pixelate::color_halftone`.)
PixelFx::ColorHalftone { max_radius, .. } => {
Some(3.0 * std::f64::consts::SQRT_2 * max_radius * line).filter(|d| d.is_finite()).or(Some(0.0))
}
PixelFx::Crystallize { cell } => Some(1.5 * cell),
PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)),
PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH),
@@ -1074,6 +1079,47 @@ pub fn gaussian_rgba(data: &mut [u8], w: usize, h: usize, sigma: f64) {
}
}
/// [`gaussian_rgba`] with the colours mixed in linear light: decoded from sRGB before the blur
/// and encoded again after it (Filter Effects 1 §16, `color-interpolation-filters: linearRGB`,
/// <https://www.w3.org/TR/filter-effects-1/#ColorInterpolationFiltersProperty>). The blur runs on
/// 16-bit values, so dark linear tones keep their steps.
pub fn gaussian_rgba_linear(data: &mut [u8], w: usize, h: usize, sigma: f64) {
use vectorcraft_doc::color::cms::lab::{linear_to_srgb, srgb_to_linear};
if let Some(px) = pixels(data, w, h)
&& sigma >= 0.2
{
// Each colour is unpremultiplied, decoded (by table) and premultiplied again.
let decode: Vec<f32> = (0..=255u8).map(|v| srgb_to_linear(f32::from(v) / 255.0)).collect();
let mut wide: Vec<Px16> = px
.iter()
.map(|p| {
let a = f32::from(p[3]) / 255.0;
let lin = |v: u8| {
if p[3] == 0 {
return 0;
}
let straight = (f32::from(v) / f32::from(p[3]) * 255.0).round().clamp(0.0, 255.0) as usize;
(decode.get(straight).copied().unwrap_or(0.0) * a * 65535.0).round().clamp(0.0, 65535.0) as u16
};
[lin(p[0]), lin(p[1]), lin(p[2]), u16::from(p[3]) * 257]
})
.collect();
gaussian(&mut wide, w, sigma);
for (p, q) in px.iter_mut().zip(&wide) {
let a = to8(q[3]);
let alpha = f32::from(q[3]) / 65535.0;
let enc = |v: u16| {
if alpha <= 0.0 {
return 0;
}
let straight = (f32::from(v) / 65535.0 / alpha).clamp(0.0, 1.0);
(linear_to_srgb(straight) * f32::from(a)).round().clamp(0.0, f32::from(a)) as u8
};
*p = [enc(q[0]), enc(q[1]), enc(q[2]), a];
}
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+4 -3
View File
@@ -436,11 +436,12 @@ impl Screen {
}
/// Color Halftone: each channel screened at its angle in square cells sized so that a dot of
/// `max_radius` fills one; each cell's dot covers the share of it that is the channel's mean
/// there (white stays white, black black). Coverage stays as it is.
/// `max_radius` (pixels of the document's raster grid, `space.line` each) fills one; each cell's
/// dot covers the share of it that is the channel's mean there (white stays white, black black).
/// Coverage stays as it is.
pub(super) fn color_halftone(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, max_radius: f64, angles: [f64; 4]) {
let Some(place) = Place::new(space, w, h) else { return };
let side = max_radius * std::f64::consts::SQRT_2;
let side = max_radius * space.line * std::f64::consts::SQRT_2;
// Cells under 3 pixels can't show their dots: on average a screen is the image itself.
if !(side.is_finite() && side / place.px >= 3.0) {
return;
+69 -28
View File
@@ -80,7 +80,7 @@ fn film_grain_parameters_are_bounded_and_neutral_settings_preserve_the_art() {
neutral.apply(&mut out, w, h, &space(w, h));
assert_eq!(out, src);
assert_eq!(neutral.outset(Rect::new(0.0, 0.0, w as f64, h as f64)), 0.0);
assert_eq!(neutral.reach(), Some(0.0));
assert_eq!(neutral.reach(1.0), Some(0.0));
}
#[test]
@@ -520,12 +520,12 @@ fn outsets_follow_the_object() {
assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9);
assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far);
assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0);
assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(), Some(4.0));
assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(1.0), Some(4.0));
// The edge modes also read the copy blurred over a wide radius's samples.
assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(), Some(5.0));
assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(1.0), Some(5.0));
assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0);
assert_eq!(spin.reach(), None);
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0));
assert_eq!(spin.reach(1.0), None);
assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(1.0), Some(6.0));
}
#[test]
@@ -887,6 +887,28 @@ fn gaussian_and_plane_blurs_keep_their_mass() {
assert!((plane.iter().sum::<f32>() - 100.0).abs() < 0.5 && plane[15 * w + 13] > 0.0 && plane[13 * w + 15] > 0.0);
}
/// Blurred in linear light, black beside white meets at about 188/255 (half the light, encoded),
/// where the plain blur meets at about 128; flat areas and alpha stay as they are (#5877).
#[test]
fn linear_light_blur_mixes_light_not_values() {
// Large enough that the transparency past the edges doesn't reach the middle.
let (w, h) = (120, 60);
let src = image(w, h, |x, _| if x < 60 { [0, 0, 0, 255] } else { [255, 255, 255, 255] });
let (mut plain, mut linear) = (src.clone(), src.clone());
gaussian_rgba(&mut plain, w, h, 3.0);
gaussian_rgba_linear(&mut linear, w, h, 3.0);
let mid = |d: &[u8]| (f32::from(at(d, w, 59, 30)[0]) + f32::from(at(d, w, 60, 30)[0])) / 2.0;
assert!((mid(&plain) - 128.0).abs() < 6.0, "{}", mid(&plain));
assert!((mid(&linear) - 188.0).abs() < 6.0, "{}", mid(&linear));
assert_eq!((at(&linear, w, 30, 30), at(&linear, w, 90, 30)), ([0, 0, 0, 255], [255, 255, 255, 255]));
// Premultiplied input: a half-transparent white square blurs to white at any alpha.
let mut soft = image(30, 30, |x, y| if (10..20).contains(&x) && (10..20).contains(&y) { [128, 128, 128, 128] } else { [0, 0, 0, 0] });
gaussian_rgba_linear(&mut soft, 30, 30, 2.0);
for p in soft.as_chunks::<4>().0 {
assert!(p[0] <= p[3] && p[3] <= 128 && (p[3] < 8 || p[0] + 2 >= p[3]), "{p:?}");
}
}
/// Timings on a 2000 × 2000 raster (`cargo test -p vectorcraft-effects --release -- --ignored
/// --nocapture pixel_timings`).
#[test]
@@ -962,7 +984,7 @@ fn pixelate_params_take_defaults_and_stay_in_range() {
assert_eq!(fx("pixelate.crystallize", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 20})).outset(b), 30.0);
assert_eq!(fx("pixelate.colorHalftone", json!({})).outset(b), 0.0);
assert_eq!(fx("pixelate.mezzotint", json!({})).reach(), Some(0.0));
assert_eq!(fx("pixelate.mezzotint", json!({})).reach(1.0), Some(0.0));
}
/// Fraction of `d`'s pixels for which `f` holds.
@@ -1002,6 +1024,25 @@ fn color_halftone_screens_midtones_into_dots_and_keeps_white_and_black() {
assert!(lit(8) < lit(40), "{} < {}", lit(8), lit(40));
}
#[test]
fn color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution() {
// Max Radius counts the document's raster grid pixels (#3260): 16 px at 144 ppi (lines half a
// point apart) are the 8 px of 72 ppi, and draw the same dots.
let (w, h) = (64, 64);
let src = image(w, h, |x, y| [(x * 4) as u8, (y * 4) as u8, 128, 255]);
let run = |radius: f64, line: f64| {
let mut d = src.clone();
fx("pixelate.colorHalftone", json!({"maxRadius": radius})).apply(&mut d, w, h, &PixelSpace { line, ..space(w, h) });
d
};
assert_eq!(run(16.0, 0.5), run(8.0, 1.0));
assert_ne!(run(8.0, 0.5), run(8.0, 1.0), "the same radius at a finer resolution draws finer dots");
// It looks as far around a pixel as its cells reach, in document units.
let halftone = fx("pixelate.colorHalftone", json!({"maxRadius": 8}));
assert_eq!(halftone.reach(0.5), Some(3.0 * std::f64::consts::SQRT_2 * 4.0));
assert_eq!(halftone.reach(f64::NAN), Some(0.0), "no grid, no dots");
}
#[test]
fn color_halftone_screens_inks_in_cmyk_documents() {
let (w, h) = (64, 64);
@@ -1241,8 +1282,8 @@ fn texture_params_take_defaults_and_stay_in_range() {
for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] {
assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}");
}
assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0));
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0));
assert_eq!(fx("texture.grain", json!({})).reach(1.0), Some(0.0));
assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(1.0), Some(6.0));
}
/// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes.
@@ -1509,9 +1550,9 @@ fn distort_params_take_defaults_and_stay_in_range() {
// Shifted content reaches past the bounds by the longest shift, and comes from as far.
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
let glass = fx("distort.glass", json!({"distortion": 20}));
assert_eq!((glass.outset(b), glass.reach()), (10.0, Some(10.0)));
assert_eq!((glass.outset(b), glass.reach(1.0)), (10.0, Some(10.0)));
let ripple = fx("distort.oceanRipple", json!({}));
assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach() == Some(ripple.outset(b)));
assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach(1.0) == Some(ripple.outset(b)));
assert_eq!(fx("distort.diffuseGlow", json!({})).outset(b), 0.0);
}
@@ -1621,7 +1662,7 @@ fn ocean_ripple_moves_content_no_farther_than_its_magnitude() {
fx("distort.oceanRipple", json!({"rippleMagnitude": 0})).apply(&mut d, w, h, &space(w, h));
assert_eq!(d, src, "no magnitude, no ripples");
let f = fx("distort.oceanRipple", json!({"rippleSize": 6, "rippleMagnitude": 20}));
let most = f.reach().unwrap();
let most = f.reach(1.0).unwrap();
let mut d = src.clone();
f.apply(&mut d, w, h, &space(w, h));
assert_ne!(d, src);
@@ -1765,19 +1806,19 @@ fn brush_strokes_outsets() {
let reach = brushstrokes::stroke_reach;
assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9);
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0);
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0));
assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(1.0), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0));
assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0));
assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9);
assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0));
assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0));
let spatter = fx("brushStrokes.spatter", json!({}));
assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0)));
assert_eq!((spatter.outset(b), spatter.reach(1.0)), (4.0, Some(4.0)));
assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0);
assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0);
assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0);
for id in brush_strokes() {
let f = fx(id, json!({}));
assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}");
assert!(f.reach(1.0).is_some_and(|r| r >= f.outset(b)), "{id}");
}
}
@@ -1961,7 +2002,7 @@ fn spatter_scatters_content_no_farther_than_its_radius() {
let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] });
assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter");
let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15}));
let most = f.reach().unwrap();
let most = f.reach(1.0).unwrap();
let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15}));
assert_ne!(d, src);
let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0;
@@ -1990,7 +2031,7 @@ fn brush_strokes_look_no_farther_than_their_reach() {
("brushStrokes.sumiE", json!({"strokeWidth": 15})),
]);
for (id, p) in cases {
let reach = fx(id, p.clone()).reach().unwrap();
let reach = fx(id, p.clone()).reach(1.0).unwrap();
// Everything from `cut` on differs; the pixels left of 20 can't tell.
let cut = 20 + reach.ceil() as usize + 1;
assert!(cut < w, "{id}");
@@ -2047,7 +2088,7 @@ fn chalk_charcoal_params_take_defaults_and_stay_in_range() {
assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}");
let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert!(f.reach().unwrap() > 0.0);
assert!(f.reach(1.0).unwrap() > 0.0);
}
#[test]
@@ -2108,7 +2149,7 @@ fn graphic_pen_params_take_defaults_and_stay_in_range() {
assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
let f = fx("sketch.graphicPen", json!({"strokeLength": 15}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0));
assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
assert_eq!(f.reach(1.0), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH)));
assert_eq!(
fx("sketch.graphicPen", json!({"strokeLength": 99})),
PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal }
@@ -2148,7 +2189,7 @@ fn halftone_pattern_params_take_defaults_and_stay_in_range() {
assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}");
let f = fx("sketch.halftonePattern", json!({"size": 12}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
assert_eq!(f.reach(1.0), Some(0.0));
}
#[test]
@@ -2162,7 +2203,7 @@ fn note_paper_params_take_defaults_and_stay_in_range() {
assert!(doc.contains("imageBalance") && doc.contains("graininess") && doc.contains("relief"), "{doc}");
let f = fx("sketch.notePaper", json!({"relief": 12}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
assert_eq!(f.reach(1.0), Some(0.0));
}
/// Note Paper embosses a horizontal gradient and graininess adds fibre.
@@ -2198,7 +2239,7 @@ fn water_paper_params_take_defaults_and_stay_in_range() {
);
let f = fx("sketch.waterPaper", json!({"fiberLength": 40}));
assert!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)) > 0.0);
assert!(f.reach().unwrap_or(0.0) > 0.0);
assert!(f.reach(1.0).unwrap_or(0.0) > 0.0);
let doc = crate::effect_info("sketch.waterPaper").unwrap().params;
assert!(doc.contains("unverified"), "{doc}");
}
@@ -2281,7 +2322,7 @@ fn plaster_params_take_defaults_and_stay_in_range() {
let doc = crate::effect_info("sketch.plaster").unwrap().params;
assert!(doc.contains("\"bottomRight\""), "{doc}");
let f = fx("sketch.plaster", json!({"smoothness": 24}));
assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0)));
assert_eq!(f.reach(1.0), Some(super::sketch::plaster_reach(15.0)));
}
/// Plaster changes the image and different light directions give different results.
@@ -2356,8 +2397,8 @@ fn chrome_params_take_defaults_and_stay_in_range() {
assert!(doc.contains("detail") && doc.contains("smoothness"), "{doc}");
let f = fx("sketch.chrome", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(sketch::chrome_reach(4.0)));
assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(), Some(sketch::chrome_reach(10.0)));
assert_eq!(f.reach(1.0), Some(sketch::chrome_reach(4.0)));
assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(1.0), Some(sketch::chrome_reach(10.0)));
}
/// Chrome redraws in greyscale, keeps coverage, and more detail changes the pattern.
@@ -2389,7 +2430,7 @@ fn reticulation_params_take_defaults_and_stay_in_range() {
);
let f = fx("sketch.reticulation", json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0);
assert_eq!(f.reach(), Some(0.0));
assert_eq!(f.reach(1.0), Some(0.0));
}
#[test]
@@ -2431,7 +2472,7 @@ fn torn_edges_params_take_defaults_and_stay_in_range() {
for junk in [json!({"imageBalance": "x"}), json!({"smoothness": null})] {
assert_eq!(fx("sketch.tornEdges", junk), fx("sketch.tornEdges", json!({})));
}
assert_eq!(fx("sketch.tornEdges", json!({})).reach(), None);
assert_eq!(fx("sketch.tornEdges", json!({})).reach(1.0), None);
}
#[test]
@@ -2496,7 +2537,7 @@ fn artistic_params_take_defaults_and_stay_in_range() {
fx("artistic.coloredPencil", json!({"pencilWidth": 0, "strokePressure": 200, "paperBrightness": -5})),
PixelFx::ColoredPencil { width: 1.0, pressure: 15.0, paper: 0.0 }
);
assert_eq!(fx("artistic.coloredPencil", json!({})).reach(), Some(artistic::colored_pencil_reach(5.0)));
assert_eq!(fx("artistic.coloredPencil", json!({})).reach(1.0), Some(artistic::colored_pencil_reach(5.0)));
assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 });
assert_eq!(
fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})),
@@ -2860,7 +2901,7 @@ fn dry_brush_repaints_and_preserves_coverage() {
let before = clear.clone();
fx("artistic.dryBrush", json!({})).apply(&mut clear, w, h, &space(w, h));
assert_eq!(clear, before);
let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach();
let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach(1.0);
assert!(reach.is_some_and(|r| r > 0.0));
}
+1 -1
View File
@@ -176,7 +176,7 @@ fn sketch_filters_reach_beyond_nothing_they_do_not_paint() {
for id in ["sketch.basRelief", "sketch.charcoal", "sketch.conteCrayon", "sketch.photocopy", "sketch.stamp"] {
let f = fx(id, json!({}));
assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage");
assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
assert!(f.reach(1.0).is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach");
}
}
+6 -4
View File
@@ -48,9 +48,11 @@ pub enum RasterFx {
Feather {
radius: f64,
},
/// Blur the whole object (σ = radius / 2).
/// Blur the whole object (σ = radius / 2): its colours mixed as they are, or in linear light
/// (`linear`, as an SVG filter asks with `color-interpolation-filters="linearRGB"`).
GaussianBlur {
radius: f64,
linear: bool,
},
/// A Photoshop-style filter over the object's pixels (Radial Blur, Unsharp Mask…).
Pixel(PixelFx),
@@ -77,7 +79,7 @@ impl RasterFx {
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
RasterFx::GaussianBlur { radius } => 1.5 * radius,
RasterFx::GaussianBlur { radius, .. } => 1.5 * radius,
RasterFx::Pixel(p) => p.outset(bounds),
RasterFx::Rasterize(r) => r.outset(),
}
@@ -366,7 +368,7 @@ pub fn blend_mode(s: &str) -> BlendMode {
}
/// An effect's `color` parameter (`"#rrggbb"`, `[r, g, b]` 0..1 or a colour object).
pub(crate) fn color_param(p: &Value, default: Color) -> Color {
pub fn color_param(p: &Value, default: Color) -> Color {
match p.get("color") {
Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default),
Some(Value::Array(a)) if a.len() >= 3 => {
@@ -416,7 +418,7 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
},
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0), linear: flag(&p, "linear", false) },
RASTERIZE => RasterFx::Rasterize(Rasterize::parse(&p)),
id => RasterFx::Pixel(pixel::parse(id, &p)?),
})
+93 -16
View File
@@ -39,8 +39,38 @@ pub fn needs_outline(n: &Node) -> bool {
/// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy
/// paints its gradient by itself ([`copy_leaf`]), as a group's copies do.
pub fn has_gradient_copies(n: &Node) -> bool {
(n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) && has_transform_copies(n)
}
/// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and
/// strokes that paint the copies differently as one path than as separate objects
/// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy
/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it
/// (#5920).
pub(crate) fn has_layered_copies(n: &Node) -> bool {
leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n)
}
/// Do the fills and strokes `a` of a path with fill rule `rule` paint a Transform effect's copies
/// differently when the copies are one path than when each is an object of its own? They do
/// unless they paint once, in full and in Normal mode, with a stroke or a nonzero fill: then the
/// one path paints what the copies paint together. A fill and a stroke paint each copy's stroke
/// under the fills of the copies above it, a second fill or stroke likewise, the even-odd rule
/// leaves holes where copies overlap, and a partly transparent or blended fill or stroke paints
/// overlapping copies twice (#5920).
fn paints_copies_apart(a: &Appearance, rule: FillRule) -> bool {
let mut painting = a.items.iter().filter(|i| crate::paints(i));
let (Some(one), None) = (painting.next(), painting.next()) else {
// None paints (nothing to draw either way), or several do.
return a.items.iter().any(crate::paints);
};
one.opacity() < 1.0 || one.blend() != Default::default() || (one.is_fill() && rule != FillRule::NonZero)
}
/// Is `n` a path or compound path with a visible Transform effect that makes copies? Expand
/// Appearance makes each copy an object of its own ([`expand_outlined`]).
pub fn has_transform_copies(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient())
&& geometry_effects(n)
.iter()
.any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0)
@@ -72,10 +102,10 @@ fn reaches_appearance(e: &Effect) -> bool {
}
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
/// ([`has_gradient_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`])? It is drawn, baked and expanded through
/// that art ([`reshape`]).
pub fn has_transform_art(n: &Node) -> bool {
has_gradient_copies(n) || has_transform_options(n)
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n)
}
/// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a
@@ -249,11 +279,20 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), false)
}
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]); with
/// `apart`, also a path or compound path whose Transform effect makes copies
/// ([`has_transform_copies`]), as the one piece the copies are made from.
fn outline_art_with(
doc: Option<&Document>,
n: &Node,
symbol_art: Option<&Node>,
outline: OutlineHook,
nodes: &SymbolNodes,
apart: bool,
) -> Option<Node> {
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { symbol, xf } => {
@@ -268,7 +307,7 @@ fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>,
Node::group(n.id, vec![Arc::new(a)])
}
// The object as the one piece the copies of its Transform effect are made from.
_ if has_transform_art(n) => {
_ if has_transform_art(n) || (apart && has_transform_copies(n)) => {
let mut one = n.clone();
one.opacity = 1.0;
one.blend = Default::default();
@@ -429,10 +468,12 @@ pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> O
/// geometry effects, else type with fills or strokes of its own outlined, or an image with fills
/// or strokes of its own that paint a shape, in the same form. `None` when it has none of these.
pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
// patterns: each copy an object of its own.
if has_transform_art(n) {
return as_art(doc, n, symbol_art);
// A path whose Transform effect copies it, or scales its strokes or moves its patterns: each
// copy an object of its own with the path's fills and strokes, the copies of each Transform
// effect in a group of their own (copies of copies nest), as the reference app expands them
// (#5612).
if has_transform_art(n) || has_transform_copies(n) {
return as_art_opts(doc, n, symbol_art, &outline_text, &SymbolNodes::default(), true);
}
let own_paint = match n.kind {
NodeKind::Text(_) => !n.appearance.items.is_empty(),
@@ -451,6 +492,12 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
as_art_opts(doc, n, symbol_art, outline, nodes, false)
}
/// [`as_art_with`]; with `apart`, a path's or compound path's Transform effects make each copy an
/// object of its own ([`copy_leaf`]) rather than one path of every copy.
fn as_art_opts(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes, apart: bool) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -478,9 +525,12 @@ pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art_with(doc, n, symbol_art, outline, nodes)?,
_ => outline_art_with(doc, n, symbol_art, outline, nodes, apart)?,
};
reshape_leaves(&mut art, &fx);
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart);
if apart {
hoist_copies(&mut art);
}
transform_items(&mut art);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
@@ -546,6 +596,28 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost.
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) {
reshape_leaves_apart(art, fx, budget, false);
}
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
/// the group of its copies ([`copy_leaf`]) as its only member: the copies become its members, so
/// the expanded path is one group of its copies rather than a group in a group.
fn hoist_copies(art: &mut Node) {
let id = art.id;
let Some(children) = art.children_mut() else { return };
let [only] = children.as_slice() else { return };
if only.id != id || !matches!(only.kind, NodeKind::Group { clip: false, .. }) {
return;
}
let copies = only.children().cloned().unwrap_or_default();
if !copies.is_empty() {
*children = copies;
}
}
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them.
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) {
if fx.is_empty() {
return;
}
@@ -588,14 +660,19 @@ pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budg
if let Some((m, copies, options, map)) = transform {
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
// strokes; a gradient paints each copy by itself, not the one piece the copies
// make together.
let gradient = copies > 0 && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient());
// make together; and Expand Appearance (`apart`) makes each copy an object.
// Fills and strokes that stack paint each copy above the one before (#5920).
let separate = copies > 0
&& (apart
|| leaf.appearance.has_unplaced_gradient()
|| leaf.appearance.has_placed_gradient()
|| paints_copies_apart(&leaf.appearance, rule));
if options.reach_appearance() {
let made = transform_leaf(leaf, &path, map, copies, options, room);
used = used.minus(before).plus(made);
return;
}
if gradient {
if separate {
let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room);
used = used.minus(before).plus(made);
return;
+137 -35
View File
@@ -155,18 +155,87 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let (path, rule) =
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()));
}
let (path, rule) = profile(n).ok_or_else(|| {
format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.")
})?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
facets(id, &path, rule, b, p).map(|_| ())
}
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
/// it, and the opacity of the objects it is in (1 for the object with the effect itself).
struct Profile {
path: PathData,
rule: FillRule,
items: Vec<AppearanceItem>,
opacity: f32,
}
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
}
/// The bounds of every shape together: the centre a group's members turn about.
fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c))
}
/// The most members a 3D effect on a group builds solids from.
const MAX_MEMBERS: usize = 4096;
/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path,
/// compound path and type in it, at any depth of its groups, each through its own geometry
/// effects and painted with its own fills and strokes. Like the effect on a group in the
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
/// than each about its own. An error names what the group holds that can't become a solid.
fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec<Profile>) -> Result<(), String> {
for c in n.children().into_iter().flatten() {
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
continue;
}
let opacity = opacity * c.opacity;
if is_container(c) {
walk(c, opacity, label, out)?;
continue;
}
let Some((path, rule)) = profile(c) else {
let what = match &c.kind {
NodeKind::Group { clip: true, .. } => "clipping groups",
NodeKind::Image(_) => "images",
NodeKind::SymbolInstance { .. } => "symbol instances",
NodeKind::Path { clipping: true, .. } => "clipping paths",
_ => "objects other than paths, compound paths and type",
};
return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first."));
};
if out.len() >= MAX_MEMBERS {
return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects"));
}
let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c));
if path.bounds().is_some() {
out.push(Profile { path, rule, items: solid_paints(c), opacity });
}
}
Ok(())
}
let mut out = vec![];
walk(n, 1.0, label, &mut out)?;
Ok(out)
}
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
/// [`validate_solid`].
@@ -226,10 +295,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
group
}
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
/// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend.
fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity * member, f.blend),
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity * member, s.blend),
}
}
@@ -249,46 +319,50 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order.
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
let b = path.bounds()?;
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let colored = view_options(&p).has_colored_lights();
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds, so they stay together.
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
let mut out = vec![];
for item in items {
let (paint, opacity, blend) = paint_of(item);
let (area, rule) = region(path, rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
for s in shapes {
for item in &s.items {
let (paint, opacity, blend) = paint_of(item, s.opacity);
let (area, rule) = region(&s.path, s.rule, item);
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
}
}
return Some(out);
}
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items
// Each shape's top fill and its strokes, a group's members from the back up.
let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes
.iter()
.enumerate()
.filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_)))
.map(|(_, i)| {
let (area, rule) = region(path, rule, i);
(*i, area, rule)
.flat_map(|s| {
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
let (area, rule) = region(&s.path, s.rule, i);
(i, s.opacity, area, rule)
})
})
.collect();
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
// and Inflate's grid reaches over the strokes' outlines.
let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
let built = if fx.id == INFLATE {
// One eye for every puff, as far away as the highest needs.
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, area, rule)| (area, *rule)).collect();
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
} else {
parts.iter().map(|(_, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
parts.iter().map(|(_, _, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
};
let mut solids = vec![];
for ((item, _, _), f) in parts.iter().zip(built) {
for ((item, opacity, _, _), f) in parts.iter().zip(built) {
let item: &AppearanceItem = item;
match f {
Ok(f) => solids.push((item, f)),
Ok(f) => solids.push((item, *opacity, f)),
// A stroke's outline past the limits is left out; the fill's solid still draws.
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
// A loaded file or a shape edited past the limits keeps drawing its source path
@@ -300,15 +374,15 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, _)| {
let (_, opacity, blend) = paint_of(item);
let opaque = solids.iter().all(|(item, member, _)| {
let (_, opacity, blend) = paint_of(item, *member);
opacity >= 1.0 && blend == BlendMode::Normal
});
let surfaces: Vec<(Vec<f64>, Node)> = solids
.into_iter()
.map(|(item, f)| {
.map(|(item, member, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item);
let (paint, opacity, blend) = paint_of(item, member);
(depths, surface(n, f, &paint, opacity, blend, colored))
})
.collect();
@@ -338,19 +412,23 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
/// the object's id and transparency, the source path untouched. `None` without such an effect.
/// On a group or layer the effect builds a solid from each path, compound path and type in it,
/// all turned about one shared axis ([`members`]).
pub fn solid_art(n: &Node) -> Option<Node> {
if !has_solid(n) {
return None;
}
if is_container(n) {
return group_solid_art(n);
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let paints = solid_paints(n);
let painted: Vec<&AppearanceItem> = paints.iter().collect();
let mut children = vec![];
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
children.extend(art_for(n, fx, &path, rule, &painted)?);
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?);
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
@@ -363,16 +441,16 @@ pub fn solid_art(n: &Node) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
for item in &painted {
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
for mut art in art_for(n, fx, &p, rule, &[*item])? {
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? {
Arc::make_mut(&mut art).appearance.effects =
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
children.push(art);
}
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() })));
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
}
}
}
@@ -382,5 +460,29 @@ pub fn solid_art(n: &Node) -> Option<Node> {
Some(art)
}
/// [`solid_art`] of group or layer `n`: its object-level 3D effect on its members together. The
/// geometry effects before it reshape the members, those after it bend the result as a whole; the
/// group's own fills and strokes are not drawn. `None` when the effect is only on one of the
/// group's own fills or strokes, or when its members can't become solids.
fn group_solid_art(n: &Node) -> Option<Node> {
let (index, fx) = solid(&n.appearance.effects)?;
let mut shapes = members(n, "").ok()?;
let before = n.appearance.effects.get(..index)?;
if before.iter().any(|e| e.visible && is_geometry(&e.id)) {
let b = union_bounds(&shapes)?;
let ctx = GeomContext::of(n);
for s in &mut shapes {
s.path = apply_geometry_with(before, &s.path, b, &ctx);
}
}
let children = art_for(n, fx, &shapes)?;
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
#[cfg(test)]
mod tests;
+57 -2
View File
@@ -462,8 +462,8 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() {
let n = square_node();
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok());
assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be"));
let text = Node::group(NodeId(2), vec![]);
assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path"));
let empty = Node::group(NodeId(2), vec![]);
assert!(validate_solid(&empty, INFLATE, &json!({})).unwrap_err().contains("requires a group holding a shape"));
for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] {
assert!(crate::is_length(id, key, false), "{id} {key}");
}
@@ -540,3 +540,58 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
assert_eq!(clips, surfaces);
assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count");
}
/// `square_node` moved right by `dx`, with id `id` and fill `fill`.
fn square_at(id: u64, dx: f64, fill: Color) -> Node {
let mut n = square_node();
n.id = NodeId(id);
n.transform(Affine::translate((dx, 0.0)), false);
n.appearance.items = Appearance::basic(Paint::solid(fill), Paint::None, 0.0).items;
n
}
#[test]
fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
// Two squares side by side, 100 pt apart, in a group: 100–200 and 300–400 across.
let group = |id: &str, params: Value| {
let mut g = Node::group(NodeId(9), vec![Arc::new(square_at(1, 0.0, RED)), Arc::new(square_at(2, 200.0, BLUE))]);
g.appearance.effects.push(fx(id, params));
g
};
let turned = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0});
for id in SOLIDS {
let g = group(id, json!({}));
assert!(validate_solid(&g, id, &json!({})).is_ok(), "{id}");
let art = solid_art(&g).unwrap_or_else(|| panic!("{id}"));
assert_eq!(art.id, NodeId(9), "{id}: the art is the group's");
assert!(art.appearance.effects.iter().all(|e| !is_three_d(&e.id)), "{id}");
// Both members draw, each in its own fill.
let mut colours = vec![];
art.walk(&mut |c| {
if c.path_data().is_some() {
colours.push(fill_rgb(c));
}
});
assert!(colours.iter().any(|c| c[2] > c[0]) && colours.iter().any(|c| c[0] > c[2]), "{id}: red and blue faces");
}
// Turned 60° about y, the members turn together about the group's centre (x = 250): the
// pair spans half its width; turned each about its own centre they would stay 100 pt apart.
let b = solid_art(&group(ROTATE, turned.clone())).unwrap().visual_bounds().unwrap();
assert!((b.center().x - 250.0).abs() < 1e-3, "{b:?}");
assert!((b.width() - 300.0 * 0.5).abs() < 1.0, "{b:?}");
let mut alone = square_at(2, 200.0, BLUE);
alone.appearance.effects.push(fx(ROTATE, turned));
let own = solid_art(&alone).unwrap().visual_bounds().unwrap();
assert!((own.center().x - 350.0).abs() < 1e-3, "{own:?}");
// Hidden members and guides are left out; a group holding what can't become a solid is
// refused with what to do.
let mut g = group(EXTRUDE, json!({}));
let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]);
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
if let NodeKind::Group { children, .. } = &mut g.kind {
children.push(Arc::new(clip));
}
let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err();
assert!(err.contains("clipping groups"), "{err}");
assert!(solid_art(&g).is_none(), "drawn as the group it is");
}
+34 -7
View File
@@ -19,12 +19,12 @@ mod width;
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Shape};
use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Rect, Shape};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
pub use arrow::Arrow;
pub use dash::{Dashed, Dot, dash, dot_outline};
pub use dash::{Dashed, Dot, dash, dash_kept, dot_outline};
pub use gradient::{Slice, WrittenSlices, gradient_meshes, gradient_slices, written_slices};
pub use outline::{OUTLINE_TOL, Written, WrittenShape, for_writer, is_plain, outline_region};
pub use width::width_outline;
@@ -111,11 +111,7 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
}
out
}
(None, Some(d)) => {
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
(None, Some(d)) => dashed_outline(&d, st, width, tol),
(None, None) => {
let mut out = plain_outline(line, &style(st, width), tol);
out.extend(dot_outline(&point_dots(line), width, st.cap, tol).iter());
@@ -124,6 +120,37 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
}
}
/// The variable-width outline near `near`, with its profile positions measured on the whole
/// undashed line. Returns `None` when no segment is omitted or the stroke is dashed.
pub fn line_outline_profile_near(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64, near: Rect) -> Option<BezPath> {
if st.dash.is_some() || line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
return None;
}
width::width_outline_near(line, width, st.profile.as_ref()?, st, tol, near)
}
/// [`line_outline`] of a dashed line without a width profile, made of only the dashes and dots on
/// the segments of `line` that `keep` accepts ([`dash_kept`]). `None` when `st` has no dash
/// pattern or has a width profile, or when its pattern draws the line solid.
pub fn dashed_line_outline_kept(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64, keep: &dyn Fn(&PathSeg) -> bool) -> Option<BezPath> {
if st.profile.is_some() {
return None;
}
let d = dash_kept(line, st.dash.as_ref()?, keep)?;
if width <= 0.0 || !width.is_finite() {
return Some(BezPath::new());
}
Some(dashed_outline(&d, st, width, tol.max(1e-4)))
}
/// The filled outline of the dashes and dots `d` of a stroke `st` without a width profile, at
/// `width` and flattened/fitted to `tol`.
fn dashed_outline(d: &Dashed, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
let mut out = kurbo::stroke(d.path.iter(), &style(st, width), &kurbo::StrokeOpts::default(), tol);
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
out
}
/// The subpaths of `line` that never leave their first point (`m` then `L` to the same point, as
/// `.ai` files write dots, #1613), as dots: kurbo strokes nothing for them, where PDF and SVG
/// viewers paint the cap. They lie along the x axis, which a projecting cap's square follows
+25 -7
View File
@@ -45,6 +45,14 @@ pub struct Dashed {
/// entry, as in SVG and PDF) or has no positive length (the line is solid), or is too fine to draw
/// (then the line is drawn solid too).
pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
dash_kept(bp, d, &|_| true)
}
/// [`dash`] with only the dashes and dots on the segments of `bp` that `keep` accepts. The pattern
/// runs along every segment as in [`dash`], so a kept dash lies where [`dash`] puts it: a dash
/// running onto a segment left out ends where that segment starts and goes on from where the next
/// kept segment starts. Segments left out still count toward the limit on the number of dashes.
pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Option<Dashed> {
if d.pattern.iter().any(|v| !v.is_finite() || *v < 0.0) || !d.offset.is_finite() {
return None;
}
@@ -82,7 +90,7 @@ pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
let mut out = Dashed::default();
for plan in &plans {
dash_subpath(&mut out, plan, &pat, phase);
dash_subpath(&mut out, plan, &pat, phase, keep);
}
Some(out)
}
@@ -175,7 +183,7 @@ impl Piece {
}
}
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) {
let first_dot = out.dots.len();
let total: f64 = plan.lens.iter().sum();
// Fraction of the subpath's length at `x` along the plan.
@@ -188,7 +196,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
};
let mut dashes: Vec<Piece> = vec![];
let mut cur = Piece::default();
let (mut cuts, mut acc, mut starts_on) = (0, 0.0, false);
let (mut cuts, mut acc, mut starts_on, mut left_out) = (0, 0.0, false, false);
for (i, (seg, &len)) in plan.segs.iter().zip(&plan.lens).enumerate() {
let k = plan.scale[i];
if plan.restart[i] {
@@ -199,21 +207,30 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
}
ph = Phase { ix: 0, rem: pat[0] * k / 2.0, on: true };
}
// A segment left out ends the dash running onto it; the pattern still advances along it.
let kept = keep(seg);
if !kept {
left_out = true;
if !cur.segs.is_empty() {
cur.end = acc;
dashes.push(std::mem::take(&mut cur));
}
}
if i == 0 {
starts_on = ph.on && ph.rem > 0.0;
starts_on = kept && ph.on && ph.rem > 0.0;
}
let mut s = 0.0;
loop {
let left = len - s;
if ph.rem > left + EPS {
if ph.on && left > EPS {
if kept && ph.on && left > EPS {
cur.push(sub(seg, len, s, len), acc + s);
}
ph.rem -= left;
break;
}
let end = (s + ph.rem).min(len);
if ph.on {
if kept && ph.on {
if pat[ph.ix] > 0.0 {
if end > s {
cur.push(sub(seg, len, s, end), acc + s);
@@ -240,6 +257,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
&& plan.restart[0]
&& pat[0] <= 0.0
&& let Some(seg) = plan.segs.last()
&& keep(seg)
{
push_dot(out, first_dot, Dot { at: seg.end(), dir: tangent(seg, 1.0), t: 1.0 });
}
@@ -249,7 +267,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase) {
dashes.push(cur);
}
if plan.closed {
if cuts == 0 && ends_on {
if cuts == 0 && ends_on && !left_out {
// One dash all the way round: keep it closed so the start gets a join, not caps.
let d = dashes.pop().unwrap_or_default();
write(&mut out.path, &d.segs);
+14 -3
View File
@@ -174,10 +174,21 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
}
let steps = if max_dt < 1.0 { (1.0 / max_dt).ceil() as usize } else { 1 };
let profile = st.profile.iter().flat_map(|p| p.points.iter().map(|q| q.0));
let at: Vec<f64> =
marks.iter().copied().chain(profile).chain((1..steps).map(|i| i as f64 / steps as f64)).map(|t| t * total).collect();
let heads = if closed { (0.0, 0.0) } else { heads };
// Where an arrowhead's reach ends gets a rib of its own, so the ribs that reach
// across a head run all the way there instead of narrowing towards the next corner
// from the head's tip, which leaves the corners of its base outside (#5964).
let head_ends = [heads.0, total - heads.1].into_iter().filter(|&s| s > 0.0 && s < total);
let at: Vec<f64> = marks
.iter()
.copied()
.chain(profile)
.chain((1..steps).map(|i| i as f64 / steps as f64))
.map(|t| t * total)
.chain(head_ends)
.collect();
let (pts, _) = split_at(&pts, &corner, closed, &at);
strip(&lay, &pts, closed, total, side, if closed { (0.0, 0.0) } else { heads })
strip(&lay, &pts, closed, total, side, heads)
}
};
out.push(ribs);
+123 -4
View File
@@ -110,6 +110,80 @@ fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none());
}
/// The dashes of `d` as (start x, end x), for dashes along the x axis.
fn spans_x(d: &Dashed) -> Vec<(f64, f64)> {
let mut out = vec![];
for el in d.path.elements() {
match el {
PathEl::MoveTo(p) => out.push((p.x, p.x)),
PathEl::LineTo(p) => {
if let Some(last) = out.last_mut() {
last.1 = p.x;
}
}
_ => {}
}
}
out
}
/// Ten segments of 10 pt along the x axis from 0 to 100.
fn steps() -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
for i in 1..=10 {
b.line_to((f64::from(i) * 10.0, 0.0));
}
b
}
/// Whether a segment lies outside x 30..60.
fn outside(s: &PathSeg) -> bool {
s.start().x >= 60.0 || s.end().x <= 30.0
}
#[test]
fn dashes_kept_on_some_segments_keep_the_phase_of_the_whole_line() {
// A period of 7 starting 2 into it: dashes at -2..2, 5..9, 12..16, …
let pattern = dashed(&[4.0, 3.0], 2.0);
let all = dash(&steps(), &pattern).unwrap();
let kept = dash_kept(&steps(), &pattern, &|s| outside(s)).unwrap();
// The dashes of the whole line, cut at x 30 and 60.
let cut: Vec<(f64, f64)> =
spans_x(&all).into_iter().flat_map(|(a, b)| [(a, b.min(30.0)), (a.max(60.0), b)]).filter(|(a, b)| b - a > 1e-9).collect();
let got = spans_x(&kept);
assert_eq!(got.len(), cut.len(), "{got:?} against {cut:?}");
for ((a, b), (c, d)) in got.iter().zip(&cut) {
assert!(near(*a, *c, 1e-9) && near(*b, *d, 1e-9), "{got:?} against {cut:?}");
}
// The dash after the gap starts where the whole line's does, at 61, not at 60.
assert!(got.iter().any(|(a, _)| near(*a, 61.0, 1e-9)), "{got:?}");
// Dots on segments left out are left out (the dot at 60 ends a segment left out).
let dots = dash_kept(&steps(), &dashed(&[0.0, 5.0], 0.0), &|s| outside(s)).unwrap();
let xs: Vec<f64> = dots.dots.iter().map(|d| d.at.x).collect();
let want = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 65.0, 70.0, 75.0, 80.0, 85.0, 90.0, 95.0, 100.0];
assert!(xs.len() == want.len() && xs.iter().zip(want).all(|(x, w)| near(*x, w, 1e-9)), "{xs:?}");
// Keeping every segment is the whole line's dashing, and keeping none leaves nothing.
assert_eq!(dash_kept(&steps(), &pattern, &|_| true).unwrap().path, all.path);
let none = dash_kept(&steps(), &pattern, &|_| false).unwrap();
assert!(none.path.elements().is_empty() && none.dots.is_empty());
// A pattern too fine for the whole line draws it solid, however few segments are kept.
assert!(dash_kept(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0), &|s| s.start().x < 1.0).is_none());
}
#[test]
fn a_closed_path_with_segments_left_out_is_dashed_in_open_pieces() {
// One dash all the way round: with the right side left out it is no longer closed.
let keep_left = |s: &PathSeg| s.start().x < 5.0 || s.end().x < 5.0;
let whole = dash_kept(&square(10.0), &dashed(&[100.0, 1.0], 0.0), &keep_left).unwrap();
assert!(!is_closed(&whole.path), "{:?}", whole.path.to_svg());
// The bottom side left out: of the 10 dashes, the one through the start point stays one dash,
// the one into the bottom side is cut and the two on it are left out.
let kept = dash_kept(&square(100.0), &dashed(&[20.0, 20.0], 10.0), &|s| s.start().y < 50.0 || s.end().y < 50.0).unwrap();
assert_eq!(moves(&kept.path), 8, "{}", kept.path.to_svg());
assert!(!kept.path.elements().iter().any(|e| matches!(e, PathEl::MoveTo(p) if p.distance(Point::ZERO) < 1e-9)), "{}", kept.path.to_svg());
}
#[test]
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0), t: 0.0 }];
@@ -151,20 +225,23 @@ fn expand_stroke_region_of_a_round_line_stays_compact() {
assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors");
}
/// A line's outline takes its width profile, its dashes (each reading the profile where it sits) or neither.
#[test]
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
let l = line(0.0, 100.0);
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
assert!(near(plain.area().abs(), 1000.0, 1e-6));
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
assert!(near(lens.area().abs(), 500.0, 1e-6));
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0): the dash over
// 0..25 averages a quarter of the width, the one over 50..75 three quarters.
// The lens swells on a cubic, flat at the middle: 7/12 of the full-width band (#5714).
assert!(near(lens.area().abs(), 1000.0 * 7.0 / 12.0, 0.05));
// Each dash takes its widths from where it sits along the lens (0 → 1 → 0). Half of the lens
// rises as w(u) = u + u² − u³ (u from 0 to 1 across that half): the dash over 0..25 covers
// its first half, ∫₀^½ w = 29/192, the one over 50..75 the wider other half, 83/192.
let st = stroke(10.0, |s| {
s.profile = Some(WidthProfile::lens());
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
});
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 62.5 + 187.5, 1e-6));
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0 * (29.0 + 83.0) / 192.0, 0.05));
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
}
@@ -427,3 +504,45 @@ fn a_stroke_narrower_than_the_shape_keeps_its_hole() {
assert_eq!(moves(&outline), 3, "{join:?}: two contours for the ring, one for the blob");
}
}
/// A subpixel stroke must retain its geometric thickness even when the normal display tolerance
/// is close to its weight. Original uneven circular arcs, independent of any user fixture.
#[test]
fn thin_curved_strokes_keep_their_width_between_notches() {
use kurbo::{CubicBez, ParamCurve, ParamCurveDeriv, Shape};
for (radius, degrees) in [(450.0, 47.0_f64), (438.0, 77.0), (450.0, 54.0)] {
let a = degrees.to_radians();
let k = 4.0 / 3.0 * (a / 4.0).tan();
let curve = CubicBez::new(
(radius, 0.0),
(radius, k * radius),
(radius * a.cos() + k * radius * a.sin(), radius * a.sin() - k * radius * a.cos()),
(radius * a.cos(), radius * a.sin()),
);
let mut line = BezPath::new();
line.move_to(curve.p0);
line.curve_to(curve.p1, curve.p2, curve.p3);
for width in [0.25, 1.0 / 3.0, 0.5, 1.0] {
let st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
let outline = line_outline(&line, &st, width, 0.25);
for step in 1..40 {
let t = step as f64 / 40.0;
let p = curve.eval(t);
let tangent = curve.deriv().eval(t).to_vec2().normalize();
let normal = kurbo::Vec2::new(-tangent.y, tangent.x);
for side in [-1.0, 1.0] {
assert_ne!(
outline.winding(p + normal * (side * width * 0.45)),
0,
"missing inner band at radius {radius}, {degrees} degrees, width {width}, t {t}"
);
assert_eq!(
outline.winding(p + normal * (side * width * 0.55)),
0,
"outside intended band at radius {radius}, {degrees} degrees, width {width}, t {t}"
);
}
}
}
}
}
+29 -3
View File
@@ -117,6 +117,7 @@ fn along_keeps_a_corners_colour_round_its_outside() {
assert!((param(&sl, Point::new(95.0, 8.0)).unwrap() - 0.54).abs() < 1e-6);
}
/// An Across stroke gradient runs from the stroke's left edge to its right edge, also where a width profile moves them.
#[test]
fn across_runs_from_the_left_edge_to_the_right() {
let st = stroke(10.0, StrokeGradientMode::Across);
@@ -137,8 +138,9 @@ fn across_runs_from_the_left_edge_to_the_right() {
let mut lens = st.clone();
lens.profile = Some(WidthProfile::lens());
let sl = slices(&line, &lens);
// A quarter of the way along, the lens is half as wide: its top edge is 2.5 up.
assert!(param(&sl, Point::new(25.0, -2.4)).unwrap() < 0.05);
// A quarter of the way along, the lens is 0.625 as wide (its curve, #5714): its top edge is
// 3.125 up.
assert!(param(&sl, Point::new(25.0, -3.0)).unwrap() < 0.05);
assert!((param(&sl, Point::new(50.0, -2.5)).unwrap() - 0.25).abs() < 0.02);
}
@@ -162,6 +164,7 @@ fn an_aligned_stroke_spans_the_gradient_across_the_side_it_shows_on() {
}
}
/// The gradient slices cover every point the stroke paints, with each join, caps, arrowheads, a profile and dashes.
#[test]
fn slices_cover_everything_the_stroke_paints() {
let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false);
@@ -182,7 +185,9 @@ fn slices_cover_everything_the_stroke_paints() {
assert_covered(&zig, &st);
st.arrow_align = ArrowAlign::Tip;
assert_covered(&curve, &st);
st.profile = WidthProfile::preset("wave");
// Teardrop, not Wave: with most profiles a dash across the zigzag's sharp corner
// paints outside every slice (#5964). Back to Wave once that is fixed.
st.profile = WidthProfile::preset("teardrop");
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
assert_covered(&zig, &st);
st.align = StrokeAlign::Outside;
@@ -211,3 +216,24 @@ fn meshes_colour_the_stroke_as_the_slices_do() {
}
}
}
/// #5964: with ribs only at the path's corners (an Along gradient), an arrowhead whose tip sits on
/// the end of a long straight leg is covered all the way to where it can reach: the ribs that
/// reach across it run to the end of its reach, rather than narrowing from its tip towards the
/// next corner and leaving the corners of its base outside.
#[test]
fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
let zig = poly(&[(0.0, 0.0), (60.0, 40.0), (0.0, 80.0), (70.0, 90.0), (75.0, 20.0)], false);
for profile in [None, WidthProfile::preset("lens"), WidthProfile::preset("taperStart")] {
for align in [ArrowAlign::Tip, ArrowAlign::Extend] {
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
let mut st = stroke(12.0, mode);
st.start_arrow = Some(Arrowhead::Triangle);
st.end_arrow = Some(Arrowhead::Triangle);
st.arrow_align = align;
st.profile = profile.clone();
assert_covered(&zig, &st);
}
}
}
}
+152 -9
View File
@@ -10,7 +10,7 @@
use std::f64::consts::PI;
use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Vec2};
use kurbo::{BezPath, ParamCurve, PathEl, PathSeg, Point, Rect, Vec2};
use vectorcraft_doc::{LineCap, LineJoin, StrokeLayer, WidthProfile};
use super::{kink, segments, subpaths, unit};
@@ -21,6 +21,70 @@ pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, st: &Stro
outline_spans(bp, &[], width, profile, st, tol)
}
/// Outline only flattened segments near `near`, retaining each run's original fractions of its
/// whole subpath for the width profile. `None` means no segments could be omitted.
pub(super) fn width_outline_near(bp: &BezPath, width: f64, profile: &WidthProfile, st: &StrokeLayer, tol: f64, near: Rect) -> Option<BezPath> {
let ctx = Ctx { half: width / 2.0, profile, cap: st.cap, join: st.join, miter_limit: st.miter_limit, tol: tol.max(1e-4) };
let mut out = BezPath::new();
let mut cut = false;
for (r, closed) in subpaths(bp) {
if closed {
return None;
}
let (pts, corner) = flatten(&segments(&bp.elements()[r]), closed, ctx.tol);
let n = pts.len();
if n < 2 {
continue;
}
let count = n - 1;
let mut lengths = Vec::with_capacity(count);
let mut kept = Vec::with_capacity(count);
let mut total = 0.0;
for i in 0..count {
let (a, b) = (pts[i], pts[i + 1]);
let len = a.distance(b);
if !len.is_finite() {
return None;
}
total += len;
lengths.push(len);
kept.push(!(a.x.max(b.x) < near.x0 || a.x.min(b.x) > near.x1 || a.y.max(b.y) < near.y0 || a.y.min(b.y) > near.y1));
}
if kept.iter().all(|v| *v) {
ctx.subpath(&mut out, &pts, &corner, closed, (0.0, 1.0));
continue;
}
cut = true;
if total <= 1e-12 {
continue;
}
let mut walked = 0.0;
let mut run = Vec::new();
let mut run_corner = Vec::new();
let mut from = 0.0;
for i in 0..count {
if kept[i] {
if run.is_empty() {
from = walked / total;
run.push(pts[i]);
run_corner.push(corner[i]);
}
run.push(pts[i + 1]);
run_corner.push(corner[i + 1]);
} else if run.len() > 1 {
ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total));
run.clear();
run_corner.clear();
}
walked += lengths[i];
}
if run.len() > 1 {
ctx.subpath(&mut out, &run, &run_corner, false, (from, walked / total));
}
}
cut.then_some(out)
}
/// [`width_outline`] of pieces of longer subpaths: subpath `i` of `bp` covers the fractions
/// `spans[i]` of the subpath it was cut from (see [`Dashed::spans`](super::Dashed::spans));
/// subpaths without a span are whole.
@@ -86,17 +150,56 @@ pub(super) fn flatten(segs: &[PathSeg], closed: bool, tol: f64) -> (Vec<Point>,
(pts, corner)
}
/// Insert samples where the profile has width points (so the piecewise-linear profile is exact
/// along straight runs; the two points of a discontinuous point share one sample). `span` maps the
/// subpath onto the profile (see [`outline_spans`]).
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64)) -> (Vec<Point>, Vec<bool>) {
/// The most samples added between two width points.
const MAX_SPAN_SAMPLES: usize = 64;
/// The most samples [`densify`] adds between width points along one piece of a subpath.
const MAX_CURVE_SAMPLES: usize = 4096;
/// Insert samples where the profile has width points (the two points of a discontinuous point
/// share one sample), and between them where the profile's curve ([`WidthProfile::at`]) bends, so
/// that a straight run follows it to within `tol` with a stroke of half width `half`. `span` maps
/// the subpath onto the profile (see [`outline_spans`]).
fn densify(pts: &[Point], corner: &[bool], closed: bool, profile: &WidthProfile, span: (f64, f64), half: f64, tol: f64) -> (Vec<Point>, Vec<bool>) {
let total = length(pts, closed);
let range = span.1 - span.0;
if total <= 1e-12 || range <= 1e-12 {
return (pts.to_vec(), corner.to_vec());
}
let along = |t: f64| (t - span.0) / range * total;
// Where the profile's points fall along this piece (a span past 1 wraps round a closed path).
let marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(|t| (t - span.0) / range * total).collect();
let mut marks: Vec<f64> = profile.points.iter().flat_map(|p| [p.0, p.0 + 1.0]).map(along).collect();
let mut added = 0;
for shift in [0.0, 1.0] {
for w in profile.points.windows(2) {
let (a, b) = (&w[0], &w[1]);
let (t0, t1) = (a.0 + shift, b.0 + shift);
if t1 - t0 <= 1e-7 || t1 <= span.0 || t0 >= span.1 {
continue;
}
// A span whose curve is the straight line between its points (a two-point taper,
// points on one line) needs nothing between them. A cubic off the line is off it at a
// quarter or three quarters of the way.
let off_line = |u: f64| {
let (l, r) = profile.at(a.0 + (b.0 - a.0) * u);
half * (l - (a.1 + (b.1 - a.1) * u)).abs().max((r - (a.2 + (b.2 - a.2) * u)).abs())
};
if off_line(0.25) <= tol * 1e-3 && off_line(0.75) <= tol * 1e-3 {
continue;
}
// A monotone cubic span bends by at most 12 times its change in width over the span
// squared, so the chords of `n` equal parts stray at most 1.5 · change / n² from it.
let change = half * (b.1 - a.1).abs().max((b.2 - a.2).abs());
let n = (1.5 * change / tol).sqrt().ceil();
let n = if n.is_finite() { n.clamp(1.0, MAX_SPAN_SAMPLES as f64) as usize } else { MAX_SPAN_SAMPLES };
for k in 1..n {
if added >= MAX_CURVE_SAMPLES {
break;
}
marks.push(along(t0 + (t1 - t0) * k as f64 / n as f64));
added += 1;
}
}
}
split_at(pts, corner, closed, &marks)
}
@@ -161,7 +264,7 @@ struct Vertex {
impl Ctx<'_> {
fn subpath(&self, out: &mut BezPath, pts: &[Point], corner: &[bool], closed: bool, span: (f64, f64)) {
let (pts, corner) = densify(pts, corner, closed, self.profile, span);
let (pts, corner) = densify(pts, corner, closed, self.profile, span, self.half, self.tol);
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let seg_dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
@@ -320,10 +423,13 @@ mod tests {
assert!((area(&width_outline(&line(), 10.0, &p, &capped(LineCap::Square), 0.01)) - 1100.0).abs() < 1e-6);
}
/// The Lens outline swells on its curve, and a profile with one side at zero paints only the other side.
#[test]
fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() {
fn lens_profile_swells_smoothly_and_one_sided_profiles_stay_on_their_side() {
// Each half of the lens rises on a cubic from 0 to full width, flat at the middle: it
// covers 7/12 of the full-width band (a diamond would cover half).
let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), &capped(LineCap::Butt), 0.01));
assert!((a - 500.0).abs() < 1e-6, "{a}");
assert!((a - 1000.0 * 7.0 / 12.0).abs() < 0.5, "{a}");
let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] };
let o = width_outline(&line(), 10.0, &one, &capped(LineCap::Butt), 0.01);
assert!((area(&o) - 500.0).abs() < 1e-6);
@@ -347,6 +453,43 @@ mod tests {
assert!(a.is_finite() && (a - 3000.0).abs() < 150.0, "{a}");
}
/// #5714: between width points a straight run follows the profile's smooth curve, not a
/// straight line from one width to the next.
#[test]
fn a_straight_run_follows_the_curve_between_width_points() {
// 5 pt either side at the ends, 15 pt at the middle: a quarter of the way along the curve
// is at 2.25 × 5 = 11.25 pt (a straight ramp would be at 10 pt).
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 3.0, 3.0), (1.0, 1.0, 1.0)] };
let o = width_outline(&line(), 10.0, &p, &capped(LineCap::Butt), 0.01);
for y in [-11.0, 11.0] {
assert!(o.contains(kurbo::Point::new(25.0, y)), "inside at y {y}");
}
for y in [-11.5, 11.5] {
assert!(!o.contains(kurbo::Point::new(25.0, y)), "outside at y {y}");
}
// Round the widest point the side is flat: no corner sticks out above 15 pt.
let bb = o.bounding_box();
assert!((bb.y0 + 15.0).abs() < 1e-6 && (bb.y1 - 15.0).abs() < 1e-6, "{bb:?}");
}
/// A taper is a straight ramp: its outline gets no samples between the width points.
#[test]
fn a_straight_taper_gets_no_samples_between_its_points() {
let o = width_outline(&line(), 10.0, &WidthProfile::taper_end(), &capped(LineCap::Butt), 0.01);
assert_eq!(o.elements().len(), 5, "{:?}", o.elements());
}
/// A profile read from a file may have thousands of points and absurd widths: the samples added
/// between them stay bounded and the outline finite.
#[test]
fn samples_between_width_points_are_capped() {
let points = (0..=10_000).map(|i| (i as f64 / 10_000.0, if i % 2 == 0 { 0.0 } else { 1e12 }, 1.0)).collect();
let o = width_outline(&line(), 10.0, &WidthProfile { points }, &capped(LineCap::Butt), 0.01);
let n = o.elements().len();
assert!(n < 2 * (2 * 10_001 + MAX_CURVE_SAMPLES) + 64, "{n}");
assert!(o.bounding_box().area().is_finite());
}
#[test]
fn closed_paths_make_a_ring() {
let mut sq = BezPath::new();
+20 -5
View File
@@ -1231,14 +1231,29 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() {
#[test]
fn a_distance_parameters_range_comes_from_its_documentation() {
assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0));
assert_eq!(length_range("stylize.glowingEdges", "edgeWidth"), Some(1.0..=14.0));
// A distance documented without a range, and unknown effects and parameters, have none.
assert_eq!(length_range("path.offsetPath", "offset"), None);
assert_eq!(length_range("stylize.glowingEdges", "edgeBrightness"), None);
assert_eq!(length_range("no.such", "edgeWidth"), None);
}
#[test]
fn color_halftones_radius_is_in_raster_pixels_not_a_distance() {
// Pixels of the Document Raster Effects resolution, as public documentation gives it (#3260):
// no unit setting changes it, so it isn't a distance in points.
assert_eq!(raster_px_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0));
assert!(!is_length("pixelate.colorHalftone", "maxRadius", false));
assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), None);
assert_eq!(raster_px_range("pixelate.colorHalftone", "channel1"), None);
assert_eq!(raster_px_range("stylize.glowingEdges", "edgeWidth"), None);
// The effect applies the range's floor to a radius below it.
assert_eq!(
crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 1})),
crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 4}))
);
// A distance documented without a range, and unknown effects and parameters, have none.
assert_eq!(length_range("path.offsetPath", "offset"), None);
assert_eq!(length_range("pixelate.colorHalftone", "channel1"), None);
assert_eq!(length_range("no.such", "maxRadius"), None);
// Scale Strokes & Effects scales it with the object, as it does distances.
let mut e = crate::new_effect("pixelate.colorHalftone", &json!({"maxRadius": 10})).unwrap();
crate::scale_effect(&mut e, 2.0);
assert_eq!((e.params["maxRadius"].as_f64(), e.params["channel1"].as_f64()), (Some(20.0), Some(108.0)));
}
+43
View File
@@ -384,3 +384,46 @@ fn a_strokes_own_transform_leaves_room_for_a_fill_above_it() {
assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}");
assert!(Budget::EFFECTS.fits(cost), "{cost:?}");
}
/// A 100 pt square at the origin painted with `fill` and a 1 pt white stroke, with the Transform
/// effect `params`.
fn filled_and_stroked(fill: Paint, params: Value) -> Node {
let mut n = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, Paint::solid(Color::WHITE), 1.0));
n.appearance.effects.push(transform(params));
n
}
#[test]
fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
// Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its
// own above the one before, so its fill covers the strokes of the copies below.
let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25});
let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
assert!(has_transform_art(&n));
let art = reshape(None, &n, None).unwrap();
let paths = leaves(&art);
assert_eq!(paths.len(), 26, "the original and 25 copies");
for p in &paths {
assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy");
assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some());
}
// The original is at the bottom, the smallest copy on top.
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
let top = paths[25].geometric_bounds().unwrap();
assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}");
// Drawn as Expand Appearance makes it.
assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26);
// One opaque stroke, or one nonzero fill, paints the copies alike as one path: no art.
assert!(!has_transform_art(&filled_and_stroked(Paint::None, params.clone())));
let mut fill_only = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
fill_only.appearance.items.retain(AppearanceItem::is_fill);
assert!(!has_transform_art(&fill_only));
// A partly transparent fill paints where copies overlap twice.
if let Some(AppearanceItem::Fill(f)) = fill_only.appearance.items.first_mut() {
f.opacity = 0.5;
}
assert!(has_transform_art(&fill_only));
// No copies: nothing to stack.
assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10}))));
}
+2
View File
@@ -61,6 +61,8 @@ vectorcraft-testkit = { workspace = true }
proptest = { workspace = true }
# A variable font built in code (no font file is committed).
vectorcraft-text = { workspace = true, features = ["test-fonts"] }
# How many times an open reads a PDF.
vectorcraft-pdf = { workspace = true, features = ["read-count"] }
wat = { workspace = true }
# Zstandard streams for the Illustrator editing data fuzz tests.
ruzstd = { workspace = true }
+336
View File
@@ -0,0 +1,336 @@
//! `cargo run --release -p vectorcraft-engine --example anchor_bench` prints the wall time of the
//! engine steps of editing anchors with Direct Selection, on four synthetic documents: one closed path of
//! 50,000 anchors, 2,000 closed paths of 50 anchors each, one open path of 200,000 anchors, and the closed
//! path of 50,000 anchors filled and with a dashed stroke.
//! `ITERS` sets the runs per step (default 7); each row is the median in ms.
// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::hint::black_box;
use std::time::Instant;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, NodeId};
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
use vectorcraft_engine::{Session, ViewInfo};
use vectorcraft_geom::{Affine, Anchor, PathData, Point, SubPath, Vec2};
use vectorcraft_render::{RenderOptions, Renderer};
/// Anchors are 10 pt apart and their handles 3 pt long, along the path.
const STEP: f64 = 10.0;
const HANDLE: f64 = 3.0;
/// A test document: its paths, an anchor to hover, click and drag, a point on a segment, and a
/// marquee from an empty point over about 10,000 anchors.
struct Shape {
name: &'static str,
doc: Document,
ids: Vec<NodeId>,
anchor: (NodeId, usize),
segment: Point,
marquee: (Point, Point),
}
/// A closed path of `n` smooth anchors around `center`.
fn circle(center: Point, n: usize) -> PathData {
let r = n as f64 * STEP / std::f64::consts::TAU;
let anchors = (0..n)
.map(|i| {
let t = i as f64 / n as f64 * std::f64::consts::TAU;
let p = center + Vec2::new(r * t.cos(), r * t.sin());
Anchor::smooth(p, p + Vec2::new(-t.sin(), t.cos()) * HANDLE)
})
.collect();
PathData::single(SubPath::new(anchors, true))
}
fn add(doc: &mut Document, path: PathData) -> NodeId {
let l = doc.layers.first().map(|l| l.id);
let id = doc.alloc_id();
doc.insert(l, usize::MAX, Node::path(id, path, Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0))).unwrap();
id
}
fn one_outline() -> Shape {
let n = 50_000;
let mut doc = Document::new(1600.0, 1200.0);
let id = add(&mut doc, circle(Point::ZERO, n));
let r = n as f64 * STEP / std::f64::consts::TAU;
let a = 72f64.to_radians();
let mid = (n / 2) as f64 / n as f64 * std::f64::consts::TAU;
let next = (n / 2 + 1) as f64 / n as f64 * std::f64::consts::TAU;
let segment = Point::new(r * (mid.cos() + next.cos()) / 2.0, r * (mid.sin() + next.sin()) / 2.0);
Shape {
name: "one closed path, 50,000 anchors",
doc,
ids: vec![id],
anchor: (id, n / 2),
segment,
marquee: (Point::new(r + 50.0, -5.0), Point::new(r * a.cos() - 1.0, r * a.sin() + 1.0)),
}
}
/// [`one_outline`] filled, with a 1 pt stroke of 6 pt dashes and 4 pt gaps.
fn dashed_outline() -> Shape {
let mut shape = one_outline();
let mut look = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.2)), Paint::solid(Color::BLACK), 1.0);
for item in &mut look.items {
if let AppearanceItem::Stroke(st) = item {
st.dash = Some(Dash { pattern: vec![6.0, 4.0], offset: 0.0, align_corners: false });
}
}
shape.doc.node_mut(shape.ids[0]).unwrap().appearance = look.into();
shape.name = "one closed path, 50,000 anchors, filled, dashed stroke";
shape
}
fn many_paths() -> Shape {
let mut doc = Document::new(1600.0, 1200.0);
let ids: Vec<NodeId> =
(0..2000).map(|i| add(&mut doc, circle(Point::new((i % 50) as f64 * 200.0 + 100.0, (i / 50) as f64 * 200.0 + 100.0), 50))).collect();
let r = 50.0 * STEP / std::f64::consts::TAU;
// Path 1,025 (row 20, column 25), its anchors 0 and 1.
let c = Point::new(25.0 * 200.0 + 100.0, 20.0 * 200.0 + 100.0);
let t = std::f64::consts::TAU / 50.0;
let segment = c + Vec2::new(r * (1.0 + t.cos()) / 2.0, r * t.sin() / 2.0);
Shape {
name: "2,000 closed paths, 50 anchors each",
doc,
anchor: (ids[1025], 0),
ids,
segment,
marquee: (Point::ZERO, Point::new(10_000.0, 800.0)),
}
}
fn long_open_path() -> Shape {
let n = 200_000;
let mut doc = Document::new(1600.0, 1200.0);
let anchors = (0..n)
.map(|i| {
let p = Point::new(i as f64 * STEP, 0.0);
Anchor::smooth(p, p + Vec2::new(HANDLE, 0.0))
})
.collect();
let id = add(&mut doc, PathData::single(SubPath::new(anchors, false)));
Shape {
name: "one open path, 200,000 anchors",
doc,
ids: vec![id],
anchor: (id, n / 2),
segment: Point::new((n / 2) as f64 * STEP + STEP / 2.0, 0.0),
marquee: (Point::new(-5.0, -20.0), Point::new(99_995.0, 30.0)),
}
}
fn iters() -> usize {
std::env::var("ITERS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1)
}
fn median(mut ms: Vec<f64>) -> f64 {
ms.sort_by(f64::total_cmp);
ms[ms.len() / 2]
}
fn time(mut f: impl FnMut()) -> f64 {
median(
(0..iters())
.map(|_| {
let t = Instant::now();
f();
t.elapsed().as_secs_f64() * 1000.0
})
.collect(),
)
}
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
PointerEvent::new(kind, p.x, p.y)
}
fn exec(s: &mut Session, cmd: &str, p: Value) {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
fn anchor_at(s: &Session, (id, ai): (NodeId, usize)) -> Anchor {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors[ai]
}
/// The anchors selected: those direct-selected, and every anchor of a path selected whole.
fn selected_anchors(s: &Session) -> usize {
let st = s.doc().unwrap();
let count = |id: &NodeId| st.doc.node(*id).and_then(|n| n.path_data()).map_or(0, |pd| pd.anchors().count());
st.selection.objects.iter().map(|id| st.selection.partial(*id).map_or_else(|| count(id), |set| set.len())).sum()
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn row(name: &str, ms: f64) {
println!(" {name:<58} {ms:>10.3} ms");
}
/// Select `count` anchors: the first ones of the first path, or the first anchors of as many
/// paths as needed.
fn select_count(s: &mut Session, shape: &Shape, count: usize) {
exec(s, "select.none", json!({}));
let mut left = count;
for id in &shape.ids {
if left == 0 {
break;
}
let n = s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len();
let take = left.min(n);
let refs: Vec<[usize; 2]> = (0..take).map(|i| [0, i]).collect();
exec(s, "select.anchors", json!({"id": id.0, "anchors": refs, "mode": "add"}));
left -= take;
}
}
fn bench(shape: Shape) {
println!("{}", shape.name);
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
let mut s = Session::new();
s.add_document(shape.doc.clone(), None);
s.select_tool("directSelection", view).unwrap();
let a = anchor_at(&s, shape.anchor).p;
let none = Mods::default();
{
let doc = s.doc().unwrap().doc.clone();
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0, clip_outlines: true, ..Default::default() };
row("hit test at an anchor", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, a, opt)))));
row("hit test at a segment", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment, opt)))));
row("hit test beside the path", time(|| drop(black_box(vectorcraft_doc::hit::hit_test(&doc, shape.segment + Vec2::new(0.0, 40.0), opt)))));
}
// Hover with nothing selected, then with one anchor of the path selected.
for selected in [false, true] {
if selected {
exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"}));
}
let what = if selected { "one anchor selected" } else { "nothing selected" };
row(&format!("hover over an anchor, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, a), view).unwrap())));
row(&format!("cursor over an anchor, {what}"), time(|| _ = black_box(s.cursor(a, none, view))));
row(&format!("hover over a segment, {what}"), time(|| drop(s.pointer(&ev(PointerKind::Move, shape.segment), view).unwrap())));
row(&format!("cursor over a segment, {what}"), time(|| _ = black_box(s.cursor(shape.segment, none, view))));
}
// A click that selects one anchor.
exec(&mut s, "select.none", json!({}));
row(
"click that selects one anchor",
time(|| {
s.pointer(&ev(PointerKind::Down, a), view).unwrap();
s.pointer(&ev(PointerKind::Up, a), view).unwrap();
}),
);
assert_eq!(selected_anchors(&s), 1, "the click selects one anchor");
// One anchor dragged: each pointer move, then the release.
let undo = undo_len(&s);
s.pointer(&ev(PointerKind::Down, a), view).unwrap();
s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, 20.0)), view).unwrap();
let mut y = 20.0;
row(
"anchor drag, one pointer move",
time(|| {
y += 1.0;
s.pointer(&ev(PointerKind::Drag, a + Vec2::new(0.0, y)), view).unwrap();
}),
);
let t = Instant::now();
s.pointer(&ev(PointerKind::Up, a + Vec2::new(0.0, y)), view).unwrap();
row("anchor drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0);
assert_eq!(undo_len(&s), undo + 1, "the anchor drag records one step");
exec(&mut s, "edit.undo", json!({}));
// The selected anchor's outgoing handle dragged.
exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"}));
let h = anchor_at(&s, shape.anchor).h_out;
let undo = undo_len(&s);
s.pointer(&ev(PointerKind::Down, h), view).unwrap();
s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, 20.0)), view).unwrap();
let mut y = 20.0;
row(
"handle drag, one pointer move",
time(|| {
y += 1.0;
s.pointer(&ev(PointerKind::Drag, h + Vec2::new(0.0, y)), view).unwrap();
}),
);
let t = Instant::now();
s.pointer(&ev(PointerKind::Up, h + Vec2::new(0.0, y)), view).unwrap();
row("handle drag, release (commit)", t.elapsed().as_secs_f64() * 1000.0);
assert_eq!(undo_len(&s), undo + 1, "the handle drag records one step");
assert_eq!(anchor_at(&s, shape.anchor).p, a, "the handle drag leaves the anchor in place");
exec(&mut s, "edit.undo", json!({}));
// A marquee over about 10,000 anchors.
let (m0, m1) = shape.marquee;
let ms = time(|| {
exec(&mut s, "select.none", json!({}));
s.pointer(&ev(PointerKind::Down, m0), view).unwrap();
s.pointer(&ev(PointerKind::Drag, m0.lerp(m1, 0.5)), view).unwrap();
s.pointer(&ev(PointerKind::Drag, m1), view).unwrap();
s.pointer(&ev(PointerKind::Up, m1), view).unwrap();
});
row(&format!("marquee over {} anchors (press to release)", selected_anchors(&s)), ms);
// Select All with Direct Selection.
row("Select All", time(|| exec(&mut s, "select.all", json!({}))));
row("tool overlays, everything selected", time(|| drop(s.overlays(view))));
// Nudges of 1, 1,000 and every anchor, and Undo and Redo of each.
let total: usize = shape.ids.iter().map(|id| s.doc().unwrap().doc.node(*id).unwrap().path_data().unwrap().subpaths[0].anchors.len()).sum();
for count in [1, 1000, total] {
select_count(&mut s, &shape, count);
row(&format!("nudge of {count} anchors"), time(|| exec(&mut s, "path.moveAnchors", json!({"dx": 1, "dy": 0}))));
row(&format!("undo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.undo", json!({}))));
row(&format!("redo of a nudge of {count} anchors"), time(|| exec(&mut s, "edit.redo", json!({}))));
}
// The render of the document after one anchor moves (the renderer warm).
exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"}));
let opts = RenderOptions::default();
let mut r = Renderer::new();
let at = Affine::translate(Vec2::new(800.0 - a.x, 600.0 - a.y));
r.render(&s.doc().unwrap().doc, 1600, 1200, at, &opts);
let ms = median(
(0..iters())
.map(|_| {
exec(&mut s, "path.moveAnchors", json!({"dx": 0, "dy": 1}));
let doc = s.doc().unwrap().doc.clone();
let t = Instant::now();
drop(r.render(&doc, 1600, 1200, at, &opts));
t.elapsed().as_secs_f64() * 1000.0
})
.collect(),
);
row("render 1600x1200 around the anchor after it moves", ms);
let doc = s.doc().unwrap().doc.clone();
row("render 1600x1200 around the anchor, nothing changed", time(|| drop(r.render(&doc, 1600, 1200, at, &opts))));
// Delete of one anchor, undone after each run.
let ms = median(
(0..iters())
.map(|_| {
exec(&mut s, "select.anchors", json!({"id": shape.anchor.0.0, "anchors": [[0, shape.anchor.1]], "mode": "set"}));
let t = Instant::now();
exec(&mut s, "edit.clear", json!({}));
let ms = t.elapsed().as_secs_f64() * 1000.0;
exec(&mut s, "edit.undo", json!({}));
ms
})
.collect(),
);
row("Delete of one anchor", ms);
}
fn main() {
bench(one_outline());
bench(many_paths());
bench(long_open_path());
bench(dashed_outline());
}
+33 -4
View File
@@ -168,9 +168,20 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
/// Objects are selected, or the Artboard tool is chosen (Copy and Cut take its artboard).
pub fn has_selection_or_artboard_tool(s: &Session) -> std::result::Result<(), String> {
if s.tool_id() == "artboard" { has_doc(s) } else { has_selection(s) }
/// A path is selected, or inside a selected group or compound path: Object › Path's items that
/// work on paths (not on text, images or other objects alone).
pub fn has_selected_path(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().ok_or("no document open")?;
// Stops at the first path: menus ask this every frame, and a selected group can hold many.
fn holds_path(n: &vectorcraft_doc::Node) -> bool {
matches!(n.kind, vectorcraft_doc::NodeKind::Path { guide: false, .. }) || n.children().is_some_and(|c| c.iter().any(|c| holds_path(c)))
}
if st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).any(holds_path) { Ok(()) } else { Err("select a path".into()) }
}
/// Objects or ruler guides are selected, or the Artboard tool is chosen (what Copy and Cut take).
pub fn has_selection_guides_or_artboard_tool(s: &Session) -> std::result::Result<(), String> {
if s.tool_id() == "artboard" { has_doc(s) } else { has_selection_or_guides(s) }
}
/// Objects or ruler guides are selected (what Delete and the arrow keys act on).
pub fn has_selection_or_guides(s: &Session) -> std::result::Result<(), String> {
@@ -484,9 +495,10 @@ pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
}
/// A colour from `"#rrggbb"`, a colour key (`"cmyk 0 100 100 0"`, `"lab 55 60 40"`), `[r,g,b]` (0..1),
/// `{"c":..,"m":..,"y":..,"k":..}` (percent when any component is above 1, else 0..1), `{"gray":..}` or `{"l":..,"a":..,"b":..}` (CIE Lab: L 0..100, a and b about
/// −128..127).
/// −128..127). `None` for a hex color with an alpha part (`#rrggbbaa` or `#rgba`, with or without the `#`).
pub fn color_value(v: &Value) -> Option<Color> {
match v {
Value::String(s) if hex_with_alpha(s) => None,
// A hex, or a colour key ("cmyk 0 100 100 0", as Recolor Artwork lists colours).
Value::String(s) => Color::from_hex(s).or_else(|| vectorcraft_color::recolor::ColorKey::parse(s).map(|k| k.color())),
Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)),
@@ -512,6 +524,23 @@ pub fn color_value(v: &Value) -> Option<Color> {
}
}
/// Is `s` a hex color with an alpha part: eight or four hex digits, with or without the `#`?
/// [`Color::from_hex`] reads `#rrggbbaa` as `#rrggbb`, without the alpha.
pub(crate) fn hex_with_alpha(s: &str) -> bool {
let s = s.trim();
let h = s.strip_prefix('#').unwrap_or(s);
matches!(h.len(), 4 | 8) && h.chars().all(|c| c.is_ascii_hexdigit())
}
/// The color `p` gives for `key`, read by [`color_value`]: `None` when `p` leaves it out or gives
/// null. Any other value that is not a color is an error: "bad color {v}".
pub(crate) fn color_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Color>> {
match p.get(key).filter(|v| !v.is_null()) {
None => Ok(None),
Some(v) => color_value(v).map(Some).ok_or_else(|| bad(cmd, format!("bad color {v}"))),
}
}
/// `v` as an object id: an error unless it is a non-negative integer.
fn object_id(v: &Value) -> Result<NodeId> {
v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}")))
+6
View File
@@ -4,9 +4,12 @@
mod arrange;
mod cleanup;
mod color;
mod contour;
mod document;
mod pathtools;
mod pen;
mod select;
mod shapes;
mod typetools;
use super::CommandSpec;
@@ -16,9 +19,12 @@ pub fn specs() -> Vec<CommandSpec> {
let mut v = arrange::specs();
v.extend(cleanup::specs());
v.extend(color::specs());
v.extend(contour::specs());
v.extend(document::specs());
v.extend(pathtools::specs());
v.extend(pen::specs());
v.extend(select::specs());
v.extend(shapes::specs());
v.extend(typetools::specs());
v
}
+71 -22
View File
@@ -13,7 +13,7 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit};
use crate::cmd::fonts::{edit_symbol_texts, symbol_texts};
use crate::cmd::textedit::editing_range;
use crate::cmd::textedit::{editing_range, editing_story};
use crate::cmd::typecmd::refresh_bounds;
use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param};
use crate::{EngineError, Result, Session};
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Breaks",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}",
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool (after Select All in a frame of threaded text, in the text of every frame of the story), else in all the text of the selected type, as one undo step; a break between two words becomes a space, the frames of a threaded story selected all together being read as one text → {ids, removed}",
has_selection,
remove_breaks
),
@@ -730,12 +730,11 @@ fn join_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0, "joined": joined, "lines": count }))
}
/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many
/// went.
fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
let text = t.plain_text();
let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b));
let Some(range) = text.get(a..b) else { return 0 };
/// The breaks `brk` in bytes `a..b` of `text`, each with whether it becomes a space (it is between
/// two words).
fn break_cuts(text: &str, a: usize, b: usize, brk: char) -> Vec<(usize, bool)> {
let (a, b) = (edit::floor_char(text, a), edit::floor_char(text, b));
let Some(range) = text.get(a..b) else { return vec![] };
let mut prev = text.get(..a).and_then(|s| s.chars().next_back());
let mut cuts = vec![];
for (i, c) in range.char_indices() {
@@ -753,6 +752,12 @@ fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
}
cuts.push((at, space));
}
cuts
}
/// Remove the breaks `brk` at the bytes of `cuts` from `t`, each one that `cuts` marks becoming a
/// space → how many went.
fn apply_cuts(t: &mut TextObject, cuts: &[(usize, bool)], brk: char) -> usize {
for &(at, space) in cuts.iter().rev() {
let insert = if space { " " } else { "" };
let end = at + brk.len_utf8();
@@ -763,6 +768,30 @@ fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
cuts.len()
}
/// The breaks `brk` in the text of the frames of `story`, read as one text: a break at the end or
/// start of a frame between words of the frames on either side becomes a space. Each editable frame
/// comes with its own breaks, at its own bytes.
fn story_cuts(d: &Document, story: &[NodeId], brk: char) -> Vec<(NodeId, Vec<(usize, bool)>)> {
let texts: Vec<(NodeId, String)> = story.iter().filter_map(|id| Some((*id, text_of(d, *id)?.plain_text()))).collect();
let joined: String = texts.iter().map(|(_, t)| t.as_str()).collect();
// The breaks in story order, handed to the frames in one pass.
let mut cuts = break_cuts(&joined, 0, joined.len(), brk).into_iter().peekable();
let mut out = vec![];
let mut start = 0;
for (id, text) in texts {
let end = start + text.len();
let mut own = vec![];
while let Some((at, space)) = cuts.next_if(|(at, _)| *at < end) {
own.push((at - start, space));
}
if d.is_editable(id) {
out.push((id, own));
}
start = end;
}
out
}
fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.removeBreaks";
let (brk, label, none) = match text_param(p, "kind", C)? {
@@ -771,21 +800,41 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))),
None => return Err(bad(C, "missing `kind`")),
};
// The characters a Type tool has selected, else all the text of the selected type.
// The characters a Type tool has selected (after Select All in threaded text, all the text of
// every frame of the story), else all the text of the selected type.
let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
let story = if editing.is_some() { editing_story(s) } else { vec![] };
let d = &s.doc()?.doc;
let targets: Vec<(NodeId, usize, usize)> = match editing {
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(),
None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(),
// Each type object with the breaks that go from it.
let targets: Vec<(NodeId, Vec<(usize, bool)>)> = match editing {
Some(_) if !story.is_empty() => story_cuts(d, &story, brk),
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))]
.into_iter()
.filter(|(id, ..)| d.is_editable(*id))
.filter_map(|(id, a, b)| Some((id, break_cuts(&text_of(d, id)?.plain_text(), a, b, brk))))
.collect(),
None => {
let ids = texts_under(d, &checked_ids_or_selection(s, p, C)?)?;
// A threaded story whose frames are all among them is read as one text, once.
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
let mut done = std::collections::HashSet::new();
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
for &id in &ids {
if !done.insert(id) {
continue;
}
let story = crate::cmd::threads::story_of(d, id);
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
done.extend(story.iter().copied());
out.extend(story_cuts(d, &story, brk));
} else if let Some(t) = text_of(d, id) {
out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk)));
}
}
out
}
};
let count: usize = targets
.iter()
.filter_map(|(id, a, b)| {
let text = text_of(d, *id)?.plain_text();
let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b));
Some(text.get(a..b)?.matches(brk).count())
})
.sum();
let count: usize = targets.iter().map(|(_, cuts)| cuts.len()).sum();
if count == 0 {
return Err(EngineError::Other(none.into()));
}
@@ -794,9 +843,9 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
}
let (removed, ids) = s.edit(label, |d, _| {
let (mut removed, mut ids) = (0, vec![]);
for (id, a, b) in targets {
for (id, cuts) in targets {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
let n = strip_breaks(t, a, b, brk);
let n = apply_cuts(t, &cuts, brk);
if n > 0 {
refresh_bounds(t);
removed += n;
+121 -7
View File
@@ -3,9 +3,10 @@
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, Find Nearest
//! Swatch ([`nearest`]) matches colours to a swatch library's, and Create Swatch Legend
//! ([`legend`]) draws labelled chips of the document's swatches. Its panel and its submenu, Object ›
//! Plug-ins › Color Effects, are the UI's.
//! Swatch ([`nearest`]) matches colours to a swatch library's, Create Swatch Legend
//! ([`legend`]) draws labelled chips of the document's swatches, and Apply Fill to Stroke and Apply
//! Stroke to Fill ([`copy_paint`]) copy each object's own paint across. Its panel and its submenu,
//! Object › Plug-ins › Color Effects, are the UI's.
pub mod legend;
mod nearest;
@@ -14,13 +15,14 @@ mod print_fixes;
mod tests;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind};
use vectorcraft_render::effects;
use super::super::colorcmds::recolor_links;
use super::super::paint::{restore_unpainted_stroke, set_run_stroke};
use super::super::select::walk_selectable;
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, text_param};
use crate::{Result, Session};
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, paint_targets, switch_or, text_param};
use crate::{EngineError, Result, Session};
/// The kinds Select by Color Kind selects (`addon.color.select`).
pub const SELECT_KINDS: [&str; 5] = ["spotColors", "colorEffects", "rgbImages", "cmykImages", "grayImages"];
@@ -59,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Find Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}",
"{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up, #rrggbbaa and #rgba refused; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}",
has_doc,
nearest::find
),
@@ -72,6 +74,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
nearest::apply
),
cmd!(
"addon.color.fillToStroke",
"Apply Fill to Stroke",
["Object", "Plug-ins", "Color Effects"],
None,
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
has_doc,
|s, p| copy_paint(s, p, true)
),
cmd!(
"addon.color.strokeToFill",
"Apply Stroke to Fill",
["Object", "Plug-ins", "Color Effects"],
None,
"{ids?|id?, skipNone?: bool} each target's top fill takes its own top stroke's paint, as addon.color.fillToStroke does the other way; the stroke itself is unchanged. The default fill takes the default stroke → {objects}",
has_doc,
|s, p| copy_paint(s, p, false)
),
]
.into_iter()
.chain(legend::specs())
@@ -156,3 +176,97 @@ fn filter(s: &mut Session, p: &Value) -> Result<Value> {
let swatches = |name: &str| kinds.get(name).copied();
recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
}
/// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own
/// paints onto the other, so a mixed selection keeps each object's colour (#5530). Aimed and
/// undone like Swap Fill and Stroke; characters selected with the Type tool are the only ones that
/// change, as for `paint.setFill`. A graph's series marks and labels give the graph the paint in
/// the same undo step, as `paint.setFill` does, so drawing the graph again keeps it. When nothing
/// changes, the document and its undo history stay as they are.
fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
let (cmd, label) =
if to_stroke { ("addon.color.fillToStroke", "Apply Fill to Stroke") } else { ("addon.color.strokeToFill", "Apply Stroke to Fill") };
let skip_none = switch_or(p, "skipNone", false, cmd)?;
// One text style: its stroke takes its fill, or the other way.
let copy_run = |st: &mut CharStyle| {
let from = if to_stroke { st.fill.clone() } else { st.stroke.clone() };
if (skip_none && from.is_none()) || from == if to_stroke { st.stroke.clone() } else { st.fill.clone() } {
return;
}
if to_stroke {
set_run_stroke(st, from);
} else {
st.fill = from;
}
};
if p.get("ids").is_none()
&& p.get("id").is_none()
&& let Some((id, a, b)) = super::super::textedit::editing_range(s)?
&& a != b
{
// After Select All in threaded text, every frame's text takes it.
let story = super::super::textedit::editing_story(s);
let ranges: Vec<(NodeId, usize, usize)> =
if story.is_empty() { vec![(id, a, b)] } else { story.iter().map(|f| (*f, 0, usize::MAX)).collect() };
let ids: Vec<NodeId> = ranges.iter().map(|(f, _, _)| *f).collect();
let before = s.doc()?.doc.clone();
let n = super::super::overprint::edit_counted(s, label, |d| {
for &(id, a, b) in &ranges {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
vectorcraft_text::edit::style_range(&mut t.runs, a, b, copy_run);
super::super::typecmd::refresh_bounds(t);
}
Ok(super::super::appearance::count_changed(d, &before, &ids))
})?;
return Ok(json!({"objects": n}));
}
// A graph's series group stands for its marks, as for `paint.setFill`.
let ids = super::super::graph::paint_targets(&s.doc()?.doc, &paint_targets(s, p)?);
let mut objects = 0;
if !ids.is_empty() {
let before = s.doc()?.doc.clone();
super::super::overprint::edit_counted(s, label, |d| {
for id in &ids {
let Some(node) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut node.kind {
for r in &mut t.runs {
copy_run(&mut r.style);
}
super::super::typecmd::refresh_bounds(t);
continue;
}
let ap = &mut node.appearance;
let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) };
// A missing item reads as None: copying None onto it would add an item that
// changes nothing.
if (skip_none && from.is_none()) || from == to {
continue;
}
if to_stroke {
restore_unpainted_stroke(ap, None, &from);
ap.set_stroke(from);
} else {
ap.set_fill(from);
}
}
objects = super::super::appearance::count_changed(d, &before, &ids);
// The graph keeps the paint its series marks and labels now have.
let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke);
let labels = super::super::graph::capture_label_styles(d, &ids, &copy_run);
Ok(objects + usize::from(series || labels))
})?;
}
// The defaults for new art change the same way, as Swap Fill and Stroke changes them.
let from = if to_stroke { s.paint.fill.clone() } else { s.paint.stroke.clone() };
if !(skip_none && from.is_none()) {
if to_stroke {
if s.paint.stroke.is_none() && !from.is_none() && s.paint.stroke_width == 0.0 {
s.paint.stroke_width = 1.0;
}
s.paint.stroke = from;
} else {
s.paint.fill = from;
}
}
Ok(json!({"objects": objects}))
}
+85
View File
@@ -0,0 +1,85 @@
//! Contour (`addon.contour.*`), a built-in add-on: the Contour tool's command. A Contour is a copy of a selected shape that
//! carries a live Offset Path effect, so it grows or shrinks the shape without baking anything: its
//! fill, stroke and effect stay editable, and Expand Appearance bakes it into plain paths. The
//! original stays where it is. Running the command on an object that already carries an Offset
//! Path effect (a Contour made earlier) changes that effect instead of nesting another one.
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_render::effects;
use super::super::edit::{copy_above, selected_roots};
use super::super::pathops::{len_param, num_param};
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param};
const C: &str = "addon.contour.set";
/// The live effect that does the offsetting (the one Effect › Path › Offset Path applies).
const EFFECT: &str = "path.offsetPath";
/// The largest offset, in points; Offset Path takes the same.
const MAX_OFFSET: f64 = 1e5;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.contour.set",
"Contour",
[],
None,
"{offset: pt (negative insets), corners?: \"sharp\"|\"round\"|\"square\" (default sharp; square cuts each corner off flat), miterLimit?: 1..500 (4; sharp corners only)} make a copy of each selected path or shape directly above it with a live Offset Path effect, or, for a selected object that already has one (a Contour), change that effect: nothing is baked (Expand Appearance bakes it) and the original is kept → {ids, created, updated}",
has_selection,
contour
)]
}
/// The Offset Path join a Contour corner style stands for.
fn joins_for(corners: Option<&str>) -> Result<&'static str> {
match corners.map(str::to_ascii_lowercase).as_deref() {
None | Some("sharp") => Ok("miter"),
Some("round") => Ok("round"),
Some("square") => Ok("bevel"),
Some(other) => Err(bad(C, format!("`corners` must be sharp, round or square, not `{other}`"))),
}
}
fn contour(s: &mut Session, p: &Value) -> Result<Value> {
let offset = len_param(p, "offset").ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
if offset.abs() > MAX_OFFSET {
return Err(bad(C, "offset out of range"));
}
let joins = joins_for(str_param(p, "corners"))?;
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
let effect = effects::new_effect(EFFECT, &json!({"offset": offset, "joins": joins, "miterLimit": miter}))
.ok_or_else(|| EngineError::Other("Contour: the Offset Path effect is not available".into()))?;
let roots = selected_roots(s)?;
let (updated, created) = s.edit("Contour", |d, sel| {
// A shape that already carries Offset Path is a Contour: change it. Any other path or
// shape gets a Contour copy above it. Type, images and the rest are left alone.
let (mut again, mut fresh) = (vec![], vec![]);
for id in roots {
let Some(n) = d.node(id) else { continue };
if !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
continue;
}
if n.appearance.effects.iter().any(|e| e.id == EFFECT) {
again.push(id);
} else {
fresh.push(id);
}
}
if again.is_empty() && fresh.is_empty() {
return Err(EngineError::Other("Contour: select paths or shapes".into()));
}
for id in &again {
if let Some(e) = d.node_mut(*id).and_then(|n| n.appearance.effects.iter_mut().rev().find(|e| e.id == EFFECT)) {
e.params = effect.params.clone();
}
}
let made = copy_above(d, &fresh, |_, _, copy| copy.appearance.effects.push(effect.clone()))?;
sel.set(again.iter().chain(made.iter()).copied());
Ok((again, made))
})?;
let list = |ids: &[NodeId]| ids.iter().map(|i| i.0).collect::<Vec<_>>();
Ok(json!({ "ids": list(&[updated.as_slice(), created.as_slice()].concat()), "created": list(&created), "updated": list(&updated) }))
}

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