diff --git a/.github/workflows/print.yml b/.github/workflows/print.yml
index 400a81944..058705ce2 100644
--- a/.github/workflows/print.yml
+++ b/.github/workflows/print.yml
@@ -13,27 +13,27 @@ on:
workflow_dispatch:
permissions:
contents: read
-env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: 0
+# A newer push to a PR branch cancels its older run.
+concurrency:
+ group: print-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
+ # Desktop printing on Windows, through the suite's shared gate. Pull requests restore the cache
+ # but never save one; a manual run on main saves the cache they restore.
windows:
- runs-on: windows-2022
- timeout-minutes: 90
- steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
- with:
- toolchain: stable
- components: clippy
- - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- with:
- key: desktop-print
- - run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
- - run: cargo test --locked -p vectorcraft
- - name: Check pending submission and queue refusal feedback
- shell: bash
- # A filter that matches nothing still passes, so fail unless some test ran.
- run: |
- cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee printps.log
- grep -Eq 'test result: ok\. [1-9][0-9]* passed' printps.log || { echo "::error::tests::printps matched no tests"; exit 1; }
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ os: '["windows-2022"]'
+ timeout-minutes: 90
+ # As before: printing builds and tests without craft-fonts, nextest or the wasm target.
+ craft-fonts-ref: ""
+ tools: ""
+ targets: ""
+ # A filter that matches nothing still passes, so the printps run fails unless some test ran.
+ command: |
+ cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
+ cargo test --locked -p vectorcraft
+ log="$RUNNER_TEMP/printps.log"
+ cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee "$log"
+ grep -Eq 'test result: ok\. [1-9][0-9]* passed' "$log" || { echo "::error::tests::printps matched no tests"; exit 1; }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d1677e538..07b3c6b7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -63,6 +63,7 @@ jobs:
needs: git-sources
permissions:
contents: write
+ packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
diff --git a/ASSETS.md b/ASSETS.md
index 3233c99d0..db2a97816 100644
--- a/ASSETS.md
+++ b/ASSETS.md
@@ -224,13 +224,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-swap.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-swatch-kinds.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-symbol-sprayer.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
-| `assets/icons/dc-touch-type.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-touch-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-transform-panel.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-transparency.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-twirl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
-| `assets/icons/dc-type-area.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
-| `assets/icons/dc-type-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
-| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-type-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-type-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-type-vertical-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+| `assets/icons/dc-type-vertical-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-width-profile.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-width.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-wrinkle.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
diff --git a/Cargo.lock b/Cargo.lock
index 934be1b12..0b5542849 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -705,6 +705,17 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cfb"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b"
+dependencies = [
+ "fnv",
+ "uuid",
+ "web-time",
+]
+
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -1070,6 +1081,17 @@ dependencies = [
"zip",
]
+[[package]]
+name = "craft-vectorcraft-format"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137"
+dependencies = [
+ "flate2",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2841,7 +2863,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
-source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+source = "git+https://github.com/storytold/craft-libs?rev=fb08be5fa19eee8d37e624397fcdc96df6035e92#fb08be5fa19eee8d37e624397fcdc96df6035e92"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6050,7 +6072,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -6095,7 +6117,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"crc32fast",
"flate2",
@@ -6106,7 +6128,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -6118,7 +6140,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6134,7 +6156,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -6156,7 +6178,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -6168,7 +6190,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"chrono",
"hayro-ccitt",
@@ -6187,7 +6209,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6203,8 +6225,10 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
+ "cfb",
+ "chrono",
"craft-docpath",
"crc32fast",
"flate2",
@@ -6243,7 +6267,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"fax",
"flate2",
@@ -6267,8 +6291,9 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
+ "craft-vectorcraft-format",
"flate2",
"proptest",
"rustix 1.1.6",
@@ -6282,7 +6307,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -6294,7 +6319,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"image",
"log",
@@ -6313,7 +6338,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6327,7 +6352,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -6339,7 +6364,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"aes",
"chrono",
@@ -6371,7 +6396,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"serde",
"serde_json",
@@ -6385,7 +6410,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"flate2",
"gif 0.14.2",
@@ -6415,14 +6440,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -6447,7 +6472,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"fax",
"image",
@@ -6465,7 +6490,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -6486,7 +6511,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -6494,7 +6519,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6511,7 +6536,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6523,7 +6548,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"bytemuck",
"craft-keymap",
@@ -6561,7 +6586,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"eframe",
"egui",
@@ -7692,7 +7717,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.19.0"
+version = "0.21.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index b5b4312ea..35be6fd24 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
-version = "0.19.0"
+version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -19,6 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
+# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
+craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
@@ -150,7 +152,9 @@ hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d3
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
-krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+# The PDF writer takes craft-libs#417 (CMYK, gray and spot colors keep their exact inks); the other
+# craft-libs crates stay at 92d392cf. craft-pdf's sources are the same at both revisions.
+krilla = { git = "https://github.com/storytold/craft-libs", rev = "fb08be5fa19eee8d37e624397fcdc96df6035e92" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
diff --git a/README.md b/README.md
index 0b7b9932b..8a3c453c5 100644
--- a/README.md
+++ b/README.md
@@ -328,12 +328,21 @@ stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
-| | **VectorCraft** | **Vector illustration · you are here** | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
+| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
+| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
+| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
+| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
+| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
+| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
+| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
+| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
+| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
diff --git a/ROADMAP.md b/ROADMAP.md
index 0206168ac..8ebfd6522 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -9,7 +9,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-01 · **Change:** minor (Progress log row for the Document Tools add-on's New Symbols from Layers; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Color Effects add-on's Find Nearest Swatch; headline numbers 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
@@ -129,6 +129,11 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
+- 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).
+- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
+- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
+- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917).
- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged.
@@ -145,6 +150,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.
+- 2026-10-22: Pen tilt, bearing and barrel rotation (part of #764): Calligraphic and Scatter brushes take Tilt, Bearing and Rotation beside Pressure, the Paintbrush records them along each stroke, and the app reads a pen's tilt and an Art Pen's barrel rotation from XInput2 on Linux under X11 and XWayland and from AppKit's tablet events on macOS. Headline numbers unchanged.
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
@@ -208,6 +214,10 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
+| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. |
+| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. |
+| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. |
+| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Document Tools add-on's Save a Timestamped Copy (#4926); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for Divide into Dashes (Path Precision Tools add-on, #4950); headline numbers unchanged. |
diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs
index ac3a9cf3a..33815fbf3 100644
--- a/apps/vectorcraft-cli/src/perf.rs
+++ b/apps/vectorcraft-cli/src/perf.rs
@@ -116,7 +116,8 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut drag = |down: Point| -> Result {
- let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0 };
+ let ev =
+ |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0, pen: None };
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
s.select_tool("directSelection", view).map_err(err)?;
let undo = s.doc().map_err(err)?.history.undo.len();
@@ -215,7 +216,14 @@ fn deep(n: usize) -> Result<(), String> {
let first = s.doc().map_err(err)?.selection.objects.first().copied();
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
if let Some(down) = at {
- let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 };
+ let ev = |kind, dx: f64| PointerEvent {
+ kind,
+ pos: down + Vec2::new(dx, 0.0),
+ mods: Mods::default(),
+ backquote: false,
+ pressure: 1.0,
+ pen: None,
+ };
s.select_tool("selection", view).map_err(err)?;
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut dx = 0.0;
diff --git a/apps/vectorcraft-web/src/pencil.rs b/apps/vectorcraft-web/src/pencil.rs
index b7140d7e5..88b85e9d5 100644
--- a/apps/vectorcraft-web/src/pencil.rs
+++ b/apps/vectorcraft-web/src/pencil.rs
@@ -23,7 +23,7 @@ use std::cell::RefCell;
use std::rc::Rc;
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
-use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
use vectorcraft_ui_egui::touch::{self, Source};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
@@ -95,7 +95,7 @@ impl Pointers {
/// A pen moved or pressed with `pressure`, with the end it last pressed with.
fn pen_sample(&self, pressure: f32) {
- self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser }));
+ self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser, pose: PenPose::default() }));
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option) {
diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs
index 08bb6aa8a..cec7fca1f 100644
--- a/apps/vectorcraft/src/clipboard.rs
+++ b/apps/vectorcraft/src/clipboard.rs
@@ -20,7 +20,7 @@ use std::io::{Cursor, Read};
use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
-use vectorcraft_ui_egui::SystemClipboard;
+use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard};
/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
@@ -172,7 +172,8 @@ mod win {
use clipboard_win::{formats, options::NoClear, raw};
use super::{
- BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
+ BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of,
+ windows_formats,
};
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
@@ -249,10 +250,16 @@ mod win {
// A clipboard manager may have copied our data again as its own.
let Some(ours) = self.text.as_deref() else { return false };
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
- read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())
+ read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes())
}
fn read(&mut self, mimes: &[&'static str]) -> Option {
+ self.read_deferred(mimes)?.flavour()
+ }
+
+ /// A device-independent bitmap is copied off the clipboard here and decoded and encoded
+ /// as PNG by the work that comes back.
+ fn read_deferred(&mut self, mimes: &[&'static str]) -> Option {
let mut files = vec![];
{
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
@@ -260,7 +267,7 @@ mod win {
let _ = raw::get_file_list_path(&mut files);
}
// The files are read with the clipboard closed again, not keeping other apps waiting.
- copied_file(files, mimes).or_else(|| {
+ copied_file(files, mimes).map(ClipboardRead::Now).or_else(|| {
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
mimes.iter().find_map(|m| read_one(m))
})
@@ -284,16 +291,28 @@ mod win {
})
}
- /// One flavour, while the clipboard is open.
- fn read_one(mime: &'static str) -> Option {
+ /// A device-independent bitmap as a PNG flavour.
+ fn dib_png(dib: &[u8]) -> Option {
+ png_flavour(&dib_image(dib)?)
+ }
+
+ /// Copy both bitmap formats while the clipboard is open, then decode them in preference order.
+ pub(super) fn read_dibs(mut get: impl FnMut(u32) -> Option>) -> Option {
+ let dibs: Vec<_> = DIBS.into_iter().filter_map(&mut get).collect();
+ (!dibs.is_empty()).then_some(ClipboardRead::Later(Box::new(move || dibs.iter().find_map(|dib| dib_png(dib)))))
+ }
+
+ /// One flavour, while the clipboard is open. A device-independent bitmap is copied here, and
+ /// the work that comes back decodes each captured format in preference order.
+ fn read_one(mime: &'static str) -> Option {
let mut data = vec![];
let mime = match mime {
BITMAP if read_registered(PNG, &mut data) => PNG,
BITMAP => {
- return DIBS.into_iter().find_map(|id| {
- data.clear();
- raw::get_vec(id, &mut data).ok()?;
- png_flavour(&dib_image(&data)?)
+ return read_dibs(|id| {
+ let mut dib = vec![];
+ raw::get_vec(id, &mut dib).ok()?;
+ Some(dib)
});
}
TEXT => {
@@ -309,7 +328,7 @@ mod win {
{
data.truncate(end);
}
- (!data.is_empty()).then_some(Flavour { mime, data })
+ (!data.is_empty()).then_some(ClipboardRead::Now(Flavour { mime, data }))
}
}
@@ -322,7 +341,7 @@ mod portable {
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
- use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
+ use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
/// What our last write put on the clipboard.
enum Ours {
@@ -580,22 +599,27 @@ mod portable {
}
fn read(&mut self, mimes: &[&'static str]) -> Option {
+ self.read_deferred(mimes)?.flavour()
+ }
+
+ /// arboard's image is read here and encoded as PNG by the work that comes back.
+ fn read_deferred(&mut self, mimes: &[&'static str]) -> Option {
let cb = self.cb(Asker::User).ok()?;
if let Some(f) = copied_file(cb.file_list(), mimes) {
- return Some(f);
+ return Some(ClipboardRead::Now(f));
}
mimes.iter().find_map(|m| {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::read(m) {
- return Some(f);
+ return Some(ClipboardRead::Now(f));
}
match *m {
- TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
+ TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })),
BITMAP => {
let img = cb.get_image().ok()?;
- let rgba =
- image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
- png_flavour(&rgba)
+ let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
+ let bytes = img.bytes.into_owned();
+ Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?))))
}
_ => None,
}
@@ -1057,6 +1081,28 @@ mod tests {
img.pixels().map(|p| p.0).collect()
}
+ #[test]
+ #[cfg(windows)]
+ fn dib_fallback_captures_both_formats_before_decoding() {
+ use clipboard_win::formats::{CF_DIB, CF_DIBV5};
+
+ let valid = dib(40, 1, 1, 32, 0, &[], &bgra(&[RED]));
+ let mut captured = Vec::new();
+ let read = win::read_dibs(|id| {
+ captured.push(id);
+ match id {
+ CF_DIBV5 => Some(vec![40, 0, 0]),
+ CF_DIB => Some(valid.clone()),
+ _ => None,
+ }
+ })
+ .unwrap();
+ assert_eq!(captured, [CF_DIBV5, CF_DIB]);
+ let flavour = read.flavour().unwrap();
+ assert_eq!(flavour.mime, PNG);
+ assert_eq!(pixels(&decode_png(&flavour.data).unwrap()), [RED]);
+ }
+
#[test]
fn a_screenshot_dib_pastes_opaque_and_upright() {
// CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero.
diff --git a/apps/vectorcraft/src/mac_tablet.rs b/apps/vectorcraft/src/mac_tablet.rs
index b624262aa..1d9aabd44 100644
--- a/apps/vectorcraft/src/mac_tablet.rs
+++ b/apps/vectorcraft/src/mac_tablet.rs
@@ -1,6 +1,7 @@
-//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
-//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
-//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
+//! macOS: a drawing tablet's pen pressure (#852), tilt and barrel rotation (#764). winit 0.30
+//! hands a pen to the window there as a mouse, without them, so the app reads them from AppKit
+//! itself and writes them into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
+//! reads, as on X11 ([`crate::tablet`]).
//!
//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
//! each event it is about to dispatch" in this app:
@@ -9,9 +10,14 @@
//! it on unchanged:
//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
-//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
-//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
-//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
+//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. Its `tilt`
+//! (−1..1 on each axis, ±[`TILT_DEGREES`] at the ends) and its `rotation` go into the feed as the
+//! pen's pose, kept for that move too. AppKit gives a rotation of 0 for a pen that does not read
+//! one and a tilt of 0 for an upright pen, so one event cannot tell a pen without tilt or rotation
+//! from one held upright and unturned. Each reads as none until the pen reports a value other
+//! than 0 after it comes near ([`Pen`]).
+//! A mouse event of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
+//! - An `NSEventTypeTabletPoint` event carries the same pressure, tilt and rotation without a move.
//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
@@ -20,12 +26,14 @@
//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
//! merges the drags that queue up between two reads into one: every report of the pen arrives.
//!
-//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
-//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
-//! reads them.
+//! The event mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`). The tilt
+//! and rotation follow Apple's `NSEvent` documentation, as [`RawEvent::pose`] describes. The mapping
+//! works on plain values and is tested on every platform; the monitor reads them.
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
-use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
+
+use crate::tablet::TILT_DEGREES;
/// `NSEventType` values (AppKit headers).
mod event_type {
@@ -56,6 +64,10 @@ struct RawEvent {
subtype: i16,
/// `pressure` (tablet point events and mouse events of that subtype only).
pressure: f32,
+ /// `tilt` (x, y), each −1..1 (the same events as `pressure`).
+ tilt: (f64, f64),
+ /// `rotation`, in degrees (the same events as `pressure`).
+ rotation: f32,
/// `pointingDeviceType` (proximity events only).
device: usize,
/// `isEnteringProximity` (proximity events only).
@@ -74,6 +86,40 @@ impl RawEvent {
fn point(&self) -> bool {
self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
}
+
+ /// The pen's tilt and rotation in [`PenPose`]'s units.
+ ///
+ /// AppKit's `tilt` is −1.0..1.0 on each axis and 0.0 on both with the pen perpendicular to the
+ /// tablet. A negative x is a tilt to the left and a positive x a tilt to the right; a negative y
+ /// is a tilt to the top and a positive y a tilt to the bottom
+ /// (). [`PenPose`]'s x is positive
+ /// to the right and its y positive down the page, the same signs, so each axis keeps its sign.
+ /// Apple gives the ends of the range as maximum tilt and does not give them in degrees
+ /// (), so
+ /// ±1.0 is scaled to ±[`TILT_DEGREES`], the degrees the X11 reader ([`crate::tablet`]) gives the
+ /// ends of a tilt range that is not in degrees. A value past ±1.0 is clamped.
+ ///
+ /// AppKit's `rotation` is in degrees, and a device without rotation reports 0.0
+ /// (). Apple's tablet pages give
+ /// no direction for rotation; the Cocoa Event Handling Guide gives the `rotation` accessor's
+ /// degrees as counterclockwise for the rotate gesture
+ /// (),
+ /// and the pen's rotation is taken the same way. [`PenPose`]'s rotation runs clockwise, so the
+ /// angle is negated and brought into 0..360.
+ ///
+ /// The tilt is left out unless `tilts`, and the rotation unless `turns` ([`Pen`]).
+ fn pose(&self, tilts: bool, turns: bool) -> PenPose {
+ let degrees = |v: f64| (v.clamp(-1.0, 1.0) * TILT_DEGREES) as f32;
+ // `0.0 - r` rather than `-r`: a rotation of 0 gives 0, not −0.
+ let rotation = turns.then(|| (0.0 - self.rotation).rem_euclid(360.0));
+ PenPose { tilt: tilts.then(|| [degrees(self.tilt.0), degrees(self.tilt.1)]), rotation }
+ }
+
+ /// Whether the event reports a tilt other than 0, and a rotation other than 0.
+ fn reads(&self) -> (bool, bool) {
+ let read = |v: f64| v.is_finite() && v != 0.0;
+ (read(self.tilt.0) || read(self.tilt.1), read(f64::from(self.rotation)))
+ }
}
/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
@@ -86,7 +132,7 @@ fn event_mask() -> u64 {
enum Update {
/// Nothing: an event the monitor doesn't map.
Keep,
- /// The pressure and end of a pen, or `None` for a mouse.
+ /// The sample of a pen, or `None` for a mouse.
Set(Option),
/// A pen's sample that comes with a pointer move.
Move(PenSample),
@@ -102,10 +148,14 @@ impl Update {
}
}
-/// Which end of the pen is near the tablet.
+/// Which end of the pen is near the tablet, and whether it has reported a tilt or a rotation other
+/// than 0 since it came near: until it has, its tilt or rotation reads as none, since AppKit gives
+/// 0 for a pen without one and for one held upright or unturned.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Pen {
eraser: bool,
+ tilts: bool,
+ turns: bool,
}
impl Pen {
@@ -113,10 +163,16 @@ impl Pen {
if e.proximity() {
// Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
self.eraser = e.entering && e.device == device::ERASER;
+ (self.tilts, self.turns) = (false, false);
let pen = e.entering && e.device != device::CURSOR;
- return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
+ return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser, ..PenSample::default() }));
}
- let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
+ if e.point() {
+ let (tilts, turns) = e.reads();
+ self.tilts |= tilts;
+ self.turns |= turns;
+ }
+ let sample = PenSample { pressure: e.pressure, eraser: self.eraser, pose: e.pose(self.tilts, self.turns) };
match (e.point(), e.mouse()) {
(true, true) => Update::Move(sample),
(true, false) => Update::Set(Some(sample)),
@@ -187,6 +243,9 @@ mod monitor {
}
if raw.point() {
raw.pressure = e.pressure();
+ let tilt = e.tilt();
+ raw.tilt = (tilt.x, tilt.y);
+ raw.rotation = e.rotation();
}
if raw.proximity() {
raw.device = e.pointingDeviceType().0;
@@ -211,8 +270,14 @@ mod tests {
RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
}
+ /// A sample of the tip of a pen that has reported no tilt or rotation.
fn tip(pressure: f32) -> PenSample {
- PenSample { pressure, eraser: false }
+ PenSample { pressure, eraser: false, pose: PenPose::default() }
+ }
+
+ /// A sample of the pen near the tablet but not yet read: no pose.
+ fn near(eraser: bool) -> PenSample {
+ PenSample { pressure: 0.0, eraser, pose: PenPose::default() }
}
#[test]
@@ -240,16 +305,45 @@ mod tests {
#[test]
fn proximity_says_which_end_is_near() {
let mut p = Pen::default();
- let eraser = |pressure| PenSample { pressure, eraser: true };
- assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
+ let eraser = |pressure| PenSample { pressure, eraser: true, pose: PenPose::default() };
+ assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(near(true))));
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
- assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
- assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
+ assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(near(false))), "the tip");
+ assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(near(false))), "an unknown tool draws as the tip");
assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
- assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
+ assert_eq!(p.handle(&e), Update::Set(Some(near(true))), "as a mouse event's subtype");
+ }
+
+ /// #764: AppKit's tilt (−1..1, x positive to the right, y positive to the bottom) reaches the
+ /// feed in degrees with the same signs, and its rotation, taken as counterclockwise, reaches it
+ /// clockwise.
+ #[test]
+ fn a_pens_tilt_and_rotation_go_into_the_feed() {
+ let mut p = Pen::default();
+ let sample = |pressure, pose| PenSample { pressure, eraser: false, pose };
+ assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)), "no tilt or rotation reported yet");
+ let leaning = RawEvent { tilt: (0.5, -0.25), rotation: 30.0, ..pen(DRAG, 0.5) };
+ let pose = PenPose { tilt: Some([30.0, -15.0]), rotation: Some(330.0) };
+ assert_eq!(p.handle(&leaning), Update::Move(sample(0.5, pose)));
+ let past_the_ends = RawEvent { tilt: (-4.0, 1.5), rotation: 270.0, ..pen(event_type::TABLET_POINT, 0.8) };
+ let pose = PenPose { tilt: Some([-60.0, 60.0]), rotation: Some(90.0) };
+ assert_eq!(p.handle(&past_the_ends), Update::Set(Some(sample(0.8, pose))), "clamped");
+ let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
+ let Update::Move(upright) = p.handle(&pen(DRAG, 0.6)) else { panic!("a pen's drag is a move") };
+ assert_eq!(upright, sample(0.6, pose), "once reported, 0 is upright and unturned");
+ assert!(upright.pose.rotation.is_some_and(f32::is_sign_positive), "{upright:?}");
+ let feed = StylusFeed::default();
+ p.handle(&RawEvent { tilt: (f64::NAN, 0.0), rotation: -90.0, ..pen(DRAG, 0.3) }).apply(&feed);
+ assert_eq!(feed.pose(), Some(PenPose { tilt: None, rotation: Some(90.0) }), "a tilt that is not a number is left out");
+ p.handle(&proximity(1, true));
+ let tilted = RawEvent { tilt: (0.0, 0.5), ..pen(event_type::TABLET_POINT, 0.8) };
+ let pose = PenPose { tilt: Some([0.0, 30.0]), rotation: None };
+ assert_eq!(p.handle(&tilted), Update::Set(Some(sample(0.8, pose))), "no rotation reported since the pen came near");
+ p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
+ assert_eq!(feed.pose(), None, "a mouse moved");
}
#[test]
@@ -259,7 +353,8 @@ mod tests {
for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
p.handle(&e).apply(&feed);
}
- assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
+ let pressures: Vec = feed.take_moves().into_iter().map(|(p, _)| p).collect();
+ assert_eq!((pressures, feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
assert_eq!(feed.held(), None, "a mouse moved");
}
diff --git a/apps/vectorcraft/src/prefs.rs b/apps/vectorcraft/src/prefs.rs
index 728fe9ba8..cf4e59b28 100644
--- a/apps/vectorcraft/src/prefs.rs
+++ b/apps/vectorcraft/src/prefs.rs
@@ -171,6 +171,11 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else {
return mine.clone();
};
+ // The current Actions set (`action_set`, an index) is this window's when this window chose
+ // another one or the other window did not; otherwise it is the other window's.
+ let use_mine = chose_another_action_set(base, mine) || !chose_another_action_set(base, &out);
+ // The list the saved current set was chosen from.
+ let chosen_from = if use_mine { mine.get("action_sets").cloned() } else { out.get("action_sets").cloned() };
let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect();
for key in keys {
if PASSING.contains(&key.as_str()) {
@@ -180,7 +185,7 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
continue;
}
let (was, now) = (base.get(key), mine.get(key));
- if was == now {
+ if was == now || key == "action_set" {
continue;
}
if key == "engine_prefs"
@@ -209,9 +214,95 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
}
}
}
+ if use_mine {
+ match mine.get("action_set") {
+ Some(v) => {
+ out.insert("action_set".to_string(), v.clone());
+ }
+ None => {
+ out.remove("action_set");
+ }
+ }
+ }
+ keep_current_action_set(&mut out, chosen_from.as_ref());
Value::Object(out)
}
+/// Point the saved current Actions set (`action_set`) at the same set in the merged
+/// `action_sets` when the merge took the index from one window and the list from another
+/// ([`find_action_set`]). With no such set, no set is current.
+fn keep_current_action_set(out: &mut serde_json::Map, chosen_from: Option<&serde_json::Value>) {
+ use serde_json::Value;
+ let Some(i) = out.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()) else { return };
+ let merged = out.get("action_sets");
+ if merged == chosen_from {
+ return;
+ }
+ match find_action_set(chosen_from, i, merged) {
+ Some(j) => {
+ out.insert("action_set".to_string(), Value::from(j));
+ }
+ None => {
+ out.remove("action_set");
+ }
+ }
+}
+
+/// Was another Actions set made current from `base` to `now`? Recording into the current set,
+/// renaming it, deleting it, or deleting a set before it changes the list or the index without
+/// choosing another set.
+fn chose_another_action_set(base: &serde_json::Map, now: &serde_json::Map) -> bool {
+ use serde_json::Value;
+ let index = |v: &serde_json::Map| v.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
+ let (was, sets) = (base.get("action_sets"), now.get("action_sets"));
+ let (Some(i), Some(k)) = (index(base), index(now)) else {
+ return match (index(base), index(now)) {
+ (None, None) => false,
+ // No set is current: another set was chosen only if the current one is still there.
+ (Some(i), None) => find_action_set(was, i, sets).is_some(),
+ _ => true,
+ };
+ };
+ if was == sets {
+ return i != k;
+ }
+ if let Some(j) = find_action_set(was, i, sets) {
+ return j != k;
+ }
+ // The set at the same place changed while every other set stayed as it was: it was edited.
+ let edited_in_place = was
+ .and_then(Value::as_array)
+ .zip(sets.and_then(Value::as_array))
+ .is_some_and(|(was, sets)| was.len() == sets.len() && was.iter().zip(sets).enumerate().all(|(j, (a, b))| j == i || a == b));
+ !(i == k && edited_in_place)
+}
+
+/// Where the Actions set at index `i` of `old` is in `new` (two `action_sets` lists): found by
+/// its contents when no other set in either list has the same contents, otherwise by its name when
+/// no other set in either list has it.
+fn find_action_set(old: Option<&serde_json::Value>, i: usize, new: Option<&serde_json::Value>) -> Option {
+ use serde_json::Value;
+ let (old, new) = (old?.as_array()?, new?.as_array()?);
+ let set = old.get(i)?;
+ let only = |list: &[Value], same: &dyn Fn(&Value) -> bool| {
+ let mut found = list.iter().enumerate().filter(|(_, s)| same(s));
+ match (found.next(), found.next()) {
+ (Some((j, _)), None) => Some(j),
+ _ => None,
+ }
+ };
+ let same_contents = |s: &Value| s == set;
+ if only(old, &same_contents).is_some()
+ && let Some(j) = only(new, &same_contents)
+ {
+ return Some(j);
+ }
+ let name = set.get("name")?;
+ let same_name = |s: &Value| s.get("name") == Some(name);
+ only(old, &same_name)?;
+ only(new, &same_name)
+}
+
/// The preferences file's contents: the UI state with the engine preferences.
#[cfg(test)]
pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result> {
@@ -274,6 +365,112 @@ mod tests {
assert!(!same_lasting(&base, &mine));
}
+ /// One window deleted the first Actions set and saved; this one made the second set current.
+ /// The saved index follows that set to its place in the merged list.
+ #[test]
+ fn the_current_action_set_follows_its_set_in_the_merged_list() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
+ let on_disk = json!({"action_sets": [set("B"), set("C")]});
+ let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 0}));
+ // The other window also recorded into B: B is found by its name.
+ let on_disk = json!({"action_sets": [{"name": "B", "actions": [{"name": "Action 1", "steps": []}]}, set("C")]});
+ let merged = merge_changes(&base, &mine, on_disk);
+ assert_eq!(merged["action_set"], json!(0));
+ // The other window deleted B: no set is current.
+ let on_disk = json!({"action_sets": [set("A"), set("C")]});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]}));
+ }
+
+ /// This window deleted the first set while the index saved by the other window still stands.
+ #[test]
+ fn the_other_windows_current_action_set_follows_its_set() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")]});
+ let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
+ let mine = json!({"action_sets": [set("B"), set("C")]});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
+ }
+
+ /// The other window replaced or renamed the set this one made current (the saved sets do not
+ /// tell the two apart): no set is current.
+ #[test]
+ fn a_set_in_the_place_of_the_current_set_is_not_made_current() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")]});
+ let on_disk = json!({"action_sets": [set("A"), set("D"), set("C")]});
+ let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
+ assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None);
+ let on_disk = json!({"action_sets": [set("A"), set("C"), set("D")]});
+ assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None);
+ }
+
+ /// This window deleted a set and made the set now at the old index current: its choice is
+ /// saved, though the index is the same number.
+ #[test]
+ fn a_new_current_set_at_the_same_index_is_saved() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
+ let on_disk = json!({"action_sets": [{"name": "A", "actions": [{"name": "Action 1", "steps": []}]}, set("B"), set("C")], "action_set": 1});
+ let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
+ }
+
+ /// Recording into the current set does not choose it again: the other window's choice stays.
+ #[test]
+ fn recording_into_the_current_set_keeps_the_other_windows_choice() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let recorded = json!({"name": "A", "actions": [{"name": "Action 1", "steps": []}]});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
+ let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
+ let mine = json!({"action_sets": [recorded.clone(), set("B"), set("C")], "action_set": 0});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [recorded, set("B"), set("C")], "action_set": 2}));
+ }
+
+ /// Renaming the current set does not choose it again: the other window's choice stays. With no
+ /// other choice, the renamed set stays current.
+ #[test]
+ fn renaming_the_current_set_keeps_the_other_windows_choice() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
+ let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
+ let mine = json!({"action_sets": [set("A2"), set("B"), set("C")], "action_set": 0});
+ assert_eq!(merge_changes(&base, &mine, on_disk)["action_set"], json!(1));
+ assert_eq!(merge_changes(&base, &mine, base.clone())["action_set"], json!(0));
+ }
+
+ /// Deleting the current set does not override the other window's choice.
+ #[test]
+ fn deleting_the_current_set_keeps_the_other_windows_choice() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B")], "action_set": 0});
+ let on_disk = json!({"action_sets": [set("A"), set("B")], "action_set": 1});
+ let mine = json!({"action_sets": [set("B")]});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B")], "action_set": 0}));
+ assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B")]}));
+ }
+
+ /// Deleting a set before the current one moves its index without choosing it again.
+ #[test]
+ fn deleting_a_set_before_the_current_one_keeps_it_current() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
+ let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1});
+ assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
+ }
+
+ /// Two sets with the same contents: when the other window deleted the one this window made
+ /// current, the one left is not made current in its place.
+ #[test]
+ fn a_set_with_the_same_contents_does_not_take_the_place_of_the_current_set() {
+ let set = |name: &str| json!({"name": name, "actions": []});
+ let base = json!({"action_sets": [set("A"), set("A"), set("C")]});
+ let on_disk = json!({"action_sets": [set("A"), set("C")]});
+ let mine = json!({"action_sets": [set("A"), set("A"), set("C")], "action_set": 1});
+ assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]}));
+ }
+
#[test]
fn a_file_that_is_not_an_object_is_replaced() {
let base = json!({"workspace": "Essentials"});
diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs
index f2c1fd725..ed4087e33 100644
--- a/apps/vectorcraft/src/tablet.rs
+++ b/apps/vectorcraft/src/tablet.rs
@@ -1,17 +1,24 @@
-//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a
-//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from
-//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
-//! reads.
+//! Linux: a drawing tablet's pen pressure, tilt and barrel rotation under X11, XWayland included
+//! (#764). winit 0.30 hands a pen to the window there as a mouse, without any of them, so the app
+//! reads them from XInput2 itself and writes them into the UI's [`StylusFeed`]
+//! (`Services::stylus`), which the canvas reads.
//!
//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without
//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw
//! events reach every client that selects them, also while winit holds the pointer grab of a
//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen
//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label),
-//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the
-//! valuator's range to 0..1, goes into the feed. Any other pointer
-//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so
-//! the last pressure of each pen is kept.
+//! unless its XInput type is a touchpad's or a touchscreen's. Its valuators go into the feed:
+//!
+//! - "Abs Pressure", scaled from the valuator's range to 0..1;
+//! - "Abs Tilt X" and "Abs Tilt Y" (a pen that has both), in degrees as they are when the range
+//! fits in ±90, otherwise scaled to ±[`TILT_DEGREES`];
+//! - "Abs Rotary Z" (the barrel rotation of an Art Pen), scaled to a turn with the middle of the
+//! range at 0°.
+//!
+//! Any other pointer device moving sets the feed to `None`. Raw events carry only the valuators
+//! that changed, so the last value of each axis of each pen is kept; an axis the pen has that no
+//! event has carried yet reads 0 (upright, unturned), and an axis it lacks reads nothing.
//!
//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains
//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's
@@ -22,13 +29,18 @@
//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland
//! that receives none of the window's input.
//!
-//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with
-//! the pressure axis only.
+//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`).
+//! PhotoCraft maps the rotation to 0..360° and reads an axis a pen lacks as 0; here the middle of
+//! the rotation's range is 0°, and an axis a pen lacks reads nothing.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::collections::HashMap;
-use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
+
+/// The tilt of a valuator whose range does not fit in ±90 (so does not give degrees), at the ends
+/// of its range.
+pub(crate) const TILT_DEGREES: f64 = 60.0;
/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer
/// than 16).
@@ -41,33 +53,76 @@ const MAX_DEVICES: usize = 256;
/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens.
const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"];
-/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range.
+/// Which pen axis a valuator carries.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum AxisKind {
+ Pressure,
+ TiltX,
+ TiltY,
+ Rotation,
+}
+
+impl AxisKind {
+ /// The kind of the valuator labeled `label`, if it is one of a pen's.
+ fn from_label(label: &str) -> Option {
+ match label {
+ "Abs Pressure" => Some(Self::Pressure),
+ "Abs Tilt X" => Some(Self::TiltX),
+ "Abs Tilt Y" => Some(Self::TiltY),
+ "Abs Rotary Z" => Some(Self::Rotation),
+ _ => None,
+ }
+ }
+
+ /// Where [`Pens`] keeps the last value of this axis.
+ fn slot(self) -> usize {
+ match self {
+ Self::Pressure => 0,
+ Self::TiltX => 1,
+ Self::TiltY => 2,
+ Self::Rotation => 3,
+ }
+ }
+}
+
+/// A valuator of a pen: what it carries, its number in a raw event's valuator mask, and its range.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Axis {
+ kind: AxisKind,
number: u16,
min: f64,
max: f64,
}
impl Axis {
- /// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a
- /// mouse's, 1.
- fn pressure(&self, v: f64) -> f32 {
+ /// A raw value of this valuator as a pressure of 0..1, a tilt in degrees (−90..90) or a
+ /// rotation in degrees (−180..180, the middle of the range at 0). A value that is not a number
+ /// is a mouse's pressure, 1, or a tilt or rotation of 0.
+ fn map(&self, v: f64) -> f32 {
if !v.is_finite() {
- return 1.0;
+ return if self.kind == AxisKind::Pressure { 1.0 } else { 0.0 };
}
let span = self.max - self.min;
let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v };
- unit.clamp(0.0, 1.0) as f32
+ let unit = unit.clamp(0.0, 1.0);
+ match self.kind {
+ AxisKind::Pressure => unit as f32,
+ AxisKind::TiltX | AxisKind::TiltY if self.min >= -90.0 && self.max <= 90.0 && span > 0.0 => v.clamp(-90.0, 90.0) as f32,
+ AxisKind::TiltX | AxisKind::TiltY => ((unit * 2.0 - 1.0) * TILT_DEGREES) as f32,
+ AxisKind::Rotation => ((unit - 0.5) * 360.0) as f32,
+ }
}
}
/// A pointer device the X server lists.
-#[derive(Clone, Copy, Debug, PartialEq)]
+#[derive(Clone, Debug, PartialEq)]
struct Device {
id: u16,
- /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.
- pressure: Option,
+ /// The name the X server lists, so that another device under the same id compares unequal.
+ name: String,
+ /// The pen's valuators: a pen has a pressure valuator, a mouse, a touchpad or a touchscreen
+ /// has none (and keeps none).
+ axes: Vec,
/// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.
eraser: bool,
}
@@ -77,14 +132,31 @@ impl Device {
/// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).
fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator) -> Self {
let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));
- let pressure = valuators
- .into_iter()
- .filter(|(number, ..)| *number < MAX_AXES)
- .find(|(_, label, ..)| label == "Abs Pressure")
- .map(|(number, _, min, max)| Axis { number, min, max })
- .filter(|_| !finger);
+ let mut axes: Vec = Vec::new();
+ for (number, label, min, max) in valuators {
+ // The first valuator of each kind.
+ if let Some(kind) = AxisKind::from_label(&label)
+ && number < MAX_AXES
+ && !axes.iter().any(|a| a.kind == kind)
+ {
+ axes.push(Axis { kind, number, min, max });
+ }
+ }
+ if finger || !axes.iter().any(|a| a.kind == AxisKind::Pressure) {
+ axes.clear();
+ }
let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));
- Self { id, pressure, eraser }
+ Self { id, name: name.to_string(), axes, eraser }
+ }
+
+ /// The valuator of `kind`, if the device is a pen that has one.
+ fn axis(&self, kind: AxisKind) -> Option<&Axis> {
+ self.axes.iter().find(|a| a.kind == kind)
+ }
+
+ /// A pen: a device with a pressure valuator.
+ fn is_pen(&self) -> bool {
+ !self.axes.is_empty()
}
}
@@ -93,36 +165,46 @@ impl Device {
enum Update {
/// Nothing: a device the last listing didn't have (a master, or one added since).
Keep,
- /// The pressure and end of a pen, or `None` for another pointer.
+ /// The pressure, end, tilt and rotation of a pen, or `None` for another pointer.
Set(Option),
}
-/// The pointer devices and the last pressure of each pen.
+/// The pointer devices and the last pressure, tilt and rotation of each pen.
#[derive(Debug, Default)]
struct Pens {
devices: HashMap,
- last: HashMap,
+ last: HashMap; 4]>,
}
impl Pens {
/// Replace the device list (at the start, and when devices come and go or change).
+ /// A device keeps its last values only while it is listed unchanged: another device under the
+ /// same id starts without them.
fn set_devices(&mut self, devices: impl IntoIterator) {
- self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
- self.last.retain(|id, _| self.devices.contains_key(id));
+ let devices: HashMap = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
+ self.last.retain(|id, _| devices.get(id).is_some_and(|d| self.devices.get(id) == Some(d)));
+ self.devices = devices;
}
/// A raw motion or button event of device `id`, with the valuators that changed.
fn handle(&mut self, id: u16, valuators: impl IntoIterator) -> Update {
let Some(device) = self.devices.get(&id) else { return Update::Keep };
- let Some(axis) = device.pressure else { return Update::Set(None) };
- let eraser = device.eraser;
- let last = self.last.entry(id).or_insert(0.0);
+ if !device.is_pen() {
+ return Update::Set(None);
+ }
+ let last = self.last.entry(id).or_default();
for (number, v) in valuators {
- if number == axis.number {
- *last = axis.pressure(v);
+ for axis in device.axes.iter().filter(|a| a.number == number) {
+ if let Some(slot) = last.get_mut(axis.kind.slot()) {
+ *slot = Some(axis.map(v));
+ }
}
}
- Update::Set(Some(PenSample { pressure: *last, eraser }))
+ // An axis the pen has reads 0 until an event carries it; one it lacks reads nothing.
+ let value = |kind: AxisKind| device.axis(kind).map(|_| last.get(kind.slot()).copied().flatten().unwrap_or(0.0));
+ let tilt = value(AxisKind::TiltX).zip(value(AxisKind::TiltY)).map(|(x, y)| [x, y]);
+ let pose = PenPose { tilt, rotation: value(AxisKind::Rotation) };
+ Update::Set(Some(PenSample { pressure: value(AxisKind::Pressure).unwrap_or(0.0), eraser: device.eraser, pose }))
}
/// A raw event as the X server sends it: its valuator mask and the values of the valuators
@@ -149,8 +231,8 @@ fn fp3232(integral: i32, frac: u32) -> f64 {
f64::from(integral) + f64::from(frac) / 4_294_967_296.0
}
-/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None`
-/// (logged) when the X server can't be reached or has no XInput 2.1.
+/// Start reading pen pressure, tilt and rotation for a window on X11: the feed the reader writes
+/// into, or `None` (logged) when the X server can't be reached or has no XInput 2.1.
#[cfg(target_os = "linux")]
pub fn start() -> Option {
let feed = StylusFeed::default();
@@ -224,7 +306,7 @@ mod x11 {
}
}
- /// The slave pointer devices with their names and pressure valuators (none on any error).
+ /// The slave pointer devices with their names and pen valuators (none on any error).
fn query_devices(conn: &RustConnection) -> Vec {
let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
let kinds = device_kinds(conn);
@@ -265,14 +347,16 @@ mod x11 {
mod tests {
use super::*;
- /// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to
- /// 65,535.
+ /// The valuators of a Wacom pen as the X server lists them: x and y, pressure from 0 to
+ /// 65,535, tilt in degrees, and an Art Pen's barrel rotation.
fn pen_valuators() -> Vec<(u16, String, f64, f64)> {
vec![
(0, "Abs X".to_string(), 0.0, 44704.0),
(1, "Abs Y".to_string(), 0.0, 27940.0),
(2, "Abs Pressure".to_string(), 0.0, 65535.0),
(3, "Abs Tilt X".to_string(), -64.0, 63.0),
+ (4, "Abs Tilt Y".to_string(), -64.0, 63.0),
+ (5, "Abs Rotary Z".to_string(), -900.0, 899.0),
]
}
@@ -295,19 +379,22 @@ mod tests {
#[test]
fn a_device_with_an_abs_pressure_valuator_is_a_pen() {
- assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));
- assert_eq!(mouse(12).pressure, None);
+ assert_eq!(pen(9).axis(AxisKind::Pressure), Some(&Axis { kind: AxisKind::Pressure, number: 2, min: 0.0, max: 65535.0 }));
+ assert_eq!(pen(9).axes.len(), 4, "pressure, tilt and rotation");
+ assert!(!mouse(12).is_pen());
let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
- assert_eq!(pad.pressure, None, "a tablet's pad is no pen");
+ assert!(!pad.is_pen() && pad.axes.is_empty(), "a tablet's pad is no pen");
let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
- assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");
+ assert!(!far.is_pen(), "valuator numbers past the limit are ignored");
+ let tilt_only = Device::new(14, "", Some("STYLUS"), [(3, "Abs Tilt X".to_string(), -64.0, 63.0), (4, "Abs Tilt Y".to_string(), -64.0, 63.0)]);
+ assert!(!tilt_only.is_pen() && tilt_only.axes.is_empty(), "no pressure, no pen");
// The types libinput and XWayland give pens, and a device whose type wasn't listed.
for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {
- assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");
+ assert!(Device::new(15, "", kind, pen_valuators()).is_pen(), "{kind:?}");
}
// xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".
for kind in ["TOUCHPAD", "TOUCHSCREEN"] {
- assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");
+ assert!(Device::new(16, "", Some(kind), pen_valuators()).axes.is_empty(), "{kind}");
}
}
@@ -324,7 +411,7 @@ mod tests {
];
for (name, kind) in erasers {
let d = Device::new(10, name, kind, pen_valuators());
- assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");
+ assert!(d.eraser && d.is_pen(), "{name} ({kind:?})");
}
let tips = [
("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),
@@ -343,13 +430,14 @@ mod tests {
let mut pens = Pens::default();
pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);
let feed = StylusFeed::default();
+ let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
pens.raw_event(&feed, 9, &[0b100], &[32767.5]);
- assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));
+ assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false, pose })));
assert_eq!(feed.eraser(), Some(false));
// Turned over, the pen hovers with its eraser end: the eraser device's first event, with
// no pressure yet.
pens.raw_event(&feed, 10, &[0b1], &[100.0]);
- assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));
+ assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true, pose })));
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));
@@ -361,6 +449,71 @@ mod tests {
assert_eq!(feed.eraser(), None);
}
+ /// #764: a pen's tilt goes into the feed in degrees and its barrel rotation as a turn, the
+ /// middle of the valuator's range at 0°; a pen without those valuators reads neither.
+ #[test]
+ fn a_pens_tilt_and_rotation_go_into_the_feed() {
+ let mut pens = Pens::default();
+ let pressure_only = Device::new(10, "", Some("STYLUS"), pen_valuators().into_iter().take(3));
+ let one_tilt = Device::new(11, "", Some("STYLUS"), pen_valuators().into_iter().take(4));
+ pens.set_devices([pen(9), pressure_only, one_tilt, mouse(12)]);
+ let feed = StylusFeed::default();
+ // Pressure, tilt x, tilt y and rotation: valuators 2 to 5.
+ pens.raw_event(&feed, 9, &[0b11_1100], &[32767.5, -30.0, 45.0, 0.0]);
+ let pose = feed.pose().unwrap();
+ assert_eq!((feed.held(), pose.tilt), (Some(0.5), Some([-30.0, 45.0])));
+ assert!(pose.rotation.is_some_and(|r| r < 0.2), "{pose:?}");
+ // A quarter of the range back is a quarter turn back; the tilt stays as it was.
+ pens.raw_event(&feed, 9, &[0b10_0000], &[-450.0]);
+ let pose = feed.pose().unwrap();
+ assert_eq!(pose.tilt, Some([-30.0, 45.0]));
+ assert!(pose.rotation.is_some_and(|r| (r - 270.0).abs() < 0.2), "{pose:?}");
+ // A pen with no tilt or rotation valuators, or with one tilt valuator only, reads none.
+ pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
+ assert_eq!((feed.held(), feed.pose()), (Some(1.0), None));
+ pens.raw_event(&feed, 11, &[0b1100], &[65535.0, 20.0]);
+ assert_eq!(feed.pose(), None);
+ // The mouse: none.
+ pens.raw_event(&feed, 12, &[0b1], &[3.0]);
+ assert_eq!(feed.pose(), None);
+ // A pen whose tilt and rotation no event has carried yet is upright and unturned.
+ let mut fresh = Pens::default();
+ fresh.set_devices([pen(9)]);
+ let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
+ assert_eq!(fresh.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));
+ }
+
+ /// A device listed again unchanged keeps its last tilt and rotation; another device listed
+ /// under the same id starts upright and unturned.
+ #[test]
+ fn a_device_replaced_under_the_same_id_starts_without_the_old_pose() {
+ let mut pens = Pens::default();
+ pens.set_devices([pen(9)]);
+ pens.handle(9, [(3, -30.0), (4, 45.0), (5, -450.0)]);
+ pens.set_devices([pen(9)]);
+ let Update::Set(Some(PenSample { pose, .. })) = pens.handle(9, [(2, 0.0)]) else { panic!("no pen") };
+ assert_eq!(pose.tilt, Some([-30.0, 45.0]));
+ let other = Device::new(9, "HUION Kamvas Pen Pen (0x1)", Some("STYLUS"), pen_valuators());
+ pens.set_devices([other]);
+ let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
+ assert_eq!(pens.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));
+ }
+
+ /// Tilt valuators whose range does not fit in ±90 are scaled to ±60°.
+ #[test]
+ fn tilt_ranges_that_are_not_degrees_are_scaled() {
+ let d = Device::new(
+ 1,
+ "",
+ None,
+ [(2, "Abs Pressure".into(), 0.0, 1023.0), (3, "Abs Tilt X".into(), 0.0, 127.0), (4, "Abs Tilt Y".into(), -1000.0, 1000.0)],
+ );
+ let mut pens = Pens::default();
+ pens.set_devices([d]);
+ let Update::Set(Some(PenSample { pose, .. })) = pens.handle(1, [(3, 127.0), (4, -1000.0)]) else { panic!("no pen") };
+ assert_eq!(pose.tilt, Some([60.0, -60.0]));
+ }
+
#[test]
fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {
let mut pens = Pens::default();
@@ -389,13 +542,19 @@ mod tests {
#[test]
fn hostile_device_data_gives_a_pressure_within_0_to_1() {
- let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];
+ let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN), (90.0, -90.0)];
for (min, max) in ranges {
let mut pens = Pens::default();
- pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);
+ let labels = ["Abs Pressure", "Abs Tilt X", "Abs Tilt Y", "Abs Rotary Z"];
+ pens.set_devices([Device::new(1, "", None, (2..).zip(labels).map(|(n, l)| (n, l.to_string(), min, max)))]);
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {
- let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
+ let Update::Set(Some(PenSample { pressure: p, pose, .. })) = pens.handle(1, (2..6).map(|n| (n, v))) else {
+ panic!("{min}..{max}: {v} gave no pressure")
+ };
assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");
+ let tilt = pose.tilt.unwrap_or_default();
+ assert!(tilt.iter().all(|t| (-90.0..=90.0).contains(t)), "{min}..{max}: {v} gave {pose:?}");
+ assert!(pose.rotation.is_some_and(|r| (-180.0..=180.0).contains(&r)), "{min}..{max}: {v} gave {pose:?}");
}
}
let mut pens = Pens::default();
diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs
index 27cb24438..b18bcddae 100644
--- a/apps/vectorcraft/src/windows_stylus.rs
+++ b/apps/vectorcraft/src/windows_stylus.rs
@@ -22,7 +22,7 @@ use std::cell::RefCell;
use std::sync::OnceLock;
use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
-use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
+use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
/// `wParam`, a pen in contact with its barrel button pressed.
@@ -114,7 +114,7 @@ fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) ->
_ if in_contact => 1.0,
_ => 0.0,
};
- PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 }
+ PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() }
}
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
@@ -403,7 +403,11 @@ mod tests {
fn a_pens_pressure_is_the_one_winit_gives_its_touch() {
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);
- assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering");
+ assert_eq!(
+ pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),
+ PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() },
+ "hovering"
+ );
assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");
@@ -420,7 +424,7 @@ mod tests {
#[test]
fn pen_messages_set_the_feed_and_other_pointers_clear_it() {
- let pen = PenSample { pressure: 0.0, eraser: true };
+ let pen = PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() };
// `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word.
let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7;
let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") };
diff --git a/assets/icons/dc-touch-type.svg b/assets/icons/dc-touch-type.svg
index f5af072c9..2f9a196c8 100644
--- a/assets/icons/dc-touch-type.svg
+++ b/assets/icons/dc-touch-type.svg
@@ -1 +1 @@
-
+
diff --git a/assets/icons/dc-type-area.svg b/assets/icons/dc-type-area.svg
index f37960dae..538b0ed24 100644
--- a/assets/icons/dc-type-area.svg
+++ b/assets/icons/dc-type-area.svg
@@ -1 +1 @@
-
+
diff --git a/assets/icons/dc-type-path.svg b/assets/icons/dc-type-path.svg
index 42584412c..a57eceffd 100644
--- a/assets/icons/dc-type-path.svg
+++ b/assets/icons/dc-type-path.svg
@@ -1 +1 @@
-
+
diff --git a/assets/icons/dc-type-vertical-area.svg b/assets/icons/dc-type-vertical-area.svg
new file mode 100644
index 000000000..b1c530b84
--- /dev/null
+++ b/assets/icons/dc-type-vertical-area.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-type-vertical-path.svg b/assets/icons/dc-type-vertical-path.svg
new file mode 100644
index 000000000..5e5955bd8
--- /dev/null
+++ b/assets/icons/dc-type-vertical-path.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/dc-type-vertical.svg b/assets/icons/dc-type-vertical.svg
index d72855530..69fc0b5ff 100644
--- a/assets/icons/dc-type-vertical.svg
+++ b/assets/icons/dc-type-vertical.svg
@@ -1 +1 @@
-
+
diff --git a/assets/icons/dc-type.svg b/assets/icons/dc-type.svg
new file mode 100644
index 000000000..e87a94f67
--- /dev/null
+++ b/assets/icons/dc-type.svg
@@ -0,0 +1 @@
+
diff --git a/crates/affinity/README.md b/crates/affinity/README.md
index f0339d698..fd2d6dc71 100644
--- a/crates/affinity/README.md
+++ b/crates/affinity/README.md
@@ -23,6 +23,7 @@ Affinity files.
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
+| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
@@ -33,7 +34,7 @@ Affinity files.
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
-dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
+dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs
index 8e84e4807..bb1c45746 100644
--- a/crates/affinity/src/geometry.rs
+++ b/crates/affinity/src/geometry.rs
@@ -169,6 +169,88 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option Option> {
+ let s = r.s;
+ let data = s.obj(crvd, b"Data")?;
+ let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?;
+ // Every segment takes at least an anchor record.
+ if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 {
+ return None;
+ }
+ let curve = subpath(&anchors(records)?, false, None)?;
+ if curve.segments.len() > MAX_PRESSURE_SEGMENTS {
+ return None;
+ }
+ let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2);
+ let mut samples = vec![(curve.start.x, curve.start.y)];
+ let mut from = curve.start;
+ for [c1, c2, to] in &curve.segments {
+ let mut ts: Vec = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect();
+ ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten());
+ ts.sort_by(f64::total_cmp);
+ ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
+ for t in ts {
+ let u = 1.0 - t;
+ let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
+ samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y)));
+ }
+ from = *to;
+ }
+ let mut last = 0.0f64;
+ Some(
+ samples
+ .into_iter()
+ .map(|(x, y)| {
+ last = x.clamp(last, 1.0);
+ (last, y.clamp(0.0, 1.0))
+ })
+ .collect(),
+ )
+}
+
+/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`,
+/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into
+/// -1..1 first, so huge ones can't overflow the discriminant.
+fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option; 2] {
+ let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs());
+ if !(scale.is_finite() && scale > 0.0) {
+ return [None, None];
+ }
+ let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale);
+ let (d0, d1, d2) = (b - a, c - b, d - c);
+ let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0);
+ let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t);
+ if qa.abs() < 1e-12 {
+ return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None];
+ }
+ let disc = qb * qb - 4.0 * qa * qc;
+ if disc < 0.0 {
+ return [None, None];
+ }
+ let root = disc.sqrt();
+ [inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))]
+}
+
struct Anchor {
p: Point,
inh: Option,
@@ -260,3 +342,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option> {
.collect(),
)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn turning_points_of_huge_control_values_are_found() {
+ let small = turning_points(1.0, -3.0, 3.0, 1.0);
+ let huge = turning_points(1e200, -3e200, 3e200, 1e200);
+ assert!(small.iter().all(Option::is_some), "{small:?}");
+ for (s, h) in small.iter().zip(&huge) {
+ let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") };
+ assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}");
+ }
+ }
+}
diff --git a/crates/affinity/src/model/read.rs b/crates/affinity/src/model/read.rs
index f94de75b9..00910baab 100644
--- a/crates/affinity/src/model/read.rs
+++ b/crates/affinity/src/model/read.rs
@@ -26,6 +26,9 @@ const MAX_DOCUMENT_CHARS: usize = 16 << 20;
/// Table cells of all tables together (each becomes a text object with its edge lines).
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
+/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
+const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
+
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
@@ -39,10 +42,18 @@ pub(crate) struct Budget {
pub(crate) chars: usize,
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
pub(crate) cells: usize,
+ /// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
+ pub(crate) pressure: usize,
}
impl Budget {
- pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
+ pub(crate) const FULL: Budget = Budget {
+ nodes: MAX_NODES,
+ pixels: MAX_LAYER_PIXELS,
+ chars: MAX_DOCUMENT_CHARS,
+ cells: MAX_DOCUMENT_CELLS,
+ pressure: MAX_DOCUMENT_PRESSURE_POINTS,
+ };
}
/// Read a document: archive, `doc.dat` and the model.
diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs
index 1dfdb6575..12102581e 100644
--- a/crates/affinity/src/paint.rs
+++ b/crates/affinity/src/paint.rs
@@ -121,6 +121,9 @@ pub struct Stroke {
pub dash: Option<(Vec, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
+ /// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in
+ /// order along the path, `None` for a stroke of the same width all along.
+ pub profile: Option>,
}
/// Gradient stops per gradient.
@@ -350,10 +353,38 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
}
_ => None,
};
- if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
- r.warn("brush and pressure strokes (imported as plain strokes)");
+ if s.obj(lsty, b"Brus").is_some() {
+ r.warn("brush strokes (imported as plain strokes)");
}
- Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
+ // The pressure curve sets the width along the path; one at full width all along is a plain
+ // stroke. Every curve read counts against the document's points: one at full width counts its
+ // points, and one that can't be read counts the most a curve may have. Once the points are
+ // used up the rest are not read.
+ const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)";
+ let ldep = s.obj(ldsc, b"LDeP");
+ if ldep.is_some() && r.budget.pressure == 0 {
+ r.warn(BEYOND);
+ }
+ let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) {
+ Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) {
+ Some(left) => {
+ r.budget.pressure = left;
+ (!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points)
+ }
+ None => {
+ r.budget.pressure = 0;
+ r.warn(BEYOND);
+ None
+ }
+ },
+ Some(None) => {
+ r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS);
+ r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)");
+ None
+ }
+ None => None,
+ };
+ Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile })
}
#[cfg(test)]
diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt
index 779a58552..13e8f0c53 100644
--- a/crates/affinity/tests/support/affinity3-signatures.txt
+++ b/crates/affinity/tests/support/affinity3-signatures.txt
@@ -1,7 +1,7 @@
-affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
-affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
-affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
-affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
+affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"]
+affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"]
+affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"]
+affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
@@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1,
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
-patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
+patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
diff --git a/crates/brush/src/calli.rs b/crates/brush/src/calli.rs
index 7104aa893..0b679497c 100644
--- a/crates/brush/src/calli.rs
+++ b/crates/brush/src/calli.rs
@@ -5,16 +5,16 @@
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
//!
-//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
-//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
-//! sample takes the nib for the pressure there.
+//! A Calligraphic brush with Pressure, Tilt, Bearing or Rotation variation changes its nib along
+//! the stroke with what the pen recorded on it (`StrokeLayer::pressure`, `tilt`, `bearing` and
+//! `rotation`): the sweep is sampled densely and each sample takes the nib for the pen there.
use vectorcraft_color::Paint;
-use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
+use vectorcraft_doc::{Node, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
-use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
+use crate::{Bristle, BristleShape, Calligraphic, PenAt, Variation, filled, tolerance};
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
/// it takes along a whole subpath.
@@ -127,18 +127,16 @@ fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
-/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
-/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
-/// kept within its range; the others are `base`'s.
-fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
+/// The nib's angle, roundness and size where the pen recorded `pen`: each value on a mode that
+/// follows the pen goes from the brush's value less its variation (where the pen reads 0 for it)
+/// to the value plus the variation (where it reads 1), the angle turning round past ±180° and the
+/// others kept within their ranges; the other values are `base`'s.
+fn pressured(c: &Calligraphic, base: [f64; 3], pen: PenAt) -> [f64; 3] {
let values = [c.angle, c.roundness, c.size];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
- let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
- if mode == Variation::Pressure {
- let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
- *v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
- }
+ let (Some(&value), Some(&var), Some(p)) = (values.get(i), c.variation.get(i), pen.control(mode)) else { continue };
+ *v = Calligraphic::moved(i, value, var * (2.0 * p - 1.0));
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
@@ -147,14 +145,13 @@ fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec {
let mut rng = Rng::new(seed_of(bp, name));
let base = randomized(c, &mut rng);
- let subs: Vec = if c.uses_pressure() {
- let pressure = stroke.pressure.as_ref();
+ let subs: Vec = if c.uses_pen() {
let nib = |f: f64| {
- let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
+ let [angle, round, size] = pressured(c, base, PenAt::of(stroke, f));
Nib::new(angle, round / 100.0, size * weight)
};
- // Flattened for the largest nib the pressure gives.
- let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
+ // Flattened for the largest nib the pen gives.
+ let size = pressured(c, base, PenAt::uniform(0.0))[2].max(pressured(c, base, PenAt::uniform(1.0))[2]) * weight;
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
} else {
let [angle, round, size] = base;
diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs
index fbec57011..60e8a0234 100644
--- a/crates/brush/src/lib.rs
+++ b/crates/brush/src/lib.rs
@@ -159,6 +159,24 @@ impl Calligraphic {
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
+ /// Does the pen (its pressure, tilt, bearing or rotation) change the nib?
+ pub fn uses_pen(&self) -> bool {
+ self.modes().iter().any(|m| m.follows_pen())
+ }
+ /// Value `i` (0 angle, 1 roundness, 2 size) moved by `by`: the angle turns round past ±180°,
+ /// roundness and size stay within their [`Self::RANGES`]. A result that is not a number is
+ /// `value`, kept within its range.
+ pub fn moved(i: usize, value: f64, by: f64) -> f64 {
+ let (lo, hi) = Self::RANGES.get(i).copied().unwrap_or((f64::MIN, f64::MAX));
+ let x = value + by;
+ if !x.is_finite() {
+ value.clamp(lo, hi)
+ } else if i == 0 {
+ vectorcraft_geom::normalize_deg(x)
+ } else {
+ x.clamp(lo, hi)
+ }
+ }
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
@@ -174,16 +192,29 @@ pub enum Variation {
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
+ /// From the value less the variation with the pen lying flat to the value plus the variation
+ /// with it upright.
+ Tilt,
+ /// The value plus the variation times the pen's bearing over 180°: the bearing is which way the
+ /// pen leans, counter-clockwise on the page from leaning to the right (0°) round to ±180°.
+ Bearing,
+ /// The value plus the variation times the pen barrel's rotation over 180°, counter-clockwise
+ /// from where the pen reports 0° round to ±180°.
+ Rotation,
}
impl Variation {
- pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
+ pub const ALL: [Variation; 6] =
+ [Variation::Fixed, Variation::Random, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
+ Variation::Tilt => "tilt",
+ Variation::Bearing => "bearing",
+ Variation::Rotation => "rotation",
}
}
pub fn label(self) -> &'static str {
@@ -191,11 +222,63 @@ impl Variation {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
+ Variation::Tilt => "Tilt",
+ Variation::Bearing => "Bearing",
+ Variation::Rotation => "Rotation",
}
}
pub fn parse(id: &str) -> Option {
Self::ALL.into_iter().find(|v| v.id() == id)
}
+ /// Does the value follow something the pen records along a stroke (Pressure, Tilt, Bearing,
+ /// Rotation)?
+ pub fn follows_pen(self) -> bool {
+ matches!(self, Variation::Pressure | Variation::Tilt | Variation::Bearing | Variation::Rotation)
+ }
+}
+
+/// What the pen recorded at one point of a stroke, each as the 0..1 that [`Variation`]'s pen modes
+/// read: 0 gives the value less the variation (a Scatter brush's first value), 1 the value plus it
+/// (the second value), and ½ the value itself, as a stroke without that recording does.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct PenAt {
+ /// The pressure, lightest 0.
+ pressure: f64,
+ /// 1 upright, 0 lying flat.
+ tilt: f64,
+ /// ½ plus the bearing over 360°.
+ bearing: f64,
+ /// ½ plus the counter-clockwise barrel rotation over 360°.
+ rotation: f64,
+}
+
+impl PenAt {
+ /// Every input at `c`.
+ pub(crate) fn uniform(c: f64) -> Self {
+ Self { pressure: c, tilt: c, bearing: c, rotation: c }
+ }
+ /// What `stroke` recorded at `f` (the fraction of its path's length).
+ pub(crate) fn of(stroke: &StrokeLayer, f: f64) -> Self {
+ let mid = vectorcraft_doc::PressureProfile::MID;
+ let turn = |p: Option<&vectorcraft_doc::AngleProfile>| 0.5 + p.map_or(0.0, |p| p.at(f)) / 360.0;
+ Self {
+ pressure: stroke.pressure.as_ref().map_or(mid, |p| p.at(f)),
+ tilt: 1.0 - stroke.tilt.as_ref().map_or(mid, |p| p.at(f)),
+ bearing: turn(stroke.bearing.as_ref()),
+ rotation: turn(stroke.rotation.as_ref()),
+ }
+ }
+ /// The 0..1 a value on `mode` takes, or `None` for a mode that doesn't follow the pen.
+ pub(crate) fn control(&self, mode: Variation) -> Option {
+ let c = match mode {
+ Variation::Fixed | Variation::Random => return None,
+ Variation::Pressure => self.pressure,
+ Variation::Tilt => self.tilt,
+ Variation::Bearing => self.bearing,
+ Variation::Rotation => self.rotation,
+ };
+ Some(if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })
+ }
}
/// Scatter brush: copies of the art along the path.
@@ -216,8 +299,10 @@ pub struct Scatter {
pub colorization: Colorization,
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
- /// the second at the heaviest. `None` (brushes saved before there were modes): Random where
- /// the two differ, else Fixed — see [`Scatter::modes`].
+ /// the second at the heaviest, Tilt the first with the pen lying flat and the second upright,
+ /// Bearing and Rotation the first at −180° and the second at 180° (half way at 0°). `None`
+ /// (brushes saved before there were modes): Random where the two differ, else Fixed (see
+ /// [`Scatter::modes`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 4]>,
}
@@ -719,7 +804,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
- BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
+ BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
let colorization = brush.kind.colorization();
diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs
index a7320895c..6e8113f14 100644
--- a/crates/brush/src/tests.rs
+++ b/crates/brush/src/tests.rs
@@ -889,6 +889,163 @@ fn pressure_varies_angle_and_roundness() {
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
+ // The angle turns on past 180°: 170 + 40 is 210°, the nib at 30° (not stopped at 180°).
+ let turned = Calligraphic { angle: 170.0, roundness: 10.0, variation: [40.0, 0.0, 0.0], ..c([Pressure, Fixed, Fixed]) };
+ let expected = 2.0 * (25.0 * 0.25 + 0.25 * 0.75f64).sqrt();
+ let h = draw(turned, 1.0);
+ assert!(near(h, expected, 0.05), "{h} vs {expected}");
+}
+
+/// A stroke that recorded the pen's tilt (`tilt`: 0 upright to 1 flat), bearing and barrel
+/// rotation (degrees counter-clockwise), each the same all along it where given.
+fn posed(tilt: Option, bearing: Option, rotation: Option) -> StrokeLayer {
+ let flat = |v: f64| vec![(0.0, v), (1.0, v)];
+ StrokeLayer {
+ tilt: tilt.map(|t| vectorcraft_doc::PressureProfile { points: flat(t) }),
+ bearing: bearing.map(|b| vectorcraft_doc::AngleProfile { points: flat(b) }),
+ rotation: rotation.map(|r| vectorcraft_doc::AngleProfile { points: flat(r) }),
+ ..stroke(1.0)
+ }
+}
+
+/// #764: with Roundness on Tilt the nib goes from roundness − variation with the pen lying flat to
+/// roundness + variation with it upright, along the stroke as the pen leaned.
+#[test]
+fn tilt_maps_to_roundness_along_the_stroke() {
+ let c = Calligraphic {
+ roundness: 50.0,
+ size: 10.0,
+ variation: [0.0, 50.0, 0.0],
+ modes: Some([Variation::Fixed, Variation::Tilt, Variation::Fixed]),
+ ..Default::default()
+ };
+ let b = Brush { name: "t".into(), kind: BrushKind::Calligraphic(c) };
+ let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
+ let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &upright_to_flat);
+ // Upright: round (half 5); half way: roundness 50 (half 2.5); flat: a hairline.
+ assert!(near(half_width(&out, 0.5, 1.5), 5.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
+ assert!(near(half_width(&out, 49.5, 50.5), 2.5, 0.1), "{}", half_width(&out, 49.5, 50.5));
+ assert!(half_width(&out, 98.5, 99.5) < 0.2, "{}", half_width(&out, 98.5, 99.5));
+}
+
+/// #764: with Angle on Bearing or on Rotation and a variation of 180°, the nib turns with the way
+/// the pen leans or with its barrel.
+#[test]
+fn bearing_and_rotation_turn_the_nib() {
+ let horizontal = line(0.0, 0.0, 100.0, 0.0);
+ let height = |mode: Variation, st: &StrokeLayer| {
+ let c = Calligraphic {
+ roundness: 10.0,
+ size: 10.0,
+ variation: [180.0, 0.0, 0.0],
+ modes: Some([mode, Variation::Fixed, Variation::Fixed]),
+ ..Default::default()
+ };
+ let b = Brush { name: "b".into(), kind: BrushKind::Calligraphic(c) };
+ bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, st)).height()
+ };
+ // Leaning right (0°): the nib lies along the stroke (1 pt across); leaning up the page (90°)
+ // or down it (−90°), the nib stands across it (10 pt).
+ assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(0.0), None)), 1.0, 0.05));
+ assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(90.0), None)), 10.0, 0.05));
+ assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(-90.0), None)), 10.0, 0.05));
+ // The barrel turned a quarter turn stands it across the stroke too.
+ assert!(near(height(Variation::Rotation, &posed(None, None, Some(0.0))), 1.0, 0.05));
+ assert!(near(height(Variation::Rotation, &posed(None, None, Some(90.0))), 10.0, 0.05));
+ // Each reads only its own recording.
+ assert!(near(height(Variation::Rotation, &posed(None, Some(90.0), None)), 1.0, 0.05));
+ assert!(near(height(Variation::Bearing, &posed(None, None, Some(90.0))), 1.0, 0.05));
+}
+
+/// A stroke without the pen's tilt, bearing or rotation (a mouse, a pen without that input, or a
+/// path the brush was applied to) draws Tilt, Bearing and Rotation values at the brush's own value.
+#[test]
+fn pen_modes_without_a_recording_draw_the_value_itself() {
+ let horizontal = line(0.0, 0.0, 100.0, 0.0);
+ for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {
+ let c = Calligraphic { angle: 90.0, roundness: 50.0, size: 10.0, variation: [90.0, 50.0, 6.0], modes: Some([mode; 3]) };
+ let b = Brush { name: "n".into(), kind: BrushKind::Calligraphic(c) };
+ let h = bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &stroke(1.0))).height();
+ assert!(near(h, 10.0, 0.05), "{mode:?}: {h}");
+ let sc = Scatter {
+ size: (50.0, 300.0),
+ modes: Some([mode, Variation::Fixed, Variation::Fixed, Variation::Fixed]),
+ ..scatter(&scatter_brush(100.0))
+ };
+ let s = Brush { name: "s".into(), kind: BrushKind::Scatter(sc) };
+ let heights: Vec =
+ stroke_pieces(&Document::new(1.0, 1.0), &s, &horizontal, &stroke(1.0)).iter().map(|n| n.geometric_bounds().unwrap().height()).collect();
+ assert!(!heights.is_empty() && heights.iter().all(|h| near(*h, 7.0, 1e-6)), "{mode:?}: {heights:?}");
+ }
+}
+
+/// A mouse stroke records no pressure, tilt, bearing or rotation. On it, a brush whose values are
+/// on Tilt, Bearing or Rotation draws exactly what the same brush on Pressure draws, the output a
+/// mouse stroke gave before these modes existed.
+#[test]
+fn a_mouse_stroke_draws_pen_modes_as_pressure_did() {
+ let doc = Document::new(1.0, 1.0);
+ let wavy = {
+ let mut bp = line(0.0, 0.0, 60.0, 0.0);
+ bp.curve_to((90.0, 0.0), (90.0, 40.0), (120.0, 40.0));
+ bp
+ };
+ let mouse = stroke(1.0);
+ assert!(mouse.pressure.is_none() && mouse.tilt.is_none() && mouse.bearing.is_none() && mouse.rotation.is_none());
+ let calli = |mode: Variation| {
+ let c = Calligraphic { angle: 30.0, roundness: 40.0, size: 8.0, variation: [60.0, 30.0, 4.0], modes: Some([mode; 3]) };
+ stroke_pieces(&doc, &Brush { name: "c".into(), kind: BrushKind::Calligraphic(c) }, &wavy, &mouse)
+ };
+ let scattered = |mode: Variation| {
+ let sc = Scatter {
+ size: (50.0, 300.0),
+ spacing: (80.0, 160.0),
+ scatter: (-40.0, 40.0),
+ rotation: (-90.0, 90.0),
+ modes: Some([mode; 4]),
+ ..scatter(&scatter_brush(100.0))
+ };
+ stroke_pieces(&doc, &Brush { name: "s".into(), kind: BrushKind::Scatter(sc) }, &wavy, &mouse)
+ };
+ let (calli_before, scatter_before) = (calli(Variation::Pressure), scattered(Variation::Pressure));
+ assert!(!calli_before.is_empty() && !scatter_before.is_empty());
+ for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {
+ assert_eq!(calli(mode), calli_before, "{mode:?}");
+ assert_eq!(scattered(mode), scatter_before, "{mode:?}");
+ }
+}
+
+/// The scatter brush of `b`.
+fn scatter(b: &Brush) -> Scatter {
+ let BrushKind::Scatter(sc) = &b.kind else { panic!("not a scatter brush") };
+ sc.clone()
+}
+
+/// #764: Scatter values on Tilt go from the first value with the pen lying flat to the second
+/// upright; on Bearing, from the first at −180° to the second at 180°.
+#[test]
+fn scatter_values_follow_tilt_and_bearing() {
+ let bp = line(0.0, 0.0, 400.0, 0.0);
+ let boxes = |b: &Brush, st: &StrokeLayer| -> Vec {
+ stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap()).collect()
+ };
+ // Size on Tilt, 50 % to 300 %: upright at the start (12 pt tall), flat at the end (2 pt).
+ let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
+ let h: Vec = boxes(&sized_scatter(50.0, 300.0, Variation::Tilt), &upright_to_flat).iter().map(Rect::height).collect();
+ assert!(near(h[0], 12.0, 0.2) && h[h.len() - 1] < 3.0, "{h:?}");
+ assert!(h.windows(2).all(|w| w[1] <= w[0] + 1e-9), "shrinks as the pen tips over: {h:?}");
+ // Rotation on Bearing, −180° to 180°: the copies turn with the bearing, a quarter turn
+ // standing the 10 × 4 pt art on end.
+ let sc = Scatter {
+ rotation: (-180.0, 180.0),
+ modes: Some([Variation::Fixed, Variation::Fixed, Variation::Fixed, Variation::Bearing]),
+ ..scatter(&scatter_brush(100.0))
+ };
+ let turning = Brush { name: "r".into(), kind: BrushKind::Scatter(sc) };
+ for (bearing, (w, h)) in [(0.0, (10.0, 4.0)), (90.0, (4.0, 10.0)), (-90.0, (4.0, 10.0))] {
+ let b = boxes(&turning, &posed(Some(0.5), Some(bearing), None));
+ assert!(!b.is_empty() && b.iter().all(|r| near(r.width(), w, 1e-6) && near(r.height(), h, 1e-6)), "{bearing}: {b:?}");
+ }
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
@@ -920,7 +1077,7 @@ fn brushes_without_modes_vary_randomly_as_before() {
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
);
// Modes are saved by name.
- assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
+ assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure", "tilt", "bearing", "rotation"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs
index 78c2f547f..ae0c170f3 100644
--- a/crates/brush/src/warp.rs
+++ b/crates/brush/src/warp.rs
@@ -8,11 +8,11 @@
use std::sync::Arc;
use kurbo::ParamCurveNearest;
-use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
+use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
-use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
+use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, PenAt, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@@ -536,18 +536,19 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
}
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
-/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
-/// draws, so a copy's random values stay the same whatever the other modes.
-fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
+/// value between them, and a mode that follows the pen the first where `pen` reads 0 for it and
+/// the second where it reads 1. Every value draws, so a copy's random values stay the same
+/// whatever the other modes.
+fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pen: PenAt) -> f64 {
let r = rng.range(a, b);
- match mode {
- Variation::Fixed => a,
- Variation::Random => r,
- Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
+ match pen.control(mode) {
+ Some(c) => a + (b - a) * c,
+ None if mode == Variation::Random => r,
+ None => a,
}
}
-pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec {
+pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec {
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
let mut rng = Rng::new(seed_of(bp, name));
@@ -558,8 +559,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
break;
}
let l = t.len();
- // The pen pressure at arc length `at` (half way without a recorded one).
- let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
+ // What the pen recorded at arc length `at` (the values themselves without a recording).
+ let pen_at = |at: f64| PenAt::of(stroke, if l > 1e-9 { at / l } else { 0.0 });
let mut s: Option = None;
let mut guard = 0;
loop {
@@ -567,8 +568,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
if guard > 20000 {
break;
}
- // Size and spacing place the next copy, so they take the pressure at the last one.
- let p = pressure_at(s.unwrap_or(0.0));
+ // Size and spacing place the next copy, so they take the pen at the last one.
+ let p = pen_at(s.unwrap_or(0.0));
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
let step = aw * size * spacing;
@@ -580,7 +581,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
break;
}
s = Some(pos);
- let p = pressure_at(pos);
+ let p = pen_at(pos);
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
let (p, tg) = t.frame(pos);
diff --git a/crates/cad/Cargo.toml b/crates/cad/Cargo.toml
index 4886d9ea5..2ea6dea4f 100644
--- a/crates/cad/Cargo.toml
+++ b/crates/cad/Cargo.toml
@@ -21,5 +21,6 @@ image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
-# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
-vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
+# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine)
+# and in fonts built in code (`vectorcraft_text::test_fonts`).
+vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] }
diff --git a/crates/cad/src/import/text.rs b/crates/cad/src/import/text.rs
index 137d9cc31..d6d574612 100644
--- a/crates/cad/src/import/text.rs
+++ b/crates/cad/src/import/text.rs
@@ -62,6 +62,33 @@ fn until_semicolon(chars: &mut std::iter::Peekable>) -> Stri
arg
}
+/// The character a `\U+XXXX` escape writes (`after` is the text just after its backslash), with
+/// the escape's length; a UTF-16 surrogate pair written as two escapes is one character.
+fn unicode_escape(after: &str) -> Option<(char, usize)> {
+ let hex = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+"))?.get(..4)?;
+ if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return None;
+ }
+ let value = u32::from_str_radix(hex, 16).ok()?;
+ if (0xD800..=0xDBFF).contains(&value) {
+ let next = after.get(6..)?;
+ let low = next.strip_prefix("\\U+").or_else(|| next.strip_prefix("\\u+"))?.get(..4)?;
+ if !low.bytes().all(|b| b.is_ascii_hexdigit()) {
+ return None;
+ }
+ let low = u32::from_str_radix(low, 16).ok()?;
+ if !(0xDC00..=0xDFFF).contains(&low) {
+ return None;
+ }
+ let scalar = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00);
+ return char::from_u32(scalar).map(|ch| (ch, 13));
+ }
+ if (0xDC00..=0xDFFF).contains(&value) {
+ return None;
+ }
+ char::from_u32(value).map(|ch| (ch, 6))
+}
+
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. In
/// multiline text (`mtext`) `\\` is an escaped backslash and is kept as is, so that a `U+` after
@@ -77,12 +104,10 @@ fn escapes(s: &str, mtext: bool) -> String {
continue;
}
if c == '\\'
- && let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
- && hex.bytes().all(|b| b.is_ascii_hexdigit())
- && let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
+ && let Some((ch, consumed)) = unicode_escape(after)
{
out.push(ch);
- rest = after.get(6..).unwrap_or("");
+ rest = after.get(consumed..).unwrap_or("");
continue;
}
if mtext
@@ -161,6 +186,14 @@ mod tests {
assert_eq!(plain("\\U+00C9"), "É");
}
+ #[test]
+ fn unicode_escape_decodes_surrogate_pairs() {
+ assert_eq!(plain("\\U+D83D\\U+DE00"), "😀");
+ assert_eq!(plain("\\U+D83D"), "\\U+D83D");
+ assert_eq!(plain("\\U+DE00"), "\\U+DE00");
+ assert_eq!(plain("\\U+D83D\\U+0041"), "\\U+D83DA");
+ }
+
#[test]
fn control_codes_and_escapes() {
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs
index 6ae29487a..b7aeb5c6c 100644
--- a/crates/cad/src/scene.rs
+++ b/crates/cad/src/scene.rs
@@ -544,9 +544,8 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
+ && let Some(art) = self.doc.place_symbol_art_unclipped(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
{
- let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
- art.transform(*xf, false);
self.nest += 1;
self.node(&art, true);
self.nest -= 1;
@@ -1062,7 +1061,8 @@ impl<'a> Scene<'a> {
}
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
- /// entity per run of each line.
+ /// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its
+ /// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text(&mut self, n: &Node, t: &TextObject) {
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
if outline {
@@ -1074,6 +1074,8 @@ impl<'a> Scene<'a> {
if !n.appearance.items.is_empty() {
self.warn(TYPE_ITEMS);
}
+ let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
+ let t = &*resolved;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let plain = t.plain_text();
let xf = self.to_dxf * t.xf;
diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs
index ba0a13b76..f1b537b70 100644
--- a/crates/cad/src/tests.rs
+++ b/crates/cad/src/tests.rs
@@ -906,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() {
assert_eq!(text(DxfVersion::R2018), "Größe");
}
+/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt
+/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14.
+#[test]
+fn a_font_height_reference_is_written_as_the_size_drawn() {
+ use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
+ vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
+ let mut d = text_doc("Plan A", 14.0);
+ let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
+ if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
+ t.runs[0].style.font_family = HEIGHT_FAMILY.into();
+ t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
+ }
+ let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
+ let e = entities(&editable);
+ assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40));
+}
+
#[test]
fn images_link_png_or_jpeg_files() {
let mut png = vec![];
diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs
index 528d56033..402cf3ae2 100644
--- a/crates/color/src/gradient.rs
+++ b/crates/color/src/gradient.rs
@@ -78,6 +78,14 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
const PERCEPTUAL_DEPTH: u32 = 10;
+/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within
+/// 1/65536 of a tint.
+const TINT_STEPS: u32 = 16;
+/// The least OKLab lightness by which an ink at 100% must differ from paper white for
+/// [`Gradient::ink_tints`] to tell its tints apart by lightness.
+const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0;
+/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for.
+const MAX_INK_TINTS: usize = 4096;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
@@ -100,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
}
}
+/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
+/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
+fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec {
+ let mut vs = Vec::new();
+ perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
+ vs.push(0.5);
+ perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
+ vs
+}
+
+/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint
+/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it.
+fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 {
+ let m = a.midpoint.clamp(0.01, 0.99);
+ let u = if v == 0.5 {
+ a.midpoint
+ } else if v < 0.5 {
+ 2.0 * v * m
+ } else {
+ m + (v - 0.5) * 2.0 * (1.0 - m)
+ };
+ a.offset + (b.offset - a.offset) * u
+}
+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
@@ -245,6 +277,11 @@ impl Gradient {
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
pub fn expanded(&self) -> impl Iterator)> + '_ {
+ self.expanded_with_stops().map(|(t, c, o, mid, _)| (t, c, o, mid))
+ }
+ /// [`Self::expanded`], each of the gradient's own stops with its index in [`Self::stops`]
+ /// (`None` on the stops in between).
+ fn expanded_with_stops(&self) -> impl Iterator, Option)> + '_ {
// Direct interpolation matches `sample` only for strictly increasing offsets.
let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset);
self.stops.iter().enumerate().flat_map(move |(i, s)| {
@@ -257,33 +294,100 @@ impl Gradient {
// stops again for each midpoint made many stops cost their square.
let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v);
let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) };
- (t, c, o, Some(s.midpoint))
+ (t, c, o, Some(s.midpoint), None)
});
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
- let mut vs = Vec::new();
- perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
- vs.push(0.5);
- perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
- let m = s.midpoint.clamp(0.01, 0.99);
- vs.into_iter().map(move |v| {
+ perceptual_mixes(s, n).into_iter().map(move |v| {
let mid = v == 0.5;
- let u = if mid {
- s.midpoint
- } else if v < 0.5 {
- 2.0 * v * m
- } else {
- m + (v - 0.5) * 2.0 * (1.0 - m)
- };
- let t = s.offset + (n.offset - s.offset) * u;
+ let t = curve_offset(s, n, v);
let o = s.opacity + (n.opacity - s.opacity) * v;
- (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
+ (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint), None)
})
});
- std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
+ std::iter::once((s.offset, s.color, s.opacity, None, Some(i))).chain(classic).chain(perceptual.into_iter().flatten())
})
}
+ /// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as
+ /// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab
+ /// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings, except at
+ /// the gradient's own stops, which take their tints from `stop_tints` (one per stop, in
+ /// order) where it has one. Mixed linearly between them, the tints follow a Perceptual
+ /// gradient's curve. `None` when the ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of
+ /// paper white's (a white or nearly white ink), so lightness does not tell its tints apart,
+ /// and when the curve has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
+ pub fn ink_tints(&self, ink: &Color, stop_tints: &[f32]) -> Option> {
+ use crate::libraries::srgb_to_oklab;
+ let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
+ if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN {
+ return None;
+ }
+ let curve: Vec<_> = self.expanded_with_stops().take(MAX_INK_TINTS + 1).collect();
+ if curve.len() > MAX_INK_TINTS {
+ return None;
+ }
+ let tint = |c: &Color| {
+ let want = lightness(c);
+ let (mut lo, mut hi) = (0.0f32, 1.0f32);
+ for _ in 0..TINT_STEPS {
+ let mid = (lo + hi) / 2.0;
+ if lightness(&ink.tinted(mid)) > want {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ (lo + hi) / 2.0
+ };
+ Some(curve.into_iter().map(|(t, c, o, _, i)| (t, i.and_then(|i| stop_tints.get(i).copied()).unwrap_or_else(|| tint(&c)), o)).collect())
+ }
+ /// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so
+ /// [`Self::ink_tints`] finds tints for it?
+ pub fn fits_ink_tints(&self) -> bool {
+ self.expanded().nth(MAX_INK_TINTS).is_none()
+ }
+ /// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks:
+ /// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop
+ /// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops).
+ /// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
+ /// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
+ /// position itself when the two colors' lightness differs by less than
+ /// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
+ /// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
+ pub fn ink_mixes(&self) -> Option> {
+ use crate::libraries::srgb_to_oklab;
+ if self.method.is_classic() || !self.fits_ink_tints() {
+ return None;
+ }
+ let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
+ let mut out = Vec::new();
+ for (i, s) in self.stops.iter().enumerate() {
+ out.push((s.offset, i, 0.0, s.opacity));
+ let Some(n) = self.stops.get(i + 1) else { continue };
+ let (l0, l1) = (lightness(&s.color), lightness(&n.color));
+ for v in perceptual_mixes(s, n) {
+ let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
+ v
+ } else {
+ let want = lightness(&self.method.mix(&s.color, &n.color, v));
+ let (mut lo, mut hi) = (0.0f32, 1.0f32);
+ for _ in 0..TINT_STEPS {
+ let mid = (lo + hi) / 2.0;
+ // The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
+ if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+ (lo + hi) / 2.0
+ };
+ out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v));
+ }
+ }
+ Some(out)
+ }
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
diff --git a/crates/color/src/gradient/tests.rs b/crates/color/src/gradient/tests.rs
index 19e9f44fc..224803075 100644
--- a/crates/color/src/gradient/tests.rs
+++ b/crates/color/src/gradient/tests.rs
@@ -455,3 +455,75 @@ fn perceptual_gradients_draw_as_pixels() {
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}
+
+/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
+/// the curve has the lightness of the color mixed there, so the tints between the stops are not
+/// a straight line.
+#[test]
+fn ink_tints_follow_the_perceptual_curve() {
+ use crate::libraries::srgb_to_oklab;
+ let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0);
+ let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]);
+ g.method = GradientMethod::Perceptual;
+ assert!(g.fits_ink_tints());
+ let tints = g.ink_tints(&ink, &[]).expect("a magenta ink's tints differ in lightness");
+ assert!(tints.len() > 2, "the curve's stops: {tints:?}");
+ let (first, last) = (tints[0], tints[tints.len() - 1]);
+ assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}");
+ // The stops' own tints, when given, are kept exactly; the tints in between are the same.
+ let own = g.ink_tints(&ink, &[0.8, 0.0]).expect("tints");
+ assert!(own.len() == tints.len() && own[0].1 == 0.8 && own[own.len() - 1].1 == 0.0, "{own:?}");
+ assert!(own[1..own.len() - 1] == tints[1..tints.len() - 1], "{own:?}");
+ assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}");
+ let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
+ for (t, tint, _) in &tints {
+ let want = lightness(&g.sample(*t).0);
+ assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}");
+ }
+ let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint");
+ assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}");
+ // A white ink's tints all have paper white's lightness: no tints by lightness.
+ assert_eq!(g.ink_tints(&Color::WHITE, &[]), None);
+ // More stops on the curve than MAX_INK_TINTS: no tints.
+ let n = MAX_INK_TINTS + 1;
+ g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect();
+ assert_eq!(g.ink_tints(&ink, &[]), None);
+ assert!(!g.fits_ink_tints());
+}
+
+/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their
+/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of
+/// the color on the curve there.
+#[test]
+fn ink_mixes_follow_the_perceptual_curve() {
+ use crate::libraries::srgb_to_oklab;
+ let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0));
+ let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]);
+ g.stops[0].midpoint = 0.3;
+ assert_eq!(g.ink_mixes(), None, "a Classic gradient");
+ g.method = GradientMethod::Perceptual;
+ let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
+ let curve = g.expanded_stops();
+ assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve");
+ assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops");
+ let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
+ for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) {
+ assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}");
+ if *i == 0 && *t > 0.0 {
+ let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w);
+ assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}");
+ }
+ }
+ let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
+ assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}");
+ assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}");
+ // Two colors of one lightness: the weights are the mix positions.
+ g.stops[1].color = magenta;
+ let mixes = g.ink_mixes().expect("the curve");
+ let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
+ assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}");
+ // More stops on the curve than MAX_INK_TINTS: no mixes.
+ let n = MAX_INK_TINTS + 1;
+ g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect();
+ assert_eq!(g.ink_mixes(), None);
+}
diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs
index e88f767a5..e7cf22c41 100644
--- a/crates/color/src/recolor.rs
+++ b/crates/color/src/recolor.rs
@@ -244,6 +244,12 @@ pub fn is_tint(c: Color, base: Color) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01)
}
+/// Whether `a` and `b` carry the same ink in `a`'s model, within [`is_tint`]'s tolerance.
+pub fn same_ink(a: Color, b: Color) -> bool {
+ let (x, y) = (ink(a, a.model()), ink(b, a.model()));
+ x.iter().zip(y).all(|(p, q)| (p - q).abs() <= 0.01)
+}
+
/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab.
pub fn shaded(c: Color, f: f32) -> Color {
let f = f.clamp(0.0, 1.0);
@@ -255,6 +261,77 @@ pub fn shaded(c: Color, f: f32) -> Color {
}
}
+/// Recolor Artwork's Global Adjust: the same change to every color, by four amounts from -1 to 1
+/// (-100% to +100%), where 0 leaves a color as it is. The steps run in this order:
+///
+/// 1. Saturation multiplies HSB saturation by 1 + `saturation` (-1 gives a gray, 1 doubles it).
+/// 2. Brightness moves HSB brightness that share of the way to its top (above 0) or to black
+/// (below 0).
+/// 3. Temperature adds 0.2 × `temperature` to display red and takes as much from blue (warmer
+/// above 0, cooler below).
+/// 4. Luminosity moves Lab lightness that share of the way to 100 (above 0) or to 0 (below 0),
+/// with Lab a and b, and so the Lab hue, kept.
+///
+/// The result is in the model of the color it changes, with its components clamped to their
+/// ranges. An amount that is not finite counts as 0.
+#[derive(Clone, Copy, Debug, Default, PartialEq)]
+pub struct GlobalAdjust {
+ pub saturation: f32,
+ pub brightness: f32,
+ pub temperature: f32,
+ pub luminosity: f32,
+}
+
+impl GlobalAdjust {
+ /// The four amounts, in order, each finite and within -1..=1.
+ fn amounts(&self) -> [f32; 4] {
+ [self.saturation, self.brightness, self.temperature, self.luminosity].map(|v| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 })
+ }
+
+ /// Whether every color stays as it is (all four amounts 0).
+ pub fn is_identity(&self) -> bool {
+ self.amounts().iter().all(|v| *v == 0.0)
+ }
+
+ /// `c` with the adjustment, in `c`'s model; `c` itself when [`GlobalAdjust::is_identity`].
+ pub fn apply(&self, c: Color) -> Color {
+ let [sat, bri, temp, lum] = self.amounts();
+ if [sat, bri, temp, lum].iter().all(|v| *v == 0.0) {
+ return c;
+ }
+ // `v` moved the share `a` of the way to `top` (a > 0) or to 0 (a < 0).
+ let toward = |v: f32, a: f32, top: f32| if a >= 0.0 { v + (top - v) * a } else { v * (1.0 + a) };
+ let mut out = c;
+ if sat != 0.0 || bri != 0.0 {
+ let [h, s, v] = out.to_hsb();
+ out = Color::from_hsb(h, (s * (1.0 + sat)).clamp(0.0, 1.0), toward(v.clamp(0.0, 1.0), bri, 1.0));
+ }
+ if temp != 0.0 {
+ let [r, g, b] = out.to_rgb();
+ out = Color::rgb((r + 0.2 * temp).clamp(0.0, 1.0), g.clamp(0.0, 1.0), (b - 0.2 * temp).clamp(0.0, 1.0));
+ }
+ if lum != 0.0 {
+ let Lab { l, a, b } = match out {
+ Color::Lab { l, a, b } => Lab::new(l, a, b),
+ _ => out.to_lab(),
+ };
+ out = Color::lab(toward(l.clamp(0.0, 100.0), lum, 100.0), a, b);
+ }
+ clamped(keep_model(c, out))
+ }
+}
+
+/// `c` with its components in their ranges: RGB, CMYK and Gray 0..1, Lab lightness 0..100.
+fn clamped(c: Color) -> Color {
+ let u = |v: f32| v.clamp(0.0, 1.0);
+ match c {
+ Color::Rgb { r, g, b } => Color::rgb(u(r), u(g), u(b)),
+ Color::Cmyk { c, m, y, k } => Color::cmyk(u(c), u(m), u(y), u(k)),
+ Color::Gray { k } => Color::gray(u(k)),
+ Color::Lab { l, a, b } => Color::lab(l.clamp(0.0, 100.0), a, b),
+ }
+}
+
/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`.
#[derive(Clone, Copy, Debug)]
pub struct RowMap {
@@ -615,6 +692,77 @@ mod tests {
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
}
+ fn adjust(saturation: f32, brightness: f32, temperature: f32, luminosity: f32) -> GlobalAdjust {
+ GlobalAdjust { saturation, brightness, temperature, luminosity }
+ }
+
+ #[test]
+ fn global_adjust_at_zero_leaves_every_color_as_it_is() {
+ let zero = GlobalAdjust::default();
+ assert!(zero.is_identity());
+ assert!(adjust(f32::NAN, 0.0, f32::INFINITY, 0.0).is_identity(), "amounts that aren't finite count as 0");
+ for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.1234, 0.5, 0.0, 0.07), Color::gray(0.4), Color::lab(55.0, 60.5, -40.25)] {
+ assert_eq!(zero.apply(c), c);
+ }
+ assert!(!adjust(0.0, 0.0, 0.0, 0.01).is_identity());
+ }
+
+ #[test]
+ fn global_adjust_saturation_and_brightness_reach_their_ends() {
+ let c = Color::rgb(0.8, 0.4, 0.2);
+ let [h, s, v] = c.to_hsb();
+ let gray = adjust(-1.0, 0.0, 0.0, 0.0).apply(c).to_hsb();
+ assert!(gray[1].abs() < 1e-4 && (gray[2] - v).abs() < 1e-4, "-100% saturation gives a gray as bright: {gray:?}");
+ let more = adjust(0.5, 0.0, 0.0, 0.0).apply(c).to_hsb();
+ assert!((more[0] - h).abs() < 0.5 && more[1] > s && (more[1] - (s * 1.5).min(1.0)).abs() < 1e-3, "{more:?}");
+ assert!(adjust(-1.0, 0.0, 0.0, 0.0).apply(Color::rgb(0.5, 0.5, 0.5)) == Color::rgb(0.5, 0.5, 0.5), "a gray stays gray");
+ assert!(adjust(0.0, -1.0, 0.0, 0.0).apply(c).to_hsb()[2] < 1e-4, "-100% brightness is black");
+ assert!((adjust(0.0, 1.0, 0.0, 0.0).apply(c).to_hsb()[2] - 1.0).abs() < 1e-4, "+100% brightness is full brightness");
+ let half = adjust(0.0, 0.5, 0.0, 0.0).apply(c).to_hsb();
+ assert!((half[2] - (v + (1.0 - v) * 0.5)).abs() < 1e-3 && (half[1] - s).abs() < 1e-3, "{half:?}");
+ }
+
+ #[test]
+ fn global_adjust_temperature_warms_and_cools() {
+ let c = Color::rgb(0.5, 0.5, 0.5);
+ let warm = adjust(0.0, 0.0, 1.0, 0.0).apply(c).to_rgb();
+ let cool = adjust(0.0, 0.0, -1.0, 0.0).apply(c).to_rgb();
+ assert!(warm[0] > warm[1] && warm[1] > warm[2], "warmer: more red, less blue {warm:?}");
+ assert!(cool[2] > cool[1] && cool[1] > cool[0], "cooler: more blue, less red {cool:?}");
+ let edge = adjust(0.0, 0.0, 1.0, 0.0).apply(Color::rgb(1.0, 0.0, 0.0));
+ assert_eq!(edge, Color::rgb(1.0, 0.0, 0.0), "clamped at the ends of the channels");
+ }
+
+ #[test]
+ fn global_adjust_luminosity_keeps_the_hue() {
+ let lab = Color::lab(50.0, 30.0, -20.0);
+ assert_eq!(adjust(0.0, 0.0, 0.0, 0.5).apply(lab), Color::lab(75.0, 30.0, -20.0), "a Lab color keeps a and b exactly");
+ assert_eq!(adjust(0.0, 0.0, 0.0, -0.5).apply(lab), Color::lab(25.0, 30.0, -20.0));
+ let c = Color::rgb(0.6, 0.35, 0.3);
+ let (before, after) = (c.to_lab(), adjust(0.0, 0.0, 0.0, 0.2).apply(c).to_lab());
+ assert!(after.l > before.l, "lighter: {after:?}");
+ assert!((after.hue() - before.hue()).abs() < 2.0, "hue kept: {} vs {}", after.hue(), before.hue());
+ let darkest = adjust(0.0, 0.0, 0.0, -1.0).apply(c).to_lab();
+ assert!(darkest.l < before.l * 0.5, "-100% is far darker: {darkest:?}");
+ assert_eq!(adjust(0.0, 0.0, 0.0, -1.0).apply(Color::gray(0.4)), Color::gray(1.0), "-100% makes a gray black");
+ }
+
+ #[test]
+ fn global_adjust_keeps_the_model_and_the_ranges() {
+ let all = adjust(1.0, 1.0, 1.0, 1.0);
+ for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.2, 0.9, 0.1, 0.3), Color::gray(0.4), Color::lab(55.0, 60.0, -40.0)] {
+ let out = all.apply(c);
+ assert_eq!(out.model(), c.model(), "{c:?} → {out:?}");
+ let comps: Vec = match out {
+ Color::Rgb { r, g, b } => vec![r, g, b],
+ Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
+ Color::Gray { k } => vec![k],
+ Color::Lab { l, .. } => vec![l / 100.0],
+ };
+ assert!(comps.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{out:?}");
+ }
+ }
+
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs
index 4ac758ffa..81b9aaaf5 100644
--- a/crates/doc/src/appearance.rs
+++ b/crates/doc/src/appearance.rs
@@ -10,10 +10,12 @@ mod stroke_style;
pub(crate) use layers::HEAD_REACH;
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
-pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
+pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
/// One entry of the appearance stack.
+// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each.
+#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AppearanceItem {
@@ -126,11 +128,15 @@ impl Appearance {
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
- /// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
+ /// This appearance for other art: the pen pressure, tilt, bearing and rotation its strokes
+ /// were drawn with stay with them.
pub fn without_pressure(mut self) -> Self {
for it in &mut self.items {
if let AppearanceItem::Stroke(s) = it {
s.pressure = None;
+ s.tilt = None;
+ s.bearing = None;
+ s.rotation = None;
}
}
self
@@ -504,6 +510,27 @@ impl Appearance {
self.set_contents_at(others_below + usize::from(now_below));
Some(to)
}
+ /// Move items `from` (ascending paint-order indices, no repeats) as one block, in their order,
+ /// so the lowest lands at `to` (clamped); returns it. The contents stay between the same unmoved
+ /// items; a block landing right at them keeps its lowest item's side. None (and nothing changed)
+ /// when an index is missing.
+ pub fn move_items(&mut self, from: &[usize], to: usize) -> Option {
+ let (&first, _) = from.split_first()?;
+ if from.iter().any(|i| *i >= self.items.len()) {
+ return None;
+ }
+ let below = first < self.contents_at();
+ // From the top down, so the lower indices stay valid; then back in order.
+ let mut block: Vec = from.iter().rev().filter_map(|&i| self.remove_item(i)).collect();
+ block.reverse();
+ let others_below = self.contents_at();
+ let to = to.min(self.items.len());
+ let m = block.len();
+ self.items.splice(to..to, block);
+ let now_below = if below { to <= others_below } else { to < others_below };
+ self.set_contents_at(others_below + if now_below { m } else { 0 });
+ Some(to)
+ }
}
/// The most fills, strokes or effects a stack may have to be matched against another's
diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs
index dd5c41325..880f3490d 100644
--- a/crates/doc/src/appearance/layers.rs
+++ b/crates/doc/src/appearance/layers.rs
@@ -1,4 +1,4 @@
-use super::profile::{PressureProfile, WidthProfile};
+use super::profile::{AngleProfile, PressureProfile, WidthProfile};
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
use super::{BlendMode, Deserialize, Paint, Serialize};
@@ -93,6 +93,21 @@ pub struct StrokeLayer {
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option,
+ /// How far the pen leaned from upright along the stroke (Paintbrush with a tablet that reads
+ /// tilt): 0 upright to 1 lying flat (90°). Brushes with Tilt variation vary with it. Like the
+ /// pressure, it stays with this path.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tilt: Option,
+ /// Which way the pen leaned along the stroke, in degrees counter-clockwise on the page from the
+ /// x axis. Brushes with Bearing variation vary with it. Like the pressure, it stays with this
+ /// path.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub bearing: Option,
+ /// How far the pen's barrel was turned along the stroke, in degrees counter-clockwise (a pen
+ /// that reads barrel rotation). Brushes with Rotation variation vary with it. Like the
+ /// pressure, it stays with this path.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub rotation: Option,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
@@ -167,6 +182,9 @@ impl StrokeLayer {
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
+ tilt: None,
+ bearing: None,
+ rotation: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs
index 09142df0c..1c3334f0d 100644
--- a/crates/doc/src/appearance/profile.rs
+++ b/crates/doc/src/appearance/profile.rs
@@ -1,3 +1,5 @@
+use std::borrow::Cow;
+
use super::{Deserialize, Serialize};
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
@@ -101,7 +103,8 @@ impl WidthProfile {
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
-/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
+/// are. The pen's tilt is kept the same way ([`StrokeLayer::tilt`]). Calligraphic and Scatter
+/// brushes with Pressure or Tilt variation read them; everything else ignores them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
@@ -118,18 +121,7 @@ impl PressureProfile {
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
- let p = &self.points;
- let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
- // (Positions go forward; a profile read from a file is searched as if they do, in log time.)
- let v = if t <= first.0 {
- first.1
- } else {
- let i = p.partition_point(|q| q.0 < t);
- match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
- (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
- _ => last.1,
- }
- };
+ let v = interpolated(&self.points, t).unwrap_or(Self::MID);
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
@@ -139,44 +131,21 @@ impl PressureProfile {
pub fn from_samples(samples: impl IntoIterator) -> Option {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
- if pts.is_empty() {
- return None;
- }
- // Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
- let mut pts = thin(pts, 16 * Self::MAX_POINTS);
- // Positions only ever go forward.
- let mut prev = 0.0;
- for p in &mut pts {
- p.0 = p.0.max(prev);
- prev = p.0;
- }
- let keep = simplify_1d(&pts, Self::TOLERANCE);
- let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
- let round = |v: f64| (v * 1e4).round() / 1e4;
- Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
+ simplified(pts, Self::TOLERANCE).map(|points| Self { points })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option {
- if old.is_none() && added.is_none() {
- return None;
+ match extended_points(old.map(|p| p.points.as_slice()), old_len, added.map(|p| p.points.as_slice()), added_len, Self::MID)? {
+ Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),
+ Cow::Owned(points) => Self::from_samples(points),
}
- let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
- let total = old_len + added_len;
- if !total.is_finite() || total <= 1e-9 {
- return old.or(added).cloned();
- }
- let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
- let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
- let o = old_len / total;
- let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
- Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
- Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
+ Self { points: reversed_points(&self.points) }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
@@ -185,6 +154,142 @@ impl PressureProfile {
}
}
+/// A pen angle recorded along a stroke (the Paintbrush with a tablet that reads it): (position 0..1
+/// along the path, degrees) samples in order, positions measured as a [`PressureProfile`]'s are.
+/// Each sample is joined to the one before it the shorter way round, so the degrees can run past
+/// ±180° and the angle read between two samples turns through the smaller angle between them.
+/// Calligraphic and Scatter brushes with Bearing or Rotation variation read it.
+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+pub struct AngleProfile {
+ pub points: Vec<(f64, f64)>,
+}
+
+impl AngleProfile {
+ /// How far (in degrees) simplifying may move the recorded curve.
+ const TOLERANCE: f64 = 1.0;
+
+ /// The angle at `t` (0..1), linear between samples, in degrees above −180 up to 180; 0
+ /// without any samples.
+ pub fn at(&self, t: f64) -> f64 {
+ let v = interpolated(&self.points, t).unwrap_or(0.0);
+ if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 }
+ }
+
+ /// A profile from (position, degrees) samples in order along the stroke: non-finite ones are
+ /// dropped, each sample is joined to the one before it the shorter way round, and the curve is
+ /// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
+ pub fn from_samples(samples: impl IntoIterator) -> Option {
+ let mut prev: Option = None;
+ let pts: Vec<(f64, f64)> = samples
+ .into_iter()
+ .filter(|(t, a)| t.is_finite() && a.is_finite())
+ .map(|(t, a)| {
+ let a = prev.map_or(vectorcraft_geom::normalize_deg(a), |p| p + vectorcraft_geom::normalize_deg(a - p));
+ prev = Some(a);
+ (t.clamp(0.0, 1.0), a)
+ })
+ .collect();
+ simplified(pts, Self::TOLERANCE).map(|points| Self { points })
+ }
+
+ /// The angle of a path `old_len` long (`old`; None: drawn without it, 0°) continued at its end
+ /// by a stroke `added_len` long (`added`). None when neither has one. Each part keeps the turns
+ /// it recorded, more than half a turn between two samples included; the added part is moved by
+ /// whole turns so that it starts the shorter way round from where the old part ends.
+ pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option {
+ let end = old.map_or(Some(0.0), |o| o.points.last().map(|p| p.1));
+ let added = added.map(|a| {
+ let shift = match (end, a.points.first()) {
+ (Some(end), Some(&(_, start))) => end + vectorcraft_geom::normalize_deg(start - end) - start,
+ _ => 0.0,
+ };
+ Self { points: a.points.iter().map(|&(t, v)| (t, v + shift)).collect() }
+ });
+ match extended_points(old.map(|p| p.points.as_slice()), old_len, added.as_ref().map(|p| p.points.as_slice()), added_len, 0.0)? {
+ Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),
+ Cow::Owned(points) => {
+ let pts = points.into_iter().filter(|(t, a)| t.is_finite() && a.is_finite()).map(|(t, a)| (t.clamp(0.0, 1.0), a)).collect();
+ simplified(pts, Self::TOLERANCE).map(|points| Self { points })
+ }
+ }
+ }
+
+ /// The angle along the path reversed (the angles stay as they were on the page).
+ pub fn reversed(&self) -> Self {
+ Self { points: reversed_points(&self.points) }
+ }
+
+ /// One turn through every angle along the stroke, for previews of brushes that vary with one.
+ pub fn sample() -> Self {
+ Self { points: vec![(0.0, -179.0), (1.0, 179.0)] }
+ }
+}
+
+/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any.
+fn interpolated(p: &[(f64, f64)], t: f64) -> Option {
+ let (first, last) = (p.first()?, p.last()?);
+ if t <= first.0 {
+ return Some(first.1);
+ }
+ // (Positions go forward; a profile read from a file is searched as if they do, in log time.)
+ let i = p.partition_point(|q| q.0 < t);
+ Some(match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
+ (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
+ _ => last.1,
+ })
+}
+
+/// Samples (position, value) with positions in 0..1 and finite values, simplified to within `tol`
+/// of the value and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
+fn simplified(pts: Vec<(f64, f64)>, tol: f64) -> Option> {
+ if pts.is_empty() {
+ return None;
+ }
+ // Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
+ let mut pts = thin(pts, 16 * PressureProfile::MAX_POINTS);
+ // Positions only ever go forward.
+ let mut prev = 0.0;
+ for p in &mut pts {
+ p.0 = p.0.max(prev);
+ prev = p.0;
+ }
+ let keep = simplify_1d(&pts, tol);
+ let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), PressureProfile::MAX_POINTS);
+ let round = |v: f64| (v * 1e4).round() / 1e4;
+ Some(pts.into_iter().map(|(t, p)| (round(t), round(p))).collect())
+}
+
+/// The samples of a path `old_len` long (`old`) continued at its end by a stroke `added_len` long
+/// (`added`), in order along the whole path, a side without samples (`None`) flat at `none`.
+/// `None` when neither side has samples; the side that has them, as they are, when the two
+/// lengths add up to no length.
+fn extended_points<'a>(
+ old: Option<&'a [(f64, f64)]>,
+ old_len: f64,
+ added: Option<&'a [(f64, f64)]>,
+ added_len: f64,
+ none: f64,
+) -> Option> {
+ if old.is_none() && added.is_none() {
+ return None;
+ }
+ let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
+ let total = old_len + added_len;
+ if !total.is_finite() || total <= 1e-9 {
+ return old.or(added).map(Cow::Borrowed);
+ }
+ let flat = [(0.0, none), (1.0, none)];
+ let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
+ let o = old_len / total;
+ let old_pts = old.iter().map(|&(t, p)| (t * o, p));
+ Some(Cow::Owned(old_pts.chain(added.iter().map(|&(u, p)| (o + u * (1.0 - o), p))).collect()))
+}
+
+/// Samples (position, value) of a path, for the path reversed.
+fn reversed_points(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
+ points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect()
+}
+
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs
index 3e3d610f4..089309f34 100644
--- a/crates/doc/src/appearance/tests.rs
+++ b/crates/doc/src/appearance/tests.rs
@@ -125,11 +125,50 @@ fn pressure_profile_extends_and_reverses() {
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
+/// #764: a pen's bearing or barrel rotation is kept the shorter way round between samples, so
+/// 170° then −170° turns through 180°, not through 0°.
+#[test]
+fn angle_profiles_join_samples_the_shorter_way_round() {
+ let across = AngleProfile::from_samples([(0.0, 170.0), (1.0, -170.0)]).unwrap();
+ assert_eq!(across.points, vec![(0.0, 170.0), (1.0, 190.0)]);
+ assert_eq!(across.at(0.5), 180.0);
+ assert!((across.at(0.75) + 175.0).abs() < 1e-9, "{}", across.at(0.75));
+ // Turns past a whole turn keep counting, and read back within −180..180°.
+ let spin = AngleProfile::from_samples((0..=40).map(|i| (f64::from(i) / 40.0, f64::from(i * 30) - 180.0))).unwrap();
+ assert!((spin.points.last().unwrap().1 - 1380.0).abs() < 1e-9, "{spin:?}");
+ assert!((spin.at(1.0) + 60.0).abs() < 1e-9 && spin.points.len() == 2, "{spin:?}");
+ // Junk is dropped; nothing at all is no profile, and no profile reads 0°.
+ let junk = AngleProfile::from_samples([(f64::NAN, 5.0), (0.5, f64::INFINITY), (0.2, 400.0)]).unwrap();
+ assert_eq!(junk.points, vec![(0.2, 40.0)]);
+ assert!(AngleProfile::from_samples([(0.5, f64::NAN)]).is_none());
+ assert_eq!(AngleProfile::default().at(0.3), 0.0);
+ let noisy = AngleProfile::from_samples((0..1_000_000).map(|i| (f64::from(i) / 1e6, f64::from(i % 2) * 90.0))).unwrap();
+ assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
+ // Continued: the old part, then the new one; a side without an angle is 0°. Reversed, the
+ // angles stay what they were.
+ let old = AngleProfile { points: vec![(0.0, 90.0), (1.0, 90.0)] };
+ let joined = AngleProfile::extended(Some(&old), 30.0, None, 10.0).unwrap();
+ assert!((joined.at(0.5) - 90.0).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
+ assert!(AngleProfile::extended(None, 10.0, None, 10.0).is_none());
+ assert_eq!(AngleProfile::extended(Some(&old), 0.0, None, 0.0), Some(old.clone()));
+ assert_eq!(across.reversed().points, vec![(0.0, 190.0), (1.0, 170.0)]);
+ // A part that turned more than half a turn between two of its samples keeps that turn when
+ // the path is continued, and so does the new part.
+ let quarter_turns = AngleProfile::from_samples([(0.0, 0.0), (1.0 / 3.0, 90.0), (2.0 / 3.0, 180.0), (1.0, 270.0)]).unwrap();
+ assert_eq!(quarter_turns.points, vec![(0.0, 0.0), (1.0, 270.0)]);
+ let added = AngleProfile { points: vec![(0.0, -80.0), (1.0, 280.0)] };
+ let joined = AngleProfile::extended(Some(&quarter_turns), 10.0, Some(&added), 10.0).unwrap();
+ assert!((joined.at(0.25) - 135.0).abs() < 1e-9, "{joined:?}");
+ assert!((joined.at(0.75) - 100.0).abs() < 1e-9, "{joined:?}");
+}
+
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
- assert!(json.get("pressure").is_none());
+ for key in ["pressure", "tilt", "bearing", "rotation"] {
+ assert!(json.get(key).is_none(), "{key}");
+ }
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
@@ -138,6 +177,19 @@ fn strokes_without_pressure_round_trip_unchanged() {
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
+ // The pen's tilt, bearing and rotation load, save and stay behind the same way.
+ let posed = StrokeLayer {
+ tilt: Some(PressureProfile::sample()),
+ bearing: Some(AngleProfile::sample()),
+ rotation: Some(AngleProfile { points: vec![(0.0, -45.0)] }),
+ ..back
+ };
+ let again: StrokeLayer = serde_json::from_value(serde_json::to_value(&posed).unwrap()).unwrap();
+ assert_eq!(again, posed);
+ let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
+ *ap.stroke_mut().unwrap() = posed;
+ let st = ap.without_pressure().stroke().cloned().unwrap();
+ assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
}
#[test]
diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs
index ced9472a7..ce20d29cd 100644
--- a/crates/doc/src/blend.rs
+++ b/crates/doc/src/blend.rs
@@ -20,7 +20,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::ParamCurveArclen;
-use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2};
+use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
@@ -1029,6 +1029,27 @@ impl Rail {
}
}
+/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps
+/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest
+/// any key paints from its centre. A step's shape lies within that distance of the point on the
+/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing
+/// moves.
+pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option {
+ if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath {
+ return None;
+ }
+ let spine = blend_spine(keys, spec)?.0.bounds()?;
+ let radius = keys
+ .iter()
+ .filter_map(|k| {
+ let (c, b) = (center_of(k), k.visual(symbols)?);
+ let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)];
+ Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max))
+ })
+ .fold(0.0, f64::max);
+ Some(spine.inflate(3.0 * radius, 3.0 * radius))
+}
+
// =====================================================================================
// Blend evaluation
// =====================================================================================
diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs
index 478e54ca8..976d48485 100644
--- a/crates/doc/src/document.rs
+++ b/crates/doc/src/document.rs
@@ -47,6 +47,7 @@ impl Document {
patterns,
pattern_edit,
mask_edit,
+ symbol_edit,
next_id: _,
unknown,
page_isolate,
@@ -113,6 +114,7 @@ impl Document {
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
+ && *symbol_edit == other.symbol_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
@@ -221,6 +223,7 @@ impl Document {
patterns: vec![],
pattern_edit: None,
mask_edit: None,
+ symbol_edit: None,
next_id: 1,
unknown: Default::default(),
page_isolate: false,
diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs
index c81d761f9..c9a6c5f56 100644
--- a/crates/doc/src/document_ext.rs
+++ b/crates/doc/src/document_ext.rs
@@ -98,18 +98,22 @@ impl Document {
self.views = kept;
}
- /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
- /// mask already holds (the engine syncs it after every edit).
+ /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working
+ /// copy of art the mask or the symbol already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
+ if let Some(se) = self.symbol_edit.take() {
+ self.layers.retain(|l| l.id != se.layer);
+ }
}
- /// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
- /// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
+ /// The document as saved and exported: without the opacity-mask or symbol editing layer.
+ /// Borrowed when neither mode is on. (Pattern editing is saved: its tile layer holds unapplied
+ /// edits.)
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
- if self.mask_edit.is_none() {
+ if self.mask_edit.is_none() && self.symbol_edit.is_none() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs
index b754e0d56..d20c1ffe4 100644
--- a/crates/doc/src/hit.rs
+++ b/crates/doc/src/hit.rs
@@ -1,7 +1,9 @@
//! Hit testing over the document tree.
+use std::cell::RefCell;
use std::collections::HashSet;
-use std::sync::OnceLock;
+use std::rc::Rc;
+use std::sync::{Arc, OnceLock};
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
@@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
}
+/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
+/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated
+/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the
+/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else.
+pub type LiveArt = fn(Option<&Document>, &Node) -> Vec;
+
+/// A number that changes whenever something outside the document that [`LiveArt`] reads changes,
+/// such as the installed fonts or the plug-ins.
+pub type LiveInputs = fn() -> u64;
+
+static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new();
+
+/// Register how a live object's art is evaluated for clicks, and the number that changes with what
+/// else it reads (the first registration stays).
+pub fn set_live_art(f: LiveArt, inputs: LiveInputs) {
+ // Already set: the same engine, registered by another session.
+ let _ = LIVE_ART.set((f, inputs));
+}
+
+/// The registered [`LiveInputs`] now, 0 while nothing is registered.
+fn live_inputs() -> u64 {
+ LIVE_ART.get().map_or(0, |(_, inputs)| inputs())
+}
+
+/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by
+/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`].
+struct EvaluatedLive {
+ node: Node,
+ symbols: Vec<(String, Arc)>,
+ patterns: Vec,
+ inputs: u64,
+ art: Rc>,
+ size: usize,
+}
+
+impl EvaluatedLive {
+ /// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now?
+ fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool {
+ self.inputs == inputs
+ && self.symbols.len() == doc.symbols.len()
+ && self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art))
+ && self.node == *n
+ && self.patterns == doc.patterns
+ }
+}
+
+/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an
+/// envelope's stroke band hits it again and again, and an envelope can hold a blend.
+const LIVE_KEPT: usize = 4;
+
+/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than
+/// this is not kept.
+const LIVE_KEPT_SIZE: usize = 250_000;
+
+/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths.
+fn art_size(art: &[Node]) -> usize {
+ let mut size = 0usize;
+ for n in art {
+ n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count())));
+ }
+ size
+}
+
+/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes
+/// when the file is read, so [`live_art`] does not keep an evaluation of `n`.
+fn holds_placed(doc: &Document, n: &Node) -> bool {
+ let mut placed = false;
+ let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
+ n.walk(&mut find);
+ doc.symbols.iter().for_each(|s| s.art.walk(&mut find));
+ doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find));
+ placed
+}
+
+thread_local! {
+ /// The evaluations [`live_art`] keeps, the latest first.
+ static EVALUATED: RefCell> = const { RefCell::new(Vec::new()) };
+}
+
+/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while
+/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of
+/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's
+/// symbols or patterns hold a placed document ([`holds_placed`]).
+fn live_art(doc: &Document, n: &Node) -> Rc> {
+ let inputs = live_inputs();
+ let kept = EVALUATED.with_borrow_mut(|kept| {
+ let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?;
+ let e = kept.remove(i);
+ let art = e.art.clone();
+ kept.insert(0, e);
+ Some(art)
+ });
+ if let Some(art) = kept {
+ return art;
+ }
+ #[cfg(test)]
+ LIVE_EVALUATED.with(|c| c.set(c.get() + 1));
+ let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n)));
+ let size = art_size(&art);
+ if size > LIVE_KEPT_SIZE || holds_placed(doc, n) {
+ return art;
+ }
+ let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect();
+ let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size };
+ EVALUATED.with_borrow_mut(|kept| {
+ kept.insert(0, e);
+ kept.truncate(LIVE_KEPT);
+ let mut total = 0usize;
+ kept.retain(|e| {
+ total = total.saturating_add(e.size);
+ total <= LIVE_KEPT_SIZE
+ });
+ });
+ art
+}
+
+#[cfg(test)]
+thread_local! {
+ /// How many live objects [`live_art`] evaluated on this thread.
+ static LIVE_EVALUATED: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option {
hit_test_skipping(doc, p, opt, &|_| false, true)
@@ -565,7 +689,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
+#[cfg(test)]
+thread_local! {
+ /// How many objects [`hit_leaf`] tested on this thread.
+ static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option {
+ #[cfg(test)]
+ LEAVES_TESTED.with(|c| c.set(c.get() + 1));
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -612,9 +744,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
}
- NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
+ NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => {
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
+ // A blend or an envelope is hit within its bounds, and outside them where its evaluated
+ // art paints: a blend's keys and steps on a spine can reach past the keys and the spine,
+ // and the strokes of an envelope's distorted contents past its bounds.
+ NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
+ if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
+ return Some(HitKind::Bounds);
+ }
+ live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols))
+ }
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
@@ -651,8 +792,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option {
+ // Hidden art does not hit.
+ if !n.visible {
+ return None;
+ }
+ // Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are
+ // passed by without being measured.
+ if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) {
+ return None;
+ }
match &n.kind {
- NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
+ // A clip group only hits inside its clipping path.
+ NodeKind::Group { children, clip } => {
+ if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) {
+ return None;
+ }
+ children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols))
+ }
+ // An intertwine group hits by its members, as on the canvas.
+ NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs
index e5ee6c4c8..c4d6c565e 100644
--- a/crates/doc/src/hit/tests.rs
+++ b/crates/doc/src/hit/tests.rs
@@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
}
}
+/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps
+/// whose own reach holds the point, not every step of the evaluated blend.
+#[test]
+fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() {
+ let mut d = Document::new(1000.0, 1000.0);
+ let l = d.layers[0].id;
+ // 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart.
+ let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
+ let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
+ let blend = d.alloc_id();
+ let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
+ let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
+ d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
+ let count = |p: Point| {
+ LEAVES_TESTED.with(|c| c.set(0));
+ let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf);
+ (h, LEAVES_TESTED.with(std::cell::Cell::get))
+ };
+ // On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it.
+ let (h, tested) = count(Point::new(450.0, 291.0));
+ assert_eq!(h, Some(blend));
+ assert!(tested < 20, "{tested} objects tested");
+ // In the corner of the reach, outside every stroke: nothing hits.
+ let (h, tested) = count(Point::new(6.0, 291.0));
+ assert_eq!(h, None);
+ assert!(tested < 20, "{tested} objects tested");
+}
+
+/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its
+/// members.
+#[test]
+fn evaluated_art_hits_an_intertwine_group_by_its_members() {
+ let d = Document::new(200.0, 200.0);
+ let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()));
+ let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] });
+ let symbols = d.symbol_boxes();
+ assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill));
+ assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None);
+}
+
+/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend
+/// or a symbol of the document changes.
+#[test]
+fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() {
+ let mut d = Document::new(1000.0, 1000.0);
+ let l = d.layers[0].id;
+ let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
+ let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
+ let blend = d.alloc_id();
+ let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
+ let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
+ d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
+ let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
+ let before = evaluated();
+ for x in [448.0, 450.0, 452.0] {
+ assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
+ }
+ assert_eq!(evaluated(), before + 1);
+ // 100 steps 8.7 pt apart: a step still reaches (450, 291).
+ if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) {
+ spec.spacing = crate::live::BlendSpacing::Steps(100);
+ }
+ assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
+ assert_eq!(evaluated(), before + 2);
+ let art = circle(d.alloc_id(), 0.0);
+ d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art });
+ hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
+ assert_eq!(evaluated(), before + 3);
+ hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
+ assert_eq!(evaluated(), before + 3);
+}
+
+/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band
+/// evaluates it again.
+#[test]
+fn evaluated_art_past_the_kept_size_is_not_kept() {
+ let mut d = Document::new(1000.0, 1000.0);
+ let l = d.layers[0].id;
+ let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
+ // A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps.
+ let circle = |id, x: f64| {
+ let points: Vec = (0..2000)
+ .map(|i| {
+ let a = f64::from(i) * std::f64::consts::TAU / 2000.0;
+ Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin())
+ })
+ .collect();
+ let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
+ Arc::new(Node::path(id, path, stroked.clone()))
+ };
+ let blend = d.alloc_id();
+ let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
+ let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
+ d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
+ let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
+ let before = evaluated();
+ for x in [448.0, 450.0] {
+ assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
+ }
+ assert_eq!(evaluated(), before + 2);
+}
+
+/// While the document's symbols hold a placed document, whose art changes when its file is read,
+/// each click in a blend's stroke band evaluates the blend again.
+#[test]
+fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
+ let mut d = Document::new(1000.0, 1000.0);
+ let l = d.layers[0].id;
+ let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
+ let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
+ let blend = d.alloc_id();
+ let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
+ let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
+ d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
+ let placed = crate::PlacedDocument {
+ link: crate::LinkInfo::new("/art/logo.vectorcraft"),
+ key: "logo".into(),
+ bounding: false,
+ page_box: crate::PageBox::Crop,
+ width: 80.0,
+ height: 40.0,
+ xf: vectorcraft_geom::Affine::IDENTITY,
+ placement: Default::default(),
+ };
+ let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))));
+ d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art });
+ let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
+ let before = evaluated();
+ for x in [448.0, 450.0] {
+ assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
+ }
+ assert_eq!(evaluated(), before + 2);
+}
+
+/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins
+/// ([`LiveInputs`]) are as they were.
+#[test]
+fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
+ let d = Document::new(100.0, 100.0);
+ let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
+ let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 };
+ assert!(e.is_of(&d, &n, 7));
+ assert!(!e.is_of(&d, &n, 8));
+}
+
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
index 26e2bfebf..c836390e1 100644
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -29,7 +29,7 @@ mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
-pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
+pub use symbol_nine_slice::{MAX_SLICE_GROWTH, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, charge_symbol_art};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
@@ -75,8 +75,8 @@ pub(crate) mod skip;
pub use appearance::StrokeGradientMode;
pub use appearance::{
- Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
- StrokeAlign, StrokeLayer, WidthProfile,
+ AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset,
+ SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
@@ -106,6 +106,7 @@ pub use selection::{AnchorRef, SegmentRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
+pub use text::FontHeightReference;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
@@ -141,7 +142,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
#[cfg(test)]
use limits::MAX_NEXT_ID;
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
-pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol};
+pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit};
pub use units::{ColorMode, Unit, parse_number};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -221,6 +222,10 @@ pub struct Document {
/// still read so files saved mid-edit by older versions load without the editing layer.
#[serde(default, skip_serializing)]
pub mask_edit: Option,
+ /// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the
+ /// symbol already holds the art of its editing layer.
+ #[serde(default, skip_serializing)]
+ pub symbol_edit: Option,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs
index e5c98886a..b5ae03e8b 100644
--- a/crates/doc/src/node/ops.rs
+++ b/crates/doc/src/node/ops.rs
@@ -198,7 +198,8 @@ impl Node {
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
- /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
+ /// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box
+ /// takes in where its spine can put its keys and steps.
pub fn reach_bounds(&self) -> Option {
self.reach(None)
}
@@ -234,6 +235,11 @@ impl Node {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
+ // Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
+ // keys and the spine.
+ NodeKind::Blend { children, spec } => {
+ vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols))
+ }
_ => self.visual(symbols),
}
}
diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs
index 434a85a8f..a6988f502 100644
--- a/crates/doc/src/records.rs
+++ b/crates/doc/src/records.rs
@@ -1,4 +1,4 @@
-use super::{Arc, Deserialize, Node, NodeId, Point, Serialize};
+use super::{Affine, Arc, Deserialize, Node, NodeId, Point, Serialize};
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
@@ -44,6 +44,29 @@ pub struct MaskEdit {
pub layer: NodeId,
}
+/// Symbol editing mode: the art of symbol `symbol` lives on the temporary `layer` while editing,
+/// placed by `xf` (from the symbol's coordinates to the document's), and goes back into the symbol
+/// after each edit, so every instance follows it.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct SymbolEdit {
+ pub symbol: String,
+ pub layer: NodeId,
+ /// The instance editing was entered from (a double-click on it), selected again on leaving after an edit;
+ /// `None` when it was entered from the Symbols panel.
+ pub instance: Option,
+ pub xf: Affine,
+ /// The symbol's art is a group whose members are on the layer: the group takes them back.
+ pub group: bool,
+ /// An edit changed the art since editing was entered.
+ pub changed: bool,
+ /// The isolated object from before editing was entered, isolated again on leaving.
+ #[serde(default)]
+ pub back_isolation: Option,
+ /// The current layer from before editing was entered, current again on leaving.
+ #[serde(default)]
+ pub back_layer: Option,
+}
+
/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedView {
diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs
index 62276ece9..c26918153 100644
--- a/crates/doc/src/setup.rs
+++ b/crates/doc/src/setup.rs
@@ -164,6 +164,14 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
+ // „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN
+ // 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR
+ // (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`).
+ ("Serbian: Cyrillic", "sr-Cyrl", Quotes { double: ['„', '”'], single: ['’', '’'] }),
+ ("Serbian: Latin", "sr-Latn", Quotes { double: ['„', '”'], single: ['’', '’'] }),
+ // „…“ outside, ‚…‘ inside: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml,
+ // `delimiters`).
+ ("Croatian", "hr", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
@@ -373,12 +381,19 @@ mod tests {
(char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))),
(Some("English: USA"), Some("Gujarati"), None)
);
+ // Serbian in either script, and Croatian.
+ assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl")));
+ assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn")));
+ assert_eq!(char_language("Croatian"), Ok(Some("hr")));
+ assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic"));
// Every entry has a tag of its own, and a name of its own.
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}");
}
}
+ /// Straight quotes become the language's: opening or closing by what comes before, inner
+ /// quotes as single ones, and the context carried across pieces converted apart.
#[test]
fn quotes_open_and_close_by_context() {
let mut prev = None;
@@ -387,6 +402,13 @@ mod tests {
let mut prev = Some('(');
assert_eq!(german.apply("\"a\"", &mut prev), "„a“");
assert_eq!(prev, Some('“'));
+ // Serbian closes high (”), with ’ on both sides of an inner quote; Croatian as German.
+ for name in ["Serbian: Cyrillic", "Serbian: Latin"] {
+ let mut prev = None;
+ assert_eq!(language_quotes(name).unwrap().apply("\"a 'b' c\"", &mut prev), "„a ’b’ c”", "{name}");
+ }
+ let mut prev = None;
+ assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
// Converting in pieces keeps the context.
let mut prev = None;
let a = Quotes::ENGLISH.apply("\"", &mut prev);
diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs
index 7de70be38..a37b92eef 100644
--- a/crates/doc/src/symbol_nine_slice.rs
+++ b/crates/doc/src/symbol_nine_slice.rs
@@ -1,14 +1,29 @@
-//! Symbol Options › Enable Guides for 9-Slice Scaling: four guides that cut a symbol into a 3 × 3
-//! grid so that, in Animate / Flash handoff, the corners keep their size while the edges and the
-//! centre stretch
-//! ([helpx Symbol Options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html)).
+//! Symbol Options › Enable Guides for 9-Slice Scaling: two horizontal and two vertical guides cut
+//! a symbol into nine sections, and when an instance is scaled the parts of the symbol outside the
+//! guides are not scaled
+//! ([helpx: maintain proportions while scaling symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)).
+//! New guides sit a quarter of the symbol's width and height in from each edge.
+//!
+//! VectorCraft's own rules: the corners keep their size, the top and bottom edges stretch only in
+//! width, the left and right edges only in height, and the center in both. When the instance is
+//! smaller than its two outer cells on an axis, both shrink in proportion and the center is empty.
+//! The cells are sized along the symbol's own axes, and the instance's rotation, shear, reflection
+//! and position then apply to them.
//!
//! The guides are stored as fractions (0..1) of the symbol's natural size, measured from the left
-//! and top edges and from the right and bottom edges, so they survive resizing the art. Like
-//! [`crate::SymbolExportType`] this is handoff metadata: instances place the same on the canvas.
-//! Stored under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
+//! and top edges and from the right and bottom edges, so they survive resizing the art. Stored
+//! under [`SYMBOL_NINE_SLICE`] keyed by symbol name; symbols without 9-slice have no entry.
+//!
+//! [`NineSliceMap`] places such an instance's art for the canvas, bounds, Break Link, Expand and
+//! the exporters ([`Document::place_symbol_art`]).
+
+use std::sync::Arc;
use serde_json::{Value, json};
+use vectorcraft_color::Paint;
+use vectorcraft_geom::{Affine, Anchor, CubicBez, FillRule, ParamCurve, PathData, Point, Rect, SubPath, shapes};
+
+use crate::{Appearance, Document, MAX_SYMBOL_NODES, Node, NodeId, NodeKind, SymbolBoxes};
/// Document unknown key: `{ symbolName: [left, top, right, bottom] }`.
pub const SYMBOL_NINE_SLICE: &str = "symbolNineSlice";
@@ -27,10 +42,10 @@ pub struct SymbolNineSlice {
}
impl Default for SymbolNineSlice {
- /// The centre third of each axis is the stretchy cell.
+ /// Each guide sits a quarter of the symbol's width or height in from its edge.
fn default() -> Self {
- let t = 1.0 / 3.0;
- Self { left: t, top: t, right: t, bottom: t }
+ let q = 0.25;
+ Self { left: q, top: q, right: q, bottom: q }
}
}
@@ -55,3 +70,1213 @@ impl SymbolNineSlice {
Self::new(n(0)?, n(1)?, n(2)?, n(3)?)
}
}
+
+/// The most objects [`NineSliceMap::place`] makes for each object of the art it places: an
+/// object drawn in nine cells becomes a group of nine clip groups, each a clip path and a copy.
+pub const MAX_SLICE_GROWTH: u64 = 28;
+
+/// Places a symbol instance's art within [`MAX_SYMBOL_NODES`] objects, of which `used` are already
+/// spent. `place` runs only when the budget has room for `most`, the most objects placing can make
+/// (the art's object count, or [`NineSliceMap::most_placed`] when it is placed through 9-slice
+/// guides), and returns the placed art and its object count, which `used` then grows by. `None`,
+/// with `used` as it was, when the instance does not fit.
+pub fn charge_symbol_art(used: &mut u64, most: u64, place: impl FnOnce() -> (T, u64)) -> Option {
+ if used.saturating_add(most) > MAX_SYMBOL_NODES {
+ return None;
+ }
+ let (art, placed) = place();
+ let total = used.saturating_add(placed);
+ if total > MAX_SYMBOL_NODES {
+ return None;
+ }
+ *used = total;
+ Some(art)
+}
+
+/// The effects that paint past the edges of what an object draws without reshaping it: drop
+/// shadow, outer glow and Gaussian blur. [`NineSliceMap::place`] applies them once to all the
+/// cells of art that crosses the guides.
+pub const PAINTED_EFFECTS: [&str; 3] = ["stylize.dropShadow", "stylize.outerGlow", "blur.gaussian"];
+
+/// How far an instance's scale on an axis may be from 1 and still leave its art unchanged.
+const SCALE_EPS: f64 = 1e-9;
+
+/// How deep [`NineSliceMap::place`] follows groups and opacity masks. Art deeper than this is
+/// placed as one piece ([`NineSliceMap::place`] says how).
+const MAX_SLICE_DEPTH: u32 = 64;
+
+/// One axis of a 9-slice map: the three cells between the cuts and where each one goes.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct AxisMap {
+ /// The cuts in the art at its natural size: its start, the two guides, its end.
+ src: [f64; 4],
+ /// Where the cuts go. The outer cells keep their length unless the scaled length is shorter
+ /// than both together; then both shrink in proportion and the middle cell is empty.
+ dst: [f64; 4],
+}
+
+impl AxisMap {
+ /// The axis from `lo` to `hi` with guides `near` and `far` (fractions of its length in from
+ /// each end), scaled by `k` (above 0).
+ fn new(lo: f64, hi: f64, near: f64, far: f64, k: f64) -> Self {
+ let len = hi - lo;
+ let (a, b) = (near * len, far * len);
+ let src = [lo, lo + a, hi - b, hi];
+ let (t0, t3) = (k * lo, k * hi);
+ let room = t3 - t0;
+ let (a, b) = if a + b <= room {
+ (a, b)
+ } else {
+ let f = (room / (a + b)).max(0.0);
+ (a * f, b * f)
+ };
+ let t1 = t0 + a;
+ let t2 = (t3 - b).max(t1);
+ Self { src, dst: [t0, t1, t2, t3] }
+ }
+
+ /// The cell `v` lies in; values outside the art belong to the outer cells.
+ fn cell(&self, v: f64) -> usize {
+ if v < self.src[1] {
+ 0
+ } else if v < self.src[2] {
+ 1
+ } else {
+ 2
+ }
+ }
+
+ /// The first and last cells that the span `lo..hi` reaches. A span that ends on a guide does
+ /// not reach the cell past it.
+ fn cells(&self, lo: f64, hi: f64) -> [usize; 2] {
+ let eps = 1e-9 * (self.src[3] - self.src[0]).abs().max(1.0);
+ let first = if lo >= self.src[2] - eps {
+ 2
+ } else if lo >= self.src[1] - eps {
+ 1
+ } else {
+ 0
+ };
+ let last = if hi <= self.src[1] + eps {
+ 0
+ } else if hi <= self.src[2] + eps {
+ 1
+ } else {
+ 2
+ };
+ [first, last.max(first)]
+ }
+
+ /// The part of `lo..hi` in cell `i`, the outer cells reaching past the art; `None` when it is
+ /// empty.
+ fn part(&self, i: usize, lo: f64, hi: f64) -> Option<(f64, f64)> {
+ let start = if i == 0 { lo } else { lo.max(self.src[i]) };
+ let end = if i == 2 { hi } else { hi.min(self.src[i + 1]) };
+ (start < end).then_some((start, end))
+ }
+
+ /// Cell `i`'s map, as (scale, offset).
+ fn linear(&self, i: usize) -> (f64, f64) {
+ let (s0, s1, d0, d1) = (self.src[i], self.src[i + 1], self.dst[i], self.dst[i + 1]);
+ let k = if (s1 - s0).abs() > 1e-12 { (d1 - d0) / (s1 - s0) } else { 1.0 };
+ (k, d0 - s0 * k)
+ }
+
+ fn map(&self, v: f64) -> f64 {
+ let (k, o) = self.linear(self.cell(v));
+ v * k + o
+ }
+}
+
+/// How an instance of a symbol with 9-slice guides places its art: a map from symbol space to
+/// document space that is affine within each of the nine cells and continuous across the guides.
+/// The guides cut the art along the symbol's own axes; the instance's rotation, shear, reflection
+/// and position apply after the cells are sized (VectorCraft's own rule).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct NineSliceMap {
+ /// Symbol space → the art at its natural size (an axis-aligned scale).
+ natural: Affine,
+ x: AxisMap,
+ y: AxisMap,
+ /// The instance's transform without its scale along the symbol's axes.
+ outer: Affine,
+ /// Whether [`Self::place`] draws art that crosses a guide in clip groups, one per cell.
+ clips: bool,
+}
+
+impl NineSliceMap {
+ /// The map for an instance placed by `xf` that shows art with natural-size transform
+ /// `natural` and geometric bounds `art` (in symbol space). `None` when the art or the
+ /// transform is degenerate, or when the instance is at its natural size on both axes (then
+ /// `xf` places the art as it is).
+ pub fn new(guides: SymbolNineSlice, art: Rect, natural: Affine, xf: Affine) -> Option {
+ let b = natural.transform_rect_bbox(art);
+ if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= 1e-9 || b.height() <= 1e-9 {
+ return None;
+ }
+ if natural.determinant().abs() < 1e-18 {
+ return None;
+ }
+ let m = (xf * natural.inverse()).as_coeffs();
+ if !m.into_iter().all(f64::is_finite) {
+ return None;
+ }
+ // The linear part as (rotation or reflection) · (shear along the x axis) · (scale along the
+ // symbol's axes): x scales by the length of the first column, y by the extent of the second
+ // column across it, so a shear along x alone scales neither axis. A shear along y alone
+ // keeps the second column's length and the area: it is the natural size too.
+ let kx = m[0].hypot(m[1]);
+ if kx <= 1e-12 {
+ return None;
+ }
+ let (u, det) = ((m[0] / kx, m[1] / kx), m[0] * m[3] - m[1] * m[2]);
+ if (m[2].hypot(m[3]) - 1.0).abs() < SCALE_EPS && (det.abs() - 1.0).abs() < SCALE_EPS {
+ return None;
+ }
+ let v = if det < 0.0 { (u.1, -u.0) } else { (-u.1, u.0) };
+ let ky = m[2] * v.0 + m[3] * v.1;
+ if ky <= 1e-12 || !ky.is_finite() || ((kx - 1.0).abs() < SCALE_EPS && (ky - 1.0).abs() < SCALE_EPS) {
+ return None;
+ }
+ let h = (m[2] * u.0 + m[3] * u.1) / ky;
+ let outer = Affine::new([u.0, u.1, v.0 + h * u.0, v.1 + h * u.1, m[4], m[5]]);
+ let x = AxisMap::new(b.x0, b.x1, guides.left, guides.right, kx);
+ let y = AxisMap::new(b.y0, b.y1, guides.top, guides.bottom, ky);
+ Some(Self { natural, x, y, outer, clips: true })
+ }
+
+ /// Where a point of the symbol's art draws.
+ pub fn map_point(&self, p: Point) -> Point {
+ let q = self.natural * p;
+ self.outer * Point::new(self.x.map(q.x), self.y.map(q.y))
+ }
+
+ /// The affine map of the cell in column `i` and row `j`, from the art at its natural size to
+ /// document space.
+ fn cell_local(&self, i: usize, j: usize) -> Affine {
+ let (kx, ox) = self.x.linear(i);
+ let (ky, oy) = self.y.linear(j);
+ self.outer * Affine::new([kx, 0.0, 0.0, ky, ox, oy])
+ }
+
+ /// The affine map of the cell in column `i` and row `j`: symbol space → document space.
+ fn cell_xf(&self, i: usize, j: usize) -> Affine {
+ self.cell_local(i, j) * self.natural
+ }
+
+ /// The affine map of the cell that symbol-space point `p` lies in.
+ pub fn cell_at(&self, p: Point) -> Affine {
+ let q = self.natural * p;
+ self.cell_xf(self.x.cell(q.x), self.y.cell(q.y))
+ }
+
+ /// 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();
+ let a = inv * Point::new(self.x.src[1], self.y.src[1]);
+ let b = inv * Point::new(self.x.src[2], self.y.src[2]);
+ ([a.x, b.x], [a.y, b.y])
+ }
+
+ /// `path` (in symbol space) in document space. Each segment is cut where it crosses a guide
+ /// (at most 12 cuts a segment) and each piece is placed by its cell's map, so the path's
+ /// anchors and curves follow the map exactly. The result is still one path: a stroke along it
+ /// is drawn once, without joins or caps at the guides.
+ pub fn map_path(&self, path: &PathData) -> PathData {
+ let (xs, ys) = self.cuts();
+ let mut subs = Vec::with_capacity(path.subpaths.len());
+ for sp in &path.subpaths {
+ let n = sp.anchors.len();
+ let Some(first) = sp.anchors.first() else { continue };
+ let a0 = self.cell_at(first.p);
+ let mut res = vec![Anchor { p: a0 * first.p, h_in: a0 * first.h_in, h_out: a0 * first.h_out, kind: first.kind }];
+ for i in 0..sp.segment_count() {
+ let line = sp.segment_is_line(i);
+ let pieces = split(sp.segment(i), &xs, &ys);
+ let count = pieces.len();
+ let mut end_in = None;
+ for (j, piece) in pieces.into_iter().enumerate() {
+ let a = self.cell_at(piece.eval(0.5));
+ let (p1, p2, p3) = (a * piece.p1, a * piece.p2, a * piece.p3);
+ if let Some(prev) = res.last_mut() {
+ prev.h_out = if line { prev.p } else { p1 };
+ }
+ let h_in = if line { p3 } else { p2 };
+ if j + 1 < count {
+ res.push(Anchor { p: p3, h_in, h_out: p3, kind: Default::default() });
+ } else {
+ end_in = Some(h_in);
+ }
+ }
+ let end = (i + 1) % n;
+ let b = &sp.anchors[end];
+ let ab = self.cell_at(b.p);
+ let h_in = end_in.unwrap_or(ab * b.p);
+ if end == 0 {
+ res[0].h_in = h_in;
+ } else {
+ res.push(Anchor { p: ab * b.p, h_in, h_out: ab * b.h_out, kind: b.kind });
+ }
+ }
+ subs.push(SubPath::new(res, sp.closed));
+ }
+ PathData::new(subs)
+ }
+
+ /// Symbol art `art` (in symbol space) placed in document space, its instances measured with
+ /// the symbols of `doc`. VectorCraft's own rules:
+ ///
+ /// - An object whose geometric box lies in one cell is placed by that cell's map, so an
+ /// object in a corner keeps its size and stays as it is (live shapes, type and images
+ /// included). A stroke that reaches past a guide is drawn with its path, at its weight.
+ /// - A path with solid or no paint that crosses a guide is cut at the guides
+ /// ([`Self::map_path`]); a live shape among them becomes a plain path.
+ /// - Any other object that crosses a guide (type, images, live objects, paths painted with a
+ /// gradient or pattern) is drawn once in each cell it reaches, placed by that cell's map and
+ /// clipped to the cell, so its paint lines up across the guides.
+ /// - Such an object that draws symbol art ([`draws_symbol_art`]: a symbol instance, type with
+ /// an inline graphic, a placed document, a brushed stroke, or a pattern whose tile draws any
+ /// of these, in it or in its opacity masks) is placed by the map of the cell its center is
+ /// in, and is not copied into each cell. So is art nested more than 64 levels deep that
+ /// draws symbol art; other such art is clipped to the cells as above. A path with solid or
+ /// no paint is cut at the guides whatever its strokes' brushes, and stays one path.
+ ///
+ /// Strokes keep their weight, as they do for any instance.
+ pub fn place(&self, art: &Node, doc: &Document) -> Node {
+ let boxes = doc.symbol_boxes().quick();
+ self.place_at(art, &boxes, 0)
+ }
+
+ /// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF):
+ /// every path that crosses a guide is cut at the guides whatever its paint, and any other
+ /// object that crosses one is placed by the map of the cell its center is in.
+ pub fn place_unclipped(&self, art: &Node, doc: &Document) -> Node {
+ Self { clips: false, ..*self }.place(art, doc)
+ }
+
+ /// The most objects [`Self::place`] makes of `art`, as [`Node::count_with_masks`] counts them
+ /// (the art of opacity masks included), found without placing it: an object whose box lies in
+ /// one cell counts its own objects, a path cut at the guides counts 1 and its mask, and an
+ /// object drawn in clip groups counts one group plus a clip path and a copy for each cell its
+ /// box reaches. That is never more than [`MAX_SLICE_GROWTH`] for each object of `art`.
+ pub fn most_placed(&self, art: &Node, doc: &Document) -> u64 {
+ let boxes = doc.symbol_boxes().quick();
+ self.most_at(art, &boxes, 0)
+ }
+
+ /// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`].
+ fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 {
+ let whole = n.count_with_masks() as u64;
+ let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
+ return whole;
+ };
+ let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1));
+ if cols[0] == cols[1] && rows[0] == rows[1] {
+ return whole;
+ }
+ if depth >= MAX_SLICE_DEPTH {
+ return self.most_clipped(n, boxes, b);
+ }
+ match &n.kind {
+ NodeKind::Group { .. } | NodeKind::Layer { .. } => self.most_container(n, boxes, depth),
+ NodeKind::Compound { .. } if !self.clips || plain_paint(n) => self.most_container(n, boxes, depth),
+ NodeKind::Path { .. } if !self.clips || plain_paint(n) => self.most_mask(n, boxes, depth).saturating_add(1),
+ _ => self.most_clipped(n, boxes, b),
+ }
+ }
+
+ /// [`Self::most_placed`] for a container placed by [`Self::container`].
+ fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 {
+ let own = self.most_mask(n, boxes, depth).saturating_add(1);
+ n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1)))
+ }
+
+ /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a
+ /// linked one through the map, an unlinked one as it is.
+ fn most_mask(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 {
+ match n.extras().mask.as_ref() {
+ Some(m) if m.linked => self.most_at(&m.art, boxes, depth + 1),
+ Some(m) => m.art.count_with_masks() as u64,
+ None => 0,
+ }
+ }
+
+ /// [`Self::most_placed`] for an object placed by [`Self::clipped`], whose box at the natural
+ /// size is `b`.
+ fn most_clipped(&self, n: &Node, boxes: &SymbolBoxes, b: Rect) -> u64 {
+ let whole = n.count_with_masks() as u64;
+ if !self.clips || draws_symbol_art(n, boxes.document()) {
+ return whole;
+ }
+ let (cols, rows) = self.clip_cells(n, boxes, b).0;
+ let cells = ((cols[1] - cols[0] + 1) * (rows[1] - rows[0] + 1)) as u64;
+ cells.saturating_mul(whole.saturating_add(2)).saturating_add(1)
+ }
+
+ /// The columns and rows that [`Self::clipped`] draws `n` in, and the box it clips: `n`'s visual
+ /// box at the natural size joined with its geometric box `b`.
+ fn clip_cells(&self, n: &Node, boxes: &SymbolBoxes, b: Rect) -> (([usize; 2], [usize; 2]), Rect) {
+ let v = n.visual(Some(boxes)).map(|v| self.natural.transform_rect_bbox(v)).filter(|v| finite(*v)).map_or(b, |v| v.union(b));
+ ((self.x.cells(v.x0, v.x1), self.y.cells(v.y0, v.y1)), v)
+ }
+
+ fn place_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> Node {
+ let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
+ return transformed(n, self.cell_at(Point::ZERO));
+ };
+ let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1));
+ if cols[0] == cols[1] && rows[0] == rows[1] {
+ return transformed(n, self.cell_xf(cols[0], rows[0]));
+ }
+ let center = self.cell_xf(self.x.cell(b.center().x), self.y.cell(b.center().y));
+ if depth >= MAX_SLICE_DEPTH {
+ return self.clipped(n, boxes, b, center);
+ }
+ match &n.kind {
+ NodeKind::Group { .. } | NodeKind::Layer { .. } => self.container(n, boxes, depth, center),
+ NodeKind::Compound { .. } if !self.clips || plain_paint(n) => self.container(n, boxes, depth, center),
+ NodeKind::Path { path: src, .. } if !self.clips || plain_paint(n) => {
+ let mut out = n.clone();
+ out.transform(center, false);
+ if let NodeKind::Path { path, live, .. } = &mut out.kind {
+ *path = self.map_path(src);
+ *live = None;
+ }
+ self.place_mask(n, &mut out, boxes, depth);
+ out
+ }
+ _ => self.clipped(n, boxes, b, center),
+ }
+ }
+
+ /// Group, layer or compound path `n`: its members placed one by one, the container itself
+ /// (its gradients and frames) by `center`.
+ fn container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32, center: Affine) -> Node {
+ let mut out = n.clone();
+ let kids = out.children_mut().map(std::mem::take).unwrap_or_default();
+ out.transform(center, false);
+ if let Some(children) = out.children_mut() {
+ *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect();
+ }
+ self.place_mask(n, &mut out, boxes, depth);
+ out
+ }
+
+ /// The linked opacity mask of `src` placed through the map into `out`; an unlinked one stays
+ /// where it is, as [`Node::transform`] leaves it.
+ fn place_mask(&self, src: &Node, out: &mut Node, boxes: &SymbolBoxes, depth: u32) {
+ if let Some(m) = src.extras().mask.as_ref().filter(|m| m.linked)
+ && let Some(dst) = out.extras_if_any().and_then(|e| e.mask.as_mut())
+ {
+ dst.art = Arc::new(self.place_at(&m.art, boxes, depth + 1));
+ }
+ }
+
+ /// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of
+ /// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed
+ /// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`].
+ ///
+ /// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own
+ /// effects, after its last other visible effect, go on that group with its opacity, blending
+ /// mode and isolation, and the group applies them to the copies together: they reach past the
+ /// outer cells instead of being clipped to them. Its other effects stay on each copy, in their
+ /// order before the moved ones, so effects that reshape it do not reshape the cells' clip
+ /// paths, and effects that follow its outline follow the copy's.
+ fn clipped(&self, n: &Node, boxes: &SymbolBoxes, b: Rect, center: Affine) -> Node {
+ if !self.clips || draws_symbol_art(n, boxes.document()) {
+ return transformed(n, center);
+ }
+ let mut piece = n.clone();
+ let own = &mut piece.appearance.effects;
+ let keep = own.iter().rposition(|e| e.visible && !PAINTED_EFFECTS.contains(&e.id.as_str())).map_or(0, |i| i + 1);
+ let effects = own.split_off(keep);
+ let (opacity, blend, isolate) = (piece.opacity, piece.blend, piece.isolate);
+ if !effects.is_empty() {
+ (piece.opacity, piece.blend, piece.isolate) = (1.0, vectorcraft_color::BlendMode::Normal, false);
+ }
+ let n = &piece;
+ let ((cols, rows), v) = self.clip_cells(n, boxes, b);
+ let mut cells = Vec::with_capacity(9);
+ for j in rows[0]..=rows[1] {
+ for i in cols[0]..=cols[1] {
+ let (Some((x0, x1)), Some((y0, y1))) = (self.x.part(i, v.x0, v.x1), self.y.part(j, v.y0, v.y1)) else { continue };
+ let local = self.cell_local(i, j);
+ if local.determinant().abs() < 1e-18 {
+ continue;
+ }
+ let mut area = shapes::rectangle(Rect::new(x0, y0, x1, y1));
+ area.transform(local);
+ let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false });
+ clip.appearance = Appearance::default().into();
+ let copy = transformed(n, local * self.natural);
+ cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true })));
+ }
+ }
+ let mut out = Node::group(n.id, cells);
+ out.visible = n.visible;
+ out.locked = n.locked;
+ out.name = n.name.clone();
+ out.knockout_shape = n.knockout_shape;
+ if !effects.is_empty() {
+ out.appearance.effects = effects;
+ (out.opacity, out.blend, out.isolate) = (opacity, blend, isolate);
+ }
+ out
+ }
+}
+
+/// `n` moved by `a`, its strokes at their own weight.
+fn transformed(n: &Node, a: Affine) -> Node {
+ let mut out = n.clone();
+ out.transform(a, false);
+ out
+}
+
+fn finite(b: Rect) -> bool {
+ [b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite)
+}
+
+/// Whether `n`'s own fills and strokes are solid or empty (no gradient or pattern).
+fn plain_paint(n: &Node) -> bool {
+ n.appearance.items.iter().all(|i| matches!(i.paint(), Paint::None | Paint::Solid { .. }))
+}
+
+/// Whether drawing `n` draws the art of a symbol: `n`, a member of it (the members and sources of
+/// live objects included) or the art of an opacity mask in it is a symbol instance, type with an
+/// inline graphic, a placed document (whose art may hold instances), an object with a visible
+/// brushed stroke (whose brush art may hold instances), or is painted with a pattern of `doc`
+/// whose tile draws symbol art (by a visible fill or stroke, or by its characters). Each
+/// pattern's tile is looked at once, without recursion.
+fn draws_symbol_art(n: &Node, doc: &Document) -> bool {
+ let mut seen = std::collections::HashSet::new();
+ let mut stack = vec![n];
+ while let Some(n) = stack.pop() {
+ let draws = match &n.kind {
+ NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => true,
+ NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()),
+ _ => false,
+ };
+ let brushed = n.appearance.items.iter().any(|i| matches!(i, crate::AppearanceItem::Stroke(s) if s.visible && s.brush.is_some()));
+ if draws || brushed {
+ return true;
+ }
+ let runs = match &n.kind {
+ NodeKind::Text(t) => t.runs.as_slice(),
+ _ => &[],
+ };
+ let items = n.appearance.items.iter().filter(|i| i.visible()).map(|i| i.paint());
+ for p in items.chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) {
+ if let Paint::Pattern { pattern, .. } = p
+ && seen.insert(pattern.as_str())
+ && let Some(def) = doc.pattern(pattern)
+ {
+ stack.extend(def.art.iter().map(|a| &**a));
+ }
+ }
+ stack.extend(n.children().into_iter().flatten().map(|c| &**c));
+ stack.extend(n.extras().mask.as_ref().map(|m| &*m.art));
+ }
+ false
+}
+
+/// The parameters in (0, 1) where `c` crosses one of the lines x = `xs` or y = `ys`, in order.
+fn crossings(c: CubicBez, xs: &[f64; 2], ys: &[f64; 2]) -> Vec {
+ // Power-basis coefficients of one coordinate of the curve.
+ let coeffs = |p0: f64, p1: f64, p2: f64, p3: f64| [p0, 3.0 * (p1 - p0), 3.0 * (p2 - 2.0 * p1 + p0), p3 - 3.0 * p2 + 3.0 * p1 - p0];
+ let cx = coeffs(c.p0.x, c.p1.x, c.p2.x, c.p3.x);
+ let cy = coeffs(c.p0.y, c.p1.y, c.p2.y, c.p3.y);
+ let mut ts = vec![];
+ for (k, cuts) in [(cx, xs), (cy, ys)] {
+ for v in cuts {
+ for t in vectorcraft_geom::kurbo::common::solve_cubic(k[0] - v, k[1], k[2], k[3]) {
+ if t.is_finite() && t > 1e-9 && t < 1.0 - 1e-9 {
+ ts.push(t);
+ }
+ }
+ }
+ }
+ ts.sort_by(f64::total_cmp);
+ ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
+ ts
+}
+
+/// `c` cut at every crossing of a guide.
+fn split(c: CubicBez, xs: &[f64; 2], ys: &[f64; 2]) -> Vec {
+ let ts = crossings(c, xs, ys);
+ if ts.is_empty() {
+ return vec![c];
+ }
+ let mut out = Vec::with_capacity(ts.len() + 1);
+ let mut t0 = 0.0;
+ for t in ts.into_iter().chain([1.0]) {
+ out.push(c.subsegment(t0..t));
+ t0 = t;
+ }
+ out
+}
+
+impl Document {
+ /// The 9-slice map for an instance of symbol `name` placed by `xf` (see [`NineSliceMap::new`]):
+ /// `None` when the symbol has no guides or the instance is at its natural size. The guides cut
+ /// the art's box with the instances in it measured by their own art.
+ pub fn symbol_slice_map(&self, name: &str, xf: Affine) -> Option {
+ let guides = self.symbol_nine_slice(name)?;
+ let sym = self.symbols.iter().find(|s| s.name == name)?;
+ let art = self.symbol_boxes().quick().art_bounds(name).or_else(|| sym.art.geometric_bounds())?;
+ NineSliceMap::new(guides, 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
+ /// the guides could make more than [`MAX_SYMBOL_NODES`] objects ([`NineSliceMap::most_placed`]).
+ pub fn place_symbol_art(&self, name: &str, art: &Node, xf: Affine) -> Option {
+ match self.symbol_slice_map(name, xf) {
+ Some(m) if m.most_placed(art, self) > MAX_SYMBOL_NODES => None,
+ Some(m) => Some(m.place(art, self)),
+ None => Some(transformed(art, xf)),
+ }
+ }
+
+ /// [`Self::place_symbol_art`] for writers that do not apply clipping
+ /// ([`NineSliceMap::place_unclipped`]).
+ pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option {
+ match self.symbol_slice_map(name, xf) {
+ Some(m) => {
+ let m = NineSliceMap { clips: false, ..m };
+ (m.most_placed(art, self) <= MAX_SYMBOL_NODES).then(|| m.place(art, self))
+ }
+ None => Some(transformed(art, xf)),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
+ use vectorcraft_geom::{Vec2, shapes};
+
+ use super::*;
+ use crate::{Appearance, ImageObject, OpacityMask, PatternDef, PlacedDocument, SYMBOL_SIZES, Symbol};
+
+ fn square(d: &mut Document, r: Rect, stroke: f64) -> Node {
+ let s = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None };
+ Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::BLACK), s, stroke))
+ }
+
+ /// A 100 × 100 symbol "S": a 10 × 10 square in each corner, one in the center and one at the
+ /// middle of the top edge, inside a frame with no paint that gives the art its bounds.
+ fn markers(guides: Option) -> Document {
+ let mut d = Document::new(400.0, 400.0);
+ let rects = [
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ Rect::new(0.0, 0.0, 10.0, 10.0),
+ Rect::new(90.0, 0.0, 100.0, 10.0),
+ Rect::new(0.0, 90.0, 10.0, 100.0),
+ Rect::new(90.0, 90.0, 100.0, 100.0),
+ Rect::new(45.0, 45.0, 55.0, 55.0),
+ Rect::new(45.0, 0.0, 55.0, 10.0),
+ ];
+ let art: Vec> = rects.iter().map(|r| Arc::new(square(&mut d, *r, 0.0))).collect();
+ let group = Node::group(d.alloc_id(), art);
+ d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(group) });
+ d.set_symbol_nine_slice("S", guides);
+ d
+ }
+
+ fn placed(d: &Document, xf: Affine) -> Node {
+ d.place_symbol_art("S", &d.symbols[0].art, xf).unwrap()
+ }
+
+ /// The geometric boxes of the placed art's members.
+ fn boxes(d: &Document, xf: Affine) -> Vec {
+ placed(d, xf).children().map(|c| c.iter().filter_map(|c| c.geometric_bounds()).collect()).unwrap_or_default()
+ }
+
+ fn near(a: Rect, b: Rect) -> bool {
+ [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 1e-9)
+ }
+
+ fn assert_boxes(got: &[Rect], want: &[Rect]) {
+ assert_eq!(got.len(), want.len(), "{got:?}");
+ for (g, w) in got.iter().zip(want) {
+ assert!(near(*g, *w), "{g:?} vs {w:?} in {got:?}");
+ }
+ }
+
+ /// The guides sit a quarter in by default. At 200 × 150 the corners stay 10 × 10, the center
+ /// marker becomes 30 × 20 and the top marker stretches in width only.
+ #[test]
+ fn a_scaled_instance_keeps_its_corners_and_stretches_between_the_guides() {
+ assert_eq!(SymbolNineSlice::default(), SymbolNineSlice { left: 0.25, top: 0.25, right: 0.25, bottom: 0.25 });
+ let d = markers(Some(SymbolNineSlice::default()));
+ let got = boxes(&d, Affine::translate((20.0, 30.0)) * Affine::scale_non_uniform(2.0, 1.5));
+ let want = [
+ Rect::new(0.0, 0.0, 200.0, 150.0),
+ Rect::new(0.0, 0.0, 10.0, 10.0),
+ Rect::new(190.0, 0.0, 200.0, 10.0),
+ Rect::new(0.0, 140.0, 10.0, 150.0),
+ Rect::new(190.0, 140.0, 200.0, 150.0),
+ Rect::new(85.0, 65.0, 115.0, 85.0),
+ Rect::new(85.0, 0.0, 115.0, 10.0),
+ ]
+ .map(|r| r + Vec2::new(20.0, 30.0));
+ assert_boxes(&got, &want);
+ // Asymmetric guides: 10 % from the left, 40 % from the right.
+ let d = markers(SymbolNineSlice::new(0.1, 0.25, 0.4, 0.25));
+ let got = boxes(&d, Affine::scale_non_uniform(2.0, 1.0));
+ // The middle column runs from 10 to 60 and is stretched to 10..160 (3 times).
+ assert!(near(got[5], Rect::new(10.0 + 35.0 * 3.0, 45.0, 10.0 + 45.0 * 3.0, 55.0)), "{:?}", got[5]);
+ assert!(near(got[2], Rect::new(190.0, 0.0, 200.0, 10.0)), "{:?}", got[2]);
+ }
+
+ /// Without guides, or at the natural size, the transform places the art as it is.
+ #[test]
+ fn without_guides_or_scale_the_art_is_placed_by_the_transform() {
+ let xf = Affine::scale_non_uniform(2.0, 1.5);
+ let d = markers(None);
+ assert!(d.symbol_slice_map("S", xf).is_none());
+ assert!(near(boxes(&d, xf)[1], Rect::new(0.0, 0.0, 20.0, 15.0)));
+ let d = markers(Some(SymbolNineSlice::default()));
+ let moved = Affine::translate((5.0, 6.0)) * Affine::rotate(0.5);
+ assert!(d.symbol_slice_map("S", moved).is_none());
+ assert!(d.symbol_slice_map("S", Affine::scale(0.0)).is_none());
+ assert!(d.symbol_slice_map("S", Affine::new([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0])).is_none());
+ assert!(d.symbol_slice_map("S", Affine::new([f64::INFINITY, 0.0, 0.0, 1.0, 0.0, 0.0])).is_none());
+ assert!(d.symbol_slice_map("Missing", xf).is_none());
+ }
+
+ /// At 30 × 30 the 25 + 25 of the outer cells do not fit: both shrink in proportion (to 15) and
+ /// the center is empty, never turned inside out.
+ #[test]
+ fn a_target_smaller_than_the_outer_cells_shrinks_them() {
+ let d = markers(Some(SymbolNineSlice::default()));
+ let got = boxes(&d, Affine::scale(0.3));
+ assert!(near(got[1], Rect::new(0.0, 0.0, 6.0, 6.0)), "{:?}", got[1]);
+ assert!(near(got[4], Rect::new(24.0, 24.0, 30.0, 30.0)), "{:?}", got[4]);
+ let c = got[5];
+ assert!(c.width().abs() < 1e-9 && c.height().abs() < 1e-9 && near(c, Rect::new(15.0, 15.0, 15.0, 15.0)), "{c:?}");
+ for b in boxes(&d, Affine::scale(1e-6)) {
+ assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() >= 0.0 && b.height() >= 0.0, "{b:?}");
+ }
+ // Zero-width guides on one side leave that side's corners out of the outer cells.
+ let d = markers(SymbolNineSlice::new(0.0, 0.25, 0.25, 0.0));
+ let got = boxes(&d, Affine::scale(2.0));
+ assert!(near(got[0], Rect::new(0.0, 0.0, 200.0, 200.0)), "{:?}", got[0]);
+ assert!(got.iter().all(|b| finite(*b)), "{got:?}");
+ }
+
+ /// A negative scale reflects the sized cells; a rotation turns them, the corners keeping
+ /// their size along the symbol's own axes.
+ #[test]
+ fn reflection_and_rotation_apply_after_the_cells_are_sized() {
+ let d = markers(Some(SymbolNineSlice::default()));
+ let got = boxes(&d, Affine::scale_non_uniform(-2.0, 1.0));
+ // The top-left corner square is now at the right, still 10 wide; the center is 30 wide.
+ assert!(near(got[1], Rect::new(-10.0, 0.0, 0.0, 10.0)), "{:?}", got[1]);
+ assert!(near(got[5], Rect::new(-115.0, 45.0, -85.0, 55.0)), "{:?}", got[5]);
+ // Reflected top to bottom (a negative determinant): the corner square stays 10 × 10.
+ let got = boxes(&d, Affine::scale_non_uniform(2.0, -1.5));
+ assert!(near(got[1], Rect::new(0.0, -10.0, 10.0, 0.0)), "{:?}", got[1]);
+ assert!(near(got[5], Rect::new(85.0, -85.0, 115.0, -65.0)), "{:?}", got[5]);
+ let turn = Affine::translate((50.0, 50.0)) * Affine::rotate(std::f64::consts::FRAC_PI_6);
+ let m = d.symbol_slice_map("S", turn * Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let o = m.map_point(Point::ORIGIN);
+ assert!((o - Point::new(50.0, 50.0)).hypot() < 1e-9);
+ assert!(((m.map_point(Point::new(10.0, 0.0)) - o).hypot() - 10.0).abs() < 1e-9);
+ assert!(((m.map_point(Point::new(0.0, 10.0)) - o).hypot() - 10.0).abs() < 1e-9);
+ assert!(((m.map_point(Point::new(100.0, 0.0)) - o).hypot() - 200.0).abs() < 1e-9);
+ assert!(((m.map_point(Point::new(0.0, 100.0)) - o).hypot() - 150.0).abs() < 1e-9);
+ }
+
+ /// A sheared instance sizes the cells along the symbol's axes before the shear: the corner is
+ /// the unscaled corner sheared, the art's outer corners land where the instance's transform
+ /// puts them, and the map is continuous at the guides. A shear alone scales nothing.
+ #[test]
+ fn a_sheared_instance_keeps_its_corners_along_the_symbols_axes() {
+ let d = markers(Some(SymbolNineSlice::default()));
+ let shear = Affine::new([1.0, 0.0, 0.5, 1.0, 10.0, 20.0]);
+ assert!(d.symbol_slice_map("S", shear).is_none());
+ assert!(d.symbol_slice_map("S", Affine::new([1.0, 1.0, 0.0, 1.0, 0.0, 0.0])).is_none(), "a shear along y");
+ // A turn by -60° of the shear along y by √3: (1, 0) goes to (2, 0), (0, 1) to (√0.75, 0.5).
+ let turned = Affine::rotate(-std::f64::consts::FRAC_PI_3) * Affine::new([1.0, 3f64.sqrt(), 0.0, 1.0, 0.0, 0.0]);
+ assert!((turned * Point::new(1.0, 0.0) - Point::new(2.0, 0.0)).hypot() < 1e-9, "{turned:?}");
+ assert!(d.symbol_slice_map("S", turned).is_none());
+ let xf = shear * Affine::scale_non_uniform(2.0, 1.5);
+ let m = d.symbol_slice_map("S", xf).unwrap();
+ for p in [Point::new(10.0, 0.0), Point::new(0.0, 10.0), Point::new(10.0, 10.0)] {
+ assert!((m.map_point(p) - shear * p).hypot() < 1e-9, "{p:?}");
+ }
+ for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0)] {
+ assert!((m.map_point(p) - xf * p).hypot() < 1e-9, "{p:?}");
+ }
+ for g in [25.0, 75.0] {
+ let before = m.map_point(Point::new(g - 1e-7, 40.0));
+ let after = m.map_point(Point::new(g + 1e-7, 40.0));
+ assert!((before - after).hypot() < 1e-5, "{g}");
+ }
+ }
+
+ /// A path that crosses the guides is cut where it crosses them, each piece placed by its cell;
+ /// its stroke keeps its weight and its live shape becomes a plain path.
+ #[test]
+ fn paths_across_the_guides_are_cut_at_them() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let diagonal = Node::path(
+ d.alloc_id(),
+ shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 100.0)),
+ Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0),
+ );
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let placed = m.place(&diagonal, &d);
+ let pts: Vec = placed.path_data().unwrap().anchors().map(|(_, _, a)| a.p).collect();
+ let want = [Point::new(0.0, 0.0), Point::new(25.0, 25.0), Point::new(175.0, 125.0), Point::new(200.0, 150.0)];
+ assert_eq!(pts.len(), want.len(), "{pts:?}");
+ for (p, w) in pts.iter().zip(want) {
+ assert!((*p - w).hypot() < 1e-9, "{pts:?}");
+ }
+ assert_eq!(placed.appearance.stroke().map(|s| s.width), Some(4.0));
+ // A closed curve crossing a guide keeps its shape on each side of it.
+ let circle = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(10.0, 10.0, 50.0, 40.0)), Appearance::default());
+ let placed = m.place(&circle, &d);
+ let b = placed.geometric_bounds().unwrap();
+ // The middle column is stretched 3 times and the middle row 2 times past the guides at 25.
+ assert!(near(b, Rect::new(10.0, 10.0, 25.0 + 25.0 * 3.0, 25.0 + 15.0 * 2.0)), "{b:?}");
+ // The two segments that cross the guide at x = 25 between their anchors are cut there.
+ assert_eq!(placed.path_data().unwrap().anchor_count(), 6, "{placed:?}");
+ for (_, _, a) in placed.path_data().unwrap().anchors() {
+ assert!([a.p.x, a.p.y, a.h_in.x, a.h_out.y].iter().all(|v| v.is_finite()));
+ }
+ assert!(placed.path_data().unwrap().is_closed());
+ // A path with points that are not finite gives a path, not a crash.
+ let bad = Node::path(d.alloc_id(), shapes::line(Point::new(f64::NAN, 0.0), Point::new(100.0, f64::INFINITY)), Appearance::default());
+ let _ = m.place(&bad, &d);
+ }
+
+ /// A linked opacity mask is placed through the guides with its object; an unlinked one stays
+ /// where it is, as any transform leaves it.
+ #[test]
+ fn linked_masks_follow_the_guides_and_unlinked_ones_stay() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let mask_art = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
+ for linked in [true, false] {
+ let mut mask = crate::OpacityMask::new(mask_art.clone(), true);
+ mask.linked = linked;
+ let bar = square(&mut d, Rect::new(0.0, 40.0, 100.0, 60.0), 0.0).with_mask(Some(Box::new(mask)));
+ let p = m.place(&bar, &d);
+ let got = p.extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap();
+ let want = if linked { Rect::new(0.0, 0.0, 200.0, 150.0) } else { Rect::new(0.0, 0.0, 100.0, 100.0) };
+ assert!(near(got, want), "linked {linked}: {got:?}");
+ }
+ }
+
+ /// An object that lies in one corner keeps its size and kind: a live rectangle stays live and
+ /// an image keeps its pixel scale.
+ #[test]
+ fn objects_in_one_cell_are_placed_by_that_cell() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let shape = crate::LiveShape::Rectangle {
+ w: 6.0,
+ h: 6.0,
+ radii: [1.0; 4],
+ kinds: Default::default(),
+ radii_relative: false,
+ xf: Affine::translate((92.0, 92.0)),
+ };
+ let mut live = Node::path(d.alloc_id(), shape.to_path(), Appearance::default());
+ if let NodeKind::Path { live: l, .. } = &mut live.kind {
+ *l = Some(Box::new(shape));
+ }
+ let image = ImageObject {
+ key: "k".into(),
+ width: 4,
+ height: 4,
+ xf: Affine::translate((2.0, 2.0)),
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ };
+ let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let p = m.place(&live, &d);
+ assert!(matches!(p.kind, NodeKind::Path { live: Some(_), .. }), "{p:?}");
+ assert!(near(p.geometric_bounds().unwrap(), Rect::new(192.0, 142.0, 198.0, 148.0)));
+ let p = m.place(&image, &d);
+ let NodeKind::Image(im) = &p.kind else { panic!("{p:?}") };
+ assert_eq!(im.xf, Affine::translate((2.0, 2.0)));
+ }
+
+ /// A gradient-filled path and an image that cross the guides are drawn in each cell they
+ /// reach, clipped to it: the pieces' clip areas tile the stretched box with no gap.
+ #[test]
+ fn other_art_across_the_guides_is_clipped_to_each_cell() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let p = m.place(&bar, &d);
+ assert_eq!(p.id, bar.id);
+ let cells = p.children().unwrap();
+ // Three columns by one row: the bar lies between the horizontal guides.
+ assert_eq!(cells.len(), 3, "{p:?}");
+ let mut width = 0.0;
+ for c in cells {
+ assert!(matches!(c.kind, NodeKind::Group { clip: true, .. }));
+ let clip = c.children().unwrap()[0].geometric_bounds().unwrap();
+ width += clip.width();
+ let copy = &c.children().unwrap()[1];
+ assert!(matches!(copy.appearance.fill().map(|f| &f.paint), Some(Paint::Gradient(_))));
+ }
+ // The clip areas cover the 200-point stretched bar and no more.
+ assert!((width - 200.0).abs() < 1e-9, "{width}");
+ assert!(near(p.geometric_bounds().unwrap(), Rect::new(0.0, 55.0, 200.0, 95.0)), "{:?}", p.geometric_bounds());
+ // An image across the vertical guides: three pieces, each its own image.
+ let image = ImageObject {
+ key: "k".into(),
+ width: 100,
+ height: 10,
+ xf: Affine::translate((0.0, 45.0)),
+ link: None,
+ placement: Default::default(),
+ pixels: None,
+ };
+ let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
+ let p = m.place(&image, &d);
+ assert!(p.count() as u64 <= MAX_SLICE_GROWTH);
+ let kids = p.children().unwrap();
+ assert_eq!(kids.len(), 3);
+ assert!(kids.iter().all(|c| matches!(c.children().unwrap()[1].kind, NodeKind::Image(_))));
+ // For writers without clipping, the bar is one path cut at the guides and the image one
+ // image placed by the center cell.
+ let p = m.place_unclipped(&bar, &d);
+ assert!(matches!(p.kind, NodeKind::Path { .. }), "{p:?}");
+ assert!(near(p.geometric_bounds().unwrap(), Rect::new(0.0, 55.0, 200.0, 95.0)), "{:?}", p.geometric_bounds());
+ assert!(matches!(m.place_unclipped(&image, &d).kind, NodeKind::Image(_)));
+ }
+
+ /// A shadowed, rounded, feathered, half-opaque, multiplied and isolated gradient bar across
+ /// the guides: its pieces keep the round corners and the feathering and are drawn without the
+ /// shadow at default transparency, and the group around them carries the shadow, the opacity,
+ /// the blending mode and the isolation.
+ #[test]
+ fn effects_on_art_across_the_guides_apply_to_all_its_cells() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ let shadow = crate::Effect { id: "stylize.dropShadow".into(), params: json!({"x": 7, "y": 7, "blur": 5}), visible: true };
+ let round = crate::Effect { id: "stylize.roundCorners".into(), params: json!({"radius": 4}), visible: true };
+ let feather = crate::Effect { id: "stylize.feather".into(), params: json!({"radius": 3}), visible: true };
+ bar.appearance.effects = vec![round.clone(), feather.clone(), shadow.clone()];
+ (bar.opacity, bar.blend, bar.isolate) = (0.5, vectorcraft_color::BlendMode::Multiply, true);
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let p = m.place(&bar, &d);
+ assert_eq!(p.appearance.effects, vec![shadow.clone()]);
+ assert_eq!((p.opacity, p.blend, p.isolate), (0.5, vectorcraft_color::BlendMode::Multiply, true));
+ let cells = p.children().unwrap();
+ assert_eq!(cells.len(), 3, "{p:?}");
+ // The copies keep the effects that reshape them or follow their outline.
+ for c in cells {
+ let [clip, copy] = c.children().unwrap().as_slice() else { panic!("{c:?}") };
+ assert!(clip.appearance.effects.is_empty(), "{clip:?}");
+ assert_eq!(copy.appearance.effects, vec![round.clone(), feather.clone()]);
+ assert!(copy.has_default_transparency(), "{copy:?}");
+ }
+ // A shadow followed by an effect that stays on the copies stays with it, in its order.
+ bar.appearance.effects = vec![shadow.clone(), round.clone()];
+ let p = m.place(&bar, &d);
+ assert!(p.appearance.effects.is_empty() && p.opacity == 1.0, "{p:?}");
+ assert!(p.children().unwrap().iter().all(|c| c.children().unwrap()[1].appearance.effects == vec![shadow.clone(), round.clone()]));
+ // A hidden effect after it does not keep it on the copies.
+ let hidden = crate::Effect { visible: false, ..round.clone() };
+ bar.appearance.effects = vec![shadow.clone(), hidden.clone()];
+ assert_eq!(m.place(&bar, &d).appearance.effects, vec![shadow.clone(), hidden]);
+ // Without effects the pieces keep the opacity.
+ bar.appearance.effects.clear();
+ let p = m.place(&bar, &d);
+ assert_eq!(p.opacity, 1.0);
+ assert!(p.children().unwrap().iter().all(|c| c.children().unwrap()[1].opacity == 0.5));
+ }
+
+ /// A gradient bar across the guides with Knockout Shape set keeps it on the group that holds
+ /// its cells, the object a knockout group reads it from.
+ #[test]
+ fn knockout_shape_on_art_across_the_guides_stays_on_its_group() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ bar.knockout_shape = true;
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let p = m.place(&bar, &d);
+ assert_eq!(p.children().map(|c| c.len()), Some(3), "{p:?}");
+ assert!(p.knockout_shape, "{p:?}");
+ }
+
+ /// A locked gradient bar across the guides stays locked: the group that holds its cells is
+ /// locked too, so Select All does not list it.
+ #[test]
+ fn locked_art_across_the_guides_stays_locked() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ bar.locked = true;
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let p = m.place(&bar, &d);
+ assert_eq!(p.children().map(|c| c.len()), Some(3), "{p:?}");
+ assert!(p.locked, "{p:?}");
+ }
+
+ /// An instance that does not fit leaves the budget as it was: it needs room for the most
+ /// objects placing can make, and a plain one after it still fits.
+ #[test]
+ fn a_skipped_instance_leaves_room_for_the_next() {
+ let mut used = MAX_SYMBOL_NODES - 100;
+ let mut placed = false;
+ assert_eq!(
+ charge_symbol_art(&mut used, 280, || {
+ placed = true;
+ ((), 10)
+ }),
+ None
+ );
+ assert!(!placed && used == MAX_SYMBOL_NODES - 100);
+ // Placed, but it made more objects than are left.
+ assert_eq!(charge_symbol_art(&mut used, 84, || ((), 101)), None);
+ assert_eq!(used, MAX_SYMBOL_NODES - 100);
+ assert_eq!(charge_symbol_art(&mut used, 100, || ("plain", 100)), Some("plain"));
+ assert_eq!(used, MAX_SYMBOL_NODES);
+ assert_eq!(charge_symbol_art(&mut used, 1, || ((), 1)), None);
+ // An instance is charged the objects placing made.
+ let mut used = 0;
+ assert_eq!(charge_symbol_art(&mut used, 280, || ((), 37)), Some(()));
+ assert_eq!(used, 37);
+ }
+
+ /// The bound on what placing makes counts each object in one cell once and each object across
+ /// the guides by the cells it reaches, and is never below what placing makes.
+ #[test]
+ fn the_bound_on_placed_objects_follows_the_cells_they_reach() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ // Six squares in one cell each and a plain frame cut at the guides: the group and its seven.
+ let art = d.symbols[0].art.clone();
+ assert_eq!(m.most_placed(&art, &d), 8);
+ assert_eq!(m.place(&art, &d).count(), 8);
+ // Many objects in one corner count one each.
+ let many: Vec> = (0..1_000).map(|_| Arc::new(square(&mut d, Rect::new(1.0, 1.0, 9.0, 9.0), 0.0))).collect();
+ let corner = Node::group(d.alloc_id(), many);
+ assert_eq!(m.most_placed(&corner, &d), 1_001);
+ assert_eq!(m.place(&corner, &d).count(), 1_001);
+ // A gradient bar across three columns: a group, then a clip path and a copy in each.
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ assert_eq!(m.most_placed(&bar, &d), 10);
+ assert_eq!(m.place(&bar, &d).count(), 10);
+ // Art nested past the depth limit, clipped in all nine cells.
+ let mut deep = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
+ for _ in 0..=MAX_SLICE_DEPTH {
+ deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]);
+ }
+ let most = m.most_placed(&deep, &d);
+ assert!(most >= m.place(&deep, &d).count() as u64 && most <= deep.count() as u64 * MAX_SLICE_GROWTH, "{most}");
+ }
+
+ /// The bound counts the art of opacity masks, which placing copies into each cell (unlinked)
+ /// or places through the guides (linked).
+ #[test]
+ fn the_bound_on_placed_objects_counts_opacity_masks() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let many: Vec> = (0..1_000).map(|_| Arc::new(square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0))).collect();
+ let mask = Node::group(d.alloc_id(), many);
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let mut bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 40.0, 100.0, 60.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ let mut frame = square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 0.0);
+ for linked in [false, true] {
+ let mut om = OpacityMask::new(mask.clone(), true);
+ om.linked = linked;
+ bar.extras_mut().mask = Some(Box::new(om.clone()));
+ frame.extras_mut().mask = Some(Box::new(om));
+ let group = Node::group(d.alloc_id(), vec![Arc::new(bar.clone()), Arc::new(frame.clone())]);
+ for art in [&bar, &frame, &group] {
+ let most = m.most_placed(art, &d);
+ let placed = m.place(art, &d).count_with_masks() as u64;
+ assert!(most >= placed && placed > 1_000, "linked {linked}: {most} < {placed}");
+ }
+ }
+ }
+
+ /// Art that placing through the guides could make more than [`MAX_SYMBOL_NODES`] objects of
+ /// is not placed. Without clips the same path is cut at the guides once, with one copy of its
+ /// mask, and is placed; at the natural size it is moved as it is.
+ #[test]
+ fn symbol_art_past_the_object_limit_is_not_placed() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let leaf = Arc::new(square(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0));
+ let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_500]));
+ let mut mask = OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_500]), true);
+ mask.linked = false;
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
+ art.extras_mut().mask = Some(Box::new(mask));
+ let xf = Affine::scale_non_uniform(2.0, 1.5);
+ assert!(d.place_symbol_art("S", &art, xf).is_none());
+ assert!(d.place_symbol_art_unclipped("S", &art, xf).is_some());
+ assert!(d.place_symbol_art("S", &art, Affine::translate((5.0, 5.0))).is_some());
+ }
+
+ /// A symbol instance that crosses the guides is placed as one piece, by the cell its center is
+ /// in, so copies do not multiply with nesting; one in a corner is placed by the corner.
+ #[test]
+ fn nested_instances_are_placed_as_one_piece() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let wide = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::new([0.8, 0.0, 0.0, 0.1, 10.0, 45.0]) });
+ let corner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::new([0.05, 0.0, 0.0, 0.05, 92.0, 92.0]) });
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let NodeKind::SymbolInstance { xf, .. } = m.place(&wide, &d).kind else { panic!() };
+ // Its center is in the center cell, which stretches 3 times across and 2 times down.
+ assert!((xf.as_coeffs()[0] - 2.4).abs() < 1e-9 && (xf.as_coeffs()[3] - 0.2).abs() < 1e-9, "{xf:?}");
+ let NodeKind::SymbolInstance { xf, .. } = m.place(&corner, &d).kind else { panic!() };
+ assert_eq!(xf, Affine::new([0.05, 0.0, 0.0, 0.05, 192.0, 142.0]));
+ }
+
+ /// Type with an inline graphic, a placed document, a brushed stroke and a pattern whose tile
+ /// holds an instance draw symbol art: across the guides each is placed by its center's cell as
+ /// one piece, and counted as one, while the same art without them is drawn in each cell.
+ #[test]
+ fn art_that_draws_symbol_art_across_the_guides_is_placed_as_one_piece() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let m = d.symbol_slice_map("S", Affine::scale_non_uniform(2.0, 1.5)).unwrap();
+ let across = Rect::new(0.0, 40.0, 100.0, 60.0);
+ let one_piece = |d: &Document, n: &Node| {
+ let p = m.place(n, d);
+ assert_eq!(std::mem::discriminant(&p.kind), std::mem::discriminant(&n.kind), "{p:?}");
+ assert_eq!(m.most_placed(n, d), n.count_with_masks() as u64);
+ };
+ let in_cells = |d: &Document, n: &Node| {
+ let p = m.place(n, d);
+ assert_eq!(p.children().map(Vec::len), Some(3), "{p:?}");
+ };
+ // Type.
+ let mut text = crate::TextObject::point(Point::ZERO, "Label", crate::CharStyle::default());
+ text.kind = crate::TextKind::Area { frame: shapes::rectangle(across) };
+ text.xf = Affine::IDENTITY;
+ let plain = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text.clone())));
+ text.runs[0].inline = Some(crate::InlineArt::new("S"));
+ let inline = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text)));
+ in_cells(&d, &plain);
+ one_piece(&d, &inline);
+ // A placed document.
+ let doc = PlacedDocument {
+ link: crate::LinkInfo::new("/art/logo.vectorcraft"),
+ key: "k".into(),
+ bounding: false,
+ page_box: crate::PageBox::Crop,
+ width: 100.0,
+ height: 20.0,
+ xf: Affine::translate((0.0, 40.0)),
+ placement: Default::default(),
+ };
+ let doc = Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(doc)));
+ one_piece(&d, &doc);
+ // A brushed stroke on a gradient bar, in a group so the group's member is the one placed.
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let bar = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(gradient, Paint::solid(Color::BLACK), 1.0));
+ in_cells(&d, &bar);
+ let mut brushed = bar.clone();
+ brushed.appearance.stroke_mut().unwrap().brush = Some("Brush".into());
+ one_piece(&d, &brushed);
+ let group = Node::group(d.alloc_id(), vec![Arc::new(brushed)]);
+ assert!(matches!(m.place(&group, &d).children().unwrap()[0].kind, NodeKind::Path { .. }));
+ // Patterns: one whose tile holds an instance (through a second pattern), one whose does not.
+ let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::scale(0.1) });
+ let mut tile = square(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), 0.0);
+ d.patterns.push(PatternDef::new("Plain", vec![Arc::new(tile.clone())]));
+ d.patterns.push(PatternDef::new("Inner", vec![Arc::new(instance)]));
+ tile.appearance = Appearance::basic(Paint::Pattern { pattern: "Inner".into(), xf: Default::default() }, Paint::None, 0.0).into();
+ d.patterns.push(PatternDef::new("Outer", vec![Arc::new(tile)]));
+ for (name, holds) in [("Plain", false), ("Outer", true)] {
+ let fill = Paint::Pattern { pattern: name.into(), xf: Default::default() };
+ let bar = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(fill, Paint::None, 0.0));
+ if holds { one_piece(&d, &bar) } else { in_cells(&d, &bar) }
+ }
+ // A hidden stroke painted with that pattern draws nothing: the gradient bar is drawn in
+ // each cell.
+ let outer = Paint::Pattern { pattern: "Outer".into(), xf: Default::default() };
+ let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
+ let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(across), Appearance::basic(gradient, outer, 1.0));
+ hidden.appearance.stroke_mut().unwrap().visible = false;
+ in_cells(&d, &hidden);
+ // A brushed path with solid paint is cut at the guides as one path, drawn once.
+ let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0);
+ let mut solid = Node::path(d.alloc_id(), shapes::rectangle(across), black);
+ solid.appearance.stroke_mut().unwrap().brush = Some("Brush".into());
+ assert!(matches!(m.place(&solid, &d).kind, NodeKind::Path { .. }));
+ }
+
+ /// Art nested deeper than [`MAX_SLICE_DEPTH`] is clipped to the cells it reaches, so it still
+ /// draws with its corners unstretched; paths are not cut at the guides there.
+ #[test]
+ fn art_nested_past_the_limit_is_clipped_to_the_cells() {
+ let mut d = Document::new(400.0, 400.0);
+ let mut deep = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
+ for _ in 0..=MAX_SLICE_DEPTH {
+ deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]);
+ }
+ d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(deep) });
+ d.set_symbol_nine_slice("S", Some(SymbolNineSlice::default()));
+ let mut n = placed(&d, Affine::scale_non_uniform(2.0, 1.5));
+ assert!(n.count() as u64 <= d.symbols[0].art.count() as u64 * MAX_SLICE_GROWTH);
+ for _ in 0..MAX_SLICE_DEPTH {
+ n = (*n.children().unwrap()[0]).clone();
+ }
+ let cells = n.children().unwrap();
+ assert_eq!(cells.len(), 9, "{n:?}");
+ assert!(cells.iter().all(|c| matches!(c.kind, NodeKind::Group { clip: true, .. })));
+ // The top-left cell's copy is the path moved, at its natural size.
+ let copy = &cells[0].children().unwrap()[1];
+ assert!(near(copy.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)), "{copy:?}");
+ }
+
+ /// Symbols made in the app keep their art in a 20 × 20 square and their size apart: the
+ /// guides cut the art at its natural size.
+ #[test]
+ fn the_guides_cut_the_art_at_its_natural_size() {
+ let mut d = Document::new(400.0, 400.0);
+ let corner = square(&mut d, Rect::new(-10.0, -10.0, -8.0, -8.0), 0.0);
+ let frame = square(&mut d, Rect::new(-10.0, -10.0, 10.0, 10.0), 0.0);
+ let group = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(corner)]);
+ d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(group) });
+ d.unknown.insert(SYMBOL_SIZES.into(), json!({ "S": [100.0, 100.0] }));
+ d.set_symbol_nine_slice("S", Some(SymbolNineSlice::default()));
+ // 200 × 150 is 10 × 7.5 times the 20 × 20 art.
+ let got = boxes(&d, Affine::scale_non_uniform(10.0, 7.5));
+ assert!(near(got[0], Rect::new(-100.0, -75.0, 100.0, 75.0)), "{:?}", got[0]);
+ assert!(near(got[1], Rect::new(-100.0, -75.0, -90.0, -65.0)), "{:?}", got[1]);
+ // At its natural size the guides change nothing.
+ assert!(d.symbol_slice_map("S", Affine::scale(5.0)).is_none());
+ }
+
+ /// A scaled instance with strokes in its art is measured by placing the art through the
+ /// guides only while placing it stays within the limit; past it, the art is not placed and
+ /// the instance is measured by its art box and the strokes' reach.
+ #[test]
+ fn a_stroked_instance_past_the_limit_is_measured_without_placing_its_art() {
+ let mut d = markers(Some(SymbolNineSlice::default()));
+ let ring = square(&mut d, Rect::new(40.0, 40.0, 60.0, 60.0), 4.0);
+ let mut art = (*d.symbols[0].art).clone();
+ art.children_mut().unwrap().push(Arc::new(ring));
+ d.symbols[0].art = Arc::new(art);
+ let xf = Affine::scale_non_uniform(2.0, 1.5);
+ let most = d.symbol_slice_map("S", xf).unwrap().most_placed(&d.symbols[0].art, &d);
+ let whole = Rect::new(0.0, 0.0, 200.0, 150.0);
+ let boxes = d.symbol_boxes();
+ let placed = boxes.placed_visual_within("S", xf, most);
+ assert!(placed.is_some_and(|b| near(b, whole)), "{placed:?}");
+ assert_eq!(boxes.placed_visual_within("S", xf, most - 1), None);
+ // Within the real limit the instance's visual box is the placed art's.
+ let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf });
+ let v = inst.visual_bounds_with(&d.symbol_boxes());
+ assert!(v.is_some_and(|b| near(b, whole)), "{v:?}");
+ }
+}
diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs
index bb3cbf11f..7e371c670 100644
--- a/crates/doc/src/symbols.rs
+++ b/crates/doc/src/symbols.rs
@@ -8,7 +8,7 @@ use std::sync::Arc;
use vectorcraft_geom::{Affine, Rect};
-use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
+use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF};
/// The box a symbol instance is measured by when its symbol's art has no box
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
@@ -303,17 +303,28 @@ impl<'a> SymbolBoxes<'a> {
}
}
- /// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
- /// its strokes at their own weight, the instances in it measured by their art boxes. `None`
- /// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
- /// click's box is measured.
+ /// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it
+ /// (through its 9-slice guides when it has them, [`Document::place_symbol_art`]), its strokes
+ /// at their own weight, the instances in it measured by their art boxes. `None`
+ /// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], while symbol art or a
+ /// click's box is measured, and when placing the art through its guides could make more than
+ /// [`MAX_SYMBOL_NODES`] objects ([`crate::NineSliceMap::most_placed`]).
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option {
+ self.placed_visual_within(name, xf, MAX_SYMBOL_NODES)
+ }
+
+ /// [`Self::placed_visual`], `None` when placing the art through its guides could make more
+ /// than `most` objects.
+ pub(crate) fn placed_visual_within(&self, name: &str, xf: Affine, most: u64) -> Option {
if self.quick.get() {
return None;
}
self.measure_art(name, |art| {
- let mut art = (**art).clone();
- art.transform(xf, false);
+ 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)))
})
.flatten()
diff --git a/crates/doc/src/tests/units.rs b/crates/doc/src/tests/units.rs
index 88075a5dc..5a9265c70 100644
--- a/crates/doc/src/tests/units.rs
+++ b/crates/doc/src/tests/units.rs
@@ -145,9 +145,86 @@ fn mixed_mul_div() {
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
}
+/// Q and Ha are a quarter of a millimetre each, written back and read in any field, with or
+/// without their suffix.
+#[test]
+fn q_and_ha_are_a_quarter_millimetre() {
+ for u in [Unit::Q, Unit::Ha] {
+ assert!((u.points() - Unit::Millimeters.points() / 4.0).abs() < 1e-12, "{u:?}");
+ assert!((u.to_pt(4.0) - Unit::Millimeters.to_pt(1.0)).abs() < 1e-12, "{u:?}");
+ let tol = 0.0005 * u.points() + 1e-9;
+ for pt in [0.0, 1.0, 12.5, 72.0, 100.0, 595.28, 1234.5678, -36.0] {
+ let shown = u.format(pt);
+ let back = u.parse(&shown).unwrap_or_else(|| panic!("{u:?} can't read back `{shown}`"));
+ assert!((back - pt).abs() <= tol, "{u:?}: {pt} → `{shown}` → {back}");
+ let bare = u.parse(&u.number(pt)).unwrap();
+ assert!((bare - pt).abs() <= tol, "{u:?}: bare `{}` → {bare}", u.number(pt));
+ let pts = Unit::Points.parse(&shown).unwrap();
+ assert!((pts - pt).abs() <= tol, "{u:?}: `{shown}` in a points field → {pts}");
+ }
+ }
+ assert_eq!(Unit::Q.format(72.0 / 25.4), "4 Q");
+ assert_eq!(Unit::Ha.format(Unit::Millimeters.to_pt(2.5)), "10 H");
+ // 13 Q is 3.25 mm, however it is typed.
+ let want = Unit::Millimeters.to_pt(3.25);
+ for typed in ["13q", "13 Q", "13Q+0", "26/2 q"] {
+ let got = Unit::Points.parse(typed).unwrap_or_else(|| panic!("`{typed}` reads as nothing"));
+ assert!((got - want).abs() < 1e-9, "`{typed}` → {got}");
+ }
+ assert!((Unit::Q.parse("13").unwrap() - want).abs() < 1e-9, "a bare number is in the field's unit");
+ assert!((Unit::Millimeters.parse("13 h").unwrap() - want).abs() < 1e-9, "a typed unit wins over the field's");
+ for typed in ["13h", "13 H", "13Ha", "13 ha"] {
+ assert!((Unit::Points.parse(typed).unwrap() - want).abs() < 1e-9, "`{typed}`");
+ }
+ // Mixed with other units.
+ assert!((Unit::Points.parse("4q+1mm").unwrap() - Unit::Millimeters.to_pt(2.0)).abs() < 1e-9);
+}
+
+/// Only Preferences › Units › East Asian Type offers Q and Ha: the list every other place shares
+/// leaves them out, and `named` answers for it alone.
+#[test]
+fn only_the_east_asian_type_list_names_q_and_ha() {
+ assert_eq!(Unit::EAST_ASIAN[..10], Unit::ALL);
+ assert_eq!(Unit::EAST_ASIAN[10..], [Unit::Q, Unit::Ha]);
+ for u in Unit::EAST_ASIAN {
+ assert_eq!(Unit::named_east_asian(u.label()), Some(u));
+ assert_eq!(Unit::named_east_asian(u.key()), Some(u));
+ assert_eq!(Unit::named_east_asian(&u.label().to_uppercase()), Some(u));
+ assert_eq!(Unit::named_east_asian(u.suffix()), if u == Unit::FeetInches { Some(Unit::Feet) } else { Some(u) });
+ }
+ for name in ["q", "Q", "ha", "Ha", "H", "h"] {
+ assert_eq!(Unit::named(name), None, "`{name}`");
+ }
+ assert_eq!(Unit::named_east_asian("Q"), Some(Unit::Q));
+ assert_eq!(Unit::named_east_asian("h"), Some(Unit::Ha));
+ assert_eq!(Unit::named_east_asian("Ha"), Some(Unit::Ha));
+ assert_eq!(Unit::named_east_asian("furlongs"), None);
+}
+
#[test]
fn readout_has_no_negative_zero() {
assert_eq!(Unit::Points.readout(-0.001), "0.00 pt");
assert_eq!(Unit::Points.readout(-0.0), "0.00 pt");
assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt");
}
+
+/// Q and Ha take the suffixes `q` and `h` (`ha` too) without taking anyone else's: every unit's
+/// own suffix still reads as that unit in a points field, a Q or Ha value is never read as
+/// another unit, and `h`, `ha` and `q` mean nothing to a unit that is not offered Q and Ha when
+/// they are typed as words that are not suffixes (`1hr`, `1hq`, `1qt`).
+#[test]
+fn q_and_ha_suffixes_do_not_take_other_units_suffixes() {
+ for u in Unit::EAST_ASIAN {
+ if matches!(u, Unit::FeetInches) {
+ continue;
+ }
+ let typed = format!("3{}", u.suffix());
+ let got = Unit::Points.parse(&typed).unwrap_or_else(|| panic!("`{typed}` reads as nothing"));
+ assert!((got - u.to_pt(3.0)).abs() < 1e-9, "`{typed}` is 3 {u:?}: {got}");
+ }
+ for typed in ["1hr", "1hq", "1qt", "1qq", "1hh", "1hm", "1hectare"] {
+ assert_eq!(Unit::Points.parse(typed), None, "`{typed}`");
+ }
+ // A suffix after math covers the whole expression, as for the other units.
+ assert!((Unit::Points.parse("26/2 q").unwrap() - Unit::Q.to_pt(13.0)).abs() < 1e-9);
+}
diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs
index c1a36e058..064ecbda1 100644
--- a/crates/doc/src/text.rs
+++ b/crates/doc/src/text.rs
@@ -12,16 +12,18 @@ mod object;
mod para_style;
mod runs;
mod tabs_lists;
+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, ScriptMetrics};
+pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
+pub use wrap::{resolve_wrap, wrap_work};
/// A text object. `runs` split into paragraphs at `\n`.
///
diff --git a/crates/doc/src/text/area.rs b/crates/doc/src/text/area.rs
index f53a48857..515f52627 100644
--- a/crates/doc/src/text/area.rs
+++ b/crates/doc/src/text/area.rs
@@ -141,6 +141,10 @@ pub struct AreaOptions {
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
+ /// Gutter between rows in points when it differs from [`Self::gutter`], which is then the
+ /// gutter between columns (left out when saved while unset).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub row_gutter: Option,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
@@ -162,6 +166,7 @@ impl Default for AreaOptions {
rows: 1,
columns: 1,
gutter: 18.0,
+ row_gutter: None,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs
index d53c99e6c..035b92e34 100644
--- a/crates/doc/src/text/char_style.rs
+++ b/crates/doc/src/text/char_style.rs
@@ -104,10 +104,33 @@ pub struct CharStyle {
///
#[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")]
pub tate_chu_yoko: bool,
+ /// Tate-chu-yoko Settings › Up/Down (Character panel menu, East Asian options), in points: a
+ /// block of marked characters in vertical type moves up by a positive value and down by a
+ /// negative one. The block takes the value of its first character; automatic digit blocks and
+ /// horizontal type do not move. Saved only when not 0.
+ ///
+ #[serde(default, rename = "tcyUpDown", skip_serializing_if = "crate::skip::is_default")]
+ pub tcy_up_down: f64,
+ /// Tate-chu-yoko Settings › Left/Right, in points: a block of marked characters in vertical type
+ /// moves right by a positive value and left by a negative one, as [`Self::tcy_up_down`] does.
+ #[serde(default, rename = "tcyLeftRight", skip_serializing_if = "crate::skip::is_default")]
+ pub tcy_left_right: f64,
+ /// Standard Vertical Roman Alignment (Character panel menu, East Asian options): in vertical
+ /// type the half-width characters among these (Latin letters, digits and other characters that
+ /// would lie on their side) stand upright one below the other instead of being rotated
+ /// individually. Spaces and tate-chu-yoko blocks do not change, and horizontal type keeps the
+ /// mark and does not change.
+ ///
+ #[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")]
+ pub standard_vertical_roman: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
+ /// Font height reference (Character panel menu › Show Font Height Options): the height of the
+ /// font that [`Self::size`] measures, the em box by default.
+ #[serde(default, rename = "fontHeightReference", skip_serializing_if = "crate::skip::is_default")]
+ pub font_height_reference: FontHeightReference,
/// No Break (Character panel menu): these characters are not broken across lines, including
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
/// and can overflow it.
@@ -122,6 +145,21 @@ pub struct CharStyle {
pub language: Option,
}
+/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square
+/// of the font, the height of its capitals or of its lowercase letters (the cap height and x
+/// height of its OS/2 table), or its ideographic character face (ICF, OpenType's `icfb` and
+/// `icft` baselines). Layout sets the characters at the em size at which the chosen height equals
+/// the font size; with a font that has no such value, the size measures the em box.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum FontHeightReference {
+ #[default]
+ EmBox,
+ CapHeight,
+ XHeight,
+ IcfBox,
+}
+
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits
/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like
@@ -369,8 +407,12 @@ impl Default for CharStyle {
char_direction: None,
proportional_metrics: false,
tate_chu_yoko: false,
+ tcy_up_down: 0.0,
+ tcy_left_right: 0.0,
+ standard_vertical_roman: false,
small_caps: None,
digits: Digits::Default,
+ font_height_reference: FontHeightReference::EmBox,
no_break: false,
language: None,
}
@@ -385,7 +427,8 @@ impl CharStyle {
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
- /// baseline shift that is not a number becomes 0.
+ /// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
+ /// offsets are kept as the baseline shift is.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
@@ -393,7 +436,10 @@ impl CharStyle {
self.h_scale = within(self.h_scale, default.h_scale);
self.v_scale = within(self.v_scale, default.v_scale);
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
- self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
+ let shift = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
+ self.baseline_shift = shift(self.baseline_shift);
+ self.tcy_up_down = shift(self.tcy_up_down);
+ self.tcy_left_right = shift(self.tcy_left_right);
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
diff --git a/crates/doc/src/text/object.rs b/crates/doc/src/text/object.rs
index de77da298..5ba5669e6 100644
--- a/crates/doc/src/text/object.rs
+++ b/crates/doc/src/text/object.rs
@@ -32,8 +32,13 @@ impl TextObject {
style.size *= scale.points;
style.leading = style.leading.map(|v| v * scale.points);
style.baseline_shift *= scale.points;
+ style.tcy_up_down *= scale.points;
+ style.tcy_left_right *= scale.points;
style.h_scale *= scale.horizontal;
- if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
+ if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale]
+ .iter()
+ .all(|v| v.is_finite())
+ {
return None;
}
Some(style)
diff --git a/crates/doc/src/text/tests.rs b/crates/doc/src/text/tests.rs
index bd53024b1..04deae8ee 100644
--- a/crates/doc/src/text/tests.rs
+++ b/crates/doc/src/text/tests.rs
@@ -60,6 +60,24 @@ fn digits_map_between_the_numeral_systems() {
assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
+/// The font height reference is the em box by default and is saved only when it is another.
+#[test]
+fn font_height_reference_is_saved_only_when_not_the_em_box() {
+ let st = CharStyle::default();
+ assert_eq!(st.font_height_reference, FontHeightReference::EmBox);
+ assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none());
+ for (r, key) in
+ [(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")]
+ {
+ let set = CharStyle { font_height_reference: r, ..CharStyle::default() };
+ assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key);
+ assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set);
+ }
+ // Styles written before the attribute existed read as the em box.
+ let old = serde_json::to_value(&st).unwrap();
+ assert_eq!(serde_json::from_value::(old).unwrap().font_height_reference, FontHeightReference::EmBox);
+}
+
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
diff --git a/crates/doc/src/text/wrap.rs b/crates/doc/src/text/wrap.rs
new file mode 100644
index 000000000..0b5fe45dc
--- /dev/null
+++ b/crates/doc/src/text/wrap.rs
@@ -0,0 +1,142 @@
+//! Object → Text Wrap: area type flows around the wrap objects stacked above it in the same layer.
+
+use std::sync::Arc;
+
+use vectorcraft_geom::{PathData, shapes};
+
+use super::{TextKind, WrapShape};
+use crate::{Document, Node, NodeId, NodeKind};
+
+/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
+fn outline(n: &Node, out: &mut PathData) {
+ match &n.kind {
+ NodeKind::Path { path, guide: false, .. } => {
+ out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
+ }
+ NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
+ for c in children.iter().filter(|c| c.visible) {
+ outline(c, out);
+ }
+ }
+ // A clip group wraps by its clipping path.
+ NodeKind::Group { children, clip: true } => {
+ if let Some(c) = children.first() {
+ outline(c, out);
+ }
+ }
+ _ => {
+ if let Some(b) = n.geometric_bounds() {
+ out.subpaths.extend(shapes::rectangle(b).subpaths);
+ }
+ }
+ }
+}
+
+enum Item<'a> {
+ Wrap(&'a Node),
+ Text(&'a Node),
+}
+
+fn collect<'a>(n: &'a Node, out: &mut Vec>) {
+ if !n.visible {
+ return;
+ }
+ if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
+ out.push(Item::Wrap(n));
+ return;
+ }
+ match &n.kind {
+ NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n)),
+ NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
+ for c in children {
+ collect(c, out);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// How much [`resolve_wrap`] would copy into the wrap shapes of every area text object in `doc`:
+/// each wrap object's outline (its anchors, plus one for the shape) once for each area text object
+/// below it in its layer.
+pub fn wrap_work(doc: &Document) -> usize {
+ let mut total = 0usize;
+ for layer in &doc.layers {
+ let mut items = vec![];
+ collect(layer, &mut items);
+ let mut above = 0usize;
+ for it in items.iter().rev() {
+ match it {
+ Item::Wrap(n) => {
+ let mut pd = PathData::default();
+ outline(n, &mut pd);
+ if !pd.is_empty() {
+ let anchors: usize = pd.subpaths.iter().map(|sp| sp.anchors.len()).sum();
+ above = above.saturating_add(anchors.saturating_add(1));
+ }
+ }
+ Item::Text(_) => total = total.saturating_add(above),
+ }
+ }
+ }
+ total
+}
+
+/// Resolve the wrap shapes (`TextObject::wrap`, text space) of each area text object from the
+/// outlines of the wrap objects above it in its layer. With `before`, the layers it holds unchanged
+/// are skipped; without it, every layer is resolved. Returns the area text objects whose shapes
+/// changed, whose cached bounds are then out of date.
+pub fn resolve_wrap(before: Option<&Document>, doc: &mut Document) -> Vec {
+ let mut updates: Vec<(NodeId, Vec)> = vec![];
+ for (idx, layer) in doc.layers.iter().enumerate() {
+ if let Some(before) = before
+ && (before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer))
+ || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)))
+ {
+ continue;
+ }
+ let mut items = vec![];
+ collect(layer, &mut items);
+ if items.is_empty() {
+ continue;
+ }
+ // From the top down, each wrap object's outline is made once; each area text object takes
+ // those above it, bottom first.
+ let mut above: Vec = vec![];
+ for it in items.iter().rev() {
+ match it {
+ Item::Wrap(n) => {
+ let mut pd = PathData::default();
+ outline(n, &mut pd);
+ if !pd.is_empty() {
+ above.push(WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() });
+ }
+ }
+ Item::Text(n) => {
+ let NodeKind::Text(t) = &n.kind else { continue };
+ let inv = t.xf.inverse();
+ let shapes: Vec = above
+ .iter()
+ .rev()
+ .map(|s| {
+ let mut path = s.path.clone();
+ path.transform(inv);
+ WrapShape { path, wrap: s.wrap }
+ })
+ .collect();
+ if shapes != t.wrap {
+ updates.push((n.id, shapes));
+ }
+ }
+ }
+ }
+ }
+ let mut changed = Vec::with_capacity(updates.len());
+ for (id, shapes) in updates {
+ if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
+ t.wrap = shapes;
+ changed.push(id);
+ }
+ }
+ changed
+}
diff --git a/crates/doc/src/units.rs b/crates/doc/src/units.rs
index 6a5edf75a..234513df1 100644
--- a/crates/doc/src/units.rs
+++ b/crates/doc/src/units.rs
@@ -14,6 +14,11 @@ pub enum Unit {
Meters,
Yards,
Feet,
+ /// Q (級), a quarter of a millimetre: the Japanese typesetting unit of type size.
+ Q,
+ /// Ha (歯), also a quarter of a millimetre: the Japanese unit of spacing, leading and
+ /// positions.
+ Ha,
}
impl Unit {
@@ -29,6 +34,23 @@ impl Unit {
Unit::Yards,
Unit::Feet,
];
+ /// The units Preferences › Units › East Asian Type offers: all of [`Unit::ALL`], then Q and
+ /// Ha. Only that preference lists Q and Ha; rulers, documents and the other Units
+ /// preferences stay on [`Unit::ALL`].
+ pub const EAST_ASIAN: [Unit; 12] = [
+ Unit::Points,
+ Unit::Picas,
+ Unit::Inches,
+ Unit::Millimeters,
+ Unit::Centimeters,
+ Unit::Pixels,
+ Unit::FeetInches,
+ Unit::Meters,
+ Unit::Yards,
+ Unit::Feet,
+ Unit::Q,
+ Unit::Ha,
+ ];
/// Points per unit.
pub fn points(self) -> f64 {
match self {
@@ -37,6 +59,8 @@ impl Unit {
Unit::Inches => 72.0,
Unit::Millimeters => 72.0 / 25.4,
Unit::Centimeters => 72.0 / 2.54,
+ // 0.25 mm each.
+ Unit::Q | Unit::Ha => 72.0 / 101.6,
Unit::Meters => 72.0 / 0.0254,
Unit::Yards => 72.0 * 36.0,
Unit::Feet | Unit::FeetInches => 72.0 * 12.0,
@@ -53,9 +77,11 @@ impl Unit {
Unit::FeetInches | Unit::Feet => "ft",
Unit::Meters => "m",
Unit::Yards => "yd",
+ Unit::Q => "Q",
+ Unit::Ha => "H",
}
}
- pub fn label(self) -> &'static str {
+ pub const fn label(self) -> &'static str {
match self {
Unit::Points => "Points",
Unit::Picas => "Picas",
@@ -66,6 +92,8 @@ impl Unit {
Unit::FeetInches => "Feet & Inches",
Unit::Meters => "Meters",
Unit::Yards => "Yards",
+ Unit::Q => "Q",
+ Unit::Ha => "Ha",
Unit::Feet => "Feet",
}
}
@@ -115,12 +143,13 @@ impl Unit {
pub fn number(self, pt: f64) -> String {
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
- let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
+ let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 3 } else { 4 };
let s = format!("{:.*}", decimals, self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
- /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
+ /// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), `6q` (Q) and `6h` (Ha), simple
+ /// `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
@@ -132,7 +161,7 @@ impl Unit {
}
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
/// `feetInches`).
- pub fn key(self) -> &'static str {
+ pub const fn key(self) -> &'static str {
match self {
Unit::Points => "points",
Unit::Picas => "picas",
@@ -144,19 +173,29 @@ impl Unit {
Unit::Meters => "meters",
Unit::Yards => "yards",
Unit::Feet => "feet",
+ Unit::Q => "q",
+ Unit::Ha => "ha",
}
}
- /// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and
- /// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
+ /// The unit of [`Unit::ALL`] that `name` names: its label, suffix or [`Unit::key`] in any
+ /// case, ignoring spaces and punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
+ /// Q and Ha are named by [`Unit::named_east_asian`] only.
pub fn named(name: &str) -> Option {
+ Self::named_in(&Unit::ALL, name)
+ }
+ /// [`Unit::named`] among the units of Preferences › Units › East Asian Type
+ /// ([`Unit::EAST_ASIAN`]): also `Q` and `Ha` (`q`, `H`).
+ pub fn named_east_asian(name: &str) -> Option {
+ Self::named_in(&Unit::EAST_ASIAN, name)
+ }
+ fn named_in(units: &[Unit], name: &str) -> Option {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
// is left to its label and key.
- Unit::ALL
- .into_iter()
- .find(|u| letters(u.label()).eq(given()))
- .or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
+ units.iter().copied().find(|u| letters(u.label()).eq(given())).or_else(|| {
+ units.iter().copied().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().map(|c| c.to_ascii_lowercase()).eq(given()))
+ })
}
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
@@ -254,6 +293,8 @@ fn number_unit(s: &str) -> Option<(f64, Option)> {
"ft" | "'" => Some(Unit::Feet),
"yd" => Some(Unit::Yards),
"p" | "pc" => Some(Unit::Picas),
+ "q" | "Q" => Some(Unit::Q),
+ "h" | "H" | "ha" | "Ha" => Some(Unit::Ha),
_ => return None,
};
Some((v, unit))
diff --git a/crates/doc/src/variables.rs b/crates/doc/src/variables.rs
index 96bccbd0a..efe50d9df 100644
--- a/crates/doc/src/variables.rs
+++ b/crates/doc/src/variables.rs
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
-use super::{Document, NodeId, NodeKind};
+use super::{Document, LockedArtboards, NodeId, NodeKind, TextKind};
/// What a variable drives.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -21,11 +21,15 @@ pub enum VariableKind {
Image,
/// A graph object's data (CSV of categories and series, as Graph Data uses).
Graph,
+ /// A variable a file declares without a kind (or with one VectorCraft doesn't know): it drives
+ /// nothing, no value fits it, and nothing can be bound to it.
+ Unknown,
}
impl VariableKind {
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
- /// as the same thing, as the panel and the docs call it both), `image` or `graph`.
+ /// as the same thing, as the panel and the docs call it both), `image` or `graph`. A variable
+ /// of [`Self::Unknown`] kind is never defined this way.
pub fn parse(s: &str) -> Option {
match s.to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
@@ -43,6 +47,7 @@ impl VariableKind {
Self::Visibility => "visibility",
Self::Image => "image",
Self::Graph => "graph",
+ Self::Unknown => "unknown",
}
}
}
@@ -117,7 +122,8 @@ pub struct Variables {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_dataset: Option,
/// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can
- /// still be bound to them and unbound, and datasets still change and apply.
+ /// still be bound to them and unbound, and datasets still change and apply, except that an
+ /// import whose header would define a variable is refused.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
}
@@ -189,34 +195,89 @@ impl Document {
}
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
- /// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't
- /// count, as that is what a visibility variable drives.
+ /// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard
+ /// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a
+ /// visibility variable drives.
pub fn variable_target(&self, id: NodeId) -> bool {
+ self.variable_target_with(&self.locked_artboards(), id)
+ }
+
+ /// [`Document::variable_target`] with the locked artboards `locks` of this document, measured
+ /// once for many ids.
+ pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
+ && !locks.holds_id(id)
}
- /// Whether a data set reads and rewrites the bound object `id`: it is in the document and
- /// neither it nor anything above it is locked. Hidden objects count, and so do objects in a
- /// hidden layer or group.
+ /// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither
+ /// it nor anything above it is locked, and it is not on a locked artboard
+ /// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden
+ /// layer or group.
pub fn dataset_target(&self, id: NodeId) -> bool {
- self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked)))
+ self.dataset_target_with(&self.locked_artboards(), id)
}
- /// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
- /// or a linked image's path. `None` when the object is gone, is not of that kind, has no
- /// link path yet (an embedded image), or is locked or in a locked layer or group (the
- /// document's value is whatever it was left at).
+ /// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured
+ /// once for many ids.
+ pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
+ self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id)
+ }
+
+ /// The type objects of the story that the type object `id` belongs to, in thread order: the
+ /// frames of its threaded text (area type and type on a path), else `id` alone.
+ pub fn story_of(&self, id: NodeId) -> Vec {
+ let frame = |f: &NodeId| matches!(self.node(*f).map(|n| &n.kind), Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point));
+ match self.text_threads.iter().find(|thread| thread.contains(&id)) {
+ Some(thread) if frame(&id) => thread.iter().filter(|f| frame(f)).copied().collect(),
+ _ => vec![id],
+ }
+ }
+
+ /// The characters of the story that the type object `id` belongs to ([`Document::story_of`]):
+ /// every frame's text joined in thread order.
+ pub fn story_text(&self, id: NodeId) -> String {
+ self.story_of(id)
+ .into_iter()
+ .filter_map(|f| match self.node(f).map(|n| &n.kind) {
+ Some(NodeKind::Text(t)) => Some(t.plain_text()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// Whether a data set reads and rewrites the text of the type object `id`: every frame of its
+ /// story ([`Document::story_of`]) is a [`Document::dataset_target`].
+ pub fn story_target(&self, id: NodeId) -> bool {
+ self.story_target_with(&self.locked_artboards(), id)
+ }
+
+ /// [`Document::story_target`] with the locked artboards `locks` of this document, measured
+ /// once for many ids.
+ pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
+ self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f))
+ }
+
+ /// What the object `id` holds for the variable of `kind`: its characters (the whole story
+ /// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the
+ /// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked,
+ /// in a locked layer or group, or on a locked artboard, or for text when a frame of its story
+ /// is (the document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option {
- if !self.dataset_target(id) {
+ self.variable_value_with(&self.locked_artboards(), id, kind)
+ }
+
+ /// [`Document::variable_value`] with the locked artboards `locks` of this document.
+ fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option {
+ if !self.dataset_target_with(locks, id) {
return None;
}
let node = self.node(id)?;
match (kind, &node.kind) {
- (VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
+ (VariableKind::Text, NodeKind::Text(_)) if self.story_target_with(locks, id) => Some(DataValue::Text(self.story_text(id))),
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
(VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())),
- (VariableKind::Text | VariableKind::Image, _) => None,
+ (VariableKind::Text | VariableKind::Image | VariableKind::Unknown, _) => None,
}
}
@@ -227,10 +288,11 @@ impl Document {
/// still matches what it says.
pub fn captured_values(&self) -> BTreeMap {
let mut out = BTreeMap::new();
+ let locks = self.locked_artboards();
for v in &self.variables.variables {
// An object driven by several variables of the same kind reads from the first one
// bound, so a capture is one value per variable and applies back the same way.
- let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
+ let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value_with(&locks, id, v.kind));
if let Some(value) = value {
out.insert(v.name.clone(), value);
}
diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs
index ee8aba6c2..cdde7d135 100644
--- a/crates/effects/src/bake.rs
+++ b/crates/effects/src/bake.rs
@@ -528,8 +528,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// painted by that fill and every visible stroke without a brush its filled outline in the
/// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke:
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
-/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
-/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
+/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path
+/// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`]
+/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor
+/// a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
return Some(m);
@@ -537,6 +539,15 @@ pub fn bake_appearance(n: &Node) -> Option {
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
return None;
}
+ // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
+ // patterns: the art Expand Appearance makes of it (each copy an object with its own strokes,
+ // patterns and gradients), each piece baked in turn.
+ if crate::has_transform_art(n)
+ && let Some(mut m) = reshape(None, n, None)
+ {
+ bake_appearance_pieces(&mut m);
+ return Some(m);
+ }
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)));
if !geometric && !n.appearance.items.iter().any(outlined) {
@@ -589,6 +600,19 @@ pub fn bake_appearance(n: &Node) -> Option {
Some(m)
}
+/// [`bake_appearance`] every path and compound path in `n` that it changes, in place.
+fn bake_appearance_pieces(n: &mut Node) {
+ if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
+ if let Some(m) = bake_appearance(n) {
+ *n = m;
+ }
+ return;
+ }
+ for c in n.children_mut().into_iter().flatten() {
+ bake_appearance_pieces(Arc::make_mut(c));
+ }
+}
+
/// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the
/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when
/// there is nothing to do (export it as is).
diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs
index b150f2004..e16742270 100644
--- a/crates/effects/src/bake/tests.rs
+++ b/crates/effects/src/bake/tests.rs
@@ -106,6 +106,63 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline
assert_eq!(ch[1].opacity, 0.5);
}
+/// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform
+/// effect `params`.
+fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node {
+ let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, stroke, width));
+ n.appearance.effects.push(fx("distort.transform", params));
+ n
+}
+
+/// The paths under `n`, in document order.
+fn baked_paths(n: &Node) -> Vec<&Node> {
+ match n.children() {
+ Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().flat_map(|c| baked_paths(c)).collect(),
+ _ => vec![n],
+ }
+}
+
+fn widths(n: &Node) -> Vec {
+ baked_paths(n).iter().map(|p| p.geometric_bounds().unwrap().width()).collect()
+}
+
+#[test]
+fn a_baked_transform_copy_keeps_its_scaled_stroke() {
+ // Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline
+ // is 204 pt wide.
+ let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
+ let m = bake_appearance(&n).unwrap();
+ let w = widths(&m);
+ assert_eq!(w.len(), 2, "{w:?}");
+ assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}");
+ assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined");
+}
+
+#[test]
+fn a_baked_transform_copy_keeps_its_moved_pattern() {
+ let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY };
+ let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true}));
+ let m = bake_appearance(&n).unwrap();
+ let xs: Vec = baked_paths(&m)
+ .iter()
+ .map(|p| match p.appearance.fill_paint() {
+ Paint::Pattern { xf, .. } => xf.as_coeffs()[4],
+ p => panic!("not a pattern: {p:?}"),
+ })
+ .collect();
+ assert_eq!(xs, vec![0.0, 30.0]);
+}
+
+#[test]
+fn a_baked_transform_without_its_options_outlines_the_copies_together() {
+ // Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares.
+ let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1}));
+ let m = bake_appearance(&n).unwrap();
+ let w = widths(&m);
+ assert_eq!(w.len(), 1, "{w:?}");
+ assert!((w[0] - 202.0).abs() < 0.5, "{w:?}");
+}
+
/// Black 24 pt "Type" with its baseline at (20, 60).
fn type_node(id: u64) -> Node {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs
index 5b0c14c62..1b496a9c5 100644
--- a/crates/effects/src/distort.rs
+++ b/crates/effects/src/distort.rs
@@ -298,7 +298,8 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData {
}
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle,
-/// making at most `max` anchors ([`map_nonlinear_within`]).
+/// clockwise on the page for a positive `angle`, making at most `max` anchors
+/// ([`map_nonlinear_within`]).
pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
let ang = angle.clamp(-3600.0, 3600.0).to_radians();
if ang == 0.0 {
@@ -309,7 +310,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
let f = move |q: Point| {
let d = q - c;
let t = (1.0 - d.hypot() / r).max(0.0);
- let a = -ang * t;
+ // y runs down, so a positive turn is clockwise on the page.
+ let a = ang * t;
let (s, co) = a.sin_cos();
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
};
diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs
index cad97ca56..ce8c38077 100644
--- a/crates/effects/src/group.rs
+++ b/crates/effects/src/group.rs
@@ -19,11 +19,13 @@
use std::sync::Arc;
use serde_json::Value;
-use vectorcraft_doc::color::{Color, Paint};
+use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
+use crate::reshape::SymbolNodes;
+
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
@@ -61,35 +63,188 @@ pub fn has_pathfinder(n: &Node) -> bool {
/// with the object's opacity and blend mode.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option;
-fn leaves(n: &Node, hook: Option, out: &mut Vec) {
+/// The paths and compound paths `n` takes part in a compound shape or a Pathfinder operation as,
+/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as
+/// the art it draws, and type and symbol instances as their art through `hook`. Past
+/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further
+/// instances add nothing.
+pub fn member_leaves(n: &Node, hook: Option) -> Vec {
+ let mut out = vec![];
+ leaves(n, hook, &SymbolNodes::default(), &mut out);
+ out
+}
+
+/// [`member_leaves`], with the symbol art given so far counted in `nodes`.
+fn leaves(n: &Node, hook: Option, nodes: &SymbolNodes, out: &mut Vec) {
if !n.visible {
return;
}
+ // A blend, an envelope or an object with a warp takes part as the art it draws.
+ if let Some(art) = shape_art_in(n, hook, nodes) {
+ let first = out.len();
+ leaves(&art, hook, nodes, out);
+ // Every path of a live object's, type's or symbol instance's art is drawn inside the
+ // object's opacity and blend mode.
+ if crate::needs_outline(n) {
+ for l in out.iter_mut().skip(first) {
+ within(l, n);
+ }
+ }
+ return;
+ }
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
// A compound shape takes part as its area, styled like it.
- NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art(n, hook) {
+ NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) {
Some(art) => out.push(art),
None => {
for c in children {
- leaves(c, hook, out);
+ leaves(c, hook, nodes, out);
}
}
},
_ => {
- // Every path of the outline art, at any depth, takes the object's opacity and blend
- // mode.
- if let Some(art) = hook.and_then(|h| h(n)) {
+ // Every path of the outline art, at any depth, is drawn inside the object's opacity
+ // and blend mode. A symbol's art comes with the live objects and the instances of
+ // other symbols inside it given as their art, and the type inside it outlined, so the
+ // art has nothing left for `hook` to give.
+ if let Some(h) = hook
+ && let Some(art) = h(n)
+ {
+ let art = match &n.kind {
+ NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => {
+ let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
+ crate::reshape::outline_nested_text(&mut art, false);
+ transparency_into_members(&mut art);
+ art
+ }
+ NodeKind::SymbolInstance { .. } => return,
+ _ => art,
+ };
let first = out.len();
- leaves(&art, None, out);
+ leaves(&art, None, nodes, out);
for l in out.iter_mut().skip(first) {
- (l.opacity, l.blend) = (n.opacity, n.blend);
+ within(l, n);
}
}
}
}
}
+/// `l`, a path drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend mode when it is
+/// not Normal.
+fn within(l: &mut Node, n: &Node) {
+ drawn_in(l, n.opacity, n.blend);
+}
+
+/// `l` drawn inside a group of `opacity` and `blend`: its opacity multiplied by `opacity`, and
+/// `blend` when it is not Normal.
+fn drawn_in(l: &mut Node, opacity: f32, blend: BlendMode) {
+ l.opacity *= opacity;
+ if blend != BlendMode::Normal {
+ l.blend = blend;
+ }
+}
+
+/// Move the opacity and blend mode of every group and layer inside `n` (not `n` itself, and not
+/// compound shapes, which take part with their own) onto the objects it holds ([`drawn_in`]), so
+/// that each path of `n` carries the transparency it is drawn with.
+fn transparency_into_members(n: &mut Node) {
+ let Some(ch) = n.children_mut() else { return };
+ for c in ch.iter_mut() {
+ if !is_container(c) || c.is_compound_shape() {
+ continue;
+ }
+ let c = Arc::make_mut(c);
+ let (opacity, blend) = (c.opacity, c.blend);
+ if let Some(inner) = c.children_mut() {
+ for g in inner.iter_mut() {
+ drawn_in(Arc::make_mut(g), opacity, blend);
+ }
+ }
+ (c.opacity, c.blend) = (1.0, BlendMode::Normal);
+ transparency_into_members(c);
+ }
+}
+
+/// `n` as the art its visible geometry gives a compound shape or a Pathfinder operation, where that
+/// is not its own paths: a live object evaluated (a blend's steps, an envelope's distorted art,
+/// type inside it outlined), and a path, compound path, group, compound shape, live object, type or
+/// symbol instance with visible geometry effects (such as a warp) reshaped by them, as the renderer
+/// draws it. Type and symbol instances inside the art are given as their art by `hook` (without
+/// one, type is outlined and symbol instances stay as they are);
+/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art
+/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their
+/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images,
+/// for symbol instances without geometry effects, and for one without art from `hook`.
+pub fn shape_art(n: &Node, hook: Option) -> Option {
+ shape_art_in(n, hook, &SymbolNodes::default())
+}
+
+/// [`shape_art`], with the symbol art given so far counted in `nodes`.
+fn shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option {
+ let mut art = reshaped_art(n, hook, nodes)?;
+ transparency_into_members(&mut art);
+ Some(art)
+}
+
+/// [`shape_art_in`] before the groups inside the art pass on their transparency.
+fn reshaped_art(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option {
+ let fx = crate::reshape::geometry_effects(n);
+ let outline = hook.unwrap_or(&crate::outline_text);
+ match &n.kind {
+ NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)),
+ NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) {
+ Some(area) => Some(reshaped(&area, n)),
+ None => {
+ let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
+ crate::reshape::reshape_leaves(&mut m, &fx);
+ without_geometry_effects(&mut m);
+ Some(m)
+ }
+ },
+ // A symbol instance with a warp: its symbol's art placed by the instance, with the type
+ // inside it outlined, reshaped as one piece, as the renderer draws it.
+ NodeKind::SymbolInstance { symbol, .. } if !fx.is_empty() => {
+ let h = hook?;
+ let art = h(n).filter(|art| nodes.charge(art))?;
+ let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
+ crate::reshape::outline_nested_text(&mut art, false);
+ crate::reshape::reshape_leaves(&mut art, &fx);
+ (art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate);
+ art.appearance.effects = n.appearance.effects.clone();
+ without_geometry_effects(&mut art);
+ Some(art)
+ }
+ NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
+ _ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
+ crate::reshape::as_art_with(None, n, None, outline, nodes)
+ }
+ _ => None,
+ }
+}
+
+/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
+/// geometry effects of `n`, without those effects.
+fn reshaped(src: &Node, n: &Node) -> Node {
+ let mut m = src.clone();
+ if let Some((path, rule)) = geometry(src) {
+ let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
+ let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
+ m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
+ }
+ without_geometry_effects(&mut m);
+ m
+}
+
+fn without_geometry_effects(n: &mut Node) {
+ if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) {
+ let mut ap = (*n.appearance).clone();
+ ap.effects.retain(|e| !crate::is_geometry(&e.id));
+ n.appearance = ap.into();
+ }
+}
+
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
@@ -114,8 +269,13 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp {
/// as its outlines through `hook`, open paths closed) combined with the area of the members below
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
-/// [`PATHFINDER_MAX_OVERLAPS`]).
+/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)).
pub fn compound_shape_area(n: &Node, hook: Option) -> Option {
+ compound_shape_area_in(n, hook, &SymbolNodes::default())
+}
+
+/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`.
+fn compound_shape_area_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option {
if !n.is_compound_shape() {
return None;
}
@@ -124,12 +284,12 @@ pub fn compound_shape_area(n: &Node, hook: Option) -> Option = vec![];
for c in n.children()? {
let before = all.len();
- leaves(c, hook, &mut all);
+ leaves(c, hook, nodes, &mut all);
if all.len() > before {
members.push((shape_op(c.extras().shape_mode), all.len() - before));
}
}
- if !within_budget(&all) {
+ if nodes.spent() || !within_budget(&all) {
return None;
}
if all.is_empty() {
@@ -151,7 +311,12 @@ pub fn compound_shape_area(n: &Node, hook: Option) -> Option) -> Option {
- let area = compound_shape_area(n, hook)?;
+ compound_shape_art_in(n, hook, &SymbolNodes::default())
+}
+
+/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`.
+fn compound_shape_art_in(n: &Node, hook: Option, nodes: &SymbolNodes) -> Option {
+ let area = compound_shape_area_in(n, hook, nodes)?;
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
(art.opacity, art.blend) = (n.opacity, n.blend);
Some(art)
@@ -256,7 +421,8 @@ fn within_budget<'a>(leaves: impl IntoIterator) -> bool {
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
-/// [`PATHFINDER_MAX_OVERLAPS`]) it returns the children as they are.
+/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for
+/// each effect) it returns the children as they are.
pub fn pathfinder_children(group: &Node, hook: Option) -> Option>> {
if !has_pathfinder(group) {
return None;
@@ -274,11 +440,11 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option, Vec) = lv
@@ -295,15 +461,16 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option> = stack
.iter()
.map(|c| {
let mut lv = vec![];
- leaves(c, hook, &mut lv);
+ leaves(c, hook, &nodes, &mut lv);
lv
})
.collect();
- if !within_budget(all.iter().flatten()) {
+ if nodes.spent() || !within_budget(all.iter().flatten()) {
return Some(source.to_vec());
}
// (style source, shape) per stack entry; keys index `sources`.
@@ -440,9 +607,10 @@ pub fn has_container_appearance(n: &Node) -> bool {
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
-/// objects evaluated; the members of a clip group or clipping layer without its clipping path.
-/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of
-/// `doc`'s symbols that its instances show toward its instance budget.
+/// objects evaluated; a compound shape as its area, with the symbol instances among its members
+/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
+/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
+/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
if !n.visible {
@@ -451,8 +619,8 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Group { .. } if n.is_compound_shape() => {
- let hook: &dyn Fn(&Node) -> Option = &crate::outline_text;
- match compound_shape_area(n, Some(hook)) {
+ let hook = |c: &Node| crate::outline_object(doc, c);
+ match compound_shape_area(n, Some(&hook)) {
Some(area) => out.push((area, FillRule::NonZero)),
None => members(doc, n, out),
}
@@ -518,7 +686,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
/// the members (after a clipping path) or above them, as its contents slot says. Its
/// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live
-/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]).
+/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]), and
+/// a Pathfinder operation or compound shape takes the symbol instances among its members as their
+/// symbols' art from `doc`.
pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option {
if !is_container(n) {
return None;
@@ -531,8 +701,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option {
}
let mut m = n.clone();
if pathfinder {
- let hook: &dyn Fn(&Node) -> Option = &crate::outline_text;
- let result = pathfinder_children(n, Some(hook)).unwrap_or_default();
+ let hook = |c: &Node| crate::outline_object(doc, c);
+ let result = pathfinder_children(n, Some(&hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs
index b15a642c3..9af1cfffd 100644
--- a/crates/effects/src/lib.rs
+++ b/crates/effects/src/lib.rs
@@ -71,16 +71,16 @@ pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_lea
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
- is_pathfinder, member_shapes, paints, pathfinder_children,
+ is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art,
};
pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset};
-pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
+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 reshape::{
- 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_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::{
diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs
index 769eae6b5..ee88f485c 100644
--- a/crates/effects/src/live.rs
+++ b/crates/effects/src/live.rs
@@ -19,13 +19,17 @@ pub fn text_outliner() -> Outliner<'static> {
Some(&outline_text)
}
-/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance.
-fn outline(doc: Option<&Document>, n: &Node) -> Option {
+/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
+/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
+/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
+/// Pathfinder operations the symbol instances among their members.
+pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
- let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
- let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
- art.transform(*xf, false);
+ let doc = doc?;
+ let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
+ let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
+ let art = doc.place_symbol_art(symbol, &art, *xf)?;
Some(Node::group(n.id, vec![Arc::new(art)]))
}
_ => outline_text(n),
@@ -35,7 +39,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option {
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
/// document, symbol instances and pattern tiles stay as they are).
fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
- let outline = |n: &Node| outline(doc, n);
+ let outline = |n: &Node| outline_object(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
}
diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs
index a1b8eed98..5a619f8ac 100644
--- a/crates/effects/src/preview.rs
+++ b/crates/effects/src/preview.rs
@@ -29,8 +29,8 @@ use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt};
use crate::group::has_own_paint;
use crate::reshape::{geometry_effects, leaf_geometry};
use crate::{
- GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline,
- outline_art, paints, reshape,
+ GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, has_transform_art,
+ needs_outline, outline_art, paints, reshape,
};
/// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects:
@@ -187,6 +187,9 @@ impl<'s, 'd> Drawn<'s, 'd> {
let painted = || has_own_paint(n) && inner();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n),
+ // A Transform effect that copies with a gradient, scales strokes or moves patterns makes
+ // objects of their own, as the renderer draws them.
+ NodeKind::Path { .. } | NodeKind::Compound { .. } if has_transform_art(n) => reshape(Some(self.symbols.document()), n, None),
NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n),
NodeKind::SymbolInstance { symbol, .. } => {
if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) {
diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs
index 0d912b531..fa77c5702 100644
--- a/crates/effects/src/reshape.rs
+++ b/crates/effects/src/reshape.rs
@@ -17,7 +17,7 @@ use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
-use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params};
+use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
/// neither a path nor a container)?
@@ -47,16 +47,28 @@ pub fn has_gradient_copies(n: &Node) -> bool {
}
/// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or
-/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as
-/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of
-/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]).
+/// Scale Strokes & Effects is on (#885), its own or one of a fill or stroke that paints
+/// ([`item_transform_options`])? Such an effect changes the fills and strokes as well as the
+/// geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of its
+/// own with its strokes scaled and its patterns moved ([`transform_leaf`], [`transform_items`]).
pub fn has_transform_options(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
- && geometry_effects(n)
+ && (geometry_effects(n)
.iter()
// Both options default to off, so the stored params tell without merging the defaults
// (this runs for every object drawn).
.any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance())
+ || n.appearance.items.iter().any(item_transform_options))
+}
+
+/// Does `item` paint and carry a Transform effect of its own that [`reaches_appearance`]?
+fn item_transform_options(item: &AppearanceItem) -> bool {
+ crate::paints(item) && item.effects().iter().any(reaches_appearance)
+}
+
+/// Is `e` a visible Transform effect whose Transform Patterns or Scale Strokes & Effects is on?
+fn reaches_appearance(e: &Effect) -> bool {
+ e.visible && e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance()
}
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
@@ -232,14 +244,27 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// `n` as plain art that geometry effects can reshape, without its transparency and object
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
-/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts
-/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
+/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them).
+/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
+/// 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 {
+ outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
+}
+
+/// [`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 {
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
- NodeKind::SymbolInstance { xf, .. } => {
- let mut a = symbol_art?.clone();
- a.transform(*xf, false);
+ NodeKind::SymbolInstance { symbol, xf } => {
+ let a = match doc {
+ Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?,
+ None => {
+ let mut a = symbol_art?.clone();
+ a.transform(*xf, false);
+ a
+ }
+ };
Node::group(n.id, vec![Arc::new(a)])
}
// The object as the one piece the copies of its Transform effect are made from.
@@ -252,7 +277,7 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>)
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
- _ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n),
+ _ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes),
_ => return None,
};
outline_nested_text(&mut art, false);
@@ -270,8 +295,93 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>)
/// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show
/// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]).
pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node {
- let hook: &dyn Fn(&Node) -> Option = &outline_text;
- vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() })
+ evaluated_with(doc, n, &outline_text, &SymbolNodes::default())
+}
+
+/// Most objects of symbol art that one compound shape or live Pathfinder operation gives its
+/// members in all ([`SymbolNodes`]): four for each segment the operation takes in
+/// ([`crate::group::PATHFINDER_MAX_SEGMENTS`]).
+pub const SYMBOL_ART_MAX_NODES: u64 = 4 * crate::group::PATHFINDER_MAX_SEGMENTS as u64;
+
+/// The objects of symbol art given to one compound shape or live Pathfinder operation so far,
+/// counted against [`SYMBOL_ART_MAX_NODES`]. Instances of symbols that hold several instances of
+/// others multiply at each level, which the depth limit does not stop.
+#[derive(Debug, Default)]
+pub(crate) struct SymbolNodes(std::cell::Cell);
+
+impl SymbolNodes {
+ /// Count `art`'s objects: `false` once they would pass [`SYMBOL_ART_MAX_NODES`], and from then
+ /// on for all art.
+ pub(crate) fn charge(&self, art: &Node) -> bool {
+ let total = self.0.get().saturating_add(art.count() as u64);
+ let fits = total <= SYMBOL_ART_MAX_NODES;
+ self.0.set(if fits { total } else { u64::MAX });
+ fits
+ }
+
+ /// Has some symbol art been left out for passing [`SYMBOL_ART_MAX_NODES`]?
+ pub(crate) fn spent(&self) -> bool {
+ self.0.get() > SYMBOL_ART_MAX_NODES
+ }
+}
+
+/// [`evaluated`] with type and symbol instances inside live objects given as their art by
+/// `outline`, and every symbol instance left in the result (a blend's steps, an instance in a
+/// plain group) replaced by its art from `outline`, with the instance's name, visibility and
+/// transparency. The live objects and symbol instances inside that art are given as their art in
+/// turn, except an instance of a symbol that holds an instance of itself, of a symbol nested
+/// more than [`vectorcraft_doc::depth::MAX_DEPTH`] deep, or whose art would take `nodes` past
+/// [`SYMBOL_ART_MAX_NODES`], which stays as it is. With [`outline_text`], symbol instances stay as
+/// they are.
+pub(crate) fn evaluated_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
+ resolved(doc, n, outline, nodes, &[])
+}
+
+/// `art`, the art `outline` gave an instance of the symbol named `symbol`, with the live objects
+/// and symbol instances inside it given as their art as [`evaluated_with`] does.
+pub(crate) fn symbol_art_with(art: &Node, outline: OutlineHook, nodes: &SymbolNodes, symbol: &str) -> Node {
+ resolved(None, art, outline, nodes, &[symbol.to_string()])
+}
+
+/// [`evaluated_with`] inside the art of the symbols named in `shown`, outermost first.
+fn resolved(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Node {
+ let inner = |m: &Node| art_of(doc, m, outline, nodes, shown);
+ let mut art = vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(&inner), symbols: doc, ..Default::default() });
+ instances_as_art(doc, &mut art, outline, nodes, shown);
+ art
+}
+
+/// `n`'s art from `outline`, inside the art of the symbols named in `shown`. For a symbol instance
+/// it is the symbol's art with the live objects and instances inside it given as their art in turn
+/// ([`resolved`]); `None` for an instance of a symbol in `shown` (one that holds an instance of
+/// itself), for every instance once `shown` holds [`vectorcraft_doc::depth::MAX_DEPTH`] symbols,
+/// and for an instance whose symbol's art `nodes` cannot take ([`SymbolNodes::charge`]).
+fn art_of(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Option {
+ let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return outline(n) };
+ if shown.len() >= vectorcraft_doc::depth::MAX_DEPTH || shown.contains(symbol) {
+ return None;
+ }
+ let art = outline(n).filter(|art| nodes.charge(art))?;
+ let mut inside = shown.to_vec();
+ inside.push(symbol.clone());
+ Some(resolved(doc, &art, outline, nodes, &inside))
+}
+
+/// Replace every symbol instance under `n` for which [`art_of`] gives art by that art. `shown`
+/// names the symbols whose art `n` is in, outermost first.
+fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) {
+ let Some(ch) = n.children_mut() else { return };
+ for c in ch.iter_mut() {
+ if matches!(c.kind, NodeKind::SymbolInstance { .. }) {
+ if let Some(mut o) = art_of(doc, c, outline, nodes, shown) {
+ o.name = c.name.clone();
+ (o.visible, o.opacity, o.blend, o.isolate) = (c.visible, c.opacity, c.blend, c.isolate);
+ *c = Arc::new(o);
+ }
+ } else if c.children().is_some() {
+ instances_as_art(doc, Arc::make_mut(c), outline, nodes, shown);
+ }
+ }
}
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
@@ -304,9 +414,11 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
-/// or has no art (a symbol instance without `symbol_art`).
+/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is
+/// always reshaped. The fills and strokes in the art whose own Transform effects reach their
+/// appearance become objects of their own ([`transform_items`]).
pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option {
- if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) {
+ if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) {
return None;
}
as_art(doc, n, symbol_art)
@@ -334,6 +446,11 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
/// [`reshape`] without its checks (no geometry effects: just the art).
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option {
+ as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
+}
+
+/// [`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 {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -361,9 +478,10 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
- _ => outline_art(doc, n, symbol_art)?,
+ _ => outline_art_with(doc, n, symbol_art, outline, nodes)?,
};
reshape_leaves(&mut art, &fx);
+ transform_items(&mut art);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
@@ -577,6 +695,158 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
made
}
+/// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose
+/// own Transform effect has Transform Patterns or Scale Strokes & Effects on
+/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]).
+/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is
+/// more).
+fn transform_items(art: &mut Node) {
+ let mut used = art_cost(art);
+ let max = Budget::EFFECTS.at_least(used);
+ for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used));
+}
+
+/// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform
+/// Patterns or Scale Strokes & Effects on, as a group with its id, name, transparency and object
+/// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after
+/// the geometry effects of that fill or stroke before its first such Transform effect, transformed
+/// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the
+/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the
+/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer
+/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then
+/// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other
+/// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's
+/// geometry between them, with their own effects. `max` is the budget of the whole art and `used`
+/// what it has spent.
+fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
+ if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) {
+ return;
+ }
+ let Some((path, rule)) = leaf_geometry(leaf) else { return };
+ let original = leaf.clone();
+ let ctx = GeomContext::of(&original);
+ let bounds = path.to_bezpath().bounding_box();
+ // A path of the leaf's geometry painted by `items` alone, without the leaf's transparency and
+ // object effects (the group keeps them).
+ let piece = |items: Vec| {
+ let mut p = original.clone();
+ p.appearance.items = items;
+ p.appearance.effects.clear();
+ p.appearance.contents_index = None;
+ p.opacity = 1.0;
+ p.blend = Default::default();
+ p.isolate = false;
+ p.knockout = Default::default();
+ p.extras_mut().mask = None;
+ p
+ };
+ // The pieces replace the leaf's own geometry (counted in `used`): each plain piece costs that
+ // geometry again, and each transformed fill or stroke what its paths cost.
+ let cost = Budget::of(&path);
+ *used = used.minus(cost);
+ let mut pieces: Vec> = vec![];
+ let mut plain: Vec = vec![];
+ // Which items become a transformed piece of their own; every other run of items is one plain piece.
+ let transformed: Vec = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect();
+ // The number of pieces after item `i` in paint order: each costs at least the leaf's geometry.
+ let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count();
+ for (i, item) in original.appearance.items.iter().enumerate() {
+ let fx = item.effects();
+ let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else {
+ plain.push(item.clone());
+ continue;
+ };
+ if !plain.is_empty() {
+ pieces.push(Arc::new(piece(std::mem::take(&mut plain))));
+ *used = used.plus(cost);
+ }
+ let before = &fx[..at];
+ // What the art's other paths leave of the budget, less the leaf's geometry for each piece
+ // still to come: the geometry before the Transform effect and its copies keep to it.
+ let reserved = (0..later(i)).fold(Budget { anchors: 0, lines: 0 }, |r, _| r.plus(cost));
+ let limit = max.minus(reserved);
+ let room = limit.minus(*used);
+ let g = if crate::has_geometry(before) && !path.is_empty() {
+ crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room)
+ } else {
+ path.clone()
+ };
+ let Some(tb) = g.bounds() else { continue };
+ let params = merged_params(&fx[at].id, &fx[at].params);
+ let (_, copies) = crate::distort::transform_map(tb, ¶ms, ctx.seed);
+ let options = crate::distort::TransformOptions::of(¶ms);
+ let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed);
+ let mut it = item.clone();
+ let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
+ *it.effects_mut() = rest;
+ let mut p = piece(vec![it]);
+ set_geometry(&mut p, g.clone(), rule);
+ let made = transform_leaf(&mut p, &g, map, copies, options, room);
+ *used = used.plus(made);
+ transform_piece(&mut p, ctx.seed, limit, used);
+ pieces.push(Arc::new(p));
+ }
+ if !plain.is_empty() {
+ pieces.push(Arc::new(piece(plain)));
+ *used = used.plus(cost);
+ }
+ leaf.appearance.items.clear();
+ leaf.appearance.contents_index = None;
+ leaf.knockout = vectorcraft_doc::Knockout::Off;
+ leaf.kind = NodeKind::Group { children: pieces, clip: false };
+}
+
+/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
+/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
+/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
+/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
+/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
+/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
+/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep
+/// to `max` together with the rest of the art (`used`).
+fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
+ let mut count = 0;
+ for_each_leaf(p, &mut |l| {
+ if let Some(item) = l.appearance.items.first() {
+ count = count.max(item.effects().len());
+ }
+ });
+ for j in 0..count {
+ let Some(bounds) = p.geometric_bounds() else { break };
+ for_each_leaf(p, &mut |leaf| {
+ let Some(item) = leaf.appearance.items.first().cloned() else { return };
+ let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return };
+ let Some((path, rule)) = leaf_geometry(leaf) else { return };
+ // What the other paths leave of the budget.
+ let before = Budget::of(&path);
+ let room = max.minus(used.minus(before));
+ if reaches_appearance(&e) {
+ let params = merged_params(&e.id, &e.params);
+ let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
+ let options = crate::distort::TransformOptions::of(¶ms);
+ let map = crate::distort::transform_affine(bounds, ¶ms, seed);
+ let made = transform_leaf(leaf, &path, map, copies, options, room);
+ *used = used.minus(before).plus(made);
+ return;
+ }
+ let appearance = leaf.appearance.clone();
+ let stroke = match &item {
+ AppearanceItem::Stroke(s) => Some(s),
+ AppearanceItem::Fill(_) => None,
+ };
+ let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) };
+ let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room);
+ *used = used.minus(before).plus(Budget::of(&out));
+ set_geometry(leaf, out, rule);
+ });
+ }
+ for_each_leaf(p, &mut |leaf| {
+ for item in &mut leaf.appearance.items {
+ item.effects_mut().retain(|e| !(e.visible && is_geometry(&e.id)));
+ }
+ });
+}
+
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec