mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 01:21:59 +00:00
Merge upstream/main into fix/2414-svg-classes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,27 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -32,4 +32,8 @@ jobs:
|
||||
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
|
||||
- run: cargo test --locked -p vectorcraft
|
||||
- name: Check pending submission and queue refusal feedback
|
||||
run: cargo test --locked -p vectorcraft-ui-egui tests_printps
|
||||
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; }
|
||||
|
||||
@@ -27,8 +27,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: vectorcraft
|
||||
app-dir: VectorCraft
|
||||
install-relative-path: Programs/ArtCraft/VectorCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
|
||||
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
|
||||
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
|
||||
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
|
||||
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
|
||||
name: Windows 7 x64 compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "apps/vectorcraft/Cargo.toml"
|
||||
- "apps/vectorcraft/src/printing.rs"
|
||||
- "apps/vectorcraft/src/printing/**"
|
||||
- "crates/ui-egui/src/print.rs"
|
||||
- "crates/ui-egui/src/tests/printps.rs"
|
||||
- "packaging/windows/windows7.ps1"
|
||||
- ".github/workflows/windows7.yml"
|
||||
schedule:
|
||||
- cron: "23 5 * * 1" # weekly
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
|
||||
with:
|
||||
arch: x64
|
||||
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
|
||||
with:
|
||||
toolchain: nightly-2026-10-01
|
||||
components: rust-src
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
key: windows7-x64
|
||||
- name: Build, check and test the portable package
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/windows7.ps1 -Test
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: vectorcraft-windows7-x64-portable
|
||||
path: dist/release/vectorcraft-windows7-x64-portable.zip
|
||||
if-no-files-found: error
|
||||
@@ -35,6 +35,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
|
||||
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
|
||||
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it.
|
||||
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
|
||||
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
|
||||
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
|
||||
|
||||
Generated
+32
-29
@@ -1310,7 +1310,6 @@ dependencies = [
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"egui_glow",
|
||||
"glow",
|
||||
"glutin",
|
||||
"glutin-winit",
|
||||
"image",
|
||||
@@ -3283,6 +3282,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6036,7 +6036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -6053,8 +6053,10 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-text",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
@@ -6077,7 +6079,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -6088,7 +6090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -6100,7 +6102,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6116,7 +6118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -6138,7 +6140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -6150,7 +6152,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
@@ -6169,7 +6171,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6185,7 +6187,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
@@ -6224,7 +6226,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -6248,7 +6250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -6263,7 +6265,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -6275,7 +6277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6294,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6308,7 +6310,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -6320,12 +6322,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
"flate2",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"jpeg-encoder",
|
||||
"krilla",
|
||||
@@ -6351,7 +6354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6365,7 +6368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -6394,14 +6397,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -6426,7 +6429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -6444,7 +6447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -6465,7 +6468,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -6473,7 +6476,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6489,7 +6492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6501,7 +6504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-keymap",
|
||||
@@ -6539,7 +6542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7670,7 +7673,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -116,6 +116,9 @@ webbrowser = "1"
|
||||
unsafe_code = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
# VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see
|
||||
# AGENTS.md › Robustness). Tests may unwrap (clippy.toml).
|
||||
|
||||
@@ -30,11 +30,6 @@ based in part on the work of the Independent JPEG Group.
|
||||
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
|
||||
under the Apache License 2.0 with the LLVM exception.
|
||||
|
||||
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
|
||||
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
|
||||
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
|
||||
other build uses the unmodified crate from crates.io.
|
||||
|
||||
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
|
||||
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
|
||||
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
|
||||
|
||||
@@ -227,8 +227,6 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-roo
|
||||
|
||||
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
|
||||
|
||||
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
|
||||
|
||||
## Status
|
||||
|
||||
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension
|
||||
@@ -249,7 +247,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
|
||||
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
|
||||
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
|
||||
The interface
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
|
||||
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech, Georgian and Catalan;
|
||||
[localization-parity.md](docs/localization-parity.md) has the details.
|
||||
The scores are self-assessed from our code against public documentation;
|
||||
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
|
||||
|
||||
+3
-1
@@ -112,7 +112,7 @@ Weights, evidence and the missing items per area: [target-app-parity.md](docs/ta
|
||||
| Korean | `ko` | full | 100% |
|
||||
| Vietnamese | `vi` | full | 100% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
Also shipped: Traditional Chinese (partial, 80%), Bulgarian, Finnish, Greek, Hungarian, Italian, Polish, Russian, Swedish, Ukrainian and Georgian (full), Catalan (full; AI-drafted, native review welcome), Czech (catalog complete; render-site gaps reduced; native review pending).
|
||||
The Georgian catalog has had a native speaker's review; no other catalog has. Details: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
@@ -144,6 +144,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-29 | Linux: a clipboard that can't be opened (the Flatpak on GNOME, where arboard has neither data-control nor X11) is retried after 1 s, doubling to a minute, and logged once, where the background Paste check used to reconnect and log a warning every 250 ms (267k warnings in 18 h). Scores and estimates unchanged. |
|
||||
| 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. |
|
||||
| 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. |
|
||||
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
|
||||
@@ -198,6 +199,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. |
|
||||
| 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. |
|
||||
| 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. |
|
||||
| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. |
|
||||
|
||||
@@ -35,7 +35,6 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel",
|
||||
# DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs,
|
||||
# shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR
|
||||
# Apache-2.0.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] }
|
||||
|
||||
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
|
||||
|
||||
@@ -46,7 +46,7 @@ mod perf;
|
||||
#[path = "../../vectorcraft/src/prefs_dir.rs"]
|
||||
mod prefs_dir;
|
||||
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
#[path = "../../vectorcraft/src/system_fonts.rs"]
|
||||
mod system_fonts;
|
||||
|
||||
@@ -162,7 +162,7 @@ fn help() -> Result<(), String> {
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Before any font lookup: the fonts font services load (#579).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
system_fonts::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
|
||||
@@ -447,113 +447,158 @@ fn mcp_protocol_over_stdio() {
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let mut notes = vec![];
|
||||
|
||||
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
|
||||
writeln!(stdin, "{v}").unwrap();
|
||||
exercise_mcp_core(&mut stdin, &mut lines, &mut notes);
|
||||
exercise_mcp_resource_errors(&mut stdin, &mut lines, &mut notes);
|
||||
exercise_mcp_document_resources(&mut stdin, &mut lines, &mut notes);
|
||||
exercise_mcp_logging(&mut stdin, &mut lines, &mut notes);
|
||||
exercise_mcp_parse_error(&mut stdin, &mut lines, &mut notes);
|
||||
drop(stdin);
|
||||
for line in lines {
|
||||
let v: Value = serde_json::from_str(&line.unwrap()).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
notes.push(v);
|
||||
}
|
||||
// Next reply with this id; notifications (no id) are stashed for later.
|
||||
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
|
||||
loop {
|
||||
let line = lines.next().unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
return v;
|
||||
}
|
||||
notes.push(v);
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
|
||||
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
|
||||
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
|
||||
}
|
||||
|
||||
fn mcp_send(stdin: &mut std::process::ChildStdin, v: Value) {
|
||||
writeln!(stdin, "{v}").unwrap();
|
||||
}
|
||||
|
||||
fn mcp_recv(lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
|
||||
loop {
|
||||
let line = lines.next().unwrap().unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
|
||||
return v;
|
||||
}
|
||||
notes.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
fn exercise_mcp_core(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
|
||||
notes: &mut Vec<Value>,
|
||||
) {
|
||||
mcp_send(
|
||||
stdin,
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 1);
|
||||
let r = mcp_recv(lines, notes, 1);
|
||||
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
|
||||
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
|
||||
let r = recv(&mut lines, &mut notes, 2);
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
|
||||
let r = mcp_recv(lines, notes, 2);
|
||||
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
|
||||
let r = recv(&mut lines, &mut notes, 3);
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
|
||||
let r = mcp_recv(lines, notes, 3);
|
||||
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
mcp_send(
|
||||
stdin,
|
||||
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 4);
|
||||
let r = mcp_recv(lines, notes, 4);
|
||||
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
|
||||
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
|
||||
assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
|
||||
|
||||
send(
|
||||
&mut stdin,
|
||||
mcp_send(
|
||||
stdin,
|
||||
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
|
||||
);
|
||||
let r = recv(&mut lines, &mut notes, 7);
|
||||
let r = mcp_recv(lines, notes, 7);
|
||||
assert_eq!(r["result"]["isError"], false, "{r}");
|
||||
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
|
||||
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
|
||||
let r = recv(&mut lines, &mut notes, 8);
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
|
||||
let r = mcp_recv(lines, notes, 8);
|
||||
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
|
||||
}
|
||||
|
||||
fn exercise_mcp_resource_errors(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
|
||||
notes: &mut Vec<Value>,
|
||||
) {
|
||||
// Unknown resources (-32002) and bad template values (-32602) stay apart.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
|
||||
assert_eq!(mcp_recv(lines, notes, 9)["error"]["code"], -32002);
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
|
||||
assert_eq!(mcp_recv(lines, notes, 10)["error"]["code"], -32602);
|
||||
}
|
||||
|
||||
fn exercise_mcp_document_resources(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
|
||||
notes: &mut Vec<Value>,
|
||||
) {
|
||||
// Large containers slice through run_command document.node: a truncated level reports
|
||||
// childCount, and a bad limit is a tool error, not a crash or a full read.
|
||||
send(
|
||||
&mut stdin,
|
||||
mcp_send(
|
||||
stdin,
|
||||
json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}),
|
||||
);
|
||||
assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false);
|
||||
let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
|
||||
send(&mut stdin, run(21, "select.all", json!({})));
|
||||
recv(&mut lines, &mut notes, 21);
|
||||
send(&mut stdin, run(22, "object.group", json!({})));
|
||||
let r = recv(&mut lines, &mut notes, 22);
|
||||
assert_eq!(mcp_recv(lines, notes, 20)["result"]["isError"], false);
|
||||
mcp_send(stdin, run_command(21, "select.all", json!({})));
|
||||
mcp_recv(lines, notes, 21);
|
||||
mcp_send(stdin, run_command(22, "object.group", json!({})));
|
||||
let r = mcp_recv(lines, notes, 22);
|
||||
let group = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
|
||||
send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
|
||||
let r = recv(&mut lines, &mut notes, 23);
|
||||
mcp_send(stdin, run_command(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
|
||||
let r = mcp_recv(lines, notes, 23);
|
||||
let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}");
|
||||
assert_eq!(sliced["childCount"], 2, "{sliced}");
|
||||
send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true);
|
||||
mcp_send(stdin, run_command(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
|
||||
assert_eq!(mcp_recv(lines, notes, 24)["result"]["isError"], true);
|
||||
|
||||
// Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and
|
||||
// document.find locates nodes without dumping the tree.
|
||||
send(&mut stdin, run(25, "document.inspect", json!({"depth": 0})));
|
||||
let r = recv(&mut lines, &mut notes, 25);
|
||||
mcp_send(stdin, run_command(25, "document.inspect", json!({"depth": 0})));
|
||||
let r = mcp_recv(lines, notes, 25);
|
||||
let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
|
||||
assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}");
|
||||
send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"})));
|
||||
let r = recv(&mut lines, &mut notes, 26);
|
||||
mcp_send(stdin, run_command(26, "document.find", json!({"kind": "ellipse"})));
|
||||
let r = mcp_recv(lines, notes, 26);
|
||||
let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(found["total"], 1, "{found}");
|
||||
assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}");
|
||||
send(&mut stdin, run(27, "document.find", json!({})));
|
||||
assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true);
|
||||
mcp_send(stdin, run_command(27, "document.find", json!({})));
|
||||
assert_eq!(mcp_recv(lines, notes, 27)["result"]["isError"], true);
|
||||
}
|
||||
|
||||
fn exercise_mcp_logging(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
|
||||
notes: &mut Vec<Value>,
|
||||
) {
|
||||
// The binary installed its logger: records arrive as notifications/message, and only
|
||||
// after the client asked for them.
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
|
||||
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
|
||||
assert!(mcp_recv(lines, notes, 11).get("error").is_none());
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
|
||||
assert!(mcp_recv(lines, notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
|
||||
}
|
||||
|
||||
fn exercise_mcp_parse_error(
|
||||
stdin: &mut std::process::ChildStdin,
|
||||
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
|
||||
notes: &mut Vec<Value>,
|
||||
) {
|
||||
// Malformed input is a parse error, and the server keeps serving afterwards.
|
||||
stdin.write_all(b"{not json\n").unwrap();
|
||||
let parse_err: Value = loop {
|
||||
@@ -566,21 +611,12 @@ fn mcp_protocol_over_stdio() {
|
||||
};
|
||||
assert_eq!(parse_err["error"]["code"], -32700);
|
||||
assert!(parse_err["id"].is_null(), "{parse_err}");
|
||||
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
|
||||
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
|
||||
drop(stdin);
|
||||
// Drain whatever the debug window queued; every line off the protocol stream parses.
|
||||
for line in lines.by_ref() {
|
||||
let line = line.unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["jsonrpc"], "2.0", "{v}");
|
||||
notes.push(v);
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
|
||||
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
|
||||
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
|
||||
assert!(mcp_recv(lines, notes, 13).get("error").is_none());
|
||||
}
|
||||
|
||||
fn run_command(id: u64, command: &str, params: Value) -> Value {
|
||||
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
|
||||
}
|
||||
|
||||
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="theme-color" content="#262626">
|
||||
<title>VectorCraft</title>
|
||||
<!-- Tab and home-screen icons: copied from assets/app-icon (one folder per size, the PNG names are equal). -->
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/scalable/apps/ai.storyteller.vectorcraft.svg" data-target-path="icons" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/256" />
|
||||
<link rel="icon" type="image/svg+xml" href="icons/ai.storyteller.vectorcraft.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/32/ai.storyteller.vectorcraft.png">
|
||||
<link rel="apple-touch-icon" href="icons/256/ai.storyteller.vectorcraft.png">
|
||||
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
|
||||
|
||||
@@ -13,8 +13,6 @@ workspace = true
|
||||
default = ["wgpu", "accessibility"]
|
||||
wgpu = ["eframe/wgpu", "dep:winsafe"]
|
||||
accessibility = ["eframe/accesskit"]
|
||||
# Build with --no-default-features --features windows7 and the win7 Rust target.
|
||||
windows7 = ["eframe/glow"]
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
|
||||
@@ -37,9 +35,6 @@ image = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
# The window's monitors, size and position (the same winit eframe runs on).
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
|
||||
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
|
||||
craft-print = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -49,6 +44,9 @@ clipboard-win = { workspace = true }
|
||||
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
|
||||
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
|
||||
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
|
||||
# Waits for Direct3D 12's adapter list before the window starts (src/gpu.rs, #4150). MIT OR
|
||||
# Apache-2.0, already in the build through eframe.
|
||||
pollster = "1"
|
||||
|
||||
# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font
|
||||
# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build
|
||||
@@ -56,7 +54,6 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o
|
||||
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
|
||||
# read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has
|
||||
# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
@@ -64,7 +61,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_G
|
||||
# with default features off). Pinned exactly: review the diff before bumping.
|
||||
muda = { version = "=0.21.2", default-features = false }
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
|
||||
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
||||
block2 = "0.6"
|
||||
@@ -76,6 +73,10 @@ raw-window-handle = "0.6"
|
||||
# objc2-foundation.
|
||||
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
|
||||
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
|
||||
# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs).
|
||||
# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features.
|
||||
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] }
|
||||
objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] }
|
||||
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
|
||||
# already in the build through eframe.
|
||||
pollster = "1"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
|
||||
use super::gpu;
|
||||
use super::prefs::save_prefs;
|
||||
use super::*;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
use super::windows_stylus;
|
||||
use super::{App, VectorcraftApp, file_access, window};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::{mac_menu, mac_window, open_documents};
|
||||
|
||||
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
|
||||
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
|
||||
@@ -10,12 +16,12 @@ pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option<vectorcraft_ui_e
|
||||
app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook)
|
||||
}
|
||||
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
|
||||
Some(std::sync::Arc::new(windows_stylus::raw_input_hook))
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))]
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
|
||||
None
|
||||
}
|
||||
@@ -41,9 +47,13 @@ impl eframe::App for App {
|
||||
mac_menu::raw_input_hook(raw);
|
||||
}
|
||||
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
windows_stylus::raw_input_hook(raw);
|
||||
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
|
||||
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
|
||||
// that no mouse button is down.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0);
|
||||
}
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
@@ -53,6 +63,9 @@ impl eframe::App for App {
|
||||
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
|
||||
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
|
||||
}
|
||||
// macOS converts the frame from sRGB to the display's profile (#2249).
|
||||
#[cfg(all(target_os = "macos", feature = "wgpu"))]
|
||||
super::mac_color_space::tag(ui);
|
||||
file_access::unconfined(|| self.app.ui(ui));
|
||||
// Preferences are written when confirmed, not only at exit: a session ended any other way
|
||||
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
|
||||
@@ -65,15 +78,10 @@ impl eframe::App for App {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
file_access::unconfined(|| save_prefs(&self.app));
|
||||
end_before_teardown(self.frames);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
file_access::unconfined(|| save_prefs(&self.app));
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
|
||||
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
|
||||
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol.
|
||||
//! copied), falling back to X11 where the compositor lacks the protocol. A clipboard that can't
|
||||
//! be opened is tried again after a wait that grows to a minute, not each time Paste asks.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
|
||||
@@ -164,7 +165,9 @@ mod win {
|
||||
|
||||
use clipboard_win::{formats, options::NoClear, raw};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
|
||||
};
|
||||
|
||||
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
|
||||
/// other or a `CF_BITMAP` is: the system converts between them.
|
||||
@@ -308,8 +311,10 @@ mod win {
|
||||
#[cfg_attr(windows, allow(dead_code))]
|
||||
mod portable {
|
||||
use std::borrow::Cow;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
|
||||
|
||||
/// What our last write put on the clipboard.
|
||||
enum Ours {
|
||||
@@ -317,19 +322,113 @@ mod portable {
|
||||
Image(usize, usize),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
/// Who asks for the clipboard: the background paste check waits out the whole wait after a
|
||||
/// failure, a Copy or Paste of the user's [`USER_RETRY`] at most.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum Asker {
|
||||
Probe,
|
||||
User,
|
||||
}
|
||||
|
||||
/// The wait after the first failure to open the clipboard; it doubles with each failed retry.
|
||||
const FIRST_WAIT: Duration = Duration::from_secs(1);
|
||||
/// The longest wait between retries.
|
||||
const MAX_WAIT: Duration = Duration::from_secs(60);
|
||||
/// A Copy or Paste of the user's retries at most this often.
|
||||
const USER_RETRY: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Whether "unavailable" has been logged and not yet answered by "available again": the
|
||||
/// clipboard and its background check hold a handle each, and their failures are one outage.
|
||||
static UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A failure to open, and when to try again.
|
||||
struct Failed {
|
||||
at: Instant,
|
||||
wait: Duration,
|
||||
}
|
||||
|
||||
/// A handle opened on first use and kept, or after a failure opened again only once its wait is
|
||||
/// over: where the clipboard can't be opened, asking a few times a second must not reconnect each time.
|
||||
pub(super) struct Reopen<B> {
|
||||
handle: Option<B>,
|
||||
failed: Option<Failed>,
|
||||
/// The outage flag shared with the other handles ([`UNAVAILABLE_LOGGED`]).
|
||||
logged: &'static AtomicBool,
|
||||
}
|
||||
|
||||
impl<B> Reopen<B> {
|
||||
pub(super) fn new(logged: &'static AtomicBool) -> Self {
|
||||
Self { handle: None, failed: None, logged }
|
||||
}
|
||||
|
||||
/// The open handle, opened with `open` when there is none and `asker` may try at `now`.
|
||||
/// `name` names an opened handle in the log.
|
||||
pub(super) fn get(
|
||||
&mut self,
|
||||
asker: Asker,
|
||||
now: Instant,
|
||||
open: impl FnOnce() -> Result<B, String>,
|
||||
name: impl Fn(&B) -> &'static str,
|
||||
) -> Option<&mut B> {
|
||||
if self.handle.is_none() {
|
||||
if let Some(f) = &self.failed {
|
||||
let wait = match asker {
|
||||
Asker::Probe => f.wait,
|
||||
Asker::User => f.wait.min(USER_RETRY),
|
||||
};
|
||||
if now.saturating_duration_since(f.at) < wait {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
match open() {
|
||||
Ok(handle) => {
|
||||
if self.logged.swap(false, Ordering::Relaxed) {
|
||||
log::info!("the system clipboard is available again, through {}", name(&handle));
|
||||
} else {
|
||||
log::debug!("system clipboard opened through {}", name(&handle));
|
||||
}
|
||||
self.failed = None;
|
||||
self.handle = Some(handle);
|
||||
}
|
||||
Err(why) => {
|
||||
let wait = match &self.failed {
|
||||
Some(f) => (f.wait * 2).min(MAX_WAIT),
|
||||
None => FIRST_WAIT,
|
||||
};
|
||||
if self.logged.swap(true, Ordering::Relaxed) {
|
||||
log::debug!("the system clipboard is still unavailable ({why}); next try in {wait:?}");
|
||||
} else {
|
||||
log::warn!("the system clipboard is unavailable ({why}): Copy and Paste stay inside VectorCraft");
|
||||
}
|
||||
self.failed = Some(Failed { at: now, wait });
|
||||
}
|
||||
}
|
||||
}
|
||||
self.handle.as_mut()
|
||||
}
|
||||
|
||||
/// The handle if one is open, without trying to open one.
|
||||
pub(super) fn opened(&mut self) -> Option<&mut B> {
|
||||
self.handle.as_mut()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Clipboard {
|
||||
/// Kept open: on Linux the copied data lives as long as the handle does.
|
||||
cb: Option<arboard::Clipboard>,
|
||||
cb: Reopen<arboard::Clipboard>,
|
||||
ours: Option<Ours>,
|
||||
}
|
||||
|
||||
impl Default for Clipboard {
|
||||
fn default() -> Self {
|
||||
Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), ours: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> {
|
||||
if self.cb.is_none() {
|
||||
self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
|
||||
}
|
||||
self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string())
|
||||
fn cb(&mut self, asker: Asker) -> Result<&mut arboard::Clipboard, String> {
|
||||
let open = || arboard::Clipboard::new().map_err(|e| e.to_string());
|
||||
self.cb.get(asker, Instant::now(), open, |_| "arboard").ok_or_else(|| "no system clipboard".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +439,7 @@ mod portable {
|
||||
Some(_) => None,
|
||||
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
|
||||
};
|
||||
let cb = self.cb()?;
|
||||
let cb = self.cb(Asker::User)?;
|
||||
let ours = match (text, image) {
|
||||
(Some(t), _) => {
|
||||
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
|
||||
@@ -361,7 +460,7 @@ mod portable {
|
||||
}
|
||||
|
||||
fn holds_ours(&mut self) -> bool {
|
||||
let Some(cb) = self.cb.as_mut() else { return false };
|
||||
let Some(cb) = self.cb.opened() else { return false };
|
||||
match &self.ours {
|
||||
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
|
||||
Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
|
||||
@@ -370,7 +469,7 @@ mod portable {
|
||||
}
|
||||
|
||||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
let cb = self.cb().ok()?;
|
||||
let cb = self.cb(Asker::User).ok()?;
|
||||
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
|
||||
return Some(f);
|
||||
}
|
||||
@@ -400,7 +499,126 @@ mod portable {
|
||||
if super::mac::has(mimes) {
|
||||
return true;
|
||||
}
|
||||
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A clock that starts now and moves only when told.
|
||||
struct Clock(Cell<Instant>);
|
||||
|
||||
impl Clock {
|
||||
fn new() -> Self {
|
||||
Self(Cell::new(Instant::now()))
|
||||
}
|
||||
fn now(&self) -> Instant {
|
||||
self.0.get()
|
||||
}
|
||||
fn advance(&self, by: Duration) {
|
||||
self.0.set(self.0.get() + by);
|
||||
}
|
||||
}
|
||||
|
||||
fn fail() -> Result<u8, String> {
|
||||
Err("no compositor".into())
|
||||
}
|
||||
|
||||
fn name(_: &u8) -> &'static str {
|
||||
"fake"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_paste_check_retries_with_a_growing_wait() {
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
let (clock, attempts) = (Clock::new(), Cell::new(0));
|
||||
let mut cb = Reopen::new(&LOGGED);
|
||||
// Every 250 ms for a minute, as `clipboard_probe` asks.
|
||||
for _ in 0..240 {
|
||||
let open = || {
|
||||
attempts.set(attempts.get() + 1);
|
||||
fail()
|
||||
};
|
||||
assert!(cb.get(Asker::Probe, clock.now(), open, name).is_none());
|
||||
clock.advance(Duration::from_millis(250));
|
||||
}
|
||||
// 0, 1, 3, 7, 15 and 31 s: not one connection a check.
|
||||
assert_eq!(attempts.get(), 6);
|
||||
assert!(LOGGED.load(Ordering::Relaxed), "the outage is logged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wait_stops_growing_at_a_minute() {
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
let (clock, attempts) = (Clock::new(), Cell::new(0));
|
||||
let mut cb = Reopen::new(&LOGGED);
|
||||
for _ in 0..(20 * 60) {
|
||||
let open = || {
|
||||
attempts.set(attempts.get() + 1);
|
||||
fail()
|
||||
};
|
||||
let _ = cb.get(Asker::Probe, clock.now(), open, name);
|
||||
clock.advance(Duration::from_secs(1));
|
||||
}
|
||||
// 0, 1, 3, 7, 15, 31, 63 s, then a minute apart until 20 minutes are up.
|
||||
assert_eq!(attempts.get(), 7 + (20 * 60 - 63 - 1) / 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_or_paste_retries_at_most_once_a_second() {
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
let (clock, attempts) = (Clock::new(), Cell::new(0));
|
||||
let mut cb = Reopen::new(&LOGGED);
|
||||
let mut ask = |asker| {
|
||||
let open = || {
|
||||
attempts.set(attempts.get() + 1);
|
||||
fail()
|
||||
};
|
||||
let _ = cb.get(asker, clock.now(), open, name);
|
||||
};
|
||||
// Long after the paste check gave up for a minute, a held Ctrl+V for 3 s at 30 a second.
|
||||
for _ in 0..8 {
|
||||
ask(Asker::Probe);
|
||||
clock.advance(Duration::from_secs(70));
|
||||
}
|
||||
let before = attempts.get();
|
||||
for _ in 0..90 {
|
||||
ask(Asker::User);
|
||||
clock.advance(Duration::from_millis(33));
|
||||
}
|
||||
assert_eq!(attempts.get() - before, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_success_is_kept_and_ends_the_outage() {
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
let clock = Clock::new();
|
||||
let mut cb = Reopen::new(&LOGGED);
|
||||
assert!(cb.get(Asker::Probe, clock.now(), fail, name).is_none());
|
||||
assert!(LOGGED.load(Ordering::Relaxed));
|
||||
clock.advance(FIRST_WAIT);
|
||||
assert_eq!(cb.get(Asker::Probe, clock.now(), || Ok(7), name).copied(), Some(7));
|
||||
assert!(!LOGGED.load(Ordering::Relaxed), "the recovery is logged");
|
||||
// Kept: not opened again, even where opening would now fail.
|
||||
assert_eq!(cb.get(Asker::Probe, clock.now(), fail, name).copied(), Some(7));
|
||||
assert_eq!(cb.opened().copied(), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_outage_is_logged_once_across_handles() {
|
||||
static LOGGED: AtomicBool = AtomicBool::new(false);
|
||||
let clock = Clock::new();
|
||||
let (mut ui, mut probe) = (Reopen::new(&LOGGED), Reopen::new(&LOGGED));
|
||||
assert!(probe.get(Asker::Probe, clock.now(), fail, name).is_none());
|
||||
assert!(LOGGED.load(Ordering::Relaxed));
|
||||
// The other handle's failure is the same outage: `swap` reports it already logged.
|
||||
assert!(ui.get(Asker::User, clock.now(), fail, name).is_none());
|
||||
assert!(LOGGED.load(Ordering::Relaxed));
|
||||
assert!(probe.opened().is_none() && ui.opened().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,7 +631,7 @@ mod mac {
|
||||
use objc2_app_kit::NSPasteboard;
|
||||
use objc2_foundation::{NSArray, NSString};
|
||||
|
||||
use super::*;
|
||||
use super::{Flavour, pasteboard_types};
|
||||
|
||||
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
|
||||
pub fn has(mimes: &[&'static str]) -> bool {
|
||||
|
||||
@@ -4,12 +4,18 @@ fn short_timeouts() -> ConnectionTimeouts {
|
||||
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
|
||||
}
|
||||
|
||||
/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a
|
||||
/// busy machine either thread can pause for more than 150 ms between connecting and sending, or
|
||||
/// between starting a reply and writing it, and a short budget then closes the connection with no
|
||||
/// reply.
|
||||
/// How long a test waits for the server to reply or to close a connection. The wait only ends a
|
||||
/// test whose server never does either; the budgets under test are 120 and 150 ms, and 1 s in the
|
||||
/// persistent test. On a busy machine the server's thread can pause for seconds before or between
|
||||
/// its reads and writes, and a wait of a few seconds then fails a server that closes the
|
||||
/// connection on time.
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// The short line deadline, with idle and reply budgets longer than [`WAIT`]. On a busy machine
|
||||
/// either thread can pause for more than 150 ms between connecting and sending, or between
|
||||
/// starting a reply and writing it, and a short budget then closes the connection with no reply.
|
||||
fn line_timeouts() -> ConnectionTimeouts {
|
||||
ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() }
|
||||
ConnectionTimeouts { idle: Duration::from_secs(30), write: Duration::from_secs(30), ..short_timeouts() }
|
||||
}
|
||||
|
||||
fn socket_fixture(
|
||||
@@ -27,12 +33,12 @@ fn socket_fixture(
|
||||
});
|
||||
let client = TcpStream::connect(addr).unwrap();
|
||||
socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
client.set_read_timeout(Some(WAIT)).unwrap();
|
||||
(client, done, worker)
|
||||
}
|
||||
|
||||
fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) {
|
||||
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
|
||||
let stopped = done.recv_timeout(WAIT).is_ok();
|
||||
// Always unblock the old implementation too, so a failed regression leaves no worker behind.
|
||||
let _ = client.shutdown(std::net::Shutdown::Both);
|
||||
drop(client);
|
||||
@@ -93,7 +99,7 @@ fn trickled_control_line_has_an_absolute_deadline() {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
|
||||
let stopped = done.recv_timeout(WAIT).is_ok();
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
// Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well.
|
||||
let _ = client.shutdown(std::net::Shutdown::Both);
|
||||
@@ -110,26 +116,31 @@ fn a_nonreading_control_peer_cannot_retain_the_writer() {
|
||||
expect_socket_deadline(client, done, worker);
|
||||
}
|
||||
|
||||
/// Each request gets its own line and reply budgets. The handler takes longer than both budgets,
|
||||
/// so a deadline kept from the previous request runs out. The line and reply budgets are 1 s, and
|
||||
/// the idle budget is 30 s, longer than the client's [`WAIT`]: on a busy machine either thread can
|
||||
/// pause for more than the short budgets of [`short_timeouts`] between starting a budget and the
|
||||
/// read or write it covers.
|
||||
#[test]
|
||||
fn persistent_control_requests_get_fresh_transport_deadlines() {
|
||||
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() };
|
||||
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(30), line: Duration::from_secs(1), write: Duration::from_secs(1) };
|
||||
let (mut client, done, worker) = socket_fixture(timeouts, |_, _| {
|
||||
// Application processing is not transport time; begin the reply budget afterwards.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
std::thread::sleep(Duration::from_millis(1250));
|
||||
Some(json!({"ok": true}))
|
||||
});
|
||||
let mut reader = BufReader::new(client.try_clone().unwrap());
|
||||
for id in [1, 2] {
|
||||
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
|
||||
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
|
||||
// between two of them closes the connection.
|
||||
// small writes, and when the machine is busy, a pause of more than the line deadline between
|
||||
// two of them closes the connection.
|
||||
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
|
||||
}
|
||||
client.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
done.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
done.recv_timeout(WAIT).unwrap();
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
@@ -155,7 +166,7 @@ fn timed_out_worker_returns_its_connection_slot() {
|
||||
let (tx, _rx) = channel();
|
||||
let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) {
|
||||
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < WAIT {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS;
|
||||
@@ -322,7 +333,7 @@ fn tcp_connection_is_closed_after_junk() {
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.set_read_timeout(Some(WAIT)).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
@@ -373,7 +384,7 @@ fn extra_connections_are_refused() {
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
extra.set_read_timeout(Some(WAIT)).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
|
||||
@@ -126,13 +126,60 @@ pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) ->
|
||||
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
|
||||
}
|
||||
|
||||
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
|
||||
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
|
||||
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
|
||||
/// `igvk64.dll`, #806) crashed the app before its window appeared. DX12 is Windows' own backend,
|
||||
/// as in PdfCraft. Elsewhere, `default`.
|
||||
pub fn backends(default: Backends, env_set: bool) -> Backends {
|
||||
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
|
||||
/// The graphics backends the window starts with: on Windows Direct3D 12, with OpenGL beside it
|
||||
/// only when `opengl` ([`windows_opengl`]), unless `WGPU_BACKEND` names others (`env_set`;
|
||||
/// `default` then holds them). Creating a Vulkan instance loads every installed Vulkan driver into
|
||||
/// the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window
|
||||
/// appeared. DX12 is Windows' own backend, as in PdfCraft. Elsewhere, `default`.
|
||||
pub fn backends(default: Backends, env_set: bool, opengl: bool) -> Backends {
|
||||
match (cfg!(windows) && !env_set, opengl) {
|
||||
(true, true) => Backends::DX12 | Backends::GL,
|
||||
(true, false) => Backends::DX12,
|
||||
(false, _) => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Windows start lists OpenGL's adapters beside Direct3D 12's: only when it leaves an
|
||||
/// adapter out (an earlier start crashed on it, or a restart failed on it), when
|
||||
/// `WGPU_ADAPTER_NAME` names one (it may be an OpenGL adapter), or when DX12 lists no hardware
|
||||
/// adapter (Windows 7, or a GPU without a DX12 driver, which then draws through OpenGL as before
|
||||
/// rather than on the CPU with WARP). wgpu's OpenGL backend keeps a hidden window on a thread that
|
||||
/// pumps no messages, and Windows waits on it when the input language changes, so the whole app
|
||||
/// hung on a switch to another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
|
||||
pub fn windows_opengl(left_out: &LeftOut) -> bool {
|
||||
opengl_needed(left_out, !skipped().is_empty(), std::env::var_os(NAME_ENV).is_some(), dx12_lists_hardware)
|
||||
}
|
||||
|
||||
/// [`windows_opengl`], with a restart leaving adapters out (`skipping`) and an adapter named
|
||||
/// (`named`): `dx12_hardware` is asked only when nothing else decides.
|
||||
fn opengl_needed(left_out: &LeftOut, skipping: bool, named: bool, dx12_hardware: impl FnOnce() -> bool) -> bool {
|
||||
left_out.opengl_only || !left_out.adapters.is_empty() || skipping || named || !dx12_hardware()
|
||||
}
|
||||
|
||||
/// Whether Direct3D 12 lists a hardware adapter; WARP, its software one, doesn't count. A driver
|
||||
/// that crashes here crashes a start while it lists the adapters, which the next start answers with
|
||||
/// OpenGL alone.
|
||||
#[cfg(windows)]
|
||||
fn dx12_lists_hardware() -> bool {
|
||||
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
descriptor.backends = Backends::DX12;
|
||||
// The shader compiler the window's own instance takes (#1194).
|
||||
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::from_env().unwrap_or(wgpu::Dx12Compiler::Fxc);
|
||||
let instance = wgpu::Instance::new(descriptor);
|
||||
let kinds: Vec<DeviceType> = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).iter().map(|a| a.get_info().device_type).collect();
|
||||
log::info!("Direct3D 12 lists these graphics adapters: {kinds:?}");
|
||||
any_hardware(&kinds)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn dx12_lists_hardware() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Is one of the adapters of these kinds hardware, not software such as WARP?
|
||||
#[cfg(any(windows, test))]
|
||||
fn any_hardware(kinds: &[DeviceType]) -> bool {
|
||||
kinds.iter().any(|k| *k != DeviceType::Cpu)
|
||||
}
|
||||
|
||||
/// The largest texture side to ask the device for, which bounds the window's surface: the
|
||||
|
||||
@@ -451,13 +451,50 @@ fn a_windows_gpu_renders_through_dx12_before_vulkan() {
|
||||
#[test]
|
||||
fn windows_never_loads_vulkan_drivers_unless_asked() {
|
||||
let all = Backends::all();
|
||||
let start = backends(all, false);
|
||||
let start = backends(all, false, false);
|
||||
let fallback = backends(all, false, true);
|
||||
if cfg!(windows) {
|
||||
assert_eq!(start, Backends::DX12 | Backends::GL, "#806");
|
||||
assert_eq!(start, Backends::DX12, "#806, and no OpenGL unless needed (#4150)");
|
||||
assert_eq!(fallback, Backends::DX12 | Backends::GL);
|
||||
} else {
|
||||
assert_eq!(start, all);
|
||||
assert_eq!((start, fallback), (all, all));
|
||||
}
|
||||
assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
|
||||
assert_eq!(backends(Backends::VULKAN, true, false), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
|
||||
assert_eq!(backends(Backends::GL, true, false), Backends::GL, "WGPU_BACKEND=gl still picks it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_lists_opengl_once_a_start_leaves_an_adapter_out() {
|
||||
let crashed = LeftOut { adapters: vec!["Dx12:8086:3ea0".into()], ..LeftOut::default() };
|
||||
assert!(windows_opengl(&crashed), "#2243: the next start can fall back to OpenGL");
|
||||
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
|
||||
assert!(windows_opengl(&listing));
|
||||
assert_eq!(listing.backends(backends(Backends::all(), false, true)), Backends::GL);
|
||||
}
|
||||
|
||||
/// OpenGL stays where it drew before #4150's fix: a GPU without a Direct3D 12 driver (DX12 lists
|
||||
/// WARP alone) and an adapter `WGPU_ADAPTER_NAME` names; a start with a DX12 GPU lists DX12 alone.
|
||||
#[test]
|
||||
fn windows_lists_opengl_without_a_dx12_gpu_or_with_an_adapter_named() {
|
||||
let start = LeftOut::default();
|
||||
let asked = std::cell::Cell::new(false);
|
||||
let hardware = |found: bool| {
|
||||
asked.set(true);
|
||||
found
|
||||
};
|
||||
assert!(!opengl_needed(&start, false, false, || hardware(true)), "a DX12 GPU: DX12 alone (#4150)");
|
||||
assert!(asked.replace(false));
|
||||
assert!(opengl_needed(&start, false, false, || hardware(false)), "WARP alone: the GPU draws through OpenGL, not the CPU");
|
||||
assert!(opengl_needed(&start, false, true, || hardware(true)), "a named adapter may be an OpenGL one");
|
||||
assert!(opengl_needed(&start, true, false, || hardware(true)), "a restart left an adapter out");
|
||||
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
|
||||
asked.set(false);
|
||||
assert!(opengl_needed(&listing, false, false, || hardware(true)));
|
||||
assert!(!asked.get(), "DX12 isn't listed once OpenGL must be");
|
||||
assert!(any_hardware(&[DeviceType::Cpu, DeviceType::IntegratedGpu]));
|
||||
assert!(any_hardware(&[DeviceType::VirtualGpu]));
|
||||
assert!(!any_hardware(&[DeviceType::Cpu]), "WARP");
|
||||
assert!(!any_hardware(&[]), "Windows 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -548,11 +585,14 @@ fn a_normal_exit_or_another_failure_does_not_restart() {
|
||||
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
|
||||
}
|
||||
|
||||
/// A log folder of its own for each test.
|
||||
fn start_dir(tag: &str) -> std::path::PathBuf {
|
||||
/// A log folder of its own for each test, and the guard that keeps other tests from starting a
|
||||
/// program while it runs: the test releases the start file's lock and takes it again, and a child
|
||||
/// starting meanwhile would keep it held (`crate::no_spawns`).
|
||||
fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) {
|
||||
let spawns = crate::no_spawns();
|
||||
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
(d, spawns)
|
||||
}
|
||||
|
||||
/// What a start in `dir` leaves out; it then ends without crashing.
|
||||
@@ -578,7 +618,7 @@ fn issue_2243() -> Vec<Candidate> {
|
||||
/// it, without a status-bar note.
|
||||
#[test]
|
||||
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
|
||||
let dir = start_dir("crash");
|
||||
let (dir, _spawns) = start_dir("crash");
|
||||
let c = issue_2243();
|
||||
let first = Startup::default();
|
||||
assert_eq!(first.track(&dir, None, false), LeftOut::default());
|
||||
@@ -609,7 +649,7 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
|
||||
/// an error, a restart on another adapter), leaves nothing out of the next.
|
||||
#[test]
|
||||
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
|
||||
let dir = start_dir("shown");
|
||||
let (dir, _spawns) = start_dir("shown");
|
||||
let c = issue_2243();
|
||||
for end in [Startup::presented as fn(&Startup), Startup::ended] {
|
||||
let first = Startup::default();
|
||||
@@ -633,7 +673,7 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
|
||||
/// macOS, where wgpu has none.
|
||||
#[test]
|
||||
fn a_crash_while_listing_lists_opengl_alone() {
|
||||
let dir = start_dir("listing");
|
||||
let (dir, _spawns) = start_dir("listing");
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
drop(first);
|
||||
@@ -660,7 +700,7 @@ fn a_crash_while_listing_lists_opengl_alone() {
|
||||
/// start shows its first frame on the adapter that crashed, the adapter counts again.
|
||||
#[test]
|
||||
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
|
||||
let dir = start_dir("explicit");
|
||||
let (dir, _spawns) = start_dir("explicit");
|
||||
let c = issue_2243();
|
||||
let first = Startup::default();
|
||||
first.track(&dir, None, false);
|
||||
@@ -681,7 +721,7 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
|
||||
/// are ignored: the start goes on with nothing left out.
|
||||
#[test]
|
||||
fn a_damaged_or_unreadable_start_file_is_ignored() {
|
||||
let dir = start_dir("damaged");
|
||||
let (dir, _spawns) = start_dir("damaged");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let damaged: [&[u8]; 9] = [
|
||||
b"",
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
use super::*;
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
use super::portal_save;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::tablet;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
use super::windows_stylus;
|
||||
use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::{mac_save_format, mac_screen_color, mac_tablet};
|
||||
|
||||
/// The window file dialogs belong to.
|
||||
pub(crate) type Parent = Option<std::sync::Arc<winit::window::Window>>;
|
||||
@@ -266,7 +274,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
|
||||
#[cfg(target_os = "macos")]
|
||||
stylus: mac_tablet::start(),
|
||||
// Windows reads it from the pen's pointer messages (`windows_stylus`).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
stylus: Some(windows_stylus::start()),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(write_file)),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to
|
||||
//! the display's color profile (#2249, #3161).
|
||||
//!
|
||||
//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a
|
||||
//! color space is present, Core Animation performs any necessary color space transformations when
|
||||
//! compositing this content. The default value is `nil`, indicating that the rendered content
|
||||
//! isn’t color-matched"
|
||||
//! (<https://developer.apple.com/documentation/quartzcore/cametallayer/colorspace>). Without a
|
||||
//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as
|
||||
//! the display's most saturated red, brighter than the same color in an exported PNG.
|
||||
//!
|
||||
//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on
|
||||
//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui
|
||||
//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`]
|
||||
//! adds a paint callback that draws nothing. egui calls it after the configuration and before the
|
||||
//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A
|
||||
//! layer that already has a color space keeps it.
|
||||
//!
|
||||
//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu};
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2::rc::Retained;
|
||||
use objc2::{DowncastTarget, Message};
|
||||
use objc2_app_kit::NSApplication;
|
||||
use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB};
|
||||
use objc2_quartz_core::{CALayer, CAMetalLayer};
|
||||
|
||||
/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its
|
||||
/// top-left corner, in the background layer, with nothing drawn.
|
||||
pub fn tag(ui: &egui::Ui) {
|
||||
let ctx = ui.ctx();
|
||||
let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0));
|
||||
ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers));
|
||||
}
|
||||
|
||||
struct TagLayers;
|
||||
|
||||
impl CallbackTrait for TagLayers {
|
||||
fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) {
|
||||
// AppKit's windows and views are read on the main thread only, where eframe paints.
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
for window in NSApplication::sharedApplication(mtm).windows() {
|
||||
if let Some(root) = window.contentView().and_then(|view| view.layer()) {
|
||||
for layer in metal_layers(&root) {
|
||||
tag_srgb(&layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu
|
||||
/// adds to it when it is a plain layer.
|
||||
fn metal_layers(root: &CALayer) -> Vec<Retained<CAMetalLayer>> {
|
||||
if let Some(layer) = downcast(root) {
|
||||
return vec![layer];
|
||||
}
|
||||
// SAFETY: the returned array is a copy; the layers in it are retained while it lives.
|
||||
let Some(sublayers) = (unsafe { root.sublayers() }) else {
|
||||
return Vec::new();
|
||||
};
|
||||
sublayers.iter().filter_map(|layer| downcast(&layer)).collect()
|
||||
}
|
||||
|
||||
fn downcast<T: DowncastTarget>(layer: &CALayer) -> Option<Retained<T>> {
|
||||
layer.retain().downcast::<T>().ok()
|
||||
}
|
||||
|
||||
/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set.
|
||||
fn tag_srgb(layer: &CAMetalLayer) -> bool {
|
||||
if layer.colorspace().is_some() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: a constant that CoreGraphics exports.
|
||||
let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else {
|
||||
return false;
|
||||
};
|
||||
layer.setColorspace(Some(&srgb));
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use objc2_core_foundation::CFString;
|
||||
use objc2_core_graphics::kCGColorSpaceDisplayP3;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn space_name(layer: &CAMetalLayer) -> Option<String> {
|
||||
CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string())
|
||||
}
|
||||
|
||||
fn name(constant: &CFString) -> String {
|
||||
constant.to_string()
|
||||
}
|
||||
|
||||
/// A layer without a color space is tagged sRGB once; the second call leaves it as it is.
|
||||
#[test]
|
||||
fn an_untagged_layer_becomes_srgb() {
|
||||
let layer = CAMetalLayer::new();
|
||||
assert_eq!(space_name(&layer), None, "a new layer has no color space");
|
||||
assert!(tag_srgb(&layer));
|
||||
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
|
||||
assert!(!tag_srgb(&layer));
|
||||
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
|
||||
}
|
||||
|
||||
/// A layer with a color space of its own keeps it.
|
||||
#[test]
|
||||
fn a_layer_with_a_color_space_keeps_it() {
|
||||
let layer = CAMetalLayer::new();
|
||||
let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap();
|
||||
layer.setColorspace(Some(&p3));
|
||||
assert!(!tag_srgb(&layer));
|
||||
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 })));
|
||||
}
|
||||
|
||||
/// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal
|
||||
/// layers are returned.
|
||||
#[test]
|
||||
fn metal_layers_are_found_under_the_view_layer() {
|
||||
let metal = CAMetalLayer::new();
|
||||
assert_eq!(metal_layers(&metal), vec![metal.clone()]);
|
||||
|
||||
let root = CALayer::new();
|
||||
assert!(metal_layers(&root).is_empty());
|
||||
root.addSublayer(&CALayer::new());
|
||||
root.addSublayer(&metal);
|
||||
assert_eq!(metal_layers(&root), vec![metal.clone()]);
|
||||
for layer in metal_layers(&root) {
|
||||
tag_srgb(&layer);
|
||||
}
|
||||
assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB })));
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,6 @@
|
||||
//! keyboard isn't confined.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
|
||||
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
|
||||
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
|
||||
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
|
||||
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
|
||||
compile_error!("the win7 target requires --no-default-features --features windows7");
|
||||
|
||||
mod automation_args;
|
||||
mod clipboard;
|
||||
#[cfg(feature = "wgpu")]
|
||||
@@ -33,6 +26,8 @@ mod gpu;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_stylus;
|
||||
mod logging;
|
||||
#[cfg(all(target_os = "macos", feature = "wgpu"))]
|
||||
mod mac_color_space;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_fonts;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -50,7 +45,7 @@ mod open_documents;
|
||||
mod portal_save;
|
||||
mod prefs_dir;
|
||||
mod printing;
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
mod system_fonts;
|
||||
mod tablet;
|
||||
mod window;
|
||||
@@ -190,7 +185,7 @@ fn main() -> std::process::ExitCode {
|
||||
let logger = logging::install();
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
// Before the first font scan (the app's start): the fonts font services load (#579).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
system_fonts::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
@@ -295,7 +290,7 @@ fn main() -> std::process::ExitCode {
|
||||
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
|
||||
// is down; the message hook notes each pointer contact and whether it starts with the barrel
|
||||
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
let options = {
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows as _;
|
||||
let mut options = options;
|
||||
@@ -318,8 +313,9 @@ fn main() -> std::process::ExitCode {
|
||||
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
|
||||
create.instance_descriptor.flags =
|
||||
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
create.instance_descriptor.backends =
|
||||
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
|
||||
let env_set = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let opengl = cfg!(windows) && !env_set && gpu::windows_opengl(&left_out);
|
||||
create.instance_descriptor.backends = left_out.backends(gpu::backends(create.instance_descriptor.backends, env_set, opengl));
|
||||
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
|
||||
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
|
||||
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
|
||||
@@ -436,10 +432,6 @@ fn main() -> std::process::ExitCode {
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -494,5 +486,28 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests only: while a test starts another program, the child briefly holds a copy of every file
|
||||
/// this process has open, until it runs that program. A file lock held through such a copy outlives
|
||||
/// its own handle, so a GPU start test that released the start file and opened it again found it
|
||||
/// still locked, as if another copy of the app were running, and failed. A test that starts a
|
||||
/// program holds [`spawning`]; a test that depends on the start file's lock holds [`no_spawns`].
|
||||
#[cfg(all(
|
||||
test,
|
||||
any(feature = "wgpu", any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))
|
||||
))]
|
||||
static CHILD_SPAWN: std::sync::RwLock<()> = std::sync::RwLock::new(());
|
||||
|
||||
/// Held while a test starts another program (see [`CHILD_SPAWN`]).
|
||||
#[cfg(all(test, any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))]
|
||||
fn spawning() -> std::sync::RwLockWriteGuard<'static, ()> {
|
||||
CHILD_SPAWN.write().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Held by a test that releases a locked file and opens it again (see [`CHILD_SPAWN`]).
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
fn no_spawns() -> std::sync::RwLockReadGuard<'static, ()> {
|
||||
CHILD_SPAWN.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
mod tests;
|
||||
|
||||
@@ -169,28 +169,37 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<V
|
||||
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use zbus::blocking::Proxy;
|
||||
use zbus::blocking::{MessageIterator, Proxy};
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
|
||||
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
|
||||
const REQUEST: &str = "org.freedesktop.portal.Request";
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
// Listen before asking, so that an answer that comes at once is not missed.
|
||||
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
// Listen before asking, on every request path: a portal older than version 0.9 ignores
|
||||
// `handle_token` and answers on the request path it returns, and its answer can come before
|
||||
// the call's reply. The stream keeps every `Response` from the time it is made.
|
||||
let rule = zbus::MatchRule::builder()
|
||||
.msg_type(zbus::message::Type::Signal)
|
||||
.sender(DESKTOP)
|
||||
.and_then(|b| b.interface(REQUEST))
|
||||
.and_then(|b| b.member("Response"))
|
||||
.map_err(|e| e.to_string())?
|
||||
.build();
|
||||
let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?;
|
||||
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
|
||||
// The title is empty, as rfd sends it for a save.
|
||||
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
|
||||
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
|
||||
// returns.
|
||||
let reply = if handle.as_str() == path {
|
||||
replies.next()
|
||||
} else {
|
||||
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
|
||||
}
|
||||
.ok_or("the save dialog closed without an answer")?;
|
||||
let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
|
||||
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
|
||||
// The answer is the `Response` on the path returned, from the connection that returned it.
|
||||
let portal = call.header().sender().map(|s| s.as_str().to_string());
|
||||
let reply = responses
|
||||
.filter_map(Result::ok)
|
||||
.find(|m| {
|
||||
let header = m.header();
|
||||
header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
|
||||
})
|
||||
.ok_or("the save dialog closed without an answer")?;
|
||||
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
answer(code, &results)
|
||||
}
|
||||
@@ -420,20 +429,34 @@ mod tests {
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
|
||||
|
||||
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
|
||||
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
|
||||
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
|
||||
/// is no `dbus-daemon` to run.
|
||||
/// How the mock portal answers `SaveFile`.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Portal {
|
||||
/// On the request path that `handle_token` names, after its reply.
|
||||
Current,
|
||||
/// As a portal older than 0.9: on a request path of its own, 300 ms after its reply, as a
|
||||
/// person answers the dialog.
|
||||
Old,
|
||||
/// As `Old`, with the `Response` sent before the reply that names its path.
|
||||
OldEarly,
|
||||
}
|
||||
|
||||
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
|
||||
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
|
||||
/// `None` when there is no `dbus-daemon` to run.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
|
||||
use std::io::BufRead as _;
|
||||
|
||||
use zbus::zvariant::OwnedObjectPath;
|
||||
let mut daemon = std::process::Command::new("dbus-daemon")
|
||||
let spawning = crate::spawning();
|
||||
let daemon = std::process::Command::new("dbus-daemon")
|
||||
.args(["--session", "--nofork", "--print-address=1"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
.spawn();
|
||||
drop(spawning);
|
||||
let mut daemon = daemon.ok()?;
|
||||
let mut address = String::new();
|
||||
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
|
||||
let address = address.trim().to_string();
|
||||
@@ -441,9 +464,12 @@ mod tests {
|
||||
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
|
||||
let seen = Seen::default();
|
||||
let log = seen.clone();
|
||||
// Made before the client exists: the connection drops a call that comes while no stream
|
||||
// reads it.
|
||||
let incoming = zbus::blocking::MessageIterator::from(&server);
|
||||
std::thread::spawn(move || {
|
||||
let mut script = script.into_iter();
|
||||
for msg in zbus::blocking::MessageIterator::from(&server) {
|
||||
for msg in incoming {
|
||||
let Ok(msg) = msg else { break };
|
||||
let header = msg.header();
|
||||
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
|
||||
@@ -460,16 +486,12 @@ mod tests {
|
||||
l.len()
|
||||
});
|
||||
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
|
||||
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
|
||||
let token = match portal {
|
||||
Portal::Current => text("handle_token").unwrap_or_default(),
|
||||
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
|
||||
};
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
|
||||
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
if own_paths {
|
||||
// As a person answers the dialog: after the caller listened on the path returned.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
let (code, pick) = script.next().unwrap_or((2, None));
|
||||
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
|
||||
if let Some((uri, label)) = pick {
|
||||
@@ -477,7 +499,18 @@ mod tests {
|
||||
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
|
||||
}
|
||||
let to = header.sender().cloned().map(zbus::names::BusName::from);
|
||||
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
|
||||
let respond = || server.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &results));
|
||||
if portal == Portal::OldEarly && respond().is_err() {
|
||||
break;
|
||||
}
|
||||
if server.reply(&header, &handle).is_err() {
|
||||
break;
|
||||
}
|
||||
if portal == Portal::Old {
|
||||
// As a person answers the dialog: after the caller has the path returned.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
if portal != Portal::OldEarly && respond().is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -486,20 +519,32 @@ mod tests {
|
||||
Some((client, seen, daemon))
|
||||
}
|
||||
|
||||
/// [`save_file`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails
|
||||
/// the test instead of leaving it waiting.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
fn save_within(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (conn, request) = (conn.clone(), request.clone());
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(save_file(&conn, &request));
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds")
|
||||
}
|
||||
|
||||
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
|
||||
/// name, and the answer names the file and the type picked; Cancel answers no file.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
|
||||
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
|
||||
let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
let saved = save_file(&conn, &request).unwrap();
|
||||
let saved = save_within(&conn, &request).unwrap();
|
||||
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
|
||||
assert_eq!(save_file(&conn, &request), Ok(None));
|
||||
assert_eq!(save_within(&conn, &request), Ok(None));
|
||||
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
|
||||
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
|
||||
let _ = daemon.kill();
|
||||
@@ -510,12 +555,28 @@ mod tests {
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
|
||||
/// kept until the path is known, and the next dialog does not take it.
|
||||
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
|
||||
#[test]
|
||||
fn an_answer_that_comes_before_the_path_is_kept() {
|
||||
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
|
||||
let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else {
|
||||
eprintln!("no dbus-daemon: skipped");
|
||||
return;
|
||||
};
|
||||
let request = request();
|
||||
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
|
||||
assert_eq!(save_within(&conn, &request), Ok(None));
|
||||
let _ = daemon.kill();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{VectorcraftApp, fileio, prefs_dir};
|
||||
|
||||
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
||||
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
|
||||
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
|
||||
//! Windows.Data.Pdf is unavailable there.
|
||||
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
|
||||
//! craft-print owns discovery and spooling.
|
||||
use std::process::Command;
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
|
||||
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
|
||||
mod windows7;
|
||||
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
|
||||
use windows7 as system;
|
||||
|
||||
pub struct SystemPrint;
|
||||
impl PrintService for SystemPrint {
|
||||
fn printers(&mut self) -> Vec<Printer> {
|
||||
@@ -36,9 +29,8 @@ impl PrintService for SystemPrint {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
|
||||
mod system {
|
||||
use super::*;
|
||||
use super::{Command, PrintJob, Printer};
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
|
||||
}
|
||||
@@ -95,7 +87,7 @@ mod system {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))]
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::system::setup_command;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use vectorcraft_ui_egui::print::{PrintJob, Printer};
|
||||
|
||||
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
|
||||
/// than an hour old (the print verb reads them after Print returns) are removed first.
|
||||
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
|
||||
let dir = std::env::temp_dir().join("vectorcraft-print");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
|
||||
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
|
||||
if old {
|
||||
// Another job may still be reading it: leave it for next time.
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
|
||||
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
|
||||
// truncate an existing path, and remove incomplete files or refused launches immediately.
|
||||
use std::io::Write as _;
|
||||
let mut file =
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
|
||||
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Err(format!("can't write {}: {e}", path.display()));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Run `c` → its output, or its error message.
|
||||
fn output(c: &mut Command) -> Result<String, String> {
|
||||
let program = c.get_program().to_string_lossy().to_string();
|
||||
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
|
||||
if out.status.success() {
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
|
||||
}
|
||||
|
||||
/// PowerShell's `<Default>\t<Name>` lines → the printers.
|
||||
fn windows_printers(text: &str) -> Vec<Printer> {
|
||||
text.lines()
|
||||
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
|
||||
.filter(|(_, name)| !name.trim().is_empty())
|
||||
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `s` as a PowerShell single-quoted string.
|
||||
fn ps_quote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn powershell(script: &str) -> Command {
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let mut c = Command::new(
|
||||
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
|
||||
.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe"),
|
||||
);
|
||||
c.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW);
|
||||
c
|
||||
}
|
||||
|
||||
pub fn printers() -> Vec<Printer> {
|
||||
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
|
||||
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
|
||||
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
|
||||
let path = ps_quote(&file.to_string_lossy());
|
||||
let script = match job.printer {
|
||||
Some(p) => format!(
|
||||
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
|
||||
ps_quote(&format!("\"{}\"", p.replace('"', "")))
|
||||
),
|
||||
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
|
||||
};
|
||||
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
|
||||
}
|
||||
|
||||
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
|
||||
let mut command = {
|
||||
let mut command = Command::new("control.exe");
|
||||
command.arg("printers");
|
||||
command
|
||||
};
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn print(job: &PrintJob) -> Result<String, String> {
|
||||
let file = job_file(job.pdf)?;
|
||||
let result = submit(job, &file);
|
||||
if result.is_err() {
|
||||
// No viewer accepted the launch, so no asynchronous reader needs this document.
|
||||
let _ = std::fs::remove_file(file);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn powershell_lines_and_quotes_are_preserved() {
|
||||
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
|
||||
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
|
||||
}
|
||||
#[test]
|
||||
fn job_files_hold_the_pdf_for_the_async_viewer() {
|
||||
let path = job_file(b"%PDF-1.7 test").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::window;
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::xwayland;
|
||||
|
||||
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
|
||||
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
|
||||
@@ -113,8 +115,6 @@ pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
//! mouse message that Windows did not make from a pen or a touch
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
|
||||
//! clears the feed.
|
||||
#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
|
||||
#![cfg_attr(not(windows), allow(dead_code))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::OnceLock;
|
||||
@@ -68,7 +68,7 @@ pub fn start() -> StylusFeed {
|
||||
/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
|
||||
/// and whether the barrel button was held, and writes the pen's pressure and end into the feed.
|
||||
/// It never consumes the message.
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[cfg(windows)]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
|
||||
use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO};
|
||||
|
||||
@@ -6,3 +6,4 @@ allow-panic-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.el-j]
|
||||
real_name = "Fabian Althaus"
|
||||
display_name = "faff (el-j)"
|
||||
sync_github_name = false
|
||||
|
||||
[people.lollinoo]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -169,3 +174,9 @@ sync_github_name = false
|
||||
real_name = "Andrés Ortega Montoya"
|
||||
display_name = "Andrés Ortega Montoya"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krosk93]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::read::MAX_TREE_DEPTH;
|
||||
use super::*;
|
||||
use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag};
|
||||
|
||||
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
|
||||
/// and tables), in document pixels.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream,
|
||||
Tag, paint, stream,
|
||||
};
|
||||
|
||||
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
|
||||
const MAX_PROFILE_BYTES: usize = 32 << 20;
|
||||
@@ -438,8 +441,34 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
_ => self.warn("layer effects of other kinds (left out)"),
|
||||
}
|
||||
}
|
||||
for a in s.objs(id, b"AdCh") {
|
||||
if let Some(effect) = self.attached_recolor(a) {
|
||||
out.push(effect);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
fn attached_recolor(&mut self, a: ObjId) -> Option<Effect> {
|
||||
let s = self.s;
|
||||
if !s.is(a, b"RcRA") {
|
||||
return None;
|
||||
}
|
||||
if s.bool(a, b"Visi") == Some(false) {
|
||||
return None;
|
||||
}
|
||||
let adjp = s.obj(a, b"AdjP")?;
|
||||
if !s.is(adjp, b"RecP") {
|
||||
return None;
|
||||
}
|
||||
let h = s.f64(adjp, b"RecH").unwrap_or(0.0);
|
||||
let sat = s.f64(adjp, b"RecS").unwrap_or(1.0);
|
||||
let l = s.f64(adjp, b"RecL").unwrap_or(0.5);
|
||||
let (r, g, b) = crate::paint::hsl(h, sat, l);
|
||||
let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 };
|
||||
let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let blend = self.effect_blend(a);
|
||||
Some(Effect::ColorOverlay { color, opacity, blend })
|
||||
}
|
||||
|
||||
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
|
||||
fn effect_blend(&mut self, e: ObjId) -> Blend {
|
||||
@@ -476,6 +505,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
|
||||
None => {}
|
||||
}
|
||||
} else if class == Tag::of(b"RcRA") {
|
||||
// Handled in `effects` as a colour overlay.
|
||||
} else {
|
||||
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
|
||||
})
|
||||
}
|
||||
|
||||
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = (h.rem_euclid(1.0)) * 6.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
@@ -355,3 +355,17 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
|
||||
}
|
||||
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hsl_lightness_1_is_white_and_0_is_black() {
|
||||
assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0));
|
||||
assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0));
|
||||
assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0));
|
||||
let (r, g, b) = hsl(0.0, 1.0, 0.5);
|
||||
assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +343,44 @@ pub enum PatternFit {
|
||||
Approximate,
|
||||
}
|
||||
|
||||
/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile
|
||||
/// made from the side tile.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CornerSource {
|
||||
/// No corner tile: the side tiles run up to the corner.
|
||||
None,
|
||||
/// One side tile stretched around the corner and centered on it.
|
||||
AutoCentered,
|
||||
/// A side tile on each side reaching into the corner, stretched so the two meet on the
|
||||
/// corner's diagonal.
|
||||
AutoBetween,
|
||||
/// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame.
|
||||
AutoSliced,
|
||||
/// A side tile on each side running past the corner, the two overlapping there.
|
||||
AutoOverlap,
|
||||
}
|
||||
|
||||
impl CornerSource {
|
||||
pub const ALL: [CornerSource; 5] =
|
||||
[CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
|
||||
|
||||
/// The id in a brush definition and in `brush.options` params.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CornerSource::None => "none",
|
||||
CornerSource::AutoCentered => "autoCentered",
|
||||
CornerSource::AutoBetween => "autoBetween",
|
||||
CornerSource::AutoSliced => "autoSliced",
|
||||
CornerSource::AutoOverlap => "autoOverlap",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<CornerSource> {
|
||||
CornerSource::ALL.into_iter().find(|a| a.id() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pattern brush: tiles repeated along the path, with corner and end tiles.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -352,6 +390,14 @@ pub struct PatternBrush {
|
||||
pub outer_corner: Option<Node>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inner_corner: Option<Node>,
|
||||
/// What the outer corners take when there is no `outer_corner` art. Without art or a source,
|
||||
/// they take the `inner_corner` art.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outer_source: Option<CornerSource>,
|
||||
/// What the inner corners take when there is no `inner_corner` art. Without art or a source,
|
||||
/// they take the `outer_corner` art.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inner_source: Option<CornerSource>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start: Option<Node>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -378,6 +424,8 @@ impl Default for PatternBrush {
|
||||
side: defaults::stitch_tile(),
|
||||
outer_corner: None,
|
||||
inner_corner: None,
|
||||
outer_source: None,
|
||||
inner_source: None,
|
||||
start: None,
|
||||
end: None,
|
||||
scale: 100.0,
|
||||
|
||||
+487
-22
@@ -1,6 +1,6 @@
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -27,10 +27,16 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
/// Points along every outline under `n`, its anchors among them. Bent art keeps its curves and
|
||||
/// long lines since #3926, so its anchors alone no longer trace it.
|
||||
fn all_points(n: &Node, out: &mut Vec<Point>) {
|
||||
n.walk(&mut |c| {
|
||||
if let Some(p) = c.path_data() {
|
||||
out.extend(p.anchors().map(|(_, _, a)| a.p));
|
||||
for sp in c.path_data().into_iter().flat_map(|p| &p.subpaths) {
|
||||
out.extend(sp.anchors.iter().map(|a| a.p));
|
||||
for i in 0..sp.segment_count() {
|
||||
let seg = sp.segment(i);
|
||||
out.extend((1..32).map(|j| kurbo::ParamCurve::eval(&seg, j as f64 / 32.0)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -185,9 +191,12 @@ fn art_brush_follows_curves() {
|
||||
}
|
||||
}
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
|
||||
// The long edges bend as curves (#3926), where they used to be cut into some 400 chords. The
|
||||
// path is run along in 0.05 pt chords, which the curves follow, so a few dozen remain.
|
||||
let (anchors, curved) = anchors_and_curved(&out[0]);
|
||||
assert!(anchors < 100 && curved > anchors / 2, "long edges bend as curves: {curved} of {anchors} anchors with handles");
|
||||
let mut pts = vec![];
|
||||
all_points(&out[0], &mut pts);
|
||||
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
|
||||
for p in pts {
|
||||
let r = p.distance(Point::new(100.0, 100.0));
|
||||
assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre");
|
||||
@@ -273,15 +282,362 @@ fn pattern_tile_count_by_fit() {
|
||||
fn pattern_places_corner_tiles() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
|
||||
let corners = out
|
||||
.iter()
|
||||
.filter(
|
||||
|n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0),
|
||||
)
|
||||
.count();
|
||||
assert_eq!(corners, 4);
|
||||
// The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them
|
||||
// apart. Anchor count no longer does: the side tiles keep their own four anchors now that a
|
||||
// straight run is not flattened (#3926), where they used to arrive as many-anchor polylines.
|
||||
let curved = |n: &Node| {
|
||||
matches!(&n.kind, NodeKind::Path { path, .. }
|
||||
if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p)))
|
||||
};
|
||||
let corners = out.iter().filter(|n| curved(n)).count();
|
||||
assert_eq!(corners, 4, "the four corner ellipses, still curves");
|
||||
// Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each.
|
||||
assert_eq!(out.len() - corners, 36);
|
||||
for n in out.iter().filter(|n| !curved(n)) {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { continue };
|
||||
assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors");
|
||||
}
|
||||
}
|
||||
|
||||
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
|
||||
fn auto_corner_brush(outer: Option<CornerSource>, inner: Option<CornerSource>) -> Brush {
|
||||
let mut b = pattern_brush(PatternFit::Stretch, false);
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
p.outer_source = outer;
|
||||
p.inner_source = inner;
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
const AUTO: [CornerSource; 4] = [CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
|
||||
|
||||
/// Whether `n` paints a fill over `q`: inside one of its paths, and inside the mask of every
|
||||
/// clipping group it is in.
|
||||
fn covers(n: &Node, q: Point) -> bool {
|
||||
let inside = |n: &Node| n.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), q) != 0);
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => children.first().is_some_and(|m| inside(m)) && children.iter().skip(1).any(|c| covers(c, q)),
|
||||
NodeKind::Path { .. } => inside(n),
|
||||
_ => n.children().is_some_and(|c| c.iter().any(|c| covers(c, q))),
|
||||
}
|
||||
}
|
||||
|
||||
fn points_of(nodes: &[Node]) -> Vec<Point> {
|
||||
let mut pts = vec![];
|
||||
for n in nodes {
|
||||
all_points(n, &mut pts);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
/// The corners of a 100 pt square and, for each, the outer corner of the 4 pt band around it.
|
||||
const SQUARE_CORNERS: [((f64, f64), (f64, f64)); 4] =
|
||||
[((0.0, 0.0), (-2.0, -2.0)), ((100.0, 0.0), (102.0, -2.0)), ((100.0, 100.0), (102.0, 102.0)), ((0.0, 100.0), (-2.0, 102.0))];
|
||||
|
||||
/// Whether some point of `pts` is within `tol` of `(x, y)`.
|
||||
fn reaches(pts: &[Point], (x, y): (f64, f64), tol: f64) -> bool {
|
||||
pts.iter().any(|p| p.distance(Point::new(x, y)) <= tol)
|
||||
}
|
||||
|
||||
/// Distance from `p` to the outline of the square (0, 0)–(100, 100).
|
||||
fn from_square_outline(p: Point) -> f64 {
|
||||
let dx = (p.x - p.x.clamp(0.0, 100.0)).abs();
|
||||
let dy = (p.y - p.y.clamp(0.0, 100.0)).abs();
|
||||
if dx > 0.0 || dy > 0.0 { dx.hypot(dy) } else { p.x.min(100.0 - p.x).min(p.y).min(100.0 - p.y) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_auto_corners_fill_each_corner_of_a_square() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let mut outputs = vec![];
|
||||
for kind in AUTO {
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &sq, &stroke(1.0));
|
||||
let pts = points_of(&out);
|
||||
for (corner, outside) in SQUARE_CORNERS {
|
||||
assert!(reaches(&pts, outside, 0.8), "{kind:?}: the art reaches the band's outer corner at {corner:?}");
|
||||
}
|
||||
let far = pts.iter().copied().map(from_square_outline).fold(0.0, f64::max);
|
||||
assert!(far <= 2.0 * 2f64.sqrt() + 0.05, "{kind:?}: every point stays in the 4 pt band, {far}");
|
||||
// The band is filled up to each corner along both of its sides.
|
||||
let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y)));
|
||||
for ((cx, cy), _) in SQUARE_CORNERS {
|
||||
let (sx, sy) = ((50.0 - cx).signum(), (50.0 - cy).signum());
|
||||
for d in [0.5, 2.5, 4.5, 6.5, 8.5] {
|
||||
assert!(filled(cx + sx * d, cy) && filled(cx, cy + sy * d), "{kind:?}: filled {d} pt from ({cx}, {cy})");
|
||||
}
|
||||
}
|
||||
outputs.push(out);
|
||||
}
|
||||
for i in 0..outputs.len() {
|
||||
for j in i + 1..outputs.len() {
|
||||
assert_ne!(outputs[i], outputs[j], "{:?} and {:?} draw differently", AUTO[i], AUTO[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A side tile three times as tall as it is long still fills a square's corners without a gap:
|
||||
/// the two sides meet on the corner's diagonal.
|
||||
#[test]
|
||||
fn pattern_auto_corners_of_a_tall_tile_meet_on_the_diagonal() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
|
||||
for kind in AUTO {
|
||||
let mut b = auto_corner_brush(Some(kind), Some(kind));
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
p.side = rect_art(10.0, 30.0);
|
||||
}
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
|
||||
let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y)));
|
||||
for (cx, cy) in [(0.0_f64, 0.0_f64), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)] {
|
||||
let (sx, sy) = ((100.0 - cx).signum(), (100.0 - cy).signum());
|
||||
for d in [1.0, 5.0, 10.0, 14.0] {
|
||||
// Along the diagonal, outside and inside the square, and along both sides.
|
||||
assert!(filled(cx - sx * d, cy - sy * d), "{kind:?}: outside ({cx}, {cy}) by {d}");
|
||||
assert!(filled(cx + sx * d, cy + sy * d), "{kind:?}: inside ({cx}, {cy}) by {d}");
|
||||
assert!(filled(cx + sx * 3.0 * d, cy) && filled(cx, cy + sy * 3.0 * d), "{kind:?}: {} pt from ({cx}, {cy})", 3.0 * d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a curve meets a sharp corner, the corner tile reaches the side tiles on the curve: the
|
||||
/// middle of the band is filled all the way along the path.
|
||||
#[test]
|
||||
fn pattern_auto_corners_join_a_curve_into_the_corner() {
|
||||
// A straight run into a quarter circle of radius 10, then a sharp turn.
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((-50.0, 0.0));
|
||||
bp.line_to((10.0, 0.0));
|
||||
bp.curve_to((15.52, 0.0), (20.0, 4.48), (20.0, 10.0));
|
||||
bp.line_to((-80.0, 10.0));
|
||||
let track = &crate::track::tracks(&bp, 0.05)[0];
|
||||
let l = track.len();
|
||||
for kind in AUTO {
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
|
||||
let mut s = 0.25;
|
||||
while s < l - 0.25 {
|
||||
let q = track.map(s, 0.0);
|
||||
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} of {l:.2} ({q:?})");
|
||||
s += 0.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a curve meets a sharp corner, the two halves of a corner tile still meet on the corner's
|
||||
/// diagonal: the band is filled across its width near the corner, and both Auto-Between halves end
|
||||
/// at the diagonal's two ends.
|
||||
#[test]
|
||||
fn pattern_auto_corners_after_a_curve_meet_across_the_band() {
|
||||
// A quarter circle of radius 4 straight into a sharp turn.
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((-50.0, 0.0));
|
||||
bp.line_to((10.0, 0.0));
|
||||
bp.curve_to((12.21, 0.0), (14.0, 1.79), (14.0, 4.0));
|
||||
bp.line_to((-80.0, 4.0));
|
||||
let track = &crate::track::tracks(&bp, 0.05)[0];
|
||||
let corner = track.corner_positions().first().map(|c| c.1).unwrap();
|
||||
for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] {
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
|
||||
let mut s = corner - 3.0;
|
||||
while s < corner + 3.0 {
|
||||
for j in -19..=19 {
|
||||
let y = j as f64 / 10.0;
|
||||
let q = track.map(s, y);
|
||||
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} ({y} across, {q:?})");
|
||||
}
|
||||
s += 0.05;
|
||||
}
|
||||
}
|
||||
// The path leaves the corner at (14, 4) heading left, so the 4 pt band's diagonal ends on the
|
||||
// lines y = 2 and y = 6, where both Auto-Between halves end at the same point.
|
||||
let both = auto_corner_brush(Some(CornerSource::AutoBetween), Some(CornerSource::AutoBetween));
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &both, &bp, &stroke(1.0));
|
||||
for edge in [2.0, 6.0] {
|
||||
let on_edge: Vec<Vec<Point>> = out
|
||||
.iter()
|
||||
.map(|n| points_of(std::slice::from_ref(n)).into_iter().filter(|q| near(q.y, edge, 1e-6) && near(q.x, 14.0, 3.0)).collect())
|
||||
.collect();
|
||||
let shared = on_edge.iter().enumerate().any(|(i, a)| on_edge.iter().skip(i + 1).any(|b| a.iter().any(|p| reaches(b, (p.x, p.y), 1e-6))));
|
||||
assert!(shared, "both halves end at the same point on y = {edge}");
|
||||
}
|
||||
}
|
||||
|
||||
/// On a path shorter than the side tile, a corner tile is squeezed into the path it has: it does
|
||||
/// not run past the ends, nor past half the way to the next corner.
|
||||
#[test]
|
||||
fn pattern_auto_corners_stay_within_a_short_path() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((2.0, 0.0));
|
||||
bp.line_to((2.0, 2.0));
|
||||
let mut zigzag = BezPath::new();
|
||||
zigzag.move_to((0.0, 0.0));
|
||||
for j in 1..=6 {
|
||||
zigzag.line_to((3.0 * j as f64, if j % 2 == 1 { 3.0 } else { 0.0 }));
|
||||
}
|
||||
for kind in AUTO {
|
||||
for (path, (x0, y0, x1, y1)) in [(&bp, (0.0, 0.0, 2.0, 2.0)), (&zigzag, (0.0, 0.0, 18.0, 3.0))] {
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), path, &stroke(1.0));
|
||||
assert!(!out.is_empty(), "{kind:?}: something is drawn");
|
||||
// The band is 4 pt wide, and the diagonal of a sharp turn reaches further from it.
|
||||
let reach = 2.0 * 3.0;
|
||||
for p in points_of(&out) {
|
||||
assert!(p.x >= x0 - reach && p.x <= x1 + reach && p.y >= y0 - reach && p.y <= y1 + reach, "{kind:?}: {p:?} is past the path's ends");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner squeezed into a path shorter than the band is wide still fills the band up to the
|
||||
/// corner's diagonal from both sides, with no gap between the two halves.
|
||||
#[test]
|
||||
fn pattern_auto_corners_on_a_short_path_meet_on_the_diagonal() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((1.0, 0.0));
|
||||
bp.line_to((1.0, 1.0));
|
||||
for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] {
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
|
||||
// The diagonal runs from the corner (1, 0) to the band's outer corner (3, -2).
|
||||
for f in [0.25, 0.5, 0.75] {
|
||||
let (dx, dy) = (1.0 + 2.0 * f, -2.0 * f);
|
||||
for q in [Point::new(dx - 0.05, dy), Point::new(dx, dy - 0.05)] {
|
||||
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_without_corner_tiles_or_auto_corners_leaves_corners_empty() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let plain = stroke_pieces(&doc, &auto_corner_brush(None, None), &sq, &stroke(1.0));
|
||||
assert_eq!(plain.len(), 40, "ten side tiles a side");
|
||||
let pts = points_of(&plain);
|
||||
for (corner, outside) in SQUARE_CORNERS {
|
||||
assert!(!reaches(&pts, outside, 1.5), "no art at the band's outer corner at {corner:?}");
|
||||
}
|
||||
// A square turns the same way at every corner, so only one of the two kinds of corner tile
|
||||
// draws on it; the other leaves the stroke as it is without automatic corners.
|
||||
let outer = stroke_pieces(&doc, &auto_corner_brush(Some(CornerSource::AutoBetween), None), &sq, &stroke(1.0));
|
||||
let inner = stroke_pieces(&doc, &auto_corner_brush(None, Some(CornerSource::AutoBetween)), &sq, &stroke(1.0));
|
||||
assert!((outer == plain) != (inner == plain), "exactly one kind of corner draws on a square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_corner_art_wins_over_corner_sources() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let saved = pattern_brush(PatternFit::Stretch, true);
|
||||
let mut with_auto = saved.clone();
|
||||
if let BrushKind::Pattern(p) = &mut with_auto.kind {
|
||||
p.outer_source = Some(CornerSource::AutoSliced);
|
||||
p.inner_source = Some(CornerSource::None);
|
||||
}
|
||||
assert_eq!(stroke_pieces(&doc, &with_auto, &sq, &stroke(1.0)), stroke_pieces(&doc, &saved, &sq, &stroke(1.0)));
|
||||
}
|
||||
|
||||
/// A kind of corner without art takes the other kind's art, as saved brushes draw, unless its
|
||||
/// source is None.
|
||||
#[test]
|
||||
fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() {
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let draw = |b: &Brush| stroke_pieces(&doc, b, &sq, &stroke(1.0));
|
||||
let (saved, plain) = (draw(&pattern_brush(PatternFit::Stretch, true)), draw(&auto_corner_brush(None, None)));
|
||||
let without = |outer: bool, source: Option<CornerSource>| {
|
||||
let mut b = pattern_brush(PatternFit::Stretch, true);
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
if outer {
|
||||
(p.outer_corner, p.outer_source) = (None, source);
|
||||
} else {
|
||||
(p.inner_corner, p.inner_source) = (None, source);
|
||||
}
|
||||
}
|
||||
draw(&b)
|
||||
};
|
||||
assert!(without(true, None) == saved && without(false, None) == saved, "the other kind's art fills in");
|
||||
let (outer, inner) = (without(true, Some(CornerSource::None)), without(false, Some(CornerSource::None)));
|
||||
assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() {
|
||||
let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap();
|
||||
assert!(saved.get("outer_source").is_none() && saved.get("inner_source").is_none(), "{saved}");
|
||||
let BrushKind::Pattern(p) = Brush::from_json(saved).unwrap().kind else { panic!("a pattern brush") };
|
||||
assert_eq!((p.outer_source, p.inner_source), (None, None));
|
||||
let v = serde_json::to_value(auto_corner_brush(Some(CornerSource::AutoCentered), Some(CornerSource::None))).unwrap();
|
||||
assert_eq!((v["outer_source"].as_str(), v["inner_source"].as_str()), (Some("autoCentered"), Some("none")));
|
||||
let BrushKind::Pattern(p) = Brush::from_json(v).unwrap().kind else { panic!("a pattern brush") };
|
||||
assert_eq!((p.outer_source, p.inner_source), (Some(CornerSource::AutoCentered), Some(CornerSource::None)));
|
||||
}
|
||||
|
||||
/// Auto-Sliced masks each side tile at the corner's diagonal instead of cutting its paths, so a
|
||||
/// tile's outline gains no stroke along the cut.
|
||||
#[test]
|
||||
fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() {
|
||||
let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced));
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 0.5).into();
|
||||
}
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
|
||||
let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
|
||||
assert_eq!(clips.len(), 8, "two masked tiles at each corner");
|
||||
for g in clips {
|
||||
let kids = g.children().unwrap();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert!(kids[0].appearance.items.is_empty(), "the mask paints nothing");
|
||||
let mask = kids[0].path_data().unwrap().to_bezpath();
|
||||
let tile = kids[1].path_data().unwrap();
|
||||
assert!(
|
||||
tile.anchors().any(|(_, _, a)| kurbo::Shape::winding(&mask, a.p) == 0),
|
||||
"the tile keeps its outline past the diagonal, where the mask hides it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-Sliced on a corner whose legs are too short for the side tile squeezes the tile along,
|
||||
/// but not its stroke: the mask still takes in the stroke past the tile's far end.
|
||||
#[test]
|
||||
fn pattern_auto_sliced_masks_keep_the_stroke_of_a_squeezed_tile() {
|
||||
let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced));
|
||||
if let BrushKind::Pattern(p) = &mut b.kind {
|
||||
p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0).into();
|
||||
}
|
||||
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)).to_bezpath();
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
|
||||
let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
|
||||
assert_eq!(clips.len(), 8, "two masked tiles at each corner");
|
||||
let center = Point::new(4.0, 4.0);
|
||||
for g in clips {
|
||||
let kids = g.children().unwrap();
|
||||
let mask = kids[0].path_data().unwrap().to_bezpath();
|
||||
let half = kids[1].appearance.stroke_width() / 2.0;
|
||||
assert!(half > 0.0);
|
||||
let tile = kids[1].path_data().unwrap();
|
||||
let anchors: Vec<Point> = tile.anchors().map(|(_, _, a)| a.p).collect();
|
||||
let mid = anchors.iter().fold(Point::ZERO, |m, a| m + a.to_vec2() / anchors.len() as f64);
|
||||
// The tile's corner, and its diagonal through the square's center; each mask keeps one
|
||||
// side of the diagonal.
|
||||
let p = [(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (0.0, 8.0)]
|
||||
.map(|(x, y)| Point::new(x, y))
|
||||
.into_iter()
|
||||
.min_by(|a, b| a.distance(mid).total_cmp(&b.distance(mid)))
|
||||
.unwrap();
|
||||
let cross = |q: Point| (q - p).cross(center - p);
|
||||
let side = cross(mid).signum();
|
||||
for a in &anchors {
|
||||
// The points of the stroke round the anchor, along and across the tile's leg.
|
||||
for d in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] {
|
||||
let q = *a + kurbo::Vec2::new(d.0, d.1) * half * 0.9;
|
||||
if cross(q) * side > 0.01 && (q - p).hypot() < 7.0 {
|
||||
assert!(kurbo::Shape::winding(&mask, q) != 0, "the mask takes in the stroke at {q:?} (half width {half})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -800,19 +1156,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
|
||||
let a1 = anchors(&one);
|
||||
assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}");
|
||||
|
||||
// Sixteen tracks would be sixteen times that without a total budget.
|
||||
// Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the
|
||||
// budget must not bite before the per-factor caps have actually multiplied past it.
|
||||
let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));
|
||||
assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");
|
||||
assert_eq!(anchors(&four), 4 * a1);
|
||||
// Two tracks are well inside the budget, so nothing is cut: it must not bite before the
|
||||
// per-factor caps have actually multiplied past it.
|
||||
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0));
|
||||
assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece");
|
||||
assert_eq!(anchors(&two), 2 * a1);
|
||||
|
||||
// Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.
|
||||
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));
|
||||
// Enough tracks that the per-run caps alone would pass the budget. Derived from what one
|
||||
// track costs rather than written down, because how many anchors a tile holds is not fixed:
|
||||
// a tile on a straight run keeps its own anchors instead of being flattened (#3926), which
|
||||
// moved this by a factor of four.
|
||||
let tracks = 2_000_000 / a1 + 2;
|
||||
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &stroke(1.0));
|
||||
let an = anchors(&many);
|
||||
assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");
|
||||
assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");
|
||||
assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
|
||||
assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}");
|
||||
assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}");
|
||||
assert!(many.len() < tracks * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
|
||||
}
|
||||
|
||||
/// The same for scatter, whose per-track guard is also 20,000 copies.
|
||||
@@ -843,3 +1202,109 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() {
|
||||
assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
|
||||
assert!(anchors(&out) < 2_000_000);
|
||||
}
|
||||
|
||||
/// Anchors under `n`, and how many of them keep a handle (so the segment is a curve).
|
||||
fn anchors_and_curved(n: &Node) -> (usize, usize) {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) };
|
||||
let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect();
|
||||
(all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count())
|
||||
}
|
||||
|
||||
/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the
|
||||
/// tile's curves survive. They used to be flattened into chords by the bending code, which is
|
||||
/// visible in the stroke itself and leaves no handles after Expand Brush Strokes.
|
||||
#[test]
|
||||
fn a_pattern_tile_keeps_its_curves_on_a_straight_path() {
|
||||
let circle =
|
||||
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let (tile_anchors, tile_curved) = anchors_and_curved(&circle);
|
||||
assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle");
|
||||
|
||||
let brush = Brush {
|
||||
name: "p".into(),
|
||||
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
|
||||
};
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
|
||||
assert!(!out.is_empty(), "the brush drew something");
|
||||
for n in &out {
|
||||
assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon");
|
||||
}
|
||||
// The ink is in the same place: a circle tile still covers the path it was laid along.
|
||||
let b = bounds(&out);
|
||||
assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width());
|
||||
assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height());
|
||||
}
|
||||
|
||||
/// The other half of the same behaviour: where the path really does bend, an affine cannot
|
||||
/// reproduce the bend, so the tile is bent, and it still comes out as curves: cubics fitted to the
|
||||
/// bent art rather than the chords it used to be flattened into (#3926). Here a 4-anchor circle
|
||||
/// tile runs round a circle of radius 200, so its outline stays in the band 20 either side of it.
|
||||
#[test]
|
||||
fn a_pattern_tile_on_a_bent_path_keeps_its_curves() {
|
||||
let circle =
|
||||
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let brush = Brush {
|
||||
name: "p".into(),
|
||||
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
|
||||
};
|
||||
// A circle to run along: every tile sits on a curving section.
|
||||
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
|
||||
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0));
|
||||
assert!(out.len() > 20, "tiles all the way round: {}", out.len());
|
||||
let (mut lo, mut hi) = (f64::MAX, f64::MIN);
|
||||
for n in &out {
|
||||
let (anchors, curved) = anchors_and_curved(n);
|
||||
assert!((4..=16).contains(&anchors), "a bent circle tile is a few curves, not a polygon: {anchors} anchors");
|
||||
assert!(curved == anchors, "every anchor keeps a handle: {curved} of {anchors}");
|
||||
let mut pts = vec![];
|
||||
all_points(n, &mut pts);
|
||||
for q in pts {
|
||||
let r = q.distance(Point::new(200.0, 200.0));
|
||||
(lo, hi) = (lo.min(r), hi.max(r));
|
||||
}
|
||||
}
|
||||
// The path is a cubic circle (0.06 out at most) run along in chords of 0.05, and the fit holds
|
||||
// a quarter of the 0.25 flattening tolerance, so the band holds to 0.2.
|
||||
assert!(lo > 179.8 && hi < 220.2, "the curves stay in the tile's band round the path: {lo:.3}..{hi:.3}");
|
||||
assert!(lo < 180.2 && hi > 219.8, "and reach both edges of it: {lo:.3}..{hi:.3}");
|
||||
}
|
||||
|
||||
/// The kind of every node in a piece of brushed art, in paint order.
|
||||
fn kinds(n: &Node) -> Vec<&'static str> {
|
||||
let mut out = vec![match &n.kind {
|
||||
NodeKind::Path { .. } => "path",
|
||||
NodeKind::Group { .. } => "group",
|
||||
NodeKind::SymbolInstance { .. } => "symbol",
|
||||
_ => "other",
|
||||
}];
|
||||
for c in n.children().into_iter().flatten() {
|
||||
out.extend(kinds(c));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The straight-run route differs from the bending route in the tile's curves and in nothing else,
|
||||
/// which includes the node kinds it keeps. A tile holding a kind neither route can map (here a
|
||||
/// symbol instance, whose art lives in the symbol and not in the node) loses it on a straight run
|
||||
/// exactly as it does on a bend: an affine is not a way for unmappable art to arrive untouched.
|
||||
#[test]
|
||||
fn a_straight_run_keeps_the_same_node_kinds_as_a_bend() {
|
||||
let circle =
|
||||
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let sym = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
|
||||
let tile = Node::group(NodeId(2), vec![Arc::new(circle), Arc::new(sym)]);
|
||||
let brush = Brush {
|
||||
name: "p".into(),
|
||||
kind: BrushKind::Pattern(PatternBrush { side: tile, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
|
||||
};
|
||||
let doc = Document::new(1.0, 1.0);
|
||||
let straight = stroke_pieces(&doc, &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
|
||||
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
|
||||
let bent = stroke_pieces(&doc, &brush, &round, &stroke(1.0));
|
||||
assert!(!straight.is_empty() && !bent.is_empty(), "both paths drew something");
|
||||
for (what, out) in [("a straight run", &straight), ("a bend", &bent)] {
|
||||
for n in out {
|
||||
assert_eq!(kinds(n), ["group", "path"], "{what} keeps the path and drops the symbol instance");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ fn unit(v: Vec2) -> Vec2 {
|
||||
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l }
|
||||
}
|
||||
|
||||
/// Whether two unit vectors point the same way, to within the slack a unit vector carries after
|
||||
/// a few multiplications (a bend of even a thousandth of a degree fails this).
|
||||
fn aligned(a: Vec2, b: Vec2) -> bool {
|
||||
a.dot(b) > 1.0 - 1e-12
|
||||
}
|
||||
|
||||
/// Left-hand normal in y-down document space (for a path running +x it points +y).
|
||||
pub fn normal(t: Vec2) -> Vec2 {
|
||||
Vec2::new(-t.y, t.x)
|
||||
@@ -154,6 +160,59 @@ impl Track {
|
||||
let (p, t) = self.frame(s);
|
||||
p + normal(t) * d
|
||||
}
|
||||
|
||||
/// Whether [`Self::frame`] holds one constant tangent over arc lengths `s0..s1`, so mapping
|
||||
/// through it is an affine of the art and nothing more.
|
||||
///
|
||||
/// Read off the track, not sampled. A bend in the span is two segments with different
|
||||
/// directions, however short the bent part is, so this cannot miss one; a sampled check can,
|
||||
/// because a wiggle narrower than the sample spacing passes between the samples (#3926).
|
||||
///
|
||||
/// Fails closed: a span that needs reasoning this does not do (a closed track's seam, a
|
||||
/// non-finite bound, a track with no segments) is reported as not straight, and the caller
|
||||
/// bends it the long way.
|
||||
pub fn is_straight(&self, s0: f64, s1: f64) -> bool {
|
||||
// Checked before the min/max, which quietly drop a NaN rather than propagate it.
|
||||
let n = self.segments();
|
||||
if !(s0.is_finite() && s1.is_finite()) || n == 0 {
|
||||
return false;
|
||||
}
|
||||
let (lo, hi) = (s0.min(s1), s0.max(s1));
|
||||
let l = self.len();
|
||||
// A closed track wraps, so a span reaching past either end covers segments twice.
|
||||
if self.closed && (lo < -1e-9 || hi > l + 1e-9) {
|
||||
return false;
|
||||
}
|
||||
// Past an open track's ends `frame` extrapolates along the end segment's direction, so
|
||||
// those segments are part of the span too.
|
||||
let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)];
|
||||
let touched = (0..n).filter(|&i| {
|
||||
let (a, b) = (self.cum.get(i), self.cum.get(i + 1));
|
||||
matches!((a, b), (Some(a), Some(b)) if *a < hi + 1e-9 && *b > lo - 1e-9)
|
||||
});
|
||||
let mut dir: Option<Vec2> = None;
|
||||
let mut any = false;
|
||||
for i in touched.chain(ends.into_iter().flatten()) {
|
||||
let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) =
|
||||
(self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if dir.is_some_and(|first: Vec2| !aligned(first, d)) {
|
||||
return false;
|
||||
}
|
||||
dir = Some(d);
|
||||
any = true;
|
||||
// `frame` blends the two vertex tangents across the segment unless the vertex is a
|
||||
// corner, where it uses the segment's own direction. So a neighbour outside the span
|
||||
// can still bend the tangent inside it, and checking the directions alone would miss
|
||||
// that: segments d, d, e leave vertex 2's tangent at unit(d + e).
|
||||
if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
}
|
||||
|
||||
/// The most flattened elements of a path kept.
|
||||
@@ -253,6 +312,47 @@ pub fn seed_of(bp: &BezPath, salt: &str) -> u64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A straight run is straight; a bend in the span is not, however short it is. This is the
|
||||
/// property a sampled check cannot offer, so it is worth pinning on its own (#3926 review).
|
||||
#[test]
|
||||
fn straightness_is_read_off_the_track_not_sampled() {
|
||||
let line = |pts: &[(f64, f64)], closed: bool| {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(pts[0]);
|
||||
for p in &pts[1..] {
|
||||
bp.line_to(*p);
|
||||
}
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
tracks(&bp, 0.1).remove(0)
|
||||
};
|
||||
|
||||
let straight = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false);
|
||||
assert!(straight.is_straight(0.0, 200.0), "two collinear segments are one straight run");
|
||||
// Open tracks extrapolate along the end directions, which is still the same line.
|
||||
assert!(straight.is_straight(-50.0, 250.0), "and so is the extrapolation past both ends");
|
||||
|
||||
// A detour of a fifth of a point, 0.1% of the run: far below any sampling grid this code
|
||||
// would choose, and still a bend.
|
||||
let wiggle = line(&[(0.0, 0.0), (99.9, 0.0), (100.0, 0.2), (100.1, 0.0), (200.0, 0.0)], false);
|
||||
assert!(!wiggle.is_straight(0.0, 200.0), "a 0.2 pt detour is a bend");
|
||||
assert!(!wiggle.is_straight(99.0, 101.0), "and is caught when the span is only around it");
|
||||
assert!(wiggle.is_straight(0.0, 50.0), "while a span before it is straight");
|
||||
|
||||
// `frame` blends vertex tangents, so the segment before a bend is not straight all the way
|
||||
// to the vertex: the tangent there is already turning. Pinning this because checking the
|
||||
// segment directions alone would call that span straight and be wrong.
|
||||
let kink = line(&[(0.0, 0.0), (100.0, 0.0), (160.0, 60.0)], false);
|
||||
assert!(!kink.is_straight(0.0, 100.0), "the last segment's tangent is already turning at its far vertex");
|
||||
assert!(kink.is_straight(0.0, 50.0), "a span clear of the turning vertex is straight");
|
||||
|
||||
// Fails closed where it does not reason: a closed track's seam, and nonsense bounds.
|
||||
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
assert!(!ring.is_straight(-10.0, 10.0), "a span across a closed track's seam is refused");
|
||||
assert!(ring.is_straight(10.0, 90.0), "a span inside one of its sides is straight");
|
||||
assert!(!straight.is_straight(f64::NAN, 10.0), "a non-finite bound is refused");
|
||||
}
|
||||
#[test]
|
||||
fn straight_track_frames() {
|
||||
let mut bp = BezPath::new();
|
||||
|
||||
+676
-74
@@ -1,16 +1,18 @@
|
||||
//! Art, scatter and pattern brushes: art bent along the path by arc length.
|
||||
//!
|
||||
//! Art coordinates `(x, y)` map to the path point at arc length `s(x)` offset by
|
||||
//! `(y - centre) × cross` along the path's left normal. Art outlines are flattened and
|
||||
//! subdivided first so long straight edges bend with the path instead of cutting chords.
|
||||
//! `(y - centre) × cross` along the path's left normal. Art outlines come out as cubics fitted
|
||||
//! to the bent art, so curves stay curves and long straight edges bend with the path (#3926);
|
||||
//! where that would take more anchors than chords, they are flattened and subdivided instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
|
||||
use kurbo::ParamCurveNearest;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
|
||||
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::track::{Rng, Track, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
|
||||
use crate::track::{Rng, Track, normal, seed_of, tracks};
|
||||
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
|
||||
|
||||
/// Longest mapped segment (points in document space).
|
||||
const MAX_SEG: f64 = 1.5;
|
||||
@@ -31,23 +33,29 @@ const MAX_SUB: usize = 4000;
|
||||
const MAX_ANCHORS: usize = 2_000_000;
|
||||
/// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget.
|
||||
const MAX_WALK: usize = 10_000;
|
||||
/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with.
|
||||
const MASK_STEPS: usize = 32;
|
||||
|
||||
/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and
|
||||
/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by
|
||||
/// `width_scale`. Text, images and symbol instances can't bend and are dropped.
|
||||
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, width_scale: f64) -> Option<Node> {
|
||||
/// Bend every path under `n` through `f`. Each art segment comes out as cubics that follow it
|
||||
/// through the bend to within [`fit_tol`] of `doc_tol`, the flattening tolerance in document
|
||||
/// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than
|
||||
/// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord
|
||||
/// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`.
|
||||
/// Text, images and symbol instances can't bend and are dropped.
|
||||
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option<Node> {
|
||||
let mut out = n.clone();
|
||||
out.appearance.scale_strokes(width_scale);
|
||||
match &mut out.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
*path = warp_path(path, f, flat_tol, max_du);
|
||||
*path = warp_path(path, f, flat_tol, max_du, fit_tol(doc_tol));
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect();
|
||||
let kids: Vec<Arc<Node>> =
|
||||
children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, doc_tol, width_scale)).map(Arc::new).collect();
|
||||
if kids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -58,54 +66,156 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64) -> PathData {
|
||||
let bp = p.to_bezpath();
|
||||
/// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords
|
||||
/// would, else the chords. Points `f` cannot place (not finite) are left out of the chords.
|
||||
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData {
|
||||
let mut subs = vec![];
|
||||
let mut cur: Vec<Point> = vec![];
|
||||
let mut els = vec![];
|
||||
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e));
|
||||
let finish = |cur: &mut Vec<Point>, closed: bool, subs: &mut Vec<SubPath>| {
|
||||
let pts = std::mem::take(cur);
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
for sp in &p.subpaths {
|
||||
let Some(dense) = densified(sp, flat_tol, max_du) else { continue };
|
||||
if let Some(fit) = fitted(sp, f, fit_tol, dense.len()) {
|
||||
subs.push(fit);
|
||||
continue;
|
||||
}
|
||||
let mut dense = vec![pts[0]];
|
||||
let n = pts.len();
|
||||
let segs = if closed { n } else { n - 1 };
|
||||
for i in 0..segs {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
|
||||
for j in 1..=k {
|
||||
let q = a.lerp(b, j as f64 / k as f64);
|
||||
if closed && i == segs - 1 && j == k {
|
||||
break;
|
||||
}
|
||||
dense.push(q);
|
||||
}
|
||||
}
|
||||
let mapped: Vec<Point> = dense.into_iter().map(f).collect();
|
||||
subs.push(SubPath::polyline(&mapped, closed));
|
||||
};
|
||||
for e in els {
|
||||
match e {
|
||||
PathEl::MoveTo(q) => {
|
||||
finish(&mut cur, false, &mut subs);
|
||||
cur.push(q);
|
||||
}
|
||||
PathEl::LineTo(q) => cur.push(q),
|
||||
PathEl::ClosePath => {
|
||||
if cur.len() > 1 && cur[0].distance(cur[cur.len() - 1]) < 1e-9 {
|
||||
cur.pop();
|
||||
}
|
||||
finish(&mut cur, true, &mut subs);
|
||||
}
|
||||
_ => {}
|
||||
let mapped: Vec<Point> = dense.into_iter().map(f).filter(|q| q.is_finite()).collect();
|
||||
if mapped.len() >= 2 {
|
||||
subs.push(SubPath::polyline(&mapped, sp.closed));
|
||||
}
|
||||
}
|
||||
finish(&mut cur, false, &mut subs);
|
||||
PathData::new(subs)
|
||||
}
|
||||
|
||||
/// `sp` flattened with `flat_tol` and its chords subdivided so none is longer than `max_du` along
|
||||
/// x, in art space: the points the chord route maps. `None` below two points.
|
||||
fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option<Vec<Point>> {
|
||||
let mut bp = BezPath::new();
|
||||
sp.to_bezpath_into(&mut bp);
|
||||
let mut pts = vec![];
|
||||
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| {
|
||||
if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e {
|
||||
pts.push(q);
|
||||
}
|
||||
});
|
||||
if sp.closed && pts.len() > 1 && pts.first()?.distance(*pts.last()?) < 1e-9 {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let segs = if sp.closed { pts.len() } else { pts.len() - 1 };
|
||||
let mut dense = vec![*pts.first()?];
|
||||
for (i, (&a, &b)) in pts.iter().zip(pts.iter().cycle().skip(1)).take(segs).enumerate() {
|
||||
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
|
||||
for j in 1..=k {
|
||||
if sp.closed && i == segs - 1 && j == k {
|
||||
break;
|
||||
}
|
||||
dense.push(a.lerp(b, j as f64 / k as f64));
|
||||
}
|
||||
}
|
||||
Some(dense)
|
||||
}
|
||||
|
||||
/// Times an art segment is halved at most while looking for cubics that fit it.
|
||||
const FIT_DEPTH: u32 = 12;
|
||||
/// Parameter step of the finite differences that stand in for the bend's derivative.
|
||||
const FIT_DT: f64 = 1e-6;
|
||||
/// Sections a fitted piece is checked at the ends of (the ends themselves are exact).
|
||||
const FIT_CHECKS: usize = 8;
|
||||
/// Share of the flattening tolerance the fitted curves hold to. The chords were exact at their
|
||||
/// points and a few hundredths off between them, so a straight edge bends about as closely.
|
||||
const FIT_SHARE: f64 = 0.25;
|
||||
/// Tolerance paths are flattened to before art is laid along them.
|
||||
const TRACK_TOL: f64 = 0.05;
|
||||
|
||||
/// The distance (document space) fitted curves keep to the bent art, for flattening tolerance
|
||||
/// `doc_tol`: never below [`TRACK_TOL`], since following the flattened path more closely than it
|
||||
/// follows the real one only traces its chords.
|
||||
fn fit_tol(doc_tol: f64) -> f64 {
|
||||
(doc_tol * FIT_SHARE).max(TRACK_TOL)
|
||||
}
|
||||
|
||||
/// `sp` through `f` as cubics within `tol` (document space), one or more per art segment so its
|
||||
/// anchors, corners included, stay anchors. `None` when that takes more than `most` anchors, when
|
||||
/// a piece still does not fit after [`FIT_DEPTH`] halvings, or when `f` cannot place a point.
|
||||
///
|
||||
/// Each piece is the cubic with the bent piece's ends and their derivatives, which is the piece
|
||||
/// itself where the bend is affine and converges fast where it is not. It is checked against the
|
||||
/// bend at the same parameters, so the distance measured is never less than the true one. The
|
||||
/// halving is our own rather than kurbo's `fit_to_bezpath`, which halves until the floats run out
|
||||
/// on a map that never fits; the depth and the anchor count bound this one.
|
||||
fn fitted(sp: &SubPath, f: &dyn Fn(Point) -> Point, tol: f64, most: usize) -> Option<SubPath> {
|
||||
if !(tol.is_finite() && tol > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let start = f(sp.anchors.first()?.p);
|
||||
if !start.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(start);
|
||||
// Pieces allowed: an open subpath's first anchor is one more, a closed one's last piece ends on it.
|
||||
let mut room = if sp.closed { most } else { most.checked_sub(1)? };
|
||||
for i in 0..sp.segment_count() {
|
||||
let c = sp.segment(i);
|
||||
let line = sp.segment_is_line(i);
|
||||
// A line's handles sit on its ends, which stalls its parameter there; thirds keep it even.
|
||||
let c = if line { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c };
|
||||
let at = |t: f64| Some(f(c.eval(t))).filter(|q| q.is_finite());
|
||||
fit_into(&at, (0.0, 1.0), line, tol, FIT_DEPTH, &mut bp, &mut room)?;
|
||||
}
|
||||
if sp.closed {
|
||||
bp.close_path();
|
||||
}
|
||||
// The last piece ends on the first anchor's image, so a closed subpath merges it back in; the
|
||||
// count is checked again for a map that jumps right there and keeps the two apart.
|
||||
PathData::from_bezpath(&bp).subpaths.into_iter().next().filter(|s| s.anchors.len() <= most)
|
||||
}
|
||||
|
||||
/// Pieces for parameters `t0..t1` of the bent segment `at` onto `bp`, which ends at `at(t0)`:
|
||||
/// halved until each is within `tol`, at most `depth` times and `room` pieces. A `line` that is
|
||||
/// still straight within `tol` stays a line.
|
||||
fn fit_into(
|
||||
at: &dyn Fn(f64) -> Option<Point>,
|
||||
(t0, t1): (f64, f64),
|
||||
line: bool,
|
||||
tol: f64,
|
||||
depth: u32,
|
||||
bp: &mut BezPath,
|
||||
room: &mut usize,
|
||||
) -> Option<()> {
|
||||
let span = t1 - t0;
|
||||
let (p0, p3) = (at(t0)?, at(t1)?);
|
||||
// Derivatives read from inside the piece, so a kink at either end (a corner, a turn of the
|
||||
// track) stays one.
|
||||
let d0 = (at(t0 + FIT_DT)? - p0) * (span / FIT_DT);
|
||||
let d1 = (p3 - at(t1 - FIT_DT)?) * (span / FIT_DT);
|
||||
let c = CubicBez::new(p0, p0 + d0 / 3.0, p3 - d1 / 3.0, p3);
|
||||
let chord = kurbo::Line::new(p0, p3);
|
||||
let (mut curve_fits, mut line_fits) = (c.is_finite(), line);
|
||||
for k in 1..FIT_CHECKS {
|
||||
if !(curve_fits || line_fits) {
|
||||
break;
|
||||
}
|
||||
let u = k as f64 / FIT_CHECKS as f64;
|
||||
let q = at(t0 + span * u)?;
|
||||
curve_fits &= q.distance(c.eval(u)) <= tol;
|
||||
line_fits &= chord.nearest(q, 1e-9).distance_sq <= tol * tol;
|
||||
}
|
||||
if line_fits || curve_fits {
|
||||
*room = room.checked_sub(1)?;
|
||||
if line_fits {
|
||||
bp.line_to(p3);
|
||||
} else {
|
||||
bp.curve_to(c.p1, c.p2, p3);
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
let depth = depth.checked_sub(1)?;
|
||||
let mid = t0 + span / 2.0;
|
||||
fit_into(at, (t0, mid), line, tol, depth, bp, room)?;
|
||||
fit_into(at, (mid, t1), line, tol, depth, bp, room)
|
||||
}
|
||||
|
||||
/// Art turned so it runs left → right, flipped as requested; returns it with its bounds.
|
||||
fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
|
||||
let mut a = art.clone();
|
||||
@@ -153,12 +263,111 @@ impl AlongMap {
|
||||
let w = self.xs[i + 1] - self.xs[i];
|
||||
if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w }
|
||||
}
|
||||
/// Whether `at` is one linear function across the art, so it adds no bend of its own.
|
||||
///
|
||||
/// [`AlongMap::linear`] always is: its outer two pieces have no width and the middle one
|
||||
/// spans everything. Between Guides is not, once its three pieces really do differ, which is
|
||||
/// the whole point of it.
|
||||
fn is_linear(&self) -> bool {
|
||||
let mut slope: Option<f64> = None;
|
||||
for i in 0..3 {
|
||||
// A piece with no width constrains nothing: no art x lands inside it.
|
||||
if self.xs[i + 1] - self.xs[i] <= 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let s = self.slope(i);
|
||||
if slope.is_some_and(|first: f64| (first - s).abs() > 1e-9 * first.abs().max(1.0)) {
|
||||
return false;
|
||||
}
|
||||
slope = Some(s);
|
||||
}
|
||||
slope.is_some()
|
||||
}
|
||||
|
||||
/// The arc lengths the art spans, low to high.
|
||||
fn span(&self) -> (f64, f64) {
|
||||
(self.ss[0].min(self.ss[3]), self.ss[0].max(self.ss[3]))
|
||||
}
|
||||
|
||||
/// Largest along-scale (arc length per art unit) of the pieces.
|
||||
fn max_scale(&self) -> f64 {
|
||||
(0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
/// The affine that reproduces `f` over `b` to within `tol`, if one does.
|
||||
///
|
||||
/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the
|
||||
/// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it
|
||||
/// into chords throws its curves away for nothing (#3926).
|
||||
///
|
||||
/// Derived from three corners and then checked against a grid. The check is a second opinion, not
|
||||
/// the gate: `bend` only calls this once the track and the along map have been read and found
|
||||
/// straight, because sampling can reject a bend and cannot promise there is none (#3926 review).
|
||||
fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
|
||||
let (w, h) = (b.width(), b.height());
|
||||
if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let origin = Point::new(b.x0, b.y0);
|
||||
let q0 = f(origin);
|
||||
// Images of the unit x and y directions.
|
||||
let u = (f(Point::new(b.x1, b.y0)) - q0) / w;
|
||||
let v = (f(Point::new(b.x0, b.y1)) - q0) / h;
|
||||
if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2());
|
||||
// Five by five including the corners the fit came from, so a map that merely agrees at those
|
||||
// three is rejected.
|
||||
for i in 0..=4 {
|
||||
for j in 0..=4 {
|
||||
let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0);
|
||||
let (want, got) = (f(p), a * p);
|
||||
if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// [`warp_node`] for a map that is affine: the art is transformed rather than flattened, so its
|
||||
/// curves survive.
|
||||
///
|
||||
/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between
|
||||
/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every
|
||||
/// path, an empty path or a group left with no children is dropped, and strokes are scaled by
|
||||
/// `width_scale` at every level rather than by the transform's own factor.
|
||||
fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> {
|
||||
let mut out = prepared(n, width_scale)?;
|
||||
out.transform(a, false);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The part of [`affine_node`] that mirrors [`warp_node`]'s walk, before the single transform.
|
||||
fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
|
||||
let mut out = n.clone();
|
||||
out.appearance.scale_strokes(width_scale);
|
||||
match &mut out.kind {
|
||||
NodeKind::Path { path, live, .. } => {
|
||||
*live = None;
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
|
||||
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| prepared(c, width_scale)).map(Arc::new).collect();
|
||||
if kids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
*children = kids;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`.
|
||||
fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<Node> {
|
||||
let cy = b.center().y;
|
||||
@@ -166,7 +375,21 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
|
||||
let f = move |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
|
||||
let scale = along.max_scale().max(cross.abs()).max(1e-6);
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
// A straight run needs no bending: move the art and keep its curves.
|
||||
//
|
||||
// Straightness is read off the track and the along map, not sampled. A bend anywhere in the
|
||||
// span is two segment directions that differ, so this cannot miss one however short the bent
|
||||
// part is; the five by five grid below could, since a wiggle narrower than its spacing passes
|
||||
// between the samples (#3926 review). The grid now only checks the affine that structure has
|
||||
// already said exists, so it can reject and never admit.
|
||||
let (s_lo, s_hi) = along.span();
|
||||
if along.is_linear()
|
||||
&& t.is_straight(s_lo, s_hi)
|
||||
&& let Some(a) = affine_fit(&f, b, flat_tol.max(1e-9))
|
||||
{
|
||||
return affine_node(art, a, cross.abs());
|
||||
}
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
|
||||
}
|
||||
|
||||
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
|
||||
@@ -182,7 +405,7 @@ fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, wid
|
||||
let art = widen_strokes(art, &factor);
|
||||
let scale = along.max_scale().max(cross.abs()).max(1e-6);
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
|
||||
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
|
||||
}
|
||||
|
||||
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
|
||||
@@ -262,7 +485,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
|
||||
let width = light.max(heavy);
|
||||
let cross = weight * width / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
for t in tracks(bp, TRACK_TOL) {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
continue;
|
||||
@@ -304,7 +527,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
|
||||
let mut rng = Rng::new(seed_of(bp, name));
|
||||
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
|
||||
let mut out = Budget::default();
|
||||
for t in tracks(bp, 0.05) {
|
||||
for t in tracks(bp, TRACK_TOL) {
|
||||
if out.full() {
|
||||
break;
|
||||
}
|
||||
@@ -403,6 +626,160 @@ fn tile_on(t: &Track, tile: &(Node, Rect), s0: f64, s1: f64, cross: f64) -> Opti
|
||||
bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross)
|
||||
}
|
||||
|
||||
/// What a pattern brush draws at a corner: corner tile art (oriented, with its bounds), or what
|
||||
/// [`CornerSource`] gives.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Corner<'a> {
|
||||
Art(&'a (Node, Rect)),
|
||||
Source(CornerSource),
|
||||
}
|
||||
|
||||
/// The pieces of the corner tile that `source` makes at corner `i` of `t` from `side` (oriented,
|
||||
/// with its bounds) at scale `k`, and the length of path on each side of the corner they cover,
|
||||
/// which is at most `room`.
|
||||
///
|
||||
/// At the corner, the pieces run straight along the directions the path arrives at and leaves
|
||||
/// the corner in, and towards their far ends they move onto the path itself, so they meet the side
|
||||
/// tiles where the path curves. The corner's diagonal is the line through the corner that halves
|
||||
/// the turn, where the two offset edges of the band meet. [`CornerSource::None`] and a turn of
|
||||
/// nearly 180° get no pieces.
|
||||
fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k: f64, room: f64) -> (Vec<Node>, f64) {
|
||||
let (art, b) = side;
|
||||
let (a_dir, b_dir) = t.in_out(i);
|
||||
let (Some(&p), Some(&sc)) = (t.verts.get(i), t.cum.get(i)) else { return (vec![], 0.0) };
|
||||
let bt = a_dir + b_dir;
|
||||
if source == CornerSource::None || a_dir.dot(bt) < 1e-3 {
|
||||
return (vec![], 0.0);
|
||||
}
|
||||
let (tw, r) = (b.width() * k, b.height() * k / 2.0);
|
||||
let (x0, cy) = (b.x0, b.center().y);
|
||||
// Art point → (distance along from the left edge, offset across), in points.
|
||||
let local = move |q: Point| ((q.x - x0) * k, (q.y - cy) * k);
|
||||
// How far along `d` (from the corner) the diagonal is at offset `y` across `d`'s left normal.
|
||||
let diag = move |d: Vec2, y: f64| -y * normal(d).dot(bt) / d.dot(bt);
|
||||
// `u` along `d` from the corner and `y` across, moved from the straight line onto the path as
|
||||
// `u` goes from `from` to `far` (signed: negative before the corner; `from` is the corner or
|
||||
// a point between it and `far`).
|
||||
let on_path = move |d: Vec2, u: f64, y: f64, from: f64, far: f64| {
|
||||
let straight = p + d * u + normal(d) * y;
|
||||
let span = far - from;
|
||||
let w = if span.abs() > 1e-9 { ((u - from) / span).clamp(0.0, 1.0) } else { 0.0 };
|
||||
if w > 0.0 { straight.lerp(t.map(sc + u, y), w) } else { straight }
|
||||
};
|
||||
// The most the diagonal is ahead of the corner, along either direction, within the band. Each
|
||||
// source stretches the side tile along to `len` where the band is too tall for the tile to
|
||||
// reach the diagonal, so the two sides always meet on it.
|
||||
let ahead = r * normal(a_dir).dot(bt).abs() / a_dir.dot(bt);
|
||||
// Before the corner, `u` in [-len, 0] maps onto [-len, diagonal]; after it, [0, len] onto
|
||||
// [diagonal, len]. Both ends stay on the diagonal when `len` is shorter than `ahead`, and the
|
||||
// points on the diagonal stay on the straight lines, where the two sides meet.
|
||||
let into = move |u: f64, y: f64, len: f64| {
|
||||
let reach = len.max(ahead);
|
||||
let e = diag(a_dir, y).clamp(-reach, reach);
|
||||
on_path(a_dir, -len + (u + len) * (e + len) / len.max(1e-9), y, e.min(0.0), -len)
|
||||
};
|
||||
let out_of = move |u: f64, y: f64, len: f64| {
|
||||
let reach = len.max(ahead);
|
||||
let e = diag(b_dir, y).clamp(-reach, reach);
|
||||
on_path(b_dir, e + u * (len - e) / len.max(1e-9), y, e.max(0.0), len)
|
||||
};
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * k) / k;
|
||||
// The tile is squeezed along so that it covers at most `room` of the path on each side.
|
||||
let room = room.max(0.0);
|
||||
let len = tw.max(2.0 * ahead).min(match source {
|
||||
CornerSource::AutoCentered => 2.0 * room,
|
||||
CornerSource::AutoBetween => room,
|
||||
_ => room + ahead,
|
||||
});
|
||||
if len <= 1e-9 {
|
||||
return (vec![], 0.0);
|
||||
}
|
||||
let s = if tw > 1e-9 { len / tw } else { 1.0 };
|
||||
let max_du = MAX_SEG / (2.0 * k * s.max(1.0));
|
||||
let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, flat_tol * k, k);
|
||||
// Art point → (distance along from the left edge, stretched to `len`, and offset across).
|
||||
let along = move |q: Point| {
|
||||
let (x, y) = local(q);
|
||||
(x * s, y)
|
||||
};
|
||||
match source {
|
||||
CornerSource::None => (vec![], 0.0),
|
||||
CornerSource::AutoCentered => {
|
||||
let half = len / 2.0;
|
||||
let f = move |q: Point| {
|
||||
let (x, y) = along(q);
|
||||
let u = x - half;
|
||||
if u < 0.0 { into(u, y, half) } else { out_of(u, y, half) }
|
||||
};
|
||||
(warp(&f).into_iter().collect(), half)
|
||||
}
|
||||
CornerSource::AutoBetween => {
|
||||
let before = move |q: Point| {
|
||||
let (x, y) = along(q);
|
||||
into(x - len, y, len)
|
||||
};
|
||||
let after = move |q: Point| {
|
||||
let (x, y) = along(q);
|
||||
out_of(x, y, len)
|
||||
};
|
||||
(warp(&before).into_iter().chain(warp(&after)).collect(), len)
|
||||
}
|
||||
CornerSource::AutoSliced | CornerSource::AutoOverlap => {
|
||||
// Each side's tile runs `ahead` past the corner.
|
||||
let far = len - ahead;
|
||||
let before = move |q: Point| {
|
||||
let (x, y) = along(q);
|
||||
on_path(a_dir, x - far, y, 0.0, -far)
|
||||
};
|
||||
let after = move |q: Point| {
|
||||
let (x, y) = along(q);
|
||||
on_path(b_dir, x - ahead, y, 0.0, far)
|
||||
};
|
||||
let (first, second) = (warp(&before), warp(&after));
|
||||
if source == CornerSource::AutoOverlap {
|
||||
return (first.into_iter().chain(second).collect(), far);
|
||||
}
|
||||
// Auto-Sliced masks each tile by the part of the outline of the box round its art
|
||||
// (strokes included), placed as the tile is, on its own side of the diagonal. A tile
|
||||
// squeezed along keeps its stroke width, so the box's margins at its ends grow by as
|
||||
// much as the tile is squeezed.
|
||||
let vb = art.visual_bounds().unwrap_or(*b);
|
||||
let grow = 1.0 / s.min(1.0);
|
||||
let (x0, x1) = (b.x0 - (b.x0 - vb.x0).max(0.0) * grow, b.x1 + (vb.x1 - b.x1).max(0.0) * grow);
|
||||
let corners = [(x0, vb.y0), (x1, vb.y0), (x1, vb.y1), (x0, vb.y1), (x0, vb.y0)].map(|(x, y)| Point::new(x, y));
|
||||
let outline = |f: &dyn Fn(Point) -> Point| -> Vec<Point> {
|
||||
let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64);
|
||||
corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect()
|
||||
};
|
||||
let pieces =
|
||||
first.and_then(|n| masked(n, &outline(&before), p, bt)).into_iter().chain(second.and_then(|n| masked(n, &outline(&after), p, -bt)));
|
||||
(pieces.collect(), far)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` in a clipping group whose mask is the part of polygon `quad` on the side of the line through
|
||||
/// `p` that `towards` points away from. `None` when nothing of `quad` is on that side.
|
||||
fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option<Node> {
|
||||
let side = |q: Point| (q - p).dot(towards);
|
||||
let mut kept = vec![];
|
||||
for (j, &a) in quad.iter().enumerate() {
|
||||
let b = quad.get(j + 1).or(quad.first()).copied().unwrap_or(a);
|
||||
let (da, db) = (side(a), side(b));
|
||||
if da <= 0.0 {
|
||||
kept.push(a);
|
||||
}
|
||||
if (da <= 0.0) != (db <= 0.0) {
|
||||
kept.push(a.lerp(b, da / (da - db)));
|
||||
}
|
||||
}
|
||||
if kept.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let mask = Node::path(NodeId(0), PathData::new(vec![SubPath::polyline(&kept, true)]), Appearance::default());
|
||||
Some(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(n)], clip: true }))
|
||||
}
|
||||
|
||||
pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across);
|
||||
let Some(side) = orient(&pb.side) else { return vec![] };
|
||||
@@ -413,8 +790,11 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
let end = pb.end.as_ref().and_then(orient);
|
||||
let tw = side.1.width() * k;
|
||||
let gap = tw * pb.spacing.max(0.0) / 100.0;
|
||||
// Each kind of corner takes its own art, else its source, else the other kind's art.
|
||||
let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source));
|
||||
let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source));
|
||||
let mut out = Budget::default();
|
||||
for t in tracks(bp, 0.05) {
|
||||
for t in tracks(bp, TRACK_TOL) {
|
||||
if out.full() {
|
||||
break;
|
||||
}
|
||||
@@ -423,13 +803,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
continue;
|
||||
}
|
||||
let corners = t.corner_positions();
|
||||
// Corner tiles, centred on the corner and turned to the bisector.
|
||||
// Corner tiles, centred on the corner and turned to the bisector. The path on each side of
|
||||
// a corner is shortened by `trims` (`None`: half the widest corner tile art).
|
||||
let mut corner_half = 0.0_f64;
|
||||
for &(i, _) in &corners {
|
||||
let mut trims: Vec<Option<f64>> = Vec::with_capacity(corners.len());
|
||||
// The path a tile made at corner `ci` may cover on each side: up to the ends of an open
|
||||
// track, and half the way to the next corner.
|
||||
let room = |ci: usize| {
|
||||
let at = |j: usize| corners.get(j).map(|c| c.1);
|
||||
let s = at(ci).unwrap_or(0.0);
|
||||
let last = corners.len().saturating_sub(1);
|
||||
let before = match (ci.checked_sub(1).and_then(at), t.closed) {
|
||||
(Some(prev), _) => (s - prev) / 2.0,
|
||||
(None, true) => (s - (at(last).unwrap_or(s) - l)) / 2.0,
|
||||
(None, false) => s,
|
||||
};
|
||||
let after = match (at(ci + 1), t.closed) {
|
||||
(Some(next), _) => (next - s) / 2.0,
|
||||
(None, true) => (at(0).unwrap_or(s) + l - s) / 2.0,
|
||||
(None, false) => l - s,
|
||||
};
|
||||
before.min(after)
|
||||
};
|
||||
for (ci, &(i, _)) in corners.iter().enumerate() {
|
||||
if out.full() {
|
||||
break;
|
||||
}
|
||||
let (a, b) = t.in_out(i);
|
||||
// Turning towards the left normal puts the tile's +y side on the inside.
|
||||
let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) };
|
||||
let Some((art, bb)) = tile else { continue };
|
||||
let tile = if a.cross(b) > 0.0 { inner_tile.or(outer.as_ref().map(Corner::Art)) } else { outer_tile.or(inner.as_ref().map(Corner::Art)) };
|
||||
let (art, bb) = match tile {
|
||||
Some(Corner::Art(tile)) => tile,
|
||||
Some(Corner::Source(source)) => {
|
||||
let (pieces, trim) = auto_corner(&t, i, source, &side, k, room(ci));
|
||||
for n in pieces {
|
||||
out.take(Some(n));
|
||||
}
|
||||
trims.push(Some(trim));
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
trims.push(Some(0.0));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
trims.push(None);
|
||||
corner_half = corner_half.max(bb.width() * k / 2.0);
|
||||
let bis = a + b;
|
||||
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
|
||||
@@ -441,30 +859,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
n.transform(xf, true);
|
||||
out.take(Some(n));
|
||||
}
|
||||
// Runs between corners (closed tracks wrap past the end).
|
||||
let trim = |c: Option<usize>| c.and_then(|c| trims.get(c).copied()).map_or(0.0, |t| t.unwrap_or(corner_half));
|
||||
// Runs between corners (closed tracks wrap past the end), with the corners they start and
|
||||
// end at.
|
||||
let mut bounds: Vec<f64> = corners.iter().map(|c| c.1).collect();
|
||||
let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() {
|
||||
vec![(0.0, l, false, false)]
|
||||
let nc = bounds.len();
|
||||
let runs: Vec<(f64, f64, Option<usize>, Option<usize>)> = if bounds.is_empty() {
|
||||
vec![(0.0, l, None, None)]
|
||||
} else if t.closed {
|
||||
let first = bounds[0];
|
||||
bounds.push(first + l);
|
||||
bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect()
|
||||
bounds.windows(2).enumerate().map(|(i, w)| (w[0], w[1], Some(i), Some((i + 1) % nc))).collect()
|
||||
} else {
|
||||
let mut v = vec![0.0];
|
||||
v.extend(bounds);
|
||||
v.push(l);
|
||||
let n = v.len();
|
||||
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect()
|
||||
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i.checked_sub(1), (i < nc).then_some(i))).collect()
|
||||
};
|
||||
let nruns = runs.len();
|
||||
let corner_tiles = inner.is_some() || outer.is_some();
|
||||
for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() {
|
||||
if ca && corner_tiles {
|
||||
a += corner_half;
|
||||
}
|
||||
if cb && corner_tiles {
|
||||
b -= corner_half;
|
||||
if out.full() {
|
||||
break;
|
||||
}
|
||||
a += trim(ca);
|
||||
b -= trim(cb);
|
||||
if !t.closed
|
||||
&& ri == 0
|
||||
&& let Some(st) = &start
|
||||
@@ -532,3 +950,187 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
|
||||
}
|
||||
out.out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::track::tracks;
|
||||
|
||||
/// #3926 review: the five by five grid cannot promise a run is straight, only that it looks
|
||||
/// straight where it was asked. A zigzag whose vertices land on the sample positions is
|
||||
/// invisible to it: every sample sits on a y = 0 vertex whose blended tangent is flat, so all
|
||||
/// 25 points agree with the affine exactly, while the art between them is thrown 4 pt off.
|
||||
///
|
||||
/// This is why the gate is `Track::is_straight` and the grid is only a second opinion.
|
||||
#[test]
|
||||
fn a_bend_shorter_than_the_sample_grid_is_still_a_bend() {
|
||||
// Half-period 20 pt, amplitude 4 pt. The 11.3 degree turn is under `CORNER_DEG`, so the
|
||||
// vertices are not corners and their tangents blend flat, which is what hides them.
|
||||
let (d, a) = (20.0, 4.0);
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
for i in 1..=16 {
|
||||
bp.line_to((d * i as f64, if i % 2 == 1 { a } else { 0.0 }));
|
||||
}
|
||||
let t = tracks(&bp, 0.01).remove(0);
|
||||
assert_eq!(t.corner.iter().filter(|c| **c).count(), 0, "no vertex is a corner, so every tangent is blended");
|
||||
|
||||
// A span over four full periods, held clear of both ends so no sample hits the
|
||||
// extrapolation branch: the five samples land on vertices 4, 6, 8, 10 and 12, all at y = 0.
|
||||
let seglen = (d * d + a * a).sqrt();
|
||||
let (s0, s1) = (4.0 * seglen, 12.0 * seglen);
|
||||
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
|
||||
let along = AlongMap::linear(b.width(), s0, s1);
|
||||
let (cy, x0, cross) = (b.center().y, b.x0, 1.0);
|
||||
let f = |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
|
||||
let scale = along.max_scale().max(cross.abs()).max(1e-6);
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
|
||||
|
||||
// The grid is satisfied, and it should not be.
|
||||
let fit = affine_fit(&f, b, flat_tol.max(1e-9)).expect("the sampled check accepts this bend");
|
||||
|
||||
// How wrong it is: an art x landing on a peak of the zigzag, which no sample reaches.
|
||||
let peak_x = x0 + (5.0 * seglen - s0) / (s1 - s0) * b.width();
|
||||
let at_peak = Point::new(peak_x, cy);
|
||||
let off = (f(at_peak) - fit * at_peak).hypot();
|
||||
assert!(
|
||||
off > 3.0 && off > flat_tol * 20.0,
|
||||
"the affine the grid approved is {off:.3} pt out at the peak, against a {flat_tol:.4} pt tolerance"
|
||||
);
|
||||
|
||||
// Reading the track instead refuses it, and still admits the straight run it is made of.
|
||||
assert!(!t.is_straight(s0, s1), "a bend between the samples is still a bend");
|
||||
let straight = tracks(
|
||||
&{
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((320.0, 0.0));
|
||||
bp
|
||||
},
|
||||
0.01,
|
||||
)
|
||||
.remove(0);
|
||||
assert!(straight.is_straight(s0, s1), "and the same span of an actually straight track is straight");
|
||||
|
||||
// End to end through `bend`: the aliased bend is subdivided, the straight run keeps its
|
||||
// curves. This is the gate itself, not the two predicates it is made of.
|
||||
let anchors = |n: &Node| match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(),
|
||||
_ => 0,
|
||||
};
|
||||
let circle = Node::path(
|
||||
vectorcraft_doc::NodeId(0),
|
||||
vectorcraft_geom::shapes::ellipse(b),
|
||||
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
|
||||
);
|
||||
assert_eq!(anchors(&circle), 4, "the art is a 4-anchor circle to begin with");
|
||||
let bent = bend(&t, &circle, b, along, cross).expect("the zigzag still draws something");
|
||||
assert!(anchors(&bent) > 8, "the aliased bend is subdivided, not affine-mapped: {} anchors", anchors(&bent));
|
||||
let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something");
|
||||
assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves");
|
||||
}
|
||||
|
||||
/// A 4-anchor circle tile filling `b`.
|
||||
fn circle_tile(b: Rect) -> Node {
|
||||
Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::shapes::ellipse(b),
|
||||
Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// `per` points along each segment of `sp`, through `f`.
|
||||
fn traced(sp: &SubPath, f: &dyn Fn(Point) -> Point, per: usize) -> Vec<Point> {
|
||||
(0..sp.segment_count())
|
||||
.flat_map(|i| {
|
||||
let c = sp.segment(i);
|
||||
(0..per).map(move |j| f(c.eval(j as f64 / per as f64)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How far apart two closed outlines get (Hausdorff distance, each taken as a polygon).
|
||||
fn apart(a: &[Point], b: &[Point]) -> f64 {
|
||||
let to = |q: Point, pts: &[Point]| {
|
||||
pts.iter()
|
||||
.zip(pts.iter().cycle().skip(1))
|
||||
.map(|(&a, &b)| {
|
||||
let ab = b - a;
|
||||
let t = ((q - a).dot(ab) / ab.hypot2().max(1e-18)).clamp(0.0, 1.0);
|
||||
q.distance(a + ab * t)
|
||||
})
|
||||
.fold(f64::MAX, f64::min)
|
||||
};
|
||||
let one_way = |a: &[Point], b: &[Point]| a.iter().map(|&q| to(q, b)).fold(0.0, f64::max);
|
||||
one_way(a, b).max(one_way(b, a))
|
||||
}
|
||||
|
||||
/// #3926: on a bend the tile is fitted with cubics instead of flattened into chords. A circle
|
||||
/// tile bent round a circle stays a handful of smooth curves, closer to the bent art than the
|
||||
/// chords were, in a small fraction of their anchors.
|
||||
#[test]
|
||||
fn a_circle_tile_bent_round_a_circle_stays_a_few_curves() {
|
||||
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
|
||||
let t = tracks(&round, 0.05).remove(0);
|
||||
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
|
||||
let along = AlongMap::linear(b.width(), 100.0, 140.0);
|
||||
let tile = circle_tile(b);
|
||||
let NodeKind::Path { path: art, .. } = &tile.kind else { panic!("the tile is a path") };
|
||||
let art = &art.subpaths[0];
|
||||
|
||||
let bent = bend(&t, &tile, b, along, 1.0).expect("the tile is drawn");
|
||||
let NodeKind::Path { path, .. } = &bent.kind else { panic!("a bent path is a path") };
|
||||
assert_eq!(path.subpaths.len(), 1);
|
||||
let sp = &path.subpaths[0];
|
||||
assert!(sp.closed, "still a closed outline");
|
||||
let n = sp.anchors.len();
|
||||
assert!((4..=16).contains(&n), "a few anchors, the tile's own four among them: {n}");
|
||||
assert!(sp.anchors.iter().all(|a| a.has_in() && a.has_out()), "every anchor keeps both handles");
|
||||
assert!(sp.anchors.first().map(|a| a.p) != sp.anchors.last().map(|a| a.p), "closed without a duplicate anchor");
|
||||
|
||||
// The same map `bend` uses, at scale 1, and the tolerance it fits to.
|
||||
let (cy, x0) = (b.center().y, b.x0);
|
||||
let f = |p: Point| t.map(along.at(p.x - x0), p.y - cy);
|
||||
let tol = tolerance(b.height().max(b.width()));
|
||||
let exact = traced(art, &f, 1000);
|
||||
let chords: Vec<Point> = densified(art, tol, MAX_SEG).expect("the tile flattens").into_iter().map(f).collect();
|
||||
let curves = traced(sp, &|p| p, 200);
|
||||
let (e_curves, e_chords) = (apart(&curves, &exact), apart(&chords, &exact));
|
||||
// Checked at eight points a piece, so a little room between them.
|
||||
assert!(e_curves <= fit_tol(tol) * 1.5, "the curves follow the bent art within {}: {e_curves:.4}", fit_tol(tol));
|
||||
assert!(e_curves < e_chords, "closer than the chords were: {e_curves:.4} against {e_chords:.4}");
|
||||
assert!(chords.len() >= 4 * n, "far fewer anchors than the {} chords took: {n}", chords.len());
|
||||
}
|
||||
|
||||
/// Fitting is bounded however badly the map behaves: where it gives points that are not finite
|
||||
/// the tile falls back to chords with those points left out (or to nothing), and where it never
|
||||
/// fits (a map that jumps everywhere) the halving stops at its depth and anchor count. Either
|
||||
/// way the call comes back promptly with finite geometry.
|
||||
#[test]
|
||||
fn a_map_that_misbehaves_still_returns_promptly_and_finite() {
|
||||
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
|
||||
let tile = circle_tile(b);
|
||||
let finite = |n: &Node| match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let maps: [&dyn Fn(Point) -> Point; 4] = [
|
||||
&|p| if p.x > 25.0 { Point::new(f64::NAN, p.y) } else { p },
|
||||
&|p| if p.y > 10.0 { Point::new(f64::INFINITY, p.y) } else { p + Vec2::new(p.x.sin(), 0.0) },
|
||||
&|p| Point::new(p.x, p.y + if (p.x * 1e3).sin() > 0.0 { 5.0 } else { 0.0 }),
|
||||
&|_| Point::new(f64::NAN, f64::NAN),
|
||||
];
|
||||
for (i, f) in maps.iter().enumerate() {
|
||||
let t0 = std::time::Instant::now();
|
||||
let out = warp_node(&tile, f, 0.25, 1.5, 0.25, 1.0);
|
||||
assert!(t0.elapsed() < std::time::Duration::from_secs(2), "map {i} took {:?}", t0.elapsed());
|
||||
match (i, &out) {
|
||||
(3, out) => assert!(out.is_none(), "a map that places nothing draws nothing"),
|
||||
(_, Some(n)) => assert!(finite(n), "map {i} drew non-finite geometry"),
|
||||
(_, None) => panic!("map {i} places most points, so something is drawn"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(crate) fn number(s: &str) -> Option<f64> {
|
||||
/// A whole number; some writers put integers as `1.0`.
|
||||
pub(crate) fn integer(s: &str) -> Option<i64> {
|
||||
let s = s.trim();
|
||||
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
|
||||
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64))
|
||||
}
|
||||
|
||||
/// One object: its type (the value of its `0` group) and the groups up to the next object.
|
||||
@@ -456,3 +456,14 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
|
||||
}
|
||||
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::integer;
|
||||
|
||||
#[test]
|
||||
fn integer_rejects_fractional_float_values() {
|
||||
assert_eq!(integer("1.0"), Some(1));
|
||||
assert_eq!(integer("1.9"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,61 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
|
||||
return None;
|
||||
}
|
||||
// Verify that the curve progresses monotonically around the circle without reversing,
|
||||
// both along each cubic segment's angular derivative and across sampled intervals,
|
||||
// and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn.
|
||||
let mut total_turn = 0.0;
|
||||
let mut dir: Option<bool> = None;
|
||||
let ang_threshold = radius * radius * 1e-9;
|
||||
let mut seg_from = sub.start;
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
for step in 0..=32 {
|
||||
let t = f64::from(step) / 32.0;
|
||||
let p = cubic_point(seg_from, a, b, to, t);
|
||||
let u = 1.0 - t;
|
||||
let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x);
|
||||
let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y);
|
||||
let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx;
|
||||
if cross.abs() > ang_threshold {
|
||||
let is_ccw = cross > 0.0;
|
||||
if let Some(expected_ccw) = dir {
|
||||
if is_ccw != expected_ccw {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
dir = Some(is_ccw);
|
||||
}
|
||||
}
|
||||
}
|
||||
seg_from = to;
|
||||
}
|
||||
for w in samples.windows(2) {
|
||||
let (p0, p1) = (w[0], w[1]);
|
||||
let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y);
|
||||
let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y);
|
||||
let cross = v0x * v1y - v0y * v1x;
|
||||
let dot = v0x * v1x + v0y * v1y;
|
||||
let d_theta = cross.atan2(dot);
|
||||
if d_theta.abs() > 1e-9 {
|
||||
let is_ccw = d_theta > 0.0;
|
||||
if let Some(expected_ccw) = dir {
|
||||
if is_ccw != expected_ccw {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
dir = Some(is_ccw);
|
||||
}
|
||||
total_turn += d_theta;
|
||||
}
|
||||
}
|
||||
if sub.closed {
|
||||
if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 {
|
||||
return None;
|
||||
}
|
||||
} else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 {
|
||||
return None;
|
||||
}
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,52 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_sliver_along_a_circle_is_not_a_circle() {
|
||||
let d = doc_with(|d| {
|
||||
let (cx, cy, r) = (60.0, 250.0, 200.0);
|
||||
let at = |deg: f64| {
|
||||
let rad = deg.to_radians();
|
||||
Point::new(cx + r * rad.cos(), cy + r * rad.sin())
|
||||
};
|
||||
let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| {
|
||||
let p0 = at(deg0);
|
||||
let p3 = at(deg1);
|
||||
let step = (deg1 - deg0).to_radians();
|
||||
let k = 4.0 / 3.0 * (step / 4.0).tan();
|
||||
let r0 = deg0.to_radians();
|
||||
let r1 = deg1.to_radians();
|
||||
let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k);
|
||||
let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k);
|
||||
sliver.curve_to(p1, p2, p3);
|
||||
};
|
||||
let mut sliver = vectorcraft_geom::BezPath::new();
|
||||
sliver.move_to(at(-110.0));
|
||||
along(&mut sliver, -110.0, -90.0);
|
||||
along(&mut sliver, -90.0, -70.0);
|
||||
along(&mut sliver, -70.0, -110.0);
|
||||
sliver.close_path();
|
||||
vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
|
||||
let modern = export(&d, &opts(&d)).unwrap();
|
||||
assert_ne!(kinds(&modern), ["CIRCLE"]);
|
||||
assert_eq!(kinds(&modern), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() {
|
||||
let d = doc_with(|d| {
|
||||
let mut b = vectorcraft_geom::BezPath::new();
|
||||
b.move_to(Point::new(-8.404028665134, 62.061475842818));
|
||||
b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0));
|
||||
vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
let dxf = export(&d, &opts(&d)).unwrap();
|
||||
assert_ne!(kinds(&dxf), ["ARC"]);
|
||||
assert_eq!(kinds(&dxf), ["SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
|
||||
let cases = [(true, true), (true, false), (false, false), (false, true)];
|
||||
|
||||
@@ -69,7 +69,7 @@ impl fmt::Display for ColorKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
// Components are hundredths of the written units: exact with two decimals.
|
||||
let num = |q: i32| {
|
||||
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100);
|
||||
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.unsigned_abs() / 100, q.unsigned_abs() % 100);
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
};
|
||||
let [a, b, c, k] = self.v.map(num);
|
||||
@@ -461,6 +461,14 @@ impl Rng {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn key_display_survives_saturated_lab() {
|
||||
for text in ["lab 50 -1e20 0", "lab 50 0 -1e20", "lab 50 1e20 0"] {
|
||||
let k = ColorKey::parse(text).unwrap();
|
||||
assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: Color, b: Color) -> bool {
|
||||
let v = |c: Color| match c {
|
||||
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::profile::{PressureProfile, WidthProfile};
|
||||
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
use super::*;
|
||||
use super::{BlendMode, Deserialize, Paint, Serialize};
|
||||
|
||||
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
|
||||
/// in order along the path. Two points at the same position make a discontinuous point: the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LineCap {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Artboard {
|
||||
|
||||
+259
-3
@@ -1059,13 +1059,146 @@ pub fn steps_knockout_shows(steps: &[Arc<Node>]) -> bool {
|
||||
!steps.iter().all(|c| paints_opaquely(c))
|
||||
}
|
||||
|
||||
/// Evaluate a blend: the keys and generated steps in paint order.
|
||||
/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included.
|
||||
/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps
|
||||
/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps
|
||||
/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes
|
||||
/// proportionally fewer steps between each pair of keys ([`fit_steps`]).
|
||||
pub const MAX_BLEND_STEPS: usize = 100_000;
|
||||
|
||||
/// The steps between each pair of keys, as the blend's spacing asks for them.
|
||||
fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> Vec<usize> {
|
||||
keys.windows(2)
|
||||
.enumerate()
|
||||
.map(|(i, w)| match w {
|
||||
[a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))),
|
||||
_ => 0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Most steps `spacing` makes between two keys, whatever their size and distance
|
||||
/// ([`blend_step_count`]'s clamps).
|
||||
fn most_steps(spacing: BlendSpacing) -> usize {
|
||||
match spacing {
|
||||
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
|
||||
BlendSpacing::Distance(_) => 1000,
|
||||
BlendSpacing::SmoothColor => 256,
|
||||
}
|
||||
}
|
||||
|
||||
/// `want` steps between each pair of keys, fewer where they would make more than
|
||||
/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by
|
||||
/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share
|
||||
/// of its steps, rounded down, and none when the keys alone fill the budget.
|
||||
fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec<usize> {
|
||||
let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128;
|
||||
let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add);
|
||||
if demand <= room {
|
||||
return want.to_vec();
|
||||
}
|
||||
// `demand` is more than `room`, so it isn't 0, and each share is below `n`.
|
||||
want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its
|
||||
/// address while the count runs.
|
||||
#[derive(Default)]
|
||||
struct StepCount {
|
||||
known: std::collections::HashMap<(usize, bool), usize>,
|
||||
}
|
||||
|
||||
impl StepCount {
|
||||
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
|
||||
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
|
||||
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
|
||||
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
|
||||
fn node(&mut self, n: &Node, copy: bool) -> usize {
|
||||
let at = (std::ptr::from_ref(n) as usize, copy);
|
||||
if let Some(&known) = self.known.get(&at) {
|
||||
return known;
|
||||
}
|
||||
let count = match &n.kind {
|
||||
NodeKind::Blend { children, spec } if copy => self.most(children, spec),
|
||||
NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1,
|
||||
// Each instance draws the source again.
|
||||
NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)),
|
||||
_ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy),
|
||||
};
|
||||
self.known.insert(at, count);
|
||||
count
|
||||
}
|
||||
|
||||
fn nodes(&mut self, nodes: &[Arc<Node>], copy: bool) -> usize {
|
||||
nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// What one step between keys `a` and `b` makes: itself and the steps of the blends in it.
|
||||
/// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key.
|
||||
fn step(&mut self, a: &Node, b: &Node) -> usize {
|
||||
let inner = match (&a.kind, &b.kind) {
|
||||
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 })
|
||||
if k1 == k2 && !(*k1 && ca.len() != cb.len()) =>
|
||||
{
|
||||
(0..ca.len().max(cb.len()))
|
||||
.map(|i| match (ca.get(i), cb.get(i)) {
|
||||
(Some(x), Some(y)) => self.step(x, y).saturating_sub(1),
|
||||
(Some(x), None) | (None, Some(x)) => self.node(x, true),
|
||||
(None, None) => 0,
|
||||
})
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
_ => self.node(a, true).max(self.node(b, true)),
|
||||
};
|
||||
inner.saturating_add(1)
|
||||
}
|
||||
|
||||
/// What each step between each pair of `keys` makes ([`Self::step`]).
|
||||
fn steps(&mut self, keys: &[Arc<Node>]) -> Vec<usize> {
|
||||
keys.windows(2)
|
||||
.map(|w| match w {
|
||||
[a, b] => self.step(a, b),
|
||||
_ => 1,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes
|
||||
/// in all, its keys' included.
|
||||
fn blend(&mut self, keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec<usize>, usize) {
|
||||
let inside = self.nodes(keys, false);
|
||||
let each = self.steps(keys);
|
||||
let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside);
|
||||
let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add);
|
||||
(kept, total)
|
||||
}
|
||||
|
||||
/// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts
|
||||
/// with [`most_steps`] between each pair, before rounding.
|
||||
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
|
||||
let inside = self.nodes(keys, true);
|
||||
let each = self.steps(keys);
|
||||
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
|
||||
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the
|
||||
/// spacing asks for, fewer when they and the steps of the blends nested in them would pass
|
||||
/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these).
|
||||
pub fn blend_steps(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<usize> {
|
||||
StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0
|
||||
}
|
||||
|
||||
/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to
|
||||
/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]).
|
||||
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
let k = keys.len();
|
||||
if k < 2 {
|
||||
return keys.iter().map(|n| (**n).clone()).collect();
|
||||
}
|
||||
let rail = Rail::new(keys, spec);
|
||||
let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref());
|
||||
let mut out = Vec::new();
|
||||
for (i, a) in keys.iter().enumerate() {
|
||||
let mut key = (**a).clone();
|
||||
@@ -1074,8 +1207,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
}
|
||||
out.push(key);
|
||||
let Some(b) = keys.get(i + 1) else { break };
|
||||
let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i));
|
||||
let n = blend_step_count(a, b, spec.spacing, len);
|
||||
let n = counts.get(i).copied().unwrap_or(0);
|
||||
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
|
||||
for j in 1..=n {
|
||||
// Even position of the step, then where the easing puts its shape and its colours.
|
||||
@@ -1301,4 +1433,128 @@ mod tests {
|
||||
mid.walk(&mut |n| ids.push(n.id));
|
||||
assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}");
|
||||
}
|
||||
|
||||
/// A blend of `a` and `b` spaced by `spacing`.
|
||||
fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node {
|
||||
let spec = BlendSpec { spacing, ..Default::default() };
|
||||
Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) })
|
||||
}
|
||||
|
||||
/// A blend's keys and options.
|
||||
fn blend_parts(n: &Node) -> Option<(&Vec<Arc<Node>>, &BlendSpec)> {
|
||||
match &n.kind {
|
||||
NodeKind::Blend { children, spec } => Some((children, spec)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A square of side `side` at (`x`, `y`).
|
||||
fn square(x: f64, y: f64, side: f64) -> Node {
|
||||
Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default())
|
||||
}
|
||||
|
||||
/// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input).
|
||||
fn nested(levels: u32, x: f64) -> Node {
|
||||
if levels == 0 {
|
||||
return square(x, 0.0, 4.0);
|
||||
}
|
||||
let w = 40.0 * f64::from(levels);
|
||||
blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15))
|
||||
}
|
||||
|
||||
/// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at
|
||||
/// a time, the blends in its keys and steps evaluated again.
|
||||
fn drawn_steps(n: &Node) -> usize {
|
||||
match &n.kind {
|
||||
NodeKind::Blend { children, spec } => {
|
||||
let out = blend_expand(children, spec);
|
||||
out.len() - children.len() + out.iter().map(drawn_steps).sum::<usize>()
|
||||
}
|
||||
_ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps,
|
||||
/// and again the steps of the level below in each key and each step.
|
||||
fn nested_steps(levels: u32) -> usize {
|
||||
(17usize.pow(levels) - 1) * 15 / 16
|
||||
}
|
||||
|
||||
/// #3579: blends whose keys are blends draw their steps again in every step above them. Five
|
||||
/// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps
|
||||
/// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each
|
||||
/// level past that adds only what its keys draw.
|
||||
#[test]
|
||||
fn nested_blends_keep_to_the_step_budget() {
|
||||
assert_eq!(nested_steps(5), 1_331_115);
|
||||
let four = nested_steps(4);
|
||||
assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS);
|
||||
let five = nested(5, 0.0);
|
||||
let (children, spec) = blend_parts(&five).unwrap();
|
||||
assert_eq!(blend_steps(children, spec), vec![0]);
|
||||
assert_eq!(drawn_steps(&five), 2 * four);
|
||||
assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four);
|
||||
// A blend whose keys leave room keeps the same share of its steps between each pair.
|
||||
let mut three = nested(3, 0.0);
|
||||
if let NodeKind::Blend { spec, .. } = &mut three.kind {
|
||||
spec.spacing = BlendSpacing::Steps(1000);
|
||||
}
|
||||
let (children, spec) = blend_parts(&three).unwrap();
|
||||
let inside = 2 * nested_steps(2);
|
||||
let each = 1 + nested_steps(2);
|
||||
let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each);
|
||||
assert_eq!(blend_steps(children, spec), vec![kept]);
|
||||
let drawn = drawn_steps(&three);
|
||||
assert_eq!(drawn, inside + kept * each);
|
||||
assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}");
|
||||
}
|
||||
|
||||
/// Nested blends within the budget make every step their spacing asks for, as before.
|
||||
#[test]
|
||||
fn nested_blends_within_the_budget_draw_every_step() {
|
||||
for levels in 1..=4 {
|
||||
let n = nested(levels, 0.0);
|
||||
let (children, spec) = blend_parts(&n).unwrap();
|
||||
assert_eq!(blend_steps(children, spec), vec![15]);
|
||||
assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}");
|
||||
}
|
||||
// The keys, then 15 steps, then the last key, each a blend whose keys moved with it.
|
||||
let two = nested(2, 0.0);
|
||||
let (children, spec) = blend_parts(&two).unwrap();
|
||||
let out = blend_expand(children, spec);
|
||||
assert_eq!(out.len(), 17);
|
||||
assert_eq!(out[0], *children[0]);
|
||||
assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. })));
|
||||
assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0)));
|
||||
}
|
||||
|
||||
/// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the
|
||||
/// most its spacing can make. Here a tiny blend of one step is blended with a large blend of
|
||||
/// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000
|
||||
/// steps each, which neither key's own count shows.
|
||||
#[test]
|
||||
fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() {
|
||||
let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0));
|
||||
let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1));
|
||||
assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1));
|
||||
let n = blend_of(tiny, large, BlendSpacing::Steps(1000));
|
||||
let (children, spec) = blend_parts(&n).unwrap();
|
||||
let steps = blend_steps(children, spec);
|
||||
assert!(steps[0] < 1000, "{steps:?}");
|
||||
let drawn = drawn_steps(&n);
|
||||
assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}");
|
||||
}
|
||||
|
||||
/// A flat blend of many keys shares the budget evenly between its pairs.
|
||||
#[test]
|
||||
fn many_keys_share_the_step_budget() {
|
||||
let keys: Vec<Arc<Node>> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect();
|
||||
let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() };
|
||||
let steps = blend_steps(&keys, &spec);
|
||||
assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]);
|
||||
assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::<usize>());
|
||||
// Within the budget, every step stays.
|
||||
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
|
||||
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! A read document brought within the canvas: what lies beyond [`MAX_COORD`] is removed (#3575).
|
||||
//! Edits refuse to leave anything there (`doc_sane` in the engine checks the artboards, the guides,
|
||||
//! the ruler origin and the objects an edit touches), so a file holding something beyond it could be
|
||||
//! opened but not edited: one object, artboard or guide out there made every move fail.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::{Document, MAX_COORD, Node, NodeId, NodeKind};
|
||||
|
||||
/// What [`Document::drop_beyond_canvas`] removed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct BeyondCanvas {
|
||||
/// Objects removed whole (a layer or group keeps its children within the canvas).
|
||||
pub objects: usize,
|
||||
pub artboards: usize,
|
||||
/// Guides beyond the canvas, and the artboard guides of the artboards removed.
|
||||
pub guides: usize,
|
||||
/// The ruler origin was beyond the canvas and is back at the origin.
|
||||
pub ruler_origin: bool,
|
||||
}
|
||||
|
||||
impl BeyondCanvas {
|
||||
/// The warning for what was removed, or `None` when nothing was.
|
||||
pub fn note(&self) -> Option<String> {
|
||||
let count = |n: usize, one: &str, many: &str| match n {
|
||||
0 => None,
|
||||
1 => Some(format!("1 {one}")),
|
||||
n => Some(format!("{n} {many}")),
|
||||
};
|
||||
let parts: Vec<String> =
|
||||
[count(self.objects, "object", "objects"), count(self.artboards, "artboard", "artboards"), count(self.guides, "guide", "guides")]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
let limit = format!("more than {MAX_COORD} pt from the origin");
|
||||
match (parts.is_empty(), self.ruler_origin) {
|
||||
(true, false) => None,
|
||||
(true, true) => Some(format!("the ruler origin lay beyond the canvas ({limit}) and was reset")),
|
||||
(false, origin) => Some(format!(
|
||||
"{} beyond the canvas ({limit}) {} removed{}",
|
||||
parts.join(", "),
|
||||
if self.objects + self.artboards + self.guides == 1 { "was" } else { "were" },
|
||||
if origin { "; the ruler origin, beyond it too, was reset" } else { "" }
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is every side of `r` finite and within [`MAX_COORD`] of the origin?
|
||||
fn rect_in_range(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| in_range(*v))
|
||||
}
|
||||
|
||||
fn in_range(v: f64) -> bool {
|
||||
v.is_finite() && v.abs() <= MAX_COORD
|
||||
}
|
||||
|
||||
/// Does `n` reach beyond the canvas? An object without geometry doesn't.
|
||||
fn beyond(n: &Node) -> bool {
|
||||
n.geometric_bounds().is_some_and(|b| !rect_in_range(b))
|
||||
}
|
||||
|
||||
/// Whether `n` is a layer or group, and if so whether it clips: the containers whose objects are
|
||||
/// looked at one by one. Anything else is one object, measured whole.
|
||||
fn clipping(n: &Node) -> Option<bool> {
|
||||
match n.kind {
|
||||
NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => Some(clip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by
|
||||
/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its
|
||||
/// clipping path.
|
||||
fn reaches_beyond(n: &Node) -> bool {
|
||||
match clipping(n) {
|
||||
None => beyond(n),
|
||||
Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips
|
||||
/// go together, so no other object becomes the clipping path.
|
||||
fn clips_beyond(n: &Node) -> bool {
|
||||
clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c))
|
||||
}
|
||||
|
||||
/// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go
|
||||
/// with them.
|
||||
fn layers_in(n: &Node, layers: &mut Vec<NodeId>) {
|
||||
if matches!(n.kind, NodeKind::Layer { .. }) {
|
||||
layers.push(n.id);
|
||||
}
|
||||
if clipping(n).is_some() {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
layers_in(c, layers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its
|
||||
/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything
|
||||
/// else goes whole. Only containers holding such an object are copied. Recursion follows the
|
||||
/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`.
|
||||
fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut Vec<NodeId>) {
|
||||
if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) {
|
||||
return;
|
||||
}
|
||||
let Some(children) = Arc::make_mut(node).children_mut() else { return };
|
||||
children.retain_mut(|c| {
|
||||
if !reaches_beyond(c) {
|
||||
return true;
|
||||
}
|
||||
if clipping(c).is_some() && !clips_beyond(c) {
|
||||
drop_children_beyond(c, dropped, layers);
|
||||
return true;
|
||||
}
|
||||
layers_in(c, layers);
|
||||
*dropped += 1;
|
||||
false
|
||||
});
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects
|
||||
/// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied
|
||||
/// and stops clipping), artboards with their ruler origins and artboard guides (their ids
|
||||
/// are retired, as deleting them does), guides with the layer guides of the layers removed,
|
||||
/// and put a ruler origin beyond it back at the origin. Saved selections, assets and variable
|
||||
/// bindings then forget the objects removed. Run after [`Document::limit_depth`]. A document
|
||||
/// whose artboards were all removed has none left.
|
||||
pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas {
|
||||
let mut gone = BeyondCanvas::default();
|
||||
let mut layers = vec![];
|
||||
for layer in &mut self.layers {
|
||||
if clips_beyond(layer) {
|
||||
let layer = Arc::make_mut(layer);
|
||||
if let NodeKind::Layer { children, clip, .. } = &mut layer.kind {
|
||||
for c in children.iter() {
|
||||
layers_in(c, &mut layers);
|
||||
}
|
||||
gone.objects += children.len();
|
||||
children.clear();
|
||||
*clip = false;
|
||||
}
|
||||
} else {
|
||||
drop_children_beyond(layer, &mut gone.objects, &mut layers);
|
||||
}
|
||||
}
|
||||
if gone.objects > 0 {
|
||||
self.tidy_saved_selections();
|
||||
self.prune_assets();
|
||||
self.prune_variable_bindings();
|
||||
}
|
||||
let removed: Vec<u32> = self.artboards.iter().filter(|a| !rect_in_range(a.rect)).map(|a| a.id).collect();
|
||||
self.artboards.retain(|a| rect_in_range(a.rect));
|
||||
for id in &removed {
|
||||
self.artboard_ruler_origins.remove(id);
|
||||
self.retire_artboard_id(*id);
|
||||
}
|
||||
gone.artboards = removed.len();
|
||||
let guides = self.guides.len();
|
||||
self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l)));
|
||||
gone.guides = guides - self.guides.len();
|
||||
if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) {
|
||||
self.ruler_origin = Point::ZERO;
|
||||
gone.ruler_origin = true;
|
||||
}
|
||||
gone
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Appearance, Artboard, Guide};
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
fn rect(id: u64, y: f64) -> Arc<Node> {
|
||||
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, y, 10.0, y + 10.0)), Appearance::default_art()))
|
||||
}
|
||||
|
||||
fn doc(children: Vec<Arc<Node>>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
|
||||
ch.extend(children);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn ids(d: &Document) -> Vec<u64> {
|
||||
let mut out = vec![];
|
||||
fn walk(n: &Node, out: &mut Vec<u64>) {
|
||||
out.push(n.id.0);
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(c, out);
|
||||
}
|
||||
}
|
||||
for l in &d.layers {
|
||||
walk(l, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_within_the_canvas_is_left_alone() {
|
||||
let mut d = doc(vec![rect(10, 0.0), rect(11, MAX_COORD - 10.0), rect(12, -MAX_COORD)]);
|
||||
let before = d.clone();
|
||||
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
|
||||
assert_eq!(d, before);
|
||||
assert_eq!(BeyondCanvas::default().note(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_beyond_the_canvas_are_removed_and_groups_keep_the_rest() {
|
||||
let group = Arc::new(Node::group(NodeId(20), vec![rect(21, 0.0), rect(22, 1e17)]));
|
||||
let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, f64::NAN), rect(32, 0.0)], clip: true }));
|
||||
let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), group, clip, rect(12, -MAX_COORD - 1.0)]);
|
||||
let layer = d.layers[0].id.0;
|
||||
let gone = d.drop_beyond_canvas();
|
||||
assert_eq!(gone, BeyondCanvas { objects: 4, ..Default::default() });
|
||||
assert_eq!(ids(&d), [layer, 10, 20, 21], "the group keeps its object within the canvas; the clip group goes with its clipping path");
|
||||
assert!(d.layers.iter().all(|l| !beyond(l)));
|
||||
assert_eq!(gone.note().unwrap(), format!("4 objects beyond the canvas (more than {MAX_COORD} pt from the origin) were removed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards_guides_and_the_ruler_origin_beyond_the_canvas_are_removed_or_reset() {
|
||||
let mut d = doc(vec![rect(10, 0.0)]);
|
||||
let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() };
|
||||
d.artboards.push(far);
|
||||
let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) };
|
||||
d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))];
|
||||
d.ruler_origin = Point::new(1e17, 0.0);
|
||||
let gone = d.drop_beyond_canvas();
|
||||
assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true });
|
||||
assert_eq!(d.artboards.len(), 1);
|
||||
assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it");
|
||||
assert_eq!(d.ruler_origin, Point::ZERO);
|
||||
assert_eq!(
|
||||
gone.note().unwrap(),
|
||||
format!(
|
||||
"1 artboard, 3 guides beyond the canvas (more than {MAX_COORD} pt from the origin) were removed; the ruler origin, beyond it too, was reset"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A sublayer like the document's first layer, holding `children`.
|
||||
fn sublayer(d: &Document, id: u64, children: Vec<Arc<Node>>, clip: bool) -> Arc<Node> {
|
||||
let mut l = (*d.layers[0]).clone();
|
||||
l.id = NodeId(id);
|
||||
if let NodeKind::Layer { children: c, clip: k, .. } = &mut l.kind {
|
||||
*c = children;
|
||||
*k = clip;
|
||||
}
|
||||
Arc::new(l)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_hidden_or_clipped_inside_a_container_are_looked_at_one_by_one() {
|
||||
// A layer's bounds leave out its hidden objects, and a clip group's are its clipping path's.
|
||||
let mut hidden = (*rect(41, 1e17)).clone();
|
||||
hidden.visible = false;
|
||||
let probe = Document::new(1.0, 1.0);
|
||||
let sub = sublayer(&probe, 40, vec![Arc::new(hidden), rect(42, 0.0)], false);
|
||||
let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, 0.0), rect(32, 1e17), rect(33, 5.0)], clip: true }));
|
||||
let mut d = doc(vec![sub, clip]);
|
||||
let layer = d.layers[0].id.0;
|
||||
assert_eq!(d.drop_beyond_canvas().objects, 2);
|
||||
assert_eq!(ids(&d), [layer, 40, 42, 30, 31, 33], "the far hidden object and the far clipped one go; the clipping path stays first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_level_layer_clipping_by_a_path_beyond_the_canvas_is_emptied() {
|
||||
let mut d = doc(vec![rect(10, 1e17), rect(11, 0.0), rect(12, 5.0)]);
|
||||
if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
|
||||
*clip = true;
|
||||
}
|
||||
assert_eq!(d.drop_beyond_canvas().objects, 3, "the clipping path and what it clipped go together");
|
||||
assert!(
|
||||
matches!(&d.layers[0].kind, NodeKind::Layer { children, clip: false, .. } if children.is_empty()),
|
||||
"no other object becomes the clipping path"
|
||||
);
|
||||
// A clipping sublayer goes whole, as a clip group does.
|
||||
let mut d = doc(vec![]);
|
||||
let sub = sublayer(&d, 40, vec![rect(41, 1e17), rect(42, 0.0)], true);
|
||||
let mut d2 = doc(vec![sub, rect(10, 0.0)]);
|
||||
let layer = d2.layers[0].id.0;
|
||||
assert_eq!(d2.drop_beyond_canvas().objects, 1);
|
||||
assert_eq!(ids(&d2), [layer, 10]);
|
||||
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_artboards_ruler_origin_goes_and_its_id_is_not_given_again() {
|
||||
let mut d = doc(vec![]);
|
||||
let kept = d.artboards[0].id;
|
||||
d.artboards.push(Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() });
|
||||
d.artboard_ruler_origins.insert(kept, vectorcraft_geom::Vec2::new(1.0, 2.0));
|
||||
d.artboard_ruler_origins.insert(9, vectorcraft_geom::Vec2::new(3.0, 4.0));
|
||||
assert_eq!(d.drop_beyond_canvas().artboards, 1);
|
||||
assert_eq!(d.artboard_ruler_origins.keys().copied().collect::<Vec<_>>(), [kept]);
|
||||
assert!(d.next_artboard_id() > 9, "a new artboard doesn't take the removed one's id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_names_a_removed_object_forgets_it() {
|
||||
let probe = Document::new(1.0, 1.0);
|
||||
let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true);
|
||||
let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]);
|
||||
d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }];
|
||||
d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into();
|
||||
d.assets = vec![
|
||||
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] },
|
||||
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] },
|
||||
];
|
||||
d.variables.bindings.insert(NodeId(11), ["v".to_string()].into());
|
||||
d.variables.bindings.insert(NodeId(10), ["w".to_string()].into());
|
||||
let gone = d.drop_beyond_canvas();
|
||||
assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide");
|
||||
assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays");
|
||||
assert_eq!(d.saved_selections[0].objects, [NodeId(10)]);
|
||||
assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::<Vec<_>>(), [(2, vec![NodeId(10)])]);
|
||||
assert_eq!(d.variables.bindings.keys().copied().collect::<Vec<_>>(), [NodeId(10)]);
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
}
|
||||
|
||||
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
|
||||
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
|
||||
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Compound shapes (Pathfinder panel › Make Compound Shape): a group whose members each have a
|
||||
//! shape mode. It draws as the one area the modes make, from the backmost member up, painted with
|
||||
//! the group's own appearance; the members stay editable. Evaluated in `vectorcraft-effects`.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{Node, NodeKind};
|
||||
|
||||
/// How a compound shape's member combines with the area of the members below it. The backmost
|
||||
/// member's mode does not matter: it starts the area.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ShapeMode {
|
||||
/// Adds the member's area.
|
||||
#[default]
|
||||
Add,
|
||||
/// Takes the member's area away.
|
||||
Subtract,
|
||||
/// Keeps only what the member's area overlaps.
|
||||
Intersect,
|
||||
/// Keeps where exactly one of the area and the member's area is.
|
||||
Exclude,
|
||||
}
|
||||
|
||||
impl ShapeMode {
|
||||
/// Every mode, in the Pathfinder panel's order.
|
||||
pub const ALL: [ShapeMode; 4] = [ShapeMode::Add, ShapeMode::Subtract, ShapeMode::Intersect, ShapeMode::Exclude];
|
||||
|
||||
/// The mode's name in commands and files.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ShapeMode::Add => "add",
|
||||
ShapeMode::Subtract => "subtract",
|
||||
ShapeMode::Intersect => "intersect",
|
||||
ShapeMode::Exclude => "exclude",
|
||||
}
|
||||
}
|
||||
|
||||
/// The mode named `s` ([`ShapeMode::name`]).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.name() == s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Files are untrusted: a mode that isn't one of the names reads as Add.
|
||||
impl<'de> Deserialize<'de> for ShapeMode {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = serde_json::Value::deserialize(d)?;
|
||||
Ok(v.as_str().and_then(ShapeMode::parse).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Is this a compound shape? Only a group that doesn't clip can be one: the mark on anything
|
||||
/// else (from a file) is ignored.
|
||||
pub fn is_compound_shape(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().compound_shape
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn modes_read_back_and_unknown_ones_are_add() {
|
||||
for m in ShapeMode::ALL {
|
||||
let v = serde_json::to_value(m).unwrap();
|
||||
assert_eq!(v, serde_json::json!(m.name()));
|
||||
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), m);
|
||||
}
|
||||
for v in [serde_json::json!("divide"), serde_json::json!(3), serde_json::json!(null), serde_json::json!({"a": [1]})] {
|
||||
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), ShapeMode::Add);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ use super::graphic_style::default_graphic_styles;
|
||||
use super::guides::{GridPrefs, RulerMode};
|
||||
use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID};
|
||||
use super::units::{ColorMode, Unit};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, BTreeMap, ColorProfiles, DocMetadata, DocSetup, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, PatternDef, Places, Point,
|
||||
RasterEffectsSettings, Rect, Vec2,
|
||||
};
|
||||
|
||||
impl Document {
|
||||
/// Is everything but the layers the same in `other`, as the next version of the document after
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::graphic_style::GraphicStyle;
|
||||
use super::limits::MAX_NEXT_ID;
|
||||
use super::records::{ImageBlob, SavedSelection, SavedView};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice,
|
||||
SymbolRegistration,
|
||||
};
|
||||
|
||||
impl Document {
|
||||
/// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::document_ext::free_id;
|
||||
use super::guides::Guide;
|
||||
use super::*;
|
||||
use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds};
|
||||
|
||||
impl Document {
|
||||
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Appearance, Deserialize, Knockout, Node, Rect, Serialize, skip};
|
||||
|
||||
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, NodeId, Serialize, Vec2};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Guide {
|
||||
|
||||
+68
-1
@@ -110,6 +110,66 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
|
||||
hit_test_skipping(doc, p, opt, &|_| false, false)
|
||||
}
|
||||
|
||||
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
|
||||
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
|
||||
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
|
||||
/// art instead of starting a marquee: an image in a mask, picked in the Layers panel, can be
|
||||
/// dragged by its hidden part, whose frame shows while it is selected. An object that a click
|
||||
/// can't hit anyway doesn't count: hidden, locked, a template, a guide while guides are locked
|
||||
/// or hidden, in a Shaper group's part a click can't reach, outside the isolated container
|
||||
/// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its
|
||||
/// outline, as a click there hits it.
|
||||
pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let locks = doc.locked_artboards();
|
||||
selected.iter().rev().copied().find(|&id| {
|
||||
let Some(chain) = doc.ancestry(id) else { return false };
|
||||
let nodes: Vec<&Node> = chain.iter().filter_map(|a| doc.node(*a)).collect();
|
||||
if nodes.len() != chain.len()
|
||||
|| nodes.iter().any(|n| !n.visible || n.locked || n.is_template())
|
||||
|| opt.scope.is_some_and(|s| !chain.contains(&s))
|
||||
|| locks.holds_id(id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// How a click reaches it, if one does.
|
||||
let Some(opt) = path_options(&nodes, &chain, opt) else { return false };
|
||||
// Hidden by a mask: one of the containers around it clips, and not to `p`.
|
||||
let masked = nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.skip(1)
|
||||
.any(|n| n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p)));
|
||||
// A click may reach it there ([`may_hit`]: not a guide while guides are locked or
|
||||
// hidden, nor a Shaper group's hidden part), and its own art is what a click would hit
|
||||
// in it: a group's hidden, locked or masked contents don't count.
|
||||
let [.., parent, n] = nodes.as_slice() else { return false };
|
||||
let reachable =
|
||||
parent.children().and_then(|c| c.iter().position(|c| c.id == id)).is_some_and(|i| may_hit(parent, i, n, p, opt, &symbols, true));
|
||||
masked && reachable && object_hit(n, p, opt, &symbols, true)
|
||||
})
|
||||
}
|
||||
|
||||
/// The options a click goes down `nodes` (an object's ancestry, `chain`, the layer first) with, as
|
||||
/// [`hit_test`] goes: from the isolated container (`opt.scope`), else from the layer, in outline
|
||||
/// below a layer whose Preview is off. `None` when a Shaper group's part it can't reach lies on
|
||||
/// the way ([`shaper_hides`]).
|
||||
fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option<HitOptions> {
|
||||
let scope_at = opt.scope.and_then(|s| chain.iter().position(|a| *a == s));
|
||||
let child_index = |parent: &Node, child: &Node| parent.children().and_then(|c| c.iter().position(|c| c.id == child.id));
|
||||
let hidden = nodes.windows(2).skip(scope_at.unwrap_or(0)).any(|w| match w {
|
||||
[parent, child] => child_index(parent, child).is_some_and(|i| shaper_hides(parent, i, opt.scope)),
|
||||
_ => false,
|
||||
});
|
||||
if hidden {
|
||||
return None;
|
||||
}
|
||||
// The isolated container's own Preview doesn't count: a click starts inside it.
|
||||
let below = scope_at.map_or(0, |i| i + 1);
|
||||
let outline = nodes.iter().skip(below).any(|n| matches!(n.kind, NodeKind::Layer { preview: false, .. }));
|
||||
Some(if outline { HitOptions { outline: true, ..opt } } else { opt })
|
||||
}
|
||||
|
||||
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
|
||||
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
|
||||
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
|
||||
@@ -277,7 +337,14 @@ fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
object_hit(n, p, opt, symbols, false)
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the object `n`, wherever it is? A container is hit by its contents as
|
||||
/// [`hit_test`] tries them (its own clipping mask, and with `editable_only` none of its hidden,
|
||||
/// locked or template contents).
|
||||
fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), editable_only).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
|
||||
@@ -77,6 +77,148 @@ fn half_clipped(d: &mut Document) -> (Node, Node) {
|
||||
(mask, art)
|
||||
}
|
||||
|
||||
/// The art a mask hides counts only for a selected object: there a click hits nothing, and the
|
||||
/// selection tools grab the selection by that art. Not where the art shows (the click hits it),
|
||||
/// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked,
|
||||
/// hidden, outside the isolated container). A layer's clipping mask hides art as a clip group's
|
||||
/// does.
|
||||
#[test]
|
||||
fn a_selected_objects_art_hidden_by_a_mask_is_found_under_the_pointer() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (g, art_id) = (d.alloc_id(), art.id);
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
let (hidden, shown, beyond) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0), Point::new(250.0, 50.0));
|
||||
let opt = HitOptions::default();
|
||||
assert!(hit_test(&d, hidden, opt).is_none());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[]), None);
|
||||
assert_eq!(selected_hidden_at(&d, shown, opt, &[art_id]), None);
|
||||
assert_eq!(selected_hidden_at(&d, beyond, opt, &[art_id]), None);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(g), ..opt }, &[art_id]), Some(art_id));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(art_id), ..opt }, &[art_id]), Some(art_id));
|
||||
let other = d.add_layer(Some("Other"));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(other), ..opt }, &[art_id]), None);
|
||||
for change in [|n: &mut Node| n.locked = true, |n: &mut Node| n.visible = false] {
|
||||
let mut d = d.clone();
|
||||
change(d.node_mut(art_id).unwrap());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), None);
|
||||
}
|
||||
// A layer's clipping mask.
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let art_id = art.id;
|
||||
d.insert(Some(l), 0, mask).unwrap();
|
||||
d.insert(Some(l), 1, art).unwrap();
|
||||
d.node_mut(l).unwrap().set_clips(true);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
|
||||
}
|
||||
|
||||
/// A selected group's own art is what a click would hit in it: not a hidden or locked child, nor
|
||||
/// art its own clipping mask hides, all under the pointer where an outer mask hides them.
|
||||
#[test]
|
||||
fn a_selected_groups_hidden_art_counts_only_where_a_click_would_hit_it() {
|
||||
// An outer clip group masking to the left half (0..100); the selected group inside it holds
|
||||
// `child` (100..200 × 0..100, under the pointer at (150, 50)) and `shown` (inside the mask).
|
||||
let build = |change: &dyn Fn(&mut Node, &mut Node)| {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let outer_mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||||
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
|
||||
let (gid, cid) = (d.alloc_id(), d.alloc_id());
|
||||
let mut group = Node::group(gid, vec![Arc::new(shown)]);
|
||||
change(&mut group, &mut child);
|
||||
if let NodeKind::Group { children, .. } = &mut group.kind {
|
||||
children.push(Arc::new(child));
|
||||
}
|
||||
let mut clip = Node::group(cid, vec![Arc::new(outer_mask), Arc::new(group)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
(d, gid)
|
||||
};
|
||||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||||
let (d, g) = build(&|_, _| {});
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), Some(g));
|
||||
let (d, g) = build(&|_, child| child.visible = false);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a hidden child");
|
||||
let (d, g) = build(&|_, child| child.locked = true);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a locked child");
|
||||
// The selected group is a clip group too, whose own mask (its first child, 10..40) leaves
|
||||
// `child` out.
|
||||
let (d, g) = build(&|group, _| group.set_clips(true));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "its own mask");
|
||||
}
|
||||
|
||||
/// A selected guide path where a mask hides it counts only while guides can be clicked: not
|
||||
/// while they are locked or hidden (`skip_guides`), as a click on it then hits nothing.
|
||||
#[test]
|
||||
fn a_selected_guide_hidden_by_a_mask_counts_only_while_guides_can_be_clicked() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, mut art) = half_clipped(&mut d);
|
||||
if let NodeKind::Path { guide, .. } = &mut art.kind {
|
||||
*guide = true;
|
||||
}
|
||||
let (g, guide_id) = (d.alloc_id(), art.id);
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[guide_id]), Some(guide_id));
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { skip_guides: true, ..opt }, &[guide_id]), None);
|
||||
}
|
||||
|
||||
/// A selected path in a Shaper group's original shapes, where an outer mask hides it, counts only
|
||||
/// while those shapes can be clicked (Construction mode, with them isolated), however deep it
|
||||
/// lies in them.
|
||||
#[test]
|
||||
fn a_selected_path_in_a_shaper_groups_hidden_shapes_counts_only_in_construction_mode() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
// The outer clip group masks to 0..100; the path, 100..200, lies in the Shaper group's
|
||||
// original shapes (its first child), under (150, 50).
|
||||
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
let path = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
|
||||
let path_id = path.id;
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(path)]);
|
||||
sources.name = Some(crate::shaper::SOURCES.into());
|
||||
let sources_id = sources.id;
|
||||
let face = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
|
||||
let mut shaper = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
shaper.extras_mut().shaper = Some(Box::default());
|
||||
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(shaper)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
|
||||
assert_eq!(selected_hidden_at(&d, hidden, opt, &[path_id]), None);
|
||||
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(sources_id), ..opt }, &[path_id]), Some(path_id));
|
||||
}
|
||||
|
||||
/// On a layer whose Preview is off, a click hits art in outline: a selected object's hidden art
|
||||
/// counts on its outline, not inside its fill.
|
||||
#[test]
|
||||
fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let (g, art_id) = (d.alloc_id(), art.id);
|
||||
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
|
||||
clip.set_clips(true);
|
||||
d.insert(Some(l), 0, clip).unwrap();
|
||||
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(l).unwrap().kind {
|
||||
*preview = false;
|
||||
}
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(selected_hidden_at(&d, Point::new(150.0, 50.0), opt, &[art_id]), None, "inside the fill");
|
||||
assert_eq!(selected_hidden_at(&d, Point::new(200.0, 50.0), opt, &[art_id]), Some(art_id), "on the outline");
|
||||
}
|
||||
|
||||
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
|
||||
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
|
||||
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
|
||||
|
||||
@@ -109,9 +109,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
|
||||
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
|
||||
/// dropped.
|
||||
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
|
||||
/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no
|
||||
/// link: changes to it are dropped.
|
||||
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
@@ -119,6 +119,9 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
|
||||
}
|
||||
}
|
||||
if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) {
|
||||
visit_paint(p, f);
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
for r in &mut t.runs {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod blend;
|
||||
pub mod cancel;
|
||||
pub mod clipnest;
|
||||
pub mod cmyk;
|
||||
pub mod compound_shape;
|
||||
pub mod corners;
|
||||
pub mod depth;
|
||||
pub mod dimension;
|
||||
@@ -42,7 +43,7 @@ pub mod overprint;
|
||||
pub mod pattern;
|
||||
pub mod perspective;
|
||||
mod pixels;
|
||||
pub use pixels::MAX_RASTER_ALLOC;
|
||||
pub use pixels::{GraySource, MAX_RASTER_ALLOC};
|
||||
pub mod placed_document;
|
||||
pub mod plugin_data;
|
||||
pub mod profiles;
|
||||
@@ -76,6 +77,7 @@ pub use appearance::{
|
||||
StrokeAlign, StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use compound_shape::ShapeMode;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
|
||||
pub use graph::{
|
||||
@@ -117,6 +119,7 @@ use vectorcraft_color::{Swatch, SwatchGroup};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
mod artboard;
|
||||
mod canvas_range;
|
||||
mod document;
|
||||
mod document_ext;
|
||||
mod document_guides;
|
||||
@@ -127,6 +130,7 @@ mod records;
|
||||
mod units;
|
||||
|
||||
pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
|
||||
pub use canvas_range::BeyondCanvas;
|
||||
#[cfg(test)]
|
||||
use document_ext::free_id;
|
||||
pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::NodeId;
|
||||
|
||||
/// Coordinates beyond this many points exceed the supported document range.
|
||||
pub const MAX_COORD: f64 = 4.0e6;
|
||||
|
||||
+65
-1
@@ -126,6 +126,40 @@ impl ImageBlob {
|
||||
self
|
||||
}
|
||||
|
||||
/// This blob after its pixels are decoded once, within [`crate::MAX_RASTER_ALLOC`], to check
|
||||
/// that they can be read; with `proxy`, it also gets the preview [`Self::with_proxy`] makes,
|
||||
/// made from that decode. `None` when the pixels can't be read, as with a file cut short that
|
||||
/// has a header but not all its pixels. An image whose pixels would take more than the
|
||||
/// allowance as RGBA is not decoded, and one whose decode needs more than the allowance stops
|
||||
/// decoding; either is returned as it is.
|
||||
pub fn checked_with_proxy(self, proxy: bool) -> Option<Self> {
|
||||
self.checked_with_pixels(proxy).map(|(blob, _)| blob)
|
||||
}
|
||||
|
||||
/// [`Self::checked_with_proxy`], with the pixels it decoded: `None` beside the blob when it
|
||||
/// decoded none (an image past the allowance).
|
||||
pub fn checked_with_pixels(mut self, proxy: bool) -> Option<(Self, Option<image::DynamicImage>)> {
|
||||
let max = crate::MAX_RASTER_ALLOC;
|
||||
let reader = || image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok();
|
||||
let (w, h) = reader()?.into_dimensions().ok()?;
|
||||
if u64::from(w) * u64::from(h) > max / 4 {
|
||||
return Some((self, None));
|
||||
}
|
||||
let mut r = reader()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(max);
|
||||
r.limits(limits);
|
||||
let img = match r.decode() {
|
||||
Ok(img) => img,
|
||||
Err(image::ImageError::Limits(_)) => return Some((self, None)),
|
||||
Err(_) => return None,
|
||||
};
|
||||
if proxy && self.proxy.is_none() && w.max(h) > PROXY_SIZE {
|
||||
self.proxy = proxy_of(&img).map(Arc::new);
|
||||
}
|
||||
Some((self, Some(img)))
|
||||
}
|
||||
|
||||
/// Are the bytes the preview (the linked file hasn't been read)?
|
||||
pub fn is_proxy(&self) -> bool {
|
||||
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
|
||||
@@ -149,7 +183,12 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
|
||||
reader.limits(limits);
|
||||
let small = reader.decode().ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
proxy_of(&reader.decode().ok()?)
|
||||
}
|
||||
|
||||
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
|
||||
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
|
||||
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
|
||||
let mut png = Vec::new();
|
||||
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(png)
|
||||
@@ -442,6 +481,31 @@ mod tests {
|
||||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
|
||||
}
|
||||
|
||||
/// A checked image's pixels are decoded once: the preview comes from that decode, and an image
|
||||
/// whose header reads but whose pixels don't is refused.
|
||||
#[test]
|
||||
fn a_checked_image_is_refused_when_its_pixels_cant_be_read() {
|
||||
let full = png(600, 300);
|
||||
let big = ImageBlob::new("image/png", full.clone()).checked_with_proxy(true).unwrap();
|
||||
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (256, 128));
|
||||
assert!(ImageBlob::new("image/png", full.clone()).checked_with_proxy(false).unwrap().proxy.is_none(), "no preview asked for");
|
||||
assert!(ImageBlob::new("image/png", png(40, 20)).checked_with_proxy(true).unwrap().proxy.is_none(), "small images keep their pixels");
|
||||
let cut = full[..full.len() / 2].to_vec();
|
||||
assert_eq!(image::ImageReader::new(Cursor::new(&cut)).with_guessed_format().unwrap().into_dimensions().unwrap(), (600, 300));
|
||||
assert!(ImageBlob::new("image/png", cut).checked_with_proxy(true).is_none(), "cut to half its length");
|
||||
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).checked_with_proxy(false).is_none(), "not an image");
|
||||
}
|
||||
|
||||
/// A checked image hands on the pixels it decoded, so they need not be decoded again.
|
||||
#[test]
|
||||
fn a_checked_image_hands_on_its_pixels() {
|
||||
let full = png(600, 300);
|
||||
let (blob, pixels) = ImageBlob::new("image/png", full.clone()).checked_with_pixels(true).unwrap();
|
||||
assert!(blob.proxy.is_some(), "the preview is made as checked_with_proxy makes it");
|
||||
assert_eq!(pixels.map(|p| p.to_rgba8()), Some(image::load_from_memory(&full).unwrap().to_rgba8()));
|
||||
}
|
||||
|
||||
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
|
||||
fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = !0u32;
|
||||
|
||||
@@ -221,6 +221,25 @@ mod tests {
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
/// #3232: the Appearance highlight lightens each point towards white in its own model, so a
|
||||
/// CMYK mesh stays CMYK (K shrinks with the other inks) and a grey one stays grey.
|
||||
#[test]
|
||||
fn mesh_highlight_keeps_each_points_model() {
|
||||
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let ink = Color::Cmyk { c: 0.0, m: 0.4, y: 0.6, k: 0.1 };
|
||||
let m = GradientMesh::for_path(&square, 2, 2, ink, MeshAppearance::ToCenter, 50.0).unwrap();
|
||||
assert_eq!(m.points[0].color, ink, "corners stay");
|
||||
let centre = m.points[m.idx(1, 1)].color;
|
||||
let Color::Cmyk { c, m: mg, y, k } = centre else { panic!("{centre:?}") };
|
||||
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-6 && (y - 0.3).abs() < 1e-6 && (k - 0.05).abs() < 1e-6, "{c} {mg} {y} {k}");
|
||||
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Cmyk { .. }), "patches mix in CMYK");
|
||||
let m = GradientMesh::for_path(&square, 2, 2, Color::gray(0.8), MeshAppearance::ToEdge, 100.0).unwrap();
|
||||
assert_eq!((m.points[0].color, m.points[m.idx(1, 1)].color), (Color::gray(0.0), Color::gray(0.8)));
|
||||
// RGB lightens in RGB as before.
|
||||
let m = GradientMesh::for_path(&square, 2, 2, Color::rgb(1.0, 0.0, 0.0), MeshAppearance::ToCenter, 100.0).unwrap();
|
||||
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
|
||||
}
|
||||
|
||||
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
|
||||
/// deletes nothing (it indexed past the points, a panic).
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, PathData, Serialize};
|
||||
|
||||
// =====================================================================================
|
||||
// Data
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::expand::{Hooks, Outliner, Warper, map_node};
|
||||
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
|
||||
use super::*;
|
||||
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
|
||||
|
||||
/// How an envelope distorts its content.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::envelope::{EnvelopeOptions, expand_envelope};
|
||||
use super::envelope::{EnvelopeOptions, PreserveShape, expand_envelope};
|
||||
use super::mesh::GradientMesh;
|
||||
use super::warp::map_nonlinear;
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Appearance, AppearanceItem, Arc, Color, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2,
|
||||
blend_expand,
|
||||
};
|
||||
|
||||
/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines.
|
||||
pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option<Node>>;
|
||||
@@ -154,8 +157,10 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
|
||||
|
||||
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
|
||||
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
|
||||
/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its
|
||||
/// edges (at most half a point) so neighbours overlap and no seams show.
|
||||
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
|
||||
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
|
||||
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
|
||||
/// seams show.
|
||||
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
|
||||
let k = w.cells.max(1);
|
||||
// Two triangles a cell, three objects each; an image an inner envelope already cut into
|
||||
@@ -186,11 +191,20 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
|
||||
if len > 1e-12 { *p + v * (grow / len) } else { *p }
|
||||
})
|
||||
.collect();
|
||||
let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default());
|
||||
let shape = PathData::single(SubPath::polyline(&tri, true));
|
||||
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
|
||||
image.extras_mut().pixelated = !w.options.anti_alias;
|
||||
if w.options.preserve_shape == PreserveShape::Transparency {
|
||||
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
|
||||
// white opacity mask) instead of its clipping path.
|
||||
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
|
||||
pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
|
||||
continue;
|
||||
}
|
||||
let mut clip = Node::path(n.id, shape, Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
|
||||
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
|
||||
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
|
||||
one,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MeshPoint {
|
||||
@@ -264,7 +267,8 @@ impl GradientMesh {
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
|
||||
/// the edges, by at most `highlight` %.
|
||||
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
|
||||
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
|
||||
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
|
||||
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let (rows, cols) = (self.rows as usize, self.cols as usize);
|
||||
@@ -279,7 +283,8 @@ impl GradientMesh {
|
||||
};
|
||||
if w > 0.0 {
|
||||
let i = self.idx(r, c);
|
||||
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
|
||||
let color = self.points[i].color;
|
||||
self.points[i].color = lerp_color(&color, &Color::WHITE.in_model(color.model()), w);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Anchor, BezPath, CubicBez, PI, ParamCurve, PathData, Point, SubPath};
|
||||
|
||||
// =====================================================================================
|
||||
// Warp maps (shared with vectorcraft-effects' Warp effects)
|
||||
|
||||
@@ -250,7 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
|
||||
if let Some(e) = n.extras_box() {
|
||||
b += ALLOC + size_of::<NodeExtras>();
|
||||
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
|
||||
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize);
|
||||
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
|
||||
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
|
||||
b += e.objects_on_path.as_deref().map_or(0, |o| {
|
||||
|
||||
@@ -28,10 +28,11 @@ pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttribut
|
||||
pub use clip::{ClipPaint, Uniter};
|
||||
pub use extras::{Extras, ExtrasMut, NodeExtras};
|
||||
pub use ids::{LAYER_COLORS, LayerColor, NodeId};
|
||||
pub use kind::{ImageObject, NodeKind};
|
||||
pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget};
|
||||
pub use live_shape::{LiveShape, Scaling};
|
||||
pub use opacity::{Knockout, OpacityMask, whiten};
|
||||
use wire::NodeWire;
|
||||
pub use wire::{MAX_READ_NODES, with_node_budget};
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::kind::NodeKind;
|
||||
use super::live_shape::LiveShape;
|
||||
use super::*;
|
||||
use super::{Deserialize, Node, Serialize};
|
||||
|
||||
/// Window → Attributes: an object's settings that don't change how it prints.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::kind::NodeKind;
|
||||
use super::*;
|
||||
use super::{BezPath, FillRule, Node, Outliner, Rect, shapes};
|
||||
|
||||
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
|
||||
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::attributes::ObjectAttributes;
|
||||
use super::kind::NodeKind;
|
||||
use super::live_shape::LiveShape;
|
||||
use super::opacity::OpacityMask;
|
||||
use super::*;
|
||||
use super::{Node, PathData};
|
||||
|
||||
/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without
|
||||
/// them stay small ([`Node::extras`]).
|
||||
@@ -23,6 +23,9 @@ pub struct NodeExtras {
|
||||
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
|
||||
/// the placed gradients in it with the outlines.
|
||||
pub type_outlines: bool,
|
||||
/// An image drawn without smoothing: each pixel of the picture takes the color of the image
|
||||
/// pixel nearest to it (an envelope's distorted image with Envelope Options › Anti-Alias off).
|
||||
pub pixelated: bool,
|
||||
/// Editable Shaper composition; the original art is retained in its source child.
|
||||
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
|
||||
@@ -46,6 +49,15 @@ pub struct NodeExtras {
|
||||
/// Object › Objects on Path: the group's path and the objects attached to it (see
|
||||
/// [`crate::objects_on_path`]).
|
||||
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
/// A grayscale or 1-bit image's fill color (`paint.setFill` on the image): a solid paint that
|
||||
/// colorizes it. Each pixel is drawn in this color, as opaque as the pixel is dark, so white
|
||||
/// pixels are transparent. `None`: the image shows its own pixels.
|
||||
pub colorize: Option<Box<vectorcraft_color::Paint>>,
|
||||
/// Pathfinder panel › Make Compound Shape: this group is a compound shape
|
||||
/// ([`Node::is_compound_shape`], [`crate::compound_shape`]).
|
||||
pub compound_shape: bool,
|
||||
/// As a member of a compound shape, how this object combines with the members below it.
|
||||
pub shape_mode: crate::ShapeMode,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -56,6 +68,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
graph: None,
|
||||
series_index: None,
|
||||
type_outlines: false,
|
||||
pixelated: false,
|
||||
shaper: None,
|
||||
graphic_style: None,
|
||||
attrs: None,
|
||||
@@ -65,6 +78,9 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
intertwine: None,
|
||||
live_paint_gaps: None,
|
||||
objects_on_path: None,
|
||||
colorize: None,
|
||||
compound_shape: false,
|
||||
shape_mode: crate::ShapeMode::Add,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Stable per-document object id. Never reused.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
|
||||
+147
-3
@@ -1,6 +1,6 @@
|
||||
use super::ids::LayerColor;
|
||||
use super::live_shape::LiveShape;
|
||||
use super::*;
|
||||
use super::{Affine, Arc, BlendSpec, Deserialize, EnvelopeKind, FillRule, GradientMesh, Node, PathData, RepeatSpec, Serialize, TextObject, yes};
|
||||
|
||||
/// Embedded or linked raster image.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -129,6 +129,8 @@ pub enum NodeKind {
|
||||
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
|
||||
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
|
||||
/// fields before `type` are buffered, and only those.
|
||||
pub use kind_de::{MAX_BUFFERED_VALUES, with_buffered_value_budget};
|
||||
|
||||
mod kind_de {
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
@@ -237,12 +239,150 @@ mod kind_de {
|
||||
let tag: String = map.next_value()?;
|
||||
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
|
||||
}
|
||||
before.push((key, map.next_value::<serde_json::Value>()?));
|
||||
before.push((key, map.next_value::<Counted>()?.0));
|
||||
}
|
||||
Err(de::Error::missing_field("type"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Most JSON values (each number, string, array, object…) the fields read before an object's
|
||||
/// `type` may hold together while [`with_buffered_value_budget`] runs: 32M (about 1 GB as
|
||||
/// `serde_json::Value`s, some 32 bytes each), 4M in the web build. VectorCraft writes `type`
|
||||
/// first, so its files buffer nothing; a reordered file buffers what comes before it, and
|
||||
/// a crafted one could fill memory with values two bytes each in the file before any object in
|
||||
/// them is counted (#3587).
|
||||
pub const MAX_BUFFERED_VALUES: usize = if cfg!(target_arch = "wasm32") { 4 << 20 } else { 32 << 20 };
|
||||
|
||||
std::thread_local! {
|
||||
/// Values the fields buffered on this thread may still hold, and the budget they come from.
|
||||
static VALUES_LEFT: std::cell::Cell<Option<(usize, usize)>> = const { std::cell::Cell::new(None) };
|
||||
/// Buffered fields being read back: what they hold was counted when it was buffered.
|
||||
static REPLAYING: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Run `f` (which reads objects with serde) letting the fields it buffers before an object's
|
||||
/// `type` hold at most `max` values together; buffering more fails.
|
||||
pub fn with_buffered_value_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
|
||||
struct Restore(Option<(usize, usize)>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
VALUES_LEFT.set(self.0);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(VALUES_LEFT.replace(Some((max, max))));
|
||||
f()
|
||||
}
|
||||
|
||||
/// While buffered fields are read back ([`REPLAYING`]).
|
||||
struct Replaying;
|
||||
|
||||
impl Replaying {
|
||||
fn enter() -> Self {
|
||||
REPLAYING.set(REPLAYING.get().saturating_add(1));
|
||||
Replaying
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Replaying {
|
||||
fn drop(&mut self) {
|
||||
REPLAYING.set(REPLAYING.get().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Count one buffered value against [`with_buffered_value_budget`].
|
||||
fn count<E: de::Error>() -> Result<(), E> {
|
||||
if REPLAYING.get() > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((left, max)) = VALUES_LEFT.get() {
|
||||
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("an object has more than {max} values before its type")))?;
|
||||
VALUES_LEFT.set(Some((left, max)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A field read before `type`, as a `serde_json::Value` counted value by value as it is read,
|
||||
/// so the budget stops a large one before it is all in memory.
|
||||
struct Counted(serde_json::Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for Counted {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
d.deserialize_any(CountedVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
struct CountedVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for CountedVisitor {
|
||||
type Value = Counted;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a JSON value")
|
||||
}
|
||||
|
||||
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(serde_json::Value::Bool(v)))
|
||||
}
|
||||
|
||||
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(v.into()))
|
||||
}
|
||||
|
||||
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(v.into()))
|
||||
}
|
||||
|
||||
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(serde_json::Number::from_f64(v).map_or(serde_json::Value::Null, serde_json::Value::Number)))
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(serde_json::Value::String(v.to_owned())))
|
||||
}
|
||||
|
||||
fn visit_string<E: de::Error>(self, v: String) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(serde_json::Value::String(v)))
|
||||
}
|
||||
|
||||
fn visit_none<E: de::Error>(self) -> Result<Counted, E> {
|
||||
self.visit_unit()
|
||||
}
|
||||
|
||||
fn visit_unit<E: de::Error>(self) -> Result<Counted, E> {
|
||||
count()?;
|
||||
Ok(Counted(serde_json::Value::Null))
|
||||
}
|
||||
|
||||
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Counted, D::Error> {
|
||||
Counted::deserialize(d)
|
||||
}
|
||||
|
||||
fn visit_seq<S: de::SeqAccess<'de>>(self, mut seq: S) -> Result<Counted, S::Error> {
|
||||
count()?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(Counted(v)) = seq.next_element()? {
|
||||
out.push(v);
|
||||
}
|
||||
Ok(Counted(serde_json::Value::Array(out)))
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Counted, M::Error> {
|
||||
count()?;
|
||||
let mut out = serde_json::Map::new();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
let Counted(v) = map.next_value()?;
|
||||
out.insert(key, v);
|
||||
}
|
||||
Ok(Counted(serde_json::Value::Object(out)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields read before `type` (buffered), then the rest of the object.
|
||||
struct Buffered<A> {
|
||||
before: std::vec::IntoIter<(String, serde_json::Value)>,
|
||||
@@ -265,7 +405,11 @@ mod kind_de {
|
||||
|
||||
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
|
||||
match self.value.take() {
|
||||
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
|
||||
Some(value) => {
|
||||
// Counted when it was read: the objects it holds buffer it again as they read it.
|
||||
let _replaying = Replaying::enter();
|
||||
seed.deserialize(value).map_err(de::Error::custom)
|
||||
}
|
||||
None => self.map.next_value_seed(seed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Affine, CornerKind, Deserialize, PathData, Point, Rect, Serialize, cut_corners, shapes};
|
||||
|
||||
/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these
|
||||
/// while the shape stays live; editing anchors directly converts it to a plain path.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::kind::NodeKind;
|
||||
use super::*;
|
||||
use super::{Arc, Color, Deserialize, Node, Paint, Serialize, yes};
|
||||
|
||||
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::ids::{LayerColor, NodeId};
|
||||
use super::kind::NodeKind;
|
||||
use super::live_shape::{Scaling, moves_only};
|
||||
use super::opacity::Knockout;
|
||||
use super::*;
|
||||
use super::{Affine, Appearance, Arc, BlendMode, EnvelopeKind, FillRule, Node, PathData, Point, Rect, RepeatSpec, SYMBOL_SQUARE, Shape, SymbolBoxes};
|
||||
|
||||
impl Node {
|
||||
pub fn new(id: NodeId, kind: NodeKind) -> Self {
|
||||
@@ -127,6 +127,7 @@ impl Node {
|
||||
NodeKind::Layer { .. } => "Layer",
|
||||
NodeKind::Group { clip: true, .. } => "Clip Group",
|
||||
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
|
||||
NodeKind::Group { .. } if self.extras().compound_shape => "Compound Shape",
|
||||
NodeKind::Group { .. } => "Group",
|
||||
NodeKind::Path { live: Some(l), .. } => l.label(),
|
||||
NodeKind::Path { guide: true, .. } => "Guide",
|
||||
@@ -545,9 +546,16 @@ impl Node {
|
||||
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
|
||||
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
|
||||
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
|
||||
/// geometric bounds. Strokes fit the box grown by half their weight.
|
||||
/// geometric bounds. Strokes fit the box grown by half their weight. A colorized image shows
|
||||
/// its colorize paint as its fill.
|
||||
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
|
||||
let item = self.appearance.item_of_kind(item, !stroke);
|
||||
if item.is_none()
|
||||
&& !stroke
|
||||
&& let Some(c) = self.extras().colorize.as_deref()
|
||||
{
|
||||
return Some((c, Affine::IDENTITY, self.geometric_bounds().unwrap_or(Rect::ZERO)));
|
||||
}
|
||||
if item.is_none()
|
||||
&& let NodeKind::Text(t) = &self.kind
|
||||
{
|
||||
|
||||
@@ -19,6 +19,8 @@ fn extras_are_written_flat_in_order() {
|
||||
e.slice = Some(Box::default());
|
||||
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
|
||||
e.live_paint_gaps = Some(Box::default());
|
||||
e.compound_shape = true;
|
||||
e.shape_mode = crate::ShapeMode::Exclude;
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -61,8 +63,11 @@ fn extras_are_written_flat_in_order() {
|
||||
"bbox_angle",
|
||||
"perspective",
|
||||
"live_paint_gaps",
|
||||
"compound_shape",
|
||||
"shape_mode",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(v["shape_mode"], "exclude");
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
|
||||
@@ -433,3 +438,37 @@ fn bad_kinds_are_errors() {
|
||||
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3587: [`with_node_budget`] counts every object read, nested ones included, refuses the one past
|
||||
/// it with a message naming the budget, and is gone once its reading is done.
|
||||
#[test]
|
||||
fn objects_are_read_within_the_node_budget() {
|
||||
let group = Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![])), Arc::new(Node::group(NodeId(3), vec![]))]);
|
||||
let json = serde_json::to_string(&group).unwrap();
|
||||
let read = || serde_json::from_str::<Node>(&json);
|
||||
assert_eq!(with_node_budget(3, read).unwrap(), group, "three objects fit a budget of three");
|
||||
let why = with_node_budget(2, read).unwrap_err().to_string();
|
||||
assert!(why.contains("more than 2 objects"), "{why}");
|
||||
assert_eq!(read().unwrap(), group, "no budget outside a read");
|
||||
// A budget left by a read that failed doesn't carry over to the next one.
|
||||
assert!(with_node_budget(1, read).is_err());
|
||||
assert!(with_node_budget(3, read).is_ok());
|
||||
}
|
||||
|
||||
/// #3587: fields read before an object's `type` are buffered value by value within
|
||||
/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers
|
||||
/// its own fields again without counting them twice. VectorCraft writes `type` first, which
|
||||
/// buffers nothing.
|
||||
#[test]
|
||||
fn fields_before_the_type_are_buffered_within_the_value_budget() {
|
||||
let reordered = r#"{"id":1,"kind":{"children":[{"id":2,"kind":{"children":[],"type":"group"}}],"type":"group"}}"#;
|
||||
let read = |json: &str| serde_json::from_str::<Node>(json);
|
||||
// The children's array, the inner object, its id, its kind, the kind's empty children and
|
||||
// its type: six values.
|
||||
assert!(with_buffered_value_budget(6, || read(reordered)).is_ok());
|
||||
let why = with_buffered_value_budget(5, || read(reordered)).unwrap_err().to_string();
|
||||
assert!(why.contains("more than 5 values before its type"), "{why}");
|
||||
let written = serde_json::to_string(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![]))])).unwrap();
|
||||
assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}");
|
||||
assert!(read(reordered).is_ok(), "no budget outside a read");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ use super::extras::{Extras, NO_EXTRAS, NodeExtras};
|
||||
use super::ids::NodeId;
|
||||
use super::kind::NodeKind;
|
||||
use super::opacity::{Knockout, OpacityMask};
|
||||
use super::*;
|
||||
use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes};
|
||||
|
||||
/// Field `name` of a [`Node`] written as `v`, or skipped. A function of its own, so the long field
|
||||
/// list stays one straight run of calls.
|
||||
fn write_field<S: serde::ser::SerializeStruct, T: Serialize + ?Sized>(st: &mut S, skip: bool, name: &'static str, v: &T) -> Result<(), S::Error> {
|
||||
if skip { st.skip_field(name) } else { st.serialize_field(name, v) }
|
||||
}
|
||||
|
||||
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
|
||||
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
|
||||
@@ -30,6 +36,7 @@ impl Serialize for Node {
|
||||
e.graph.is_none(),
|
||||
e.series_index.is_none(),
|
||||
is_default(&e.type_outlines),
|
||||
is_default(&e.pixelated),
|
||||
e.shaper.is_none(),
|
||||
false,
|
||||
e.graphic_style.is_none(),
|
||||
@@ -41,16 +48,15 @@ impl Serialize for Node {
|
||||
e.intertwine.is_none(),
|
||||
e.live_paint_gaps.is_none(),
|
||||
e.objects_on_path.is_none(),
|
||||
e.colorize.is_none(),
|
||||
is_default(&e.compound_shape),
|
||||
is_default(&e.shape_mode),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
macro_rules! field {
|
||||
($name:literal, $v:expr) => {
|
||||
if skip.next().unwrap_or(false) {
|
||||
st.skip_field($name)?;
|
||||
} else {
|
||||
st.serialize_field($name, $v)?;
|
||||
}
|
||||
write_field(&mut st, skip.next().unwrap_or(false), $name, $v)?;
|
||||
};
|
||||
}
|
||||
field!("id", &self.id);
|
||||
@@ -69,6 +75,7 @@ impl Serialize for Node {
|
||||
field!("graph", &e.graph);
|
||||
field!("series_index", &e.series_index);
|
||||
field!("type_outlines", &e.type_outlines);
|
||||
field!("pixelated", &e.pixelated);
|
||||
field!("shaper", &e.shaper);
|
||||
field!("kind", &self.kind);
|
||||
field!("graphic_style", &e.graphic_style);
|
||||
@@ -80,15 +87,55 @@ impl Serialize for Node {
|
||||
field!("intertwine", &e.intertwine);
|
||||
field!("live_paint_gaps", &e.live_paint_gaps);
|
||||
field!("objects_on_path", &e.objects_on_path);
|
||||
field!("colorize", &e.colorize);
|
||||
field!("compound_shape", &e.compound_shape);
|
||||
field!("shape_mode", &e.shape_mode);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Most objects [`with_node_budget`] lets a document read: 4M (about 1 GB of the smallest
|
||||
/// objects), 512K in the web build. Paths have their own anchor budget
|
||||
/// ([`vectorcraft_geom::path::MAX_READ_ANCHORS`]); this one bounds the objects themselves.
|
||||
pub const MAX_READ_NODES: usize = if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 };
|
||||
|
||||
std::thread_local! {
|
||||
/// Objects the document being read on this thread may still have, and the budget they come
|
||||
/// from ([`with_node_budget`]).
|
||||
static NODES_LEFT: std::cell::Cell<Option<(usize, usize)>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` (which reads nodes with serde) letting it read at most `max` objects together; reading
|
||||
/// more fails. A compressed file of repeated empty objects stays small while they take gigabytes
|
||||
/// once read, about 270 bytes each against some 50 in the file (#3587).
|
||||
pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
|
||||
struct Restore(Option<(usize, usize)>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
NODES_LEFT.set(self.0);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(NODES_LEFT.replace(Some((max, max))));
|
||||
f()
|
||||
}
|
||||
|
||||
/// A node's id, counting the node against [`with_node_budget`]. Every node has exactly one id, so
|
||||
/// each counts once, and the id usually comes first, before the node's children are read.
|
||||
fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<NodeId, D::Error> {
|
||||
let id = NodeId::deserialize(d)?;
|
||||
if let Some((left, max)) = NODES_LEFT.get() {
|
||||
let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?;
|
||||
NODES_LEFT.set(Some((left, max)));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
|
||||
/// (and its extras, allocated only when one is set) without a copy of what they own.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename = "Node")]
|
||||
pub(super) struct NodeWire {
|
||||
#[serde(deserialize_with = "counted_id")]
|
||||
id: NodeId,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
@@ -121,6 +168,8 @@ pub(super) struct NodeWire {
|
||||
#[serde(default)]
|
||||
type_outlines: bool,
|
||||
#[serde(default)]
|
||||
pixelated: bool,
|
||||
#[serde(default)]
|
||||
shaper: Option<Box<crate::shaper::ShaperSpec>>,
|
||||
kind: NodeKind,
|
||||
#[serde(default)]
|
||||
@@ -141,6 +190,12 @@ pub(super) struct NodeWire {
|
||||
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
|
||||
#[serde(default)]
|
||||
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
|
||||
#[serde(default)]
|
||||
colorize: Option<Box<vectorcraft_color::Paint>>,
|
||||
#[serde(default)]
|
||||
compound_shape: bool,
|
||||
#[serde(default)]
|
||||
shape_mode: crate::ShapeMode,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -152,6 +207,7 @@ impl From<NodeWire> for Node {
|
||||
graph: w.graph,
|
||||
series_index: w.series_index,
|
||||
type_outlines: w.type_outlines,
|
||||
pixelated: w.pixelated,
|
||||
shaper: w.shaper,
|
||||
graphic_style: w.graphic_style,
|
||||
attrs: w.attrs,
|
||||
@@ -161,6 +217,9 @@ impl From<NodeWire> for Node {
|
||||
intertwine: w.intertwine,
|
||||
live_paint_gaps: w.live_paint_gaps,
|
||||
objects_on_path: w.objects_on_path,
|
||||
colorize: w.colorize,
|
||||
compound_shape: w.compound_shape,
|
||||
shape_mode: w.shape_mode,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
@@ -62,6 +62,61 @@ impl ImageBlob {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct GraySource {
|
||||
/// One bit a pixel: black and white only.
|
||||
pub one_bit: bool,
|
||||
/// The file has an alpha channel.
|
||||
pub alpha: bool,
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// Whether the image's file stores grayscale or 1-bit pixels (the images a fill color
|
||||
/// colorizes), and how; `None` for color images and data that isn't an image. Reads only its
|
||||
/// header.
|
||||
pub fn gray_source(&self) -> Option<GraySource> {
|
||||
let gray = |one_bit: bool, alpha: bool| Some(GraySource { one_bit, alpha });
|
||||
if let Some((kind, _)) = crate::cmyk::tiff(&self.bytes) {
|
||||
return match kind {
|
||||
tiff::ColorType::Gray(bits) => gray(bits == 1, false),
|
||||
tiff::ColorType::GrayA(bits) => gray(bits == 1, true),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if crate::cmyk::jpeg_components(&self.bytes) == Some(1) {
|
||||
return gray(false, false);
|
||||
}
|
||||
use image::{ExtendedColorType as E, ImageDecoder as _};
|
||||
let reader = image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?;
|
||||
match reader.into_decoder().ok()?.original_color_type() {
|
||||
E::L1 => gray(true, false),
|
||||
E::La1 => gray(true, true),
|
||||
E::L2 | E::L4 | E::L8 | E::L16 => gray(false, false),
|
||||
E::La2 | E::La4 | E::La8 | E::La16 => gray(false, true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of a colorize paint each pixel shows, row by row from the top: how dark the pixel
|
||||
/// is (255 less its Rec. 709 luma) scaled by its alpha, so white and transparent pixels are 0.
|
||||
/// Returns the width, the height and one byte a pixel; `None` when the image can't be decoded
|
||||
/// within [`MAX_RASTER_ALLOC`].
|
||||
pub fn coverage(&self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let (w, h) = img.dimensions();
|
||||
let cover = img
|
||||
.pixels()
|
||||
.map(|p| {
|
||||
let [r, g, b, a] = p.0;
|
||||
let luma = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32;
|
||||
((255.0 - luma).clamp(0.0, 255.0) * a as f32 / 255.0).round() as u8
|
||||
})
|
||||
.collect();
|
||||
Some((w, h, cover))
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageBlob {
|
||||
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
|
||||
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
|
||||
@@ -298,4 +353,66 @@ mod tests {
|
||||
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
|
||||
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
|
||||
}
|
||||
|
||||
/// An uncompressed 1-bit TIFF, BlackIsZero, of `w` × `h` pixels: `rows` holds each row's bits,
|
||||
/// padded to a byte, most significant bit first.
|
||||
fn bilevel_tiff(w: u16, h: u16, rows: &[u8]) -> Vec<u8> {
|
||||
// Tag, type (3 short, 4 long) and value of each entry, in tag order.
|
||||
let data_at = 8 + 2 + 9 * 12 + 4;
|
||||
let entries: [(u16, u16, u32); 9] = [
|
||||
(256, 3, w.into()),
|
||||
(257, 3, h.into()),
|
||||
(258, 3, 1),
|
||||
(259, 3, 1),
|
||||
(262, 3, 1),
|
||||
(273, 4, data_at),
|
||||
(277, 3, 1),
|
||||
(278, 3, h.into()),
|
||||
(279, 4, rows.len() as u32),
|
||||
];
|
||||
let mut out = b"II*\0".to_vec();
|
||||
out.extend_from_slice(&8u32.to_le_bytes());
|
||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
||||
for (tag, kind, value) in entries {
|
||||
out.extend_from_slice(&tag.to_le_bytes());
|
||||
out.extend_from_slice(&kind.to_le_bytes());
|
||||
out.extend_from_slice(&1u32.to_le_bytes());
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(rows);
|
||||
out
|
||||
}
|
||||
|
||||
fn png_of(img: image::DynamicImage) -> ImageBlob {
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
ImageBlob::png(png)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_and_1_bit_files_are_told_from_color_ones() {
|
||||
let gray = |one_bit, alpha| Some(GraySource { one_bit, alpha });
|
||||
let l8 = png_of(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()));
|
||||
assert_eq!(l8.gray_source(), gray(false, false));
|
||||
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(1, 1, vec![0, 128]).unwrap()));
|
||||
assert_eq!(la8.gray_source(), gray(false, true));
|
||||
let mut jpeg = Vec::new();
|
||||
jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[0, 255, 0, 255], 2, 2, jpeg_encoder::ColorType::Luma).unwrap();
|
||||
assert_eq!(ImageBlob::new("image/jpeg", jpeg).gray_source(), gray(false, false));
|
||||
// Black, white; white, black.
|
||||
let tif = ImageBlob::new("image/tiff", bilevel_tiff(2, 2, &[0b0100_0000, 0b1000_0000]));
|
||||
assert_eq!(tif.gray_source(), gray(true, false));
|
||||
assert_eq!(tif.coverage(), Some((2, 2, vec![255, 0, 0, 255])), "black covers, white doesn't");
|
||||
// Color images and data that isn't an image.
|
||||
assert_eq!(blob(&[[0, 0, 0, 255], [9, 9, 9, 255]], 2, 1).gray_source(), None);
|
||||
assert_eq!(ImageBlob::png(vec![1, 2, 3]).gray_source(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_is_darkness_times_alpha() {
|
||||
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(4, 1, vec![0, 255, 255, 255, 51, 255, 0, 128]).unwrap()));
|
||||
assert_eq!(la8.coverage(), Some((4, 1, vec![255, 0, 204, 128])));
|
||||
assert_eq!(ImageBlob::png(vec![1, 2, 3]).coverage(), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{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.
|
||||
|
||||
@@ -145,6 +145,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
|
||||
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
|
||||
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
|
||||
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
|
||||
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
|
||||
|
||||
@@ -108,9 +108,10 @@ impl Document {
|
||||
Some(list.remove(i))
|
||||
}
|
||||
|
||||
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
|
||||
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// Visit every color that can link to a swatch (solid paints and gradient stops of fills,
|
||||
/// strokes and text runs) in the art (opacity masks included), symbol definitions, graph
|
||||
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles
|
||||
/// and the swatches themselves (tint swatches, gradient swatches' stops).
|
||||
/// `f(colour, link, tint)` may change them and returns true when it did
|
||||
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
|
||||
/// copied. Returns the number of changed paints.
|
||||
@@ -119,7 +120,10 @@ impl Document {
|
||||
for s in &mut self.symbols {
|
||||
n += map_tree(&mut s.art, f);
|
||||
}
|
||||
for p in &mut self.patterns {
|
||||
for g in &mut self.graph_designs {
|
||||
n += map_tree(&mut g.art, f);
|
||||
}
|
||||
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
|
||||
n += map_trees(&mut p.art, f);
|
||||
}
|
||||
for gs in &mut self.graphic_styles {
|
||||
@@ -225,13 +229,14 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
|
||||
}
|
||||
}
|
||||
|
||||
/// The paints a node holds itself (not its children): appearance items, then text runs.
|
||||
/// The paints a node holds itself (not its children): appearance items, then text runs, then an
|
||||
/// image's colorize paint.
|
||||
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
let runs = match &n.kind {
|
||||
NodeKind::Text(t) => t.runs.as_slice(),
|
||||
_ => &[],
|
||||
};
|
||||
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]))
|
||||
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
@@ -240,7 +245,8 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
NodeKind::Text(t) => t.runs.as_mut_slice(),
|
||||
_ => &mut [],
|
||||
};
|
||||
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
|
||||
let colorize = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut());
|
||||
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize)
|
||||
}
|
||||
|
||||
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
|
||||
@@ -307,6 +313,19 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::map_solid_paints`] for art a document keeps outside its trees, such as the art of a
|
||||
/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only
|
||||
/// the nodes holding a changed paint are copied. Returns the number of changed paints.
|
||||
pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
*n = new;
|
||||
count
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
|
||||
match map_node(n, f) {
|
||||
Some((new, count)) => {
|
||||
@@ -400,6 +419,25 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_reaches_graph_designs_and_the_tile_that_pattern_cancel_puts_back() {
|
||||
let (mut d, a, _) = doc();
|
||||
let art = d.node_arc(a).cloned().unwrap();
|
||||
d.graph_designs.push(crate::GraphDesign { name: "Bar".into(), art: art.clone() });
|
||||
d.pattern_edit =
|
||||
Some(crate::PatternEdit { pattern: "Tile".into(), layer: NodeId(0), original: Some(crate::PatternDef::new("Tile", vec![art])) });
|
||||
let mut loose = d.node(a).unwrap().clone();
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
|
||||
assert_eq!(d.map_solid_paints(&mut to_blue), 3, "the layer's rectangle, the design and the Cancel tile");
|
||||
assert_eq!(d.graph_designs[0].art.appearance.fill_paint(), linked(blue, "Brand"));
|
||||
let tile = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0];
|
||||
assert_eq!(tile.appearance.fill_paint(), linked(blue, "Brand"));
|
||||
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 1);
|
||||
assert_eq!(loose.appearance.fill_paint(), linked(blue, "Brand"));
|
||||
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_within_ids_visits_each_paint_once() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct SymbolBoxes<'a> {
|
||||
/// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`]
|
||||
/// ([`Self::unplaced_reach`]).
|
||||
unplaced: RefCell<HashMap<String, f64>>,
|
||||
/// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]).
|
||||
runs: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -87,6 +89,7 @@ impl Document {
|
||||
holders: Cell::new(0),
|
||||
farthest: RefCell::default(),
|
||||
unplaced: RefCell::default(),
|
||||
runs: Cell::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,9 +149,16 @@ impl<'a> SymbolBoxes<'a> {
|
||||
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
|
||||
self.depth(name)?;
|
||||
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
|
||||
self.runs.set(self.runs.get() + 1);
|
||||
Some(measure(&sym.art))
|
||||
}
|
||||
|
||||
/// How many times [`Self::measure_art`] has run a measure on symbol art for these boxes, the
|
||||
/// measures behind the cached art boxes and placed visual boxes included.
|
||||
pub fn art_measured(&self) -> usize {
|
||||
self.runs.get()
|
||||
}
|
||||
|
||||
/// The geometric box of the art of the symbol named `name` in symbol space, with the instances
|
||||
/// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of
|
||||
/// the symbol are then measured by [`SYMBOL_SQUARE`].
|
||||
@@ -460,6 +470,26 @@ mod tests {
|
||||
assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_symbols_art_box_is_measured_once() {
|
||||
// `Pair` holds two instances of `Dot`; three instances of `Pair` are measured twice each.
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
|
||||
symbol(&mut d, "Dot", vec![dot]);
|
||||
let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0)));
|
||||
let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0)));
|
||||
symbol(&mut d, "Pair", vec![right, below]);
|
||||
let pairs: Vec<Node> = (0..3).map(|i| instance(&mut d, "Pair", Affine::translate((f64::from(i) * 10.0, 0.0)))).collect();
|
||||
let s = d.symbol_boxes().quick();
|
||||
assert_eq!(s.art_measured(), 0);
|
||||
for _ in 0..2 {
|
||||
for p in &pairs {
|
||||
p.geometric_bounds_with(&s);
|
||||
}
|
||||
}
|
||||
assert_eq!(s.art_measured(), 2, "the art of Pair and of Dot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() {
|
||||
// A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::para_style::FirstBaseline;
|
||||
use super::*;
|
||||
use super::{Deserialize, PathData, Serialize};
|
||||
|
||||
/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::para_style::ParaDirection;
|
||||
use super::*;
|
||||
use super::{Appearance, AppearanceItem, Color, Dash, Deserialize, FillLayer, LineCap, LineJoin, Paint, Serialize, StrokeAlign, StrokeLayer};
|
||||
|
||||
/// Character attributes (the Character panel).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::char_style::CharStyle;
|
||||
use super::para_style::ParaStyle;
|
||||
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
|
||||
use super::tabs_lists::ParaList;
|
||||
use super::*;
|
||||
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
|
||||
|
||||
impl TextObject {
|
||||
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::char_style::CharStyle;
|
||||
use super::tabs_lists::{ParaList, TabStop};
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Justify {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::char_style::CharStyle;
|
||||
use super::*;
|
||||
use super::{Affine, Deserialize, PathData, Point, Rect, Serialize, Vec2, yes};
|
||||
|
||||
/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or
|
||||
/// [`ParaStyle`] fields, by their serialized names). Text using it records the name.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::char_style::{CharPosition, CharStyle};
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Tab stop alignment (Tabs panel).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Deserialize, Serialize};
|
||||
|
||||
/// Measurement units (display only; the model is always in points).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
//! and [`container_keeping_type`] list the other conditions). The renderer still draws its outlines.
|
||||
//!
|
||||
//! Object → Expand Appearance goes further ([`expand_leaf`]): every fill and stroke becomes an
|
||||
//! object of its own, strokes turned into filled art by the caller (their outlines or brush art).
|
||||
//! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by
|
||||
//! the caller).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -440,15 +441,17 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Turns one stroke of a path (the path after its geometry effects, its fill rule, the stroke
|
||||
/// without geometry effects) into filled art for [`expand_leaf`]: its outline filled with its
|
||||
/// paint, or its brush art. `None` when it paints nothing.
|
||||
/// Turns one brushed stroke of a path (the path after its geometry effects, its fill rule, the
|
||||
/// stroke without geometry effects) into its brush art for [`expand_leaf`]. `None` when it paints
|
||||
/// nothing.
|
||||
pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &StrokeLayer) -> Option<Node>;
|
||||
|
||||
/// Object → Expand Appearance of a path or compound path `n`: its geometry effects applied, every
|
||||
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke the
|
||||
/// filled art `stroke_art` makes of it (each with its fill's or stroke's opacity and blend mode as
|
||||
/// its own), in paint order, as the members of a group that keeps
|
||||
/// visible fill becomes a copy of the path painted by that fill alone and every visible stroke a
|
||||
/// copy stroked by that stroke alone (its weight, caps, joins, dashes, arrowheads, alignment and
|
||||
/// width profile stay stroke attributes, which Object → Expand or Outline Stroke outline later,
|
||||
/// #3089), or for a brushed stroke the brush art `stroke_art` makes of it; each with its fill's or
|
||||
/// stroke's opacity and blend mode as its own, in paint order, as the members of a group that keeps
|
||||
/// `n`'s id, name, transparency, opacity mask and remaining (raster) effects. A single piece
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
@@ -466,8 +469,8 @@ pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<
|
||||
clear_item_effects(&mut it);
|
||||
it.effects_mut().retain(|e| e.visible);
|
||||
let p = match &it {
|
||||
AppearanceItem::Stroke(st) => stroke_art(d, &ig, rule, st),
|
||||
AppearanceItem::Fill(_) => Some(piece(d, n, ig, rule, it)),
|
||||
AppearanceItem::Stroke(st) if st.brush.is_some() => stroke_art(d, &ig, rule, st),
|
||||
_ => Some(piece(d, n, ig, rule, it)),
|
||||
};
|
||||
pieces.extend(p.map(hoist_transparency));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -264,7 +264,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"stylize.dropShadow",
|
||||
"Drop Shadow…",
|
||||
STYLIZE,
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": the shadow is the object's own colors with `darkness` percent of black added instead of `color`: in a CMYK document their cyan, magenta and yellow stay and their black K becomes K + darkness × (1 − K); in an RGB document each channel is multiplied by 1 − darkness)",
|
||||
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), shade: \"color\"|\"darkness\" (\"color\"), color: \"#rrggbb\" (\"#000000\"), darkness: % (100)} (shade \"darkness\": use d = darkness / 100 on the source colors before document color conversion and blur; RGB channels become max(0, channel - d), CMYK keeps C/M/Y and sets K to min(1, K + d), and Gray sets its black component to min(1, K + d). The near-100% endpoint is gray black. Shadow opacity remains separate)",
|
||||
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "shade": "color", "color": "#000000", "darkness": 100.0}),
|
||||
),
|
||||
r(
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::Value;
|
||||
use vectorcraft_geom::kurbo::ParamCurveArclen;
|
||||
use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
|
||||
|
||||
use crate::util::*;
|
||||
use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points};
|
||||
|
||||
fn diag(b: Rect) -> f64 {
|
||||
b.width().hypot(b.height()).max(1e-6)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::params::{is_geometry, merged_params};
|
||||
use super::*;
|
||||
use super::{
|
||||
Appearance, AppearanceItem, BezPath, Effect, FillRule, Node, NodeKind, PathData, Rect, StrokeLayer, Value, WarpStyle, distort, stroke, stylize,
|
||||
util, warp,
|
||||
};
|
||||
|
||||
/// Context the geometry effects may need from the object's appearance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -111,16 +114,67 @@ pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ct
|
||||
apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS)
|
||||
}
|
||||
|
||||
/// Does effect `e` safely commute with pure translation when it is terminal?
|
||||
/// Only positive offsets with round joins commute exactly with pure translations for non-zero fills (#4145).
|
||||
fn is_terminal_offset_eligible(e: &Effect) -> bool {
|
||||
if e.id != "path.offsetPath" {
|
||||
return false;
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
util::join(&p, "joins") == vectorcraft_pathops::Join::Round && util::num(&p, "offset", 10.0) >= 0.0
|
||||
}
|
||||
|
||||
/// Optimize a list of visible geometry effects: move pure-translation Transform effects with
|
||||
/// copies after a terminal Offset Path effect when the effective style is safe (NonZero fill and
|
||||
/// no stroke), so that the offset path is evaluated once on the base path rather than on dozens
|
||||
/// of overlapping copies, avoiding combinatorial explosions in the planar sweep (#4145).
|
||||
pub(crate) fn optimize_geometry_effects<'a>(effects: &[&'a Effect], style_safe: bool) -> Vec<&'a Effect> {
|
||||
let mut out: Vec<&'a Effect> = effects.to_vec();
|
||||
if !style_safe || out.len() < 2 {
|
||||
return out;
|
||||
}
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for i in 0..out.len().saturating_sub(1) {
|
||||
let e1 = out[i];
|
||||
if e1.id == "distort.transform" {
|
||||
let p1 = merged_params(&e1.id, &e1.params);
|
||||
if let Some((_, copies)) = crate::distort::transform_move(&p1)
|
||||
&& copies > 0
|
||||
&& is_terminal_offset_eligible(out[i + 1])
|
||||
&& out.get(i + 2..).is_some_and(|tail| tail.iter().all(|e| e.id == "distort.transform"))
|
||||
{
|
||||
out.swap(i, i + 1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that
|
||||
/// is more.
|
||||
pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData {
|
||||
// `budget` raised to what `path` costs, measured once, and only for a path that has geometry
|
||||
// effects.
|
||||
let has_stroke = match (ctx.item, ctx.appearance) {
|
||||
(Some(idx), Some(app)) => match app.items.get(idx) {
|
||||
Some(AppearanceItem::Stroke(s)) => !s.paint.is_none() && s.width > 0.0,
|
||||
Some(AppearanceItem::Fill(_)) => false,
|
||||
None => ctx.stroke.is_some_and(|s| !s.paint.is_none() && s.width > 0.0),
|
||||
},
|
||||
_ => ctx.stroke.is_some_and(|s| !s.paint.is_none() && s.width > 0.0),
|
||||
};
|
||||
let style_safe = ctx.rule == FillRule::NonZero && !has_stroke;
|
||||
let visible_fx: Vec<&Effect> = effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
|
||||
let fx = optimize_geometry_effects(&visible_fx, style_safe);
|
||||
let mut limit = None;
|
||||
let mut cur = path.clone();
|
||||
let mut b = bounds;
|
||||
let mut first = true;
|
||||
for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) {
|
||||
for e in fx {
|
||||
if !first {
|
||||
b = cur.bounds().unwrap_or(b);
|
||||
}
|
||||
@@ -167,7 +221,7 @@ fn builds_within(id: &str) -> bool {
|
||||
|
||||
/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it.
|
||||
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
|
||||
use util::*;
|
||||
use util::{join, num};
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone());
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! result keeps the appearance of the object whose paint it takes. Trap ([`crate::trap`]) keeps the
|
||||
//! children and adds its traps above them.
|
||||
//!
|
||||
//! **Compound shapes** ([`compound_shape_area`]): a compound shape draws as the one area its
|
||||
//! members' shape modes make, painted by its own appearance as a group's own fills and strokes
|
||||
//! paint its members.
|
||||
//!
|
||||
//! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes
|
||||
//! paint every member's geometry, below or above the members as its contents slot says
|
||||
//! ([`Appearance::contents_at`]); its geometry effects reshape the members as one piece; its raster
|
||||
@@ -16,7 +20,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_pathops::{self as po, PathfinderOp};
|
||||
|
||||
@@ -45,10 +49,11 @@ fn op_of(id: &str) -> Option<PathfinderOp> {
|
||||
PATHFINDER_EFFECTS.iter().find(|e| e.0 == id).map(|e| e.2)
|
||||
}
|
||||
|
||||
/// Does `n` (a group or layer) carry a visible Pathfinder effect?
|
||||
/// Does `n` (a group or layer) carry a visible Pathfinder effect, or is it a compound shape?
|
||||
pub fn has_pathfinder(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
|
||||
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id))
|
||||
n.is_compound_shape()
|
||||
|| (matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
|
||||
&& n.appearance.effects.iter().any(|e| e.visible && is_pathfinder(&e.id)))
|
||||
}
|
||||
|
||||
/// Converts objects that aren't paths (e.g. text) to outline art, if possible. The paths and
|
||||
@@ -62,11 +67,15 @@ fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
for c in children {
|
||||
leaves(c, hook, out);
|
||||
// A compound shape takes part as its area, styled like it.
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art(n, hook) {
|
||||
Some(art) => out.push(art),
|
||||
None => {
|
||||
for c in children {
|
||||
leaves(c, hook, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// Every path of the outline art, at any depth, takes the object's opacity and blend
|
||||
// mode.
|
||||
@@ -91,6 +100,63 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The operation that combines a compound shape's member in `mode` with the area below it.
|
||||
fn shape_op(mode: ShapeMode) -> po::BoolOp {
|
||||
match mode {
|
||||
ShapeMode::Add => po::BoolOp::Union,
|
||||
ShapeMode::Subtract => po::BoolOp::Difference,
|
||||
ShapeMode::Intersect => po::BoolOp::Intersect,
|
||||
ShapeMode::Exclude => po::BoolOp::Xor,
|
||||
}
|
||||
}
|
||||
|
||||
/// The area compound shape `n` draws: each visible member's shape (a group's leaves together, type
|
||||
/// 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`]).
|
||||
pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
|
||||
if !n.is_compound_shape() {
|
||||
return None;
|
||||
}
|
||||
let mut all: Vec<Node> = vec![];
|
||||
// Each member that has a shape: its operation and how many of the leaves are its.
|
||||
let mut members: Vec<(po::BoolOp, usize)> = vec![];
|
||||
for c in n.children()? {
|
||||
let before = all.len();
|
||||
leaves(c, hook, &mut all);
|
||||
if all.len() > before {
|
||||
members.push((shape_op(c.extras().shape_mode), all.len() - before));
|
||||
}
|
||||
}
|
||||
if !within_budget(&all) {
|
||||
return None;
|
||||
}
|
||||
if all.is_empty() {
|
||||
return Some(PathData::default());
|
||||
}
|
||||
let parts: Vec<(PathData, FillRule)> = all.iter().map(|l| geometry(l).unwrap_or_default()).collect();
|
||||
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
|
||||
Some(po::boolean_n(&refs, |inside| {
|
||||
let (mut start, mut area) = (0, false);
|
||||
for (k, (op, count)) in members.iter().enumerate() {
|
||||
let here = inside.get(start..start + count).is_some_and(|flags| flags.contains(&true));
|
||||
start += count;
|
||||
area = if k == 0 { here } else { op.apply(area, here) };
|
||||
}
|
||||
area
|
||||
}))
|
||||
}
|
||||
|
||||
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
|
||||
/// opacity and blend mode: how it takes part in a Pathfinder operation.
|
||||
pub fn compound_shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
|
||||
let area = compound_shape_area(n, hook)?;
|
||||
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)
|
||||
}
|
||||
|
||||
/// A result path styled like `src` (its appearance, opacity and blend mode).
|
||||
fn styled(src: &Node, path: PathData, outline: bool) -> Node {
|
||||
let mut appearance = Appearance { items: src.appearance.items.clone(), ..Default::default() };
|
||||
@@ -196,7 +262,16 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
|
||||
return None;
|
||||
}
|
||||
let source: &[Arc<Node>] = group.children()?;
|
||||
let mut stack: Vec<Arc<Node>> = source.to_vec();
|
||||
let mut stack: Vec<Arc<Node>> = if group.is_compound_shape() {
|
||||
// The group's own appearance paints the area ([`evaluate_container`]), so the art has none.
|
||||
match compound_shape_area(group, hook) {
|
||||
Some(area) if area.is_empty() => vec![],
|
||||
Some(area) => vec![Arc::new(Node::path(group.id, area, Appearance::default()))],
|
||||
None => return Some(source.to_vec()),
|
||||
}
|
||||
} else {
|
||||
source.to_vec()
|
||||
};
|
||||
for e in group.appearance.effects.iter().filter(|e| e.visible) {
|
||||
if e.id == crate::trap::TRAP {
|
||||
let mut lv = vec![];
|
||||
@@ -375,6 +450,13 @@ 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<Node> = &crate::outline_text;
|
||||
match compound_shape_area(n, Some(hook)) {
|
||||
Some(area) => out.push((area, FillRule::NonZero)),
|
||||
None => members(doc, n, out),
|
||||
}
|
||||
}
|
||||
NodeKind::Text(t) => {
|
||||
let (_, all) = crate::reshape::glyph_outlines(t);
|
||||
if !all.elements().is_empty() {
|
||||
@@ -454,6 +536,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
if let Some(ch) = m.children_mut() {
|
||||
*ch = result;
|
||||
}
|
||||
// The area is art now: drawn again, it is no compound shape of its own.
|
||||
m.extras_mut().compound_shape = false;
|
||||
}
|
||||
if !fx.is_empty() {
|
||||
m = crate::reshape::outlined_members(doc, &m);
|
||||
|
||||
@@ -347,3 +347,72 @@ fn pathfinder_on_many_stacked_bars_still_runs() {
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
/// Two overlapping squares (0–10 and 5–15 by 10 high) as a compound shape, the front one in `mode`.
|
||||
fn compound_shape(mode: ShapeMode) -> Node {
|
||||
let mut front = (*sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0))).clone();
|
||||
front.extras_mut().shape_mode = mode;
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), Arc::new(front)]);
|
||||
g.extras_mut().compound_shape = true;
|
||||
g.appearance = Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0).into();
|
||||
g
|
||||
}
|
||||
|
||||
/// A compound shape draws the area its members' modes make, one unpainted path that its own
|
||||
/// appearance paints; evaluated, it is plain art painted with that appearance.
|
||||
#[test]
|
||||
fn compound_shapes_draw_the_area_their_shape_modes_make() {
|
||||
for (mode, want) in [(ShapeMode::Add, 150.0), (ShapeMode::Subtract, 50.0), (ShapeMode::Intersect, 50.0), (ShapeMode::Exclude, 100.0)] {
|
||||
let g = compound_shape(mode);
|
||||
assert!(has_pathfinder(&g), "{mode:?}");
|
||||
let area_path = compound_shape_area(&g, None).unwrap();
|
||||
assert!((po::area(&area_path, FillRule::NonZero).abs() - want).abs() < 0.01, "{mode:?}");
|
||||
let out = pathfinder_children(&g, None).unwrap();
|
||||
assert_eq!(out.len(), 1, "{mode:?}");
|
||||
assert!(out[0].appearance.items.is_empty(), "the group's own appearance paints it");
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
assert!(!art.is_compound_shape(), "evaluated, it is art");
|
||||
let painted: Vec<&Arc<Node>> = art.children().unwrap().iter().filter(|c| !c.appearance.items.is_empty()).collect();
|
||||
assert_eq!(painted.len(), 1);
|
||||
assert_eq!(painted[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "{mode:?}");
|
||||
assert!((area(painted[0]) - want).abs() < 0.01, "{mode:?}");
|
||||
}
|
||||
// The backmost member's mode doesn't change the area.
|
||||
let mut g = compound_shape(ShapeMode::Add);
|
||||
if let Some(ch) = g.children_mut() {
|
||||
let mut back = (*ch[0]).clone();
|
||||
back.extras_mut().shape_mode = ShapeMode::Subtract;
|
||||
ch[0] = Arc::new(back);
|
||||
}
|
||||
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 150.0).abs() < 0.01);
|
||||
// Hidden members take no part; the mark on a clip group is ignored.
|
||||
let mut g = compound_shape(ShapeMode::Subtract);
|
||||
if let Some(ch) = g.children_mut() {
|
||||
let mut front = (*ch[1]).clone();
|
||||
front.visible = false;
|
||||
ch[1] = Arc::new(front);
|
||||
}
|
||||
assert!((po::area(&compound_shape_area(&g, None).unwrap(), FillRule::NonZero).abs() - 100.0).abs() < 0.01);
|
||||
let mut clip = compound_shape(ShapeMode::Add);
|
||||
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
|
||||
assert!(!clip.is_compound_shape() && compound_shape_area(&clip, None).is_none());
|
||||
}
|
||||
|
||||
/// A compound shape inside another (and inside an Effect › Pathfinder group) takes part as its area,
|
||||
/// styled like it.
|
||||
#[test]
|
||||
fn nested_compound_shapes_take_part_as_their_area() {
|
||||
let inner = compound_shape(ShapeMode::Subtract);
|
||||
let mut cutter = (*sq(3, 2.0, Color::BLACK)).clone();
|
||||
cutter.extras_mut().shape_mode = ShapeMode::Intersect;
|
||||
let mut outer = Node::group(NodeId(20), vec![Arc::new(inner.clone()), Arc::new(cutter)]);
|
||||
outer.extras_mut().compound_shape = true;
|
||||
// (0–5 of the inner area) ∩ (2–12) = 2–5 by 10 high.
|
||||
assert!((po::area(&compound_shape_area(&outer, None).unwrap(), FillRule::NonZero).abs() - 30.0).abs() < 0.01);
|
||||
let mut effect = Node::group(NodeId(30), vec![Arc::new(inner)]);
|
||||
effect.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true });
|
||||
let out = pathfinder_children(&effect, None).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert!((area(&out[0]) - 50.0).abs() < 0.01);
|
||||
assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape");
|
||||
}
|
||||
|
||||
@@ -69,8 +69,8 @@ pub use adjust::{
|
||||
pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_leaf, fresh_ids, needs_bake};
|
||||
pub use clip::clip_outline;
|
||||
pub use group::{
|
||||
OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints,
|
||||
pathfinder_children,
|
||||
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
|
||||
is_pathfinder, member_shapes, paints, pathfinder_children,
|
||||
};
|
||||
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};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::catalog::{catalog_index, plugin_info};
|
||||
use super::*;
|
||||
use super::{CROP_MARKS, Effect, Map, Node, PIXEL_EFFECTS, RASTERIZE, Value, is_adjustment, is_pathfinder, is_three_d, json, util};
|
||||
|
||||
/// Dialog defaults for `id` (`None` for unknown effects).
|
||||
pub fn default_params(id: &str) -> Option<Value> {
|
||||
|
||||
@@ -1755,6 +1755,10 @@ fn brush_strokes_params_take_defaults_and_stay_in_range() {
|
||||
fx("brushStrokes.sumiE", json!({"strokeWidth": 2, "strokePressure": 16, "contrast": -1})),
|
||||
PixelFx::SumiE { width: 3.0, pressure: 15.0, contrast: 0.0 }
|
||||
);
|
||||
brush_strokes_outsets();
|
||||
}
|
||||
|
||||
fn brush_strokes_outsets() {
|
||||
// Outsets: the strokes and the shifts reach out of the object as far as into it; Accented
|
||||
// Edges and Sumi-e keep the object's shape.
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
@@ -2561,6 +2565,10 @@ fn artistic_params_take_defaults_and_stay_in_range() {
|
||||
fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})),
|
||||
PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 }
|
||||
);
|
||||
artistic_outsets_and_docs();
|
||||
}
|
||||
|
||||
fn artistic_outsets_and_docs() {
|
||||
for id in [
|
||||
"artistic.coloredPencil",
|
||||
"artistic.cutout",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user