mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-19 05:34:16 -04:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46888b4bf5 | ||
|
|
0f955e6c82 | ||
|
|
1d9d92341f | ||
|
|
109498a746 | ||
|
|
7f3d9ea68a | ||
|
|
faf52b5657 | ||
|
|
5e6db41fc2 | ||
|
|
a502a227a9 | ||
|
|
a0823c5e41 | ||
|
|
349dfa0e57 | ||
|
|
2ad42d028a | ||
|
|
4832e08aad | ||
|
|
4b53f620ef | ||
|
|
574d0d3d53 | ||
|
|
a4380b2953 | ||
|
|
8877a812f9 | ||
|
|
b22deaa944 | ||
|
|
f5c6f4bb65 | ||
|
|
f857b4a31f | ||
|
|
b9809ca63c | ||
|
|
d94a8f617a | ||
|
|
043e050237 | ||
|
|
d8cf255c47 | ||
|
|
107302f05c | ||
|
|
1c4cb8307c | ||
|
|
433b84fae7 | ||
|
|
3389ba558b |
No files matched your search
@@ -60,6 +60,12 @@ jobs:
|
||||
- name: Dependency layering
|
||||
run: cargo xtask layers
|
||||
|
||||
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
|
||||
# prefs audit). Static: it reads committed files only, no benchmarks run here.
|
||||
- name: Scorecard up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo xtask scorecard --check
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Perf (nightly)
|
||||
|
||||
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
|
||||
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
|
||||
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
|
||||
# more than 15 % against perf/baseline.json from the same machine class (other classes are
|
||||
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
|
||||
#
|
||||
# To give this runner class its own baseline: run it by hand with "update_baseline", download
|
||||
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
quick:
|
||||
description: "Quick subset (small documents, no budgets)"
|
||||
type: boolean
|
||||
default: false
|
||||
update_baseline:
|
||||
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: perf-nightly
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
perf:
|
||||
name: perf (macOS, Apple GPU)
|
||||
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: perf-release
|
||||
|
||||
- name: Run benchmarks
|
||||
id: perf
|
||||
env:
|
||||
QUICK: ${{ inputs.quick }}
|
||||
UPDATE: ${{ inputs.update_baseline }}
|
||||
run: |
|
||||
args=()
|
||||
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
|
||||
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
|
||||
cargo xtask perf "${args[@]}"
|
||||
|
||||
- name: Job summary
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f target/perf/summary.md ]; then
|
||||
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-results
|
||||
path: |
|
||||
target/perf/*.json
|
||||
target/perf/summary.md
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload baseline
|
||||
if: always() && inputs.update_baseline
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: perf-baseline
|
||||
path: perf/baseline.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
@@ -11,8 +11,9 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
|
||||
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
|
||||
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
|
||||
| `docs/roadmap.md` | Milestones and the **current focus** |
|
||||
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
|
||||
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
|
||||
## 2. Workspace map
|
||||
@@ -32,7 +33,7 @@ apps/
|
||||
photocraft desktop app (eframe/wgpu), TCP control server
|
||||
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
|
||||
photocraft-web the same app in the browser (trunk + wasm-bindgen)
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
|
||||
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity | perf | scorecard
|
||||
```
|
||||
|
||||
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
|
||||
@@ -67,9 +68,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
|
||||
|
||||
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
|
||||
|
||||
1. `docs/roadmap.md` → **Current focus**.
|
||||
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
3. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
|
||||
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
|
||||
round trips, workflow tests, performance), update that section with the dated figure.
|
||||
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
|
||||
the performance scenarios that are over budget or not measurable yet, and the count of
|
||||
settings that do nothing. Its numbers are measured; prefer them to estimates.
|
||||
2. `docs/roadmap.md` → **Current focus**.
|
||||
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
|
||||
4. `log/devlog.md` → the "Still open" bullets of recent entries.
|
||||
|
||||
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
|
||||
|
||||
@@ -82,6 +90,10 @@ cargo xtask layers
|
||||
cargo xtask wasm # if you touched L0–L6
|
||||
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
|
||||
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
|
||||
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
|
||||
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
|
||||
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
|
||||
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
|
||||
```
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
@@ -101,6 +113,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
|
||||
|
||||
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
|
||||
- `docs/parity.md`: generated Photoshop menu coverage.
|
||||
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
|
||||
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
|
||||
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
|
||||
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
|
||||
|
||||
@@ -51,3 +51,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
|
||||
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
|
||||
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
|
||||
|
||||
`cargo xtask corpus --psd-tools` fetches every PSD/PSB of the psd-tools test set
|
||||
([`tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files), MIT,
|
||||
Copyright (c) 2019 Kota Yamaguchi) at a pinned commit into `corpus/psd-tools/`, verified against
|
||||
the sha256 list in `xtask/psd-tools-corpus.sha256`, with the upstream `LICENSE` next to them.
|
||||
Generated
+73
@@ -248,6 +248,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -1556,6 +1557,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.10"
|
||||
@@ -2614,6 +2621,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memory-stats"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3204,6 +3221,16 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -3303,6 +3330,17 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
@@ -3639,9 +3677,11 @@ dependencies = [
|
||||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3680,7 +3720,9 @@ dependencies = [
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5084,6 +5126,17 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.4"
|
||||
@@ -6121,6 +6174,24 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
@@ -6200,7 +6271,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+30
@@ -68,3 +68,33 @@ opt-level = 1
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
|
||||
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
|
||||
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
|
||||
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
|
||||
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
|
||||
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
|
||||
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "s"
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
strip = "debuginfo"
|
||||
|
||||
[profile.wasm-release.package."*"]
|
||||
opt-level = "s"
|
||||
|
||||
[profile.wasm-release.package.photocraft-raster]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-color]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-cms]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-compose]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-algo]
|
||||
opt-level = 3
|
||||
[profile.wasm-release.package.photocraft-paint]
|
||||
opt-level = 3
|
||||
@@ -267,7 +267,7 @@ flatpak run ai.storyteller.photocraft
|
||||
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>PhotoCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -29,7 +29,7 @@ log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
arboard = "3"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
open = "5"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
|
||||
//!
|
||||
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
|
||||
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
|
||||
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
|
||||
//! before eframe starts, we look for the libraries the current session type needs and, if one is
|
||||
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
|
||||
//! crash).
|
||||
//!
|
||||
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
|
||||
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
|
||||
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
|
||||
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
|
||||
//!
|
||||
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
|
||||
//! touches the system, and it is only called on Linux.
|
||||
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
|
||||
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisplaySession {
|
||||
Wayland,
|
||||
X11,
|
||||
/// Neither is set: winit reports that itself (an error, not a panic).
|
||||
None,
|
||||
}
|
||||
|
||||
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
|
||||
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
|
||||
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
|
||||
DisplaySession::Wayland
|
||||
} else if set("DISPLAY") {
|
||||
DisplaySession::X11
|
||||
} else {
|
||||
DisplaySession::None
|
||||
}
|
||||
}
|
||||
|
||||
/// A runtime-loaded library and the package that provides it on each distro family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Lib {
|
||||
pub soname: &'static str,
|
||||
pub debian: &'static str,
|
||||
pub fedora: &'static str,
|
||||
pub arch: &'static str,
|
||||
pub suse: &'static str,
|
||||
}
|
||||
|
||||
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
|
||||
Lib { soname, debian, fedora, arch, suse }
|
||||
}
|
||||
|
||||
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
|
||||
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
|
||||
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
|
||||
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
|
||||
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
|
||||
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
|
||||
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
|
||||
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
|
||||
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
|
||||
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
|
||||
|
||||
/// One requirement: at least one of these libraries must be present.
|
||||
pub type AnyOf = &'static [Lib];
|
||||
|
||||
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
|
||||
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
|
||||
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
|
||||
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
|
||||
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
|
||||
const GPU: AnyOf = &[VULKAN, EGL];
|
||||
match session {
|
||||
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
|
||||
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
|
||||
DisplaySession::None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
|
||||
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
|
||||
fn ldconfig_arch_tag() -> Option<&'static str> {
|
||||
if cfg!(target_arch = "x86_64") {
|
||||
Some("x86-64")
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
Some("AArch64")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
|
||||
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
|
||||
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
|
||||
output.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
let Some((name, rest)) = line.split_once(' ') else { return false };
|
||||
if name != soname {
|
||||
return false;
|
||||
}
|
||||
match arch_tag {
|
||||
None => true,
|
||||
Some(tag) => {
|
||||
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
|
||||
flags.split(',').any(|f| f.trim() == tag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A real `ldconfig -p` listing starts with "N libs found in cache".
|
||||
fn ldconfig_output_is_valid(output: &str) -> bool {
|
||||
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
|
||||
}
|
||||
|
||||
/// Where we look for libraries, and what we found there.
|
||||
pub struct Probe {
|
||||
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
|
||||
pub ldconfig: Option<String>,
|
||||
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
|
||||
pub dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
|
||||
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of the check.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Ok,
|
||||
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
|
||||
/// cache was read, so the libraries really are absent (and winit would fail or panic).
|
||||
Missing {
|
||||
missing: Vec<AnyOf>,
|
||||
conclusive: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
|
||||
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
|
||||
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
|
||||
}
|
||||
|
||||
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Distro {
|
||||
Debian,
|
||||
Fedora,
|
||||
Arch,
|
||||
Suse,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn distro_from_os_release(text: &str) -> Distro {
|
||||
let mut ids = Vec::new();
|
||||
for line in text.lines() {
|
||||
let Some((key, value)) = line.split_once('=') else { continue };
|
||||
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
|
||||
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
|
||||
}
|
||||
}
|
||||
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
|
||||
if any(&["debian", "ubuntu"]) {
|
||||
Distro::Debian
|
||||
} else if any(&["fedora", "rhel", "centos"]) {
|
||||
Distro::Fedora
|
||||
} else if any(&["arch"]) {
|
||||
Distro::Arch
|
||||
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
|
||||
Distro::Suse
|
||||
} else {
|
||||
Distro::Unknown
|
||||
}
|
||||
}
|
||||
|
||||
fn package(lib: &Lib, distro: Distro) -> &'static str {
|
||||
match distro {
|
||||
Distro::Debian | Distro::Unknown => lib.debian,
|
||||
Distro::Fedora => lib.fedora,
|
||||
Distro::Arch => lib.arch,
|
||||
Distro::Suse => lib.suse,
|
||||
}
|
||||
}
|
||||
|
||||
fn install_command(packages: &[&str], distro: Distro) -> String {
|
||||
let list = packages.join(" ");
|
||||
match distro {
|
||||
Distro::Debian => format!("sudo apt install {list}"),
|
||||
Distro::Fedora => format!("sudo dnf install {list}"),
|
||||
Distro::Arch => format!("sudo pacman -S {list}"),
|
||||
Distro::Suse => format!("sudo zypper install {list}"),
|
||||
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for missing libraries: what's missing, and the command that installs it.
|
||||
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
|
||||
let session_name = match session {
|
||||
DisplaySession::Wayland => "Wayland",
|
||||
DisplaySession::X11 => "X11",
|
||||
DisplaySession::None => "display",
|
||||
};
|
||||
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
|
||||
// For an either/or requirement, suggest its first alternative.
|
||||
let mut packages: Vec<&str> = Vec::new();
|
||||
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
|
||||
if !packages.contains(&p) {
|
||||
packages.push(p);
|
||||
}
|
||||
}
|
||||
let mut msg = if conclusive {
|
||||
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
|
||||
} else {
|
||||
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
|
||||
};
|
||||
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
|
||||
if distro == Distro::Unknown {
|
||||
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
|
||||
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
|
||||
}
|
||||
if conclusive {
|
||||
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
|
||||
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
|
||||
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
|
||||
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
|
||||
dirs.push(PathBuf::from(d));
|
||||
}
|
||||
dirs.push(Path::new("/usr/lib").join(triplet));
|
||||
dirs.push(Path::new("/lib").join(triplet));
|
||||
dirs
|
||||
}
|
||||
|
||||
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
|
||||
fn read_ldconfig() -> Option<String> {
|
||||
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
|
||||
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
|
||||
&& out.status.success()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
if ldconfig_output_is_valid(&text) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Run the check against this system. `Err` carries the message to print before exiting with a
|
||||
/// non-zero status; a warning is printed here and the app starts.
|
||||
pub fn preflight() -> Result<(), String> {
|
||||
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
|
||||
return Ok(());
|
||||
}
|
||||
let session = session_from_env(|k| std::env::var(k).ok());
|
||||
if session == DisplaySession::None {
|
||||
return Ok(());
|
||||
}
|
||||
let probe = Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) };
|
||||
match check(session, &probe, |p| p.exists()) {
|
||||
Verdict::Ok => Ok(()),
|
||||
Verdict::Missing { missing, conclusive } => {
|
||||
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
|
||||
let msg = missing_message(session, &missing, distro, conclusive);
|
||||
if conclusive {
|
||||
Err(msg)
|
||||
} else {
|
||||
eprint!("{msg}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
|
||||
move |k| map.get(k).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_follows_winit_order() {
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
|
||||
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
|
||||
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
|
||||
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
|
||||
}
|
||||
|
||||
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
|
||||
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
|
||||
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
|
||||
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
|
||||
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
|
||||
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
|
||||
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
|
||||
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
|
||||
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
|
||||
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
|
||||
|
||||
#[test]
|
||||
fn ldconfig_parsing_matches_name_and_architecture() {
|
||||
assert!(ldconfig_output_is_valid(CACHE));
|
||||
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
|
||||
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
|
||||
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
|
||||
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
|
||||
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
|
||||
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
|
||||
// Prefix of another name, or a missing name, is not a match.
|
||||
assert!(!ldconfig_has(CACHE, "libX11.so", None));
|
||||
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
|
||||
assert!(!ldconfig_has("", "libX11.so.6", None));
|
||||
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
|
||||
}
|
||||
|
||||
fn cache_for_this_arch(names: &[&str]) -> String {
|
||||
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
|
||||
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
|
||||
for n in names {
|
||||
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
const ALL_X11: &[&str] =
|
||||
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
|
||||
|
||||
#[test]
|
||||
fn x11_without_xkbcommon_x11_is_reported() {
|
||||
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
|
||||
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
|
||||
let v = check(DisplaySession::X11, &probe, |_| false);
|
||||
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
|
||||
assert!(conclusive);
|
||||
assert_eq!(missing.len(), 1);
|
||||
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
|
||||
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
|
||||
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
|
||||
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
|
||||
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_systems_pass() {
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
|
||||
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
|
||||
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
|
||||
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
|
||||
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
|
||||
// Wayland doesn't need the X11 libraries; no session needs nothing.
|
||||
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_needs_vulkan_or_egl() {
|
||||
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
|
||||
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
|
||||
assert_eq!(missing.len(), 1);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
|
||||
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
|
||||
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_dirs_count_and_unread_cache_only_warns() {
|
||||
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
|
||||
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
|
||||
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
|
||||
// Found by file in a directory (no ldconfig, as on NixOS).
|
||||
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
|
||||
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
|
||||
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
|
||||
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
|
||||
let v = check(DisplaySession::Wayland, &probe, |_| false);
|
||||
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
|
||||
assert!(!conclusive);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
|
||||
assert!(msg.contains("Starting anyway"), "{msg}");
|
||||
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
|
||||
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_detection() {
|
||||
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
|
||||
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
|
||||
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
|
||||
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
|
||||
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
|
||||
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
|
||||
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
|
||||
assert_eq!(distro_from_os_release(""), Distro::Unknown);
|
||||
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
// Pure logic is tested on every platform; only Linux runs the check.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_libs;
|
||||
mod monitor_profile;
|
||||
mod services;
|
||||
|
||||
@@ -62,6 +65,14 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
|
||||
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
|
||||
// one is missing (issue #201). Name the package to install and exit instead.
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(message) = linux_libs::preflight() {
|
||||
eprint!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let control = if let Some(port) = control_port {
|
||||
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
|
||||
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
|
||||
|
||||
@@ -80,6 +80,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
|
||||
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
|
||||
let savers2 = savers.clone();
|
||||
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let automation_read = automation.clone().map(|workspace| {
|
||||
Box::new(move |path: &str| {
|
||||
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
|
||||
@@ -132,14 +133,29 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
})),
|
||||
inbox: None,
|
||||
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
|
||||
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
|
||||
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})),
|
||||
clipboard_get_image: Some(Box::new(|| {
|
||||
let img = arboard::Clipboard::new().ok()?.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})),
|
||||
clipboard_set_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move |w: u32, h: u32, px: &[u8]| {
|
||||
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?),
|
||||
};
|
||||
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
|
||||
})
|
||||
}),
|
||||
clipboard_get_image: Some({
|
||||
let clip = clip.clone();
|
||||
Box::new(move || {
|
||||
let mut slot = clip.try_borrow_mut().ok()?;
|
||||
let cb = match slot.as_mut() {
|
||||
Some(c) => c,
|
||||
None => slot.insert(arboard::Clipboard::new().ok()?),
|
||||
};
|
||||
let img = cb.get_image().ok()?;
|
||||
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
|
||||
})
|
||||
}),
|
||||
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
|
||||
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
|
||||
// Crash recovery: background incremental .pcraft saves into the recovery directory.
|
||||
|
||||
@@ -30,6 +30,15 @@ impl Transfer {
|
||||
_ => Transfer::Srgb,
|
||||
}
|
||||
}
|
||||
/// The tone curve Exposure linearises through: RGB documents use a pure 2.2 power, not the
|
||||
/// sRGB curve (psd-tools adjustment_nested_composition_4: an offset of 0.1738 lifts 76 to 134,
|
||||
/// not 136; ag-psd masks), grey documents their dot-gain gamma.
|
||||
pub fn for_exposure(self) -> Self {
|
||||
match self {
|
||||
Transfer::Srgb => Transfer::Gamma(2.2),
|
||||
t => t,
|
||||
}
|
||||
}
|
||||
fn decode(self, v: f32) -> f32 {
|
||||
match self {
|
||||
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
|
||||
@@ -51,6 +60,13 @@ pub fn apply(adj: &Adjustment, buf: &mut Buffer) {
|
||||
|
||||
/// Applies an adjustment with the document's tone transfer.
|
||||
pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
apply_depth(adj, buf, transfer, None);
|
||||
}
|
||||
|
||||
/// Applies an adjustment with the document's tone transfer, on samples with `quantum` steps
|
||||
/// per unit (the document's integer depth, see `adjustment_quantum`): Levels then works on
|
||||
/// whole levels like Photoshop's (see [`levels_q`]).
|
||||
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, quantum: Option<f32>) {
|
||||
match adj {
|
||||
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
|
||||
Adjustment::Threshold { level } => {
|
||||
@@ -89,6 +105,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
// through the document tone curve (fitted on the corpus).
|
||||
let m = 2f32.powf(*exposure);
|
||||
let g = gamma.max(0.01);
|
||||
let transfer = transfer.for_exposure();
|
||||
map_rgb(buf, |c| {
|
||||
c.map(|v| {
|
||||
let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g);
|
||||
@@ -97,7 +114,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
})
|
||||
}
|
||||
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
|
||||
let luts = tone_luts(adj);
|
||||
let luts = tone_luts_q(adj, quantum);
|
||||
match space {
|
||||
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
|
||||
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
|
||||
@@ -203,12 +220,18 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
|
||||
/// `per_channel` channels then black (identity unless the space is CMYK). In Lab there is no
|
||||
/// composite record, so the master is ignored.
|
||||
pub fn tone_luts(adj: &Adjustment) -> [Vec<f32>; 4] {
|
||||
tone_luts_q(adj, None)
|
||||
}
|
||||
|
||||
/// [`tone_luts`] for samples with `quantum` steps per unit (Levels works on whole levels; see
|
||||
/// [`levels_q`]).
|
||||
pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
|
||||
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
|
||||
match adj {
|
||||
Adjustment::Levels { master, per_channel, space, black } => {
|
||||
let ident = LevelsChannel::default();
|
||||
let m = if *space == ToneSpace::Lab { &ident } else { master };
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels(c, levels(m, x(k)))).collect();
|
||||
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels_q(c, levels_q(m, x(k), quantum), quantum)).collect();
|
||||
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &ident })]
|
||||
}
|
||||
Adjustment::Curves { master, per_channel, space, black } => {
|
||||
@@ -458,20 +481,37 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
|
||||
}
|
||||
|
||||
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
|
||||
levels_q(ch, v, None)
|
||||
}
|
||||
|
||||
/// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole
|
||||
/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole
|
||||
/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 →
|
||||
/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT
|
||||
/// the result is a staircase that is exact at every level.
|
||||
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
|
||||
let range = (ch.in_white - ch.in_black).max(1e-6);
|
||||
let u = ((v - ch.in_black) / range).clamp(0.0, 1.0);
|
||||
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
|
||||
Some(q) => {
|
||||
let v = (v * q).round() / q;
|
||||
(((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q
|
||||
}
|
||||
None => ((v - ch.in_black) / range).clamp(0.0, 1.0),
|
||||
};
|
||||
let g = ch.gamma.max(0.01);
|
||||
let t = if g > 1.0 {
|
||||
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with the slope-2^gamma line; reduces
|
||||
// to the exact power curve in the body, so highlights are unchanged. gamma <= 1 is untouched.
|
||||
// which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against
|
||||
// the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact
|
||||
// power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and
|
||||
// sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and
|
||||
// psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both).
|
||||
let power = u.powf(1.0 / g);
|
||||
let line = 2.0f32.powf(g) * u;
|
||||
let line = 0.93 * 2.0f32.powf(g) * u;
|
||||
if power <= 1e-6 || line <= 1e-6 {
|
||||
power.min(line)
|
||||
} else {
|
||||
let p = 1.8 + 8.8 / g; // p-norm sharpness, fit to Photoshop ground truth
|
||||
let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth
|
||||
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
|
||||
}
|
||||
} else {
|
||||
|
||||
+122
-35
@@ -5,17 +5,19 @@
|
||||
//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows,
|
||||
//! inner shadows, strokes and the inner bevel on top. Exterior effects blend
|
||||
//! straight into the backdrop with their own modes; the layer and its
|
||||
//! interior effects blend with the layer's mode. Layer opacity applies to
|
||||
//! the whole stack; fill opacity only to the layer's own pixels.
|
||||
//! interior effects blend with the layer's mode. Emboss and pillow emboss then
|
||||
//! shade the composited result (inside and outside halves). Layer opacity
|
||||
//! applies to the whole stack; fill opacity only to the layer's own pixels.
|
||||
//!
|
||||
//! Interior effects are painted relative to the layer's shape and then take the shape's alpha
|
||||
//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts
|
||||
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
|
||||
//! lower ones. Drop shadows are knocked out only through see-through fill.
|
||||
//!
|
||||
//! Shapes come from the layer's alpha (after its mask). Strokes measure distances with a
|
||||
//! 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does); other effects
|
||||
//! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs.
|
||||
//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure
|
||||
//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does);
|
||||
//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs
|
||||
//! are two box blurs (a tent).
|
||||
|
||||
use photocraft_color::blend::BlendMode;
|
||||
use photocraft_doc::Pattern;
|
||||
@@ -242,9 +244,9 @@ fn dist_outside(s: &Map) -> Vec<f32> {
|
||||
/// Distance metric for effect distance fields.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Metric {
|
||||
/// Exact Euclidean (spread/choke dilation, glows, bevels).
|
||||
/// Exact Euclidean (precise glows, bevels).
|
||||
Euclidean,
|
||||
/// 5 × 5 chamfer (strokes; see [`chamfer_nearest`]).
|
||||
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
|
||||
Chamfer,
|
||||
}
|
||||
|
||||
@@ -326,12 +328,16 @@ fn blur(m: &mut Map, size: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge.
|
||||
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge. Distances use
|
||||
/// the 5 × 5 chamfer metric, as for strokes: Photoshop's spread of a group drop shadow around an
|
||||
/// ellipse (ag-psd group-drop-shadows) is round at 0° and 45° and falls short in between, peaking
|
||||
/// near 15° where the chamfer metric overestimates most (5/255 lighter with an exact Euclidean
|
||||
/// dilation, within 2/255 with the chamfer).
|
||||
fn dilate(s: &Map, r: f32) -> Map {
|
||||
if r <= 0.0 {
|
||||
return s.clone();
|
||||
}
|
||||
let d = dist_outside(s);
|
||||
let d = dist_outside_by(s, Metric::Chamfer);
|
||||
let mut out = s.clone();
|
||||
for (o, d) in out.v.iter_mut().zip(d) {
|
||||
// Partly covered pixels (distance sentinel < 0) grow by `r` from their own coverage, so a
|
||||
@@ -432,9 +438,13 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
|
||||
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
|
||||
// span the width / height.
|
||||
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0) * scale.max(1e-3);
|
||||
// Linear / Reflected sample the pixel's top-left corner, half a pixel before its centre
|
||||
// (with the whole-pixel end points of `fill_layout`: layer_effects' overlay 5.8 → 1.8/255,
|
||||
// shape-fx2 2.8 → 1.1/255, gradient-fill.psd 1.9 → 0.5/255).
|
||||
let corner = 0.5 * (c - s);
|
||||
let mut t = match style {
|
||||
GradientStyle::Linear => along / chord + 0.5,
|
||||
GradientStyle::Reflected => (along / (chord / 2.0)).abs(),
|
||||
GradientStyle::Linear => (along - corner) / chord + 0.5,
|
||||
GradientStyle::Reflected => ((along - corner) / (chord / 2.0)).abs(),
|
||||
GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0),
|
||||
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
|
||||
// Clockwise sweep starting at the gradient angle.
|
||||
@@ -615,13 +625,14 @@ fn paint_fx(
|
||||
FxPaint::Color(c) => paint_color(dst, m, rgb(c), blend, opacity),
|
||||
FxPaint::Gradient(g) => {
|
||||
let prepared = PreparedGradient::new(g);
|
||||
let (angle, scale, offset) = crate::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, shape_bounds);
|
||||
let w = big.width() as usize;
|
||||
paint(
|
||||
dst,
|
||||
m,
|
||||
|i| {
|
||||
let (x, y) = ((big.x0 + (i % w) as i32) as f32 + 0.5, (big.y0 + (i / w) as i32) as f32 + 0.5);
|
||||
prepared.sample(gradient_t(g.style, g.angle, g.scale, g.reverse, g.offset, shape_bounds, x, y))
|
||||
prepared.sample(gradient_t(g.style, angle, scale, g.reverse, offset, shape_bounds, x, y))
|
||||
},
|
||||
blend,
|
||||
opacity,
|
||||
@@ -811,7 +822,7 @@ pub const BEVEL_H_EPS: f32 = 1e-5;
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BevelGeom {
|
||||
pub paint: BevelPaint,
|
||||
/// Box width of the smooth height blur (size; half the size for emboss styles).
|
||||
/// Box width of the smooth height blur (size; half the size, rounded up, for emboss styles).
|
||||
pub width: f32,
|
||||
/// Height-map gradient scale: depth × width (smooth) or depth × size (chisel), signed by
|
||||
/// direction.
|
||||
@@ -830,7 +841,10 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
|
||||
BevelStyle::Emboss | BevelStyle::PillowEmboss => BevelPaint::Both,
|
||||
BevelStyle::InnerBevel | BevelStyle::StrokeEmboss => BevelPaint::Inner,
|
||||
};
|
||||
let width = if emboss { size / 2.0 } else { size };
|
||||
// Emboss styles straddle the edge with half the size, rounded up to whole pixels: a 41 px
|
||||
// emboss blurs over 21 (psd-tools layer_effects: 20.5 left narrow letter parts up to
|
||||
// 5.7/255 dark, 21 is within 2.2).
|
||||
let width = if emboss { (size / 2.0).ceil() } else { size };
|
||||
let smooth = b.technique == BevelTechnique::Smooth;
|
||||
let sign = if b.up { 1.0 } else { -1.0 };
|
||||
BevelGeom {
|
||||
@@ -922,7 +936,16 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
let (sa, ca) = angle.to_radians().sin_cos();
|
||||
let (se, ce) = altitude.to_radians().sin_cos();
|
||||
let light_v = [ca * ce, -sa * ce, se];
|
||||
let shade_into = |depth: f32, outer: bool| -> (Map, Map) {
|
||||
// Region: 0 = inside (× the shape's alpha), 1 = under the layer's edge and outside.
|
||||
// A pixel is on the bevel where its height is raised. Emboss styles also shade the last pixel
|
||||
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
|
||||
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
|
||||
// bevel-outer-smooth).
|
||||
let reach_past = g.paint == BevelPaint::Both;
|
||||
let on_bevel = |x: i64, y: i64| {
|
||||
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
|
||||
};
|
||||
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
|
||||
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
|
||||
for y in 0..shape.h as i64 {
|
||||
for x in 0..shape.w as i64 {
|
||||
@@ -932,7 +955,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
let n = [-gx, -gy, 1.0];
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
|
||||
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
|
||||
let region = if outer { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && hmap.v[i] > BEVEL_H_EPS) } else { shape.v[i] };
|
||||
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
|
||||
let k = shade - se;
|
||||
if k > 0.0 {
|
||||
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
|
||||
@@ -945,13 +968,21 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
|
||||
};
|
||||
let maps = match g.paint {
|
||||
BevelPaint::Inner | BevelPaint::Outer => {
|
||||
let (hi, sh) = shade_into(g.depth, g.paint == BevelPaint::Outer);
|
||||
let (hi, sh) = shade_into(g.depth, u8::from(g.paint == BevelPaint::Outer));
|
||||
vec![hi, sh]
|
||||
}
|
||||
BevelPaint::Both => {
|
||||
let (hi, sh) = shade_into(g.depth, false);
|
||||
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, true);
|
||||
vec![hi, sh, ho, so]
|
||||
// One map per colour over the whole bevel: the inside half where the shape is (× its
|
||||
// alpha), the outside half (pillow: facing the other way) for the rest of each pixel.
|
||||
// A 1 % edge pixel of a pillow emboss takes the outside shading (Photoshop oracle
|
||||
// bevel-pillow-smooth), a 75 % one of an emboss mostly the inside (layer_effects).
|
||||
let (hi, sh) = shade_into(g.depth, 0);
|
||||
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
|
||||
let mix = |a: Map, b: Map| -> Map {
|
||||
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
|
||||
Map { w: a.w, h: a.h, v }
|
||||
};
|
||||
vec![mix(hi, ho), mix(sh, so)]
|
||||
}
|
||||
};
|
||||
(maps, g.paint)
|
||||
@@ -1196,7 +1227,7 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
}
|
||||
for (i, e) in rev() {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& maps.bevel_paint[i] != BevelPaint::Outer
|
||||
&& maps.bevel_paint[i] == BevelPaint::Inner
|
||||
{
|
||||
let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1)));
|
||||
paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
|
||||
@@ -1257,10 +1288,9 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
}
|
||||
for (i, e) in rev() {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& maps.bevel_paint[i] != BevelPaint::Inner
|
||||
&& maps.bevel_paint[i] == BevelPaint::Outer
|
||||
{
|
||||
let k = if maps.bevel_paint[i] == BevelPaint::Both { 2 } else { 0 };
|
||||
let (hi, sh) = (fx(i, k), fx(i, k + 1));
|
||||
let (hi, sh) = (fx(i, 0), fx(i, 1));
|
||||
paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
|
||||
paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
|
||||
}
|
||||
@@ -1274,6 +1304,17 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
|
||||
}
|
||||
}
|
||||
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
|
||||
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
|
||||
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
|
||||
for (i, e) in rev() {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& maps.bevel_paint[i] == BevelPaint::Both
|
||||
{
|
||||
paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
|
||||
paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
|
||||
}
|
||||
}
|
||||
// Layer opacity applies to the whole stack.
|
||||
let op = layer.opacity;
|
||||
for y in rect.y0..rect.y1 {
|
||||
@@ -1305,15 +1346,15 @@ fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
|
||||
/// `glow_map`, `bevel_maps` and `build_maps`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum FieldKind {
|
||||
/// `dist_outside` of the shape: spread of drop shadows and softer outer glows, precise outer
|
||||
/// and centre glows, bevels.
|
||||
/// `dist_outside` of the shape: precise outer and centre glows, bevels.
|
||||
Outside,
|
||||
/// `dist_inside` of the shape: precise edge inner glows, bevels.
|
||||
Inside,
|
||||
/// `dist_outside` of `1 - alpha`: choke of inner shadows, softer inner glows.
|
||||
OutsideInverse,
|
||||
/// Stroke distance outside the shape.
|
||||
/// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and
|
||||
/// softer outer glows ([`dilate`]).
|
||||
StrokeOutside,
|
||||
/// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows.
|
||||
ChokeInside,
|
||||
/// Stroke distance inside the shape.
|
||||
StrokeInside,
|
||||
/// Stroke distance outside a shape layer's outline (from its local coverage).
|
||||
@@ -1326,8 +1367,8 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize) -> V
|
||||
match kind {
|
||||
FieldKind::Outside => dist_outside(&s),
|
||||
FieldKind::Inside => dist_inside(&s),
|
||||
FieldKind::OutsideInverse => dist_outside(&s.map(|a| 1.0 - a)),
|
||||
FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer),
|
||||
FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer),
|
||||
FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer),
|
||||
FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer),
|
||||
}
|
||||
@@ -1374,9 +1415,10 @@ mod tests {
|
||||
let b = Rect::new(0, 0, 100, 100);
|
||||
let t = |s, x, y| gradient_t(s, 0.0, 1.0, false, (0.0, 0.0), b, x, y);
|
||||
assert!(t(GradientStyle::Linear, 0.0, 50.0) < 0.01);
|
||||
assert!((t(GradientStyle::Linear, 50.0, 50.0) - 0.5).abs() < 1e-6);
|
||||
// Linear / reflected sample pixel corners: the centre is reached half a pixel later.
|
||||
assert!((t(GradientStyle::Linear, 50.5, 50.0) - 0.5).abs() < 1e-6);
|
||||
assert!(t(GradientStyle::Linear, 100.0, 50.0) > 0.99);
|
||||
assert!(t(GradientStyle::Reflected, 50.0, 50.0) < 1e-6);
|
||||
assert!(t(GradientStyle::Reflected, 50.5, 50.0) < 1e-6);
|
||||
assert!((t(GradientStyle::Reflected, 0.0, 50.0) - 1.0).abs() < 1e-6);
|
||||
assert!(t(GradientStyle::Radial, 50.0, 50.0) < 1e-6);
|
||||
assert!((t(GradientStyle::Radial, 50.0, 0.0) - 1.0).abs() < 1e-6);
|
||||
@@ -1387,6 +1429,18 @@ mod tests {
|
||||
assert!(rev > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_gradients_snap_end_points_and_sample_corners() {
|
||||
// 87° over 600 × 60 at (99, 422): end points (397, 482) and (400, 422), so the overlay runs
|
||||
// along (3, -60) from its midpoint (398.5, 452), sampled at pixel corners.
|
||||
let frame = Rect::new(99, 422, 699, 482);
|
||||
let (a, sc, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 87.0, 1.0, (0.0, 0.0), frame);
|
||||
assert!((a - (60f32).atan2(3.0).to_degrees()).abs() < 1e-3, "{a}");
|
||||
let t = |x: f32, y: f32| gradient_t(GradientStyle::Linear, a, sc, false, o, frame, x, y);
|
||||
assert!((t(399.0, 452.5) - 0.5).abs() < 1e-4, "{}", t(399.0, 452.5));
|
||||
assert!(t(400.5, 422.5) > 0.99 && t(397.5, 482.5) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_uses_euclidean_step_lengths() {
|
||||
let (w, h) = (9, 9);
|
||||
@@ -1402,6 +1456,19 @@ mod tests {
|
||||
assert!(near.iter().all(|&n| n == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spread_dilates_with_the_chamfer_metric() {
|
||||
// One opaque pixel grown by 3 px: at offset (1, 3) the chamfer distance is √5 + 1 = 3.236
|
||||
// (Euclidean 3.162), so the anti-aliased rim keeps 3 + 0.5 − (3.236 − 0.5) = 0.764.
|
||||
let mut m = Map::new(9, 9, 0.0);
|
||||
m.v[4 * 9 + 4] = 1.0;
|
||||
let d = dilate(&m, 3.0);
|
||||
assert!((d.v[7 * 9 + 5] - 0.764).abs() < 1e-3, "{}", d.v[7 * 9 + 5]);
|
||||
// (2, 3): √5 + √2 = 3.650 (Euclidean 3.606) leaves 0.350; axis steps are exact.
|
||||
assert!((d.v[7 * 9 + 6] - (4.0 - 5f32.sqrt() - std::f32::consts::SQRT_2)).abs() < 1e-4, "{}", d.v[7 * 9 + 6]);
|
||||
assert_eq!(d.v[4 * 9 + 7], 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_sum_tent_matches_the_kernel() {
|
||||
let (w, h) = (37usize, 23usize);
|
||||
@@ -1494,13 +1561,33 @@ mod tests {
|
||||
let l = GlobalLight::default();
|
||||
let (e, paint) =
|
||||
bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
|
||||
assert_eq!((paint, e.len()), (BevelPaint::Both, 2));
|
||||
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
|
||||
// Emboss: the slope across the top edge faces the light on both sides.
|
||||
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[2], 20, 9) > 0.3);
|
||||
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[0], 20, 9) > 0.3);
|
||||
let (p, _) =
|
||||
bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[1], 20, 9) > 0.3, "pillow: outside top edge in shadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_width_rounds_up_and_shades_past_the_blur() {
|
||||
let mut b = bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth);
|
||||
b.size = 41.0;
|
||||
let g = bevel_geom(&b);
|
||||
assert_eq!((g.width, g.depth), (21.0, 21.0));
|
||||
b.size = 5.0;
|
||||
assert_eq!(bevel_geom(&b).width, 3.0);
|
||||
// The first row past the height blur's reach still slopes (its upper neighbour is raised),
|
||||
// so it shades; the row after it does not.
|
||||
let shape = square(60, 20, 40);
|
||||
b.size = 9.0;
|
||||
b.angle = 90.0;
|
||||
let (m, _) = bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
|
||||
let reach = tent_kernel(bevel_geom(&b).width).0 as usize;
|
||||
let row = 39 + reach + 1; // last shape row + reach + 1: height 0, neighbour above raised
|
||||
assert!(m[1].v[row * 60 + 30] > 0.0, "{}", m[1].v[row * 60 + 30]);
|
||||
assert_eq!(m[1].v[(row + 1) * 60 + 30], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Gradient fill layer geometry.
|
||||
//!
|
||||
//! Photoshop lays a gradient fill layer's Linear and Reflected gradients out between whole-pixel
|
||||
//! end points: the chord through the frame's centre along the angle (as [`effects::gradient_t`])
|
||||
//! ends at points truncated to the pixel grid. On large frames that is invisible, on small ones it
|
||||
//! changes the effective angle: a 30° Reflected fill on a 4 × 4 canvas ends at the corner
|
||||
//! `(4, 0)` instead of `(4, 0.845)` and renders as a 45° one (fitted on the psd-tools `colormodes`
|
||||
//! files, whose 16/32-bit versions store no fill pixels).
|
||||
//!
|
||||
//! The snapped end points are expressed as the angle / scale / centre offset that
|
||||
//! [`effects::gradient_t`] (and the GPU's `gradient_t`) already take, so both compositors share
|
||||
//! one layout.
|
||||
|
||||
use photocraft_doc::GradientStyle;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::effects;
|
||||
|
||||
/// Effective `(angle, scale, centre offset)` for a gradient fill with `style`, `angle` (degrees)
|
||||
/// and `scale` laid out in `frame`.
|
||||
pub fn fill_gradient_layout(style: GradientStyle, angle: f32, scale: f32, frame: Rect) -> (f32, f32, (f32, f32)) {
|
||||
gradient_layout(style, angle, scale, (0.0, 0.0), frame)
|
||||
}
|
||||
|
||||
/// [`fill_gradient_layout`] for a gradient centred `offset` (a fraction of the frame) off the
|
||||
/// frame's centre: layer-effect gradients (overlays, gradient glows and strokes) snap the same
|
||||
/// way (psd-tools layer_effects: an 87° overlay on a 600 × 60 text line runs along (3, 60), i.e.
|
||||
/// 87.14°).
|
||||
pub fn gradient_layout(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: Rect) -> (f32, f32, (f32, f32)) {
|
||||
let unchanged = (angle, scale, offset);
|
||||
if !matches!(style, GradientStyle::Linear | GradientStyle::Reflected)
|
||||
|| !angle.is_finite()
|
||||
|| !scale.is_finite()
|
||||
|| !offset.0.is_finite()
|
||||
|| !offset.1.is_finite()
|
||||
{
|
||||
return unchanged;
|
||||
}
|
||||
let w = f64::from(frame.width().max(1));
|
||||
let h = f64::from(frame.height().max(1));
|
||||
let (cx, cy) = (f64::from(frame.x0) + w / 2.0 + f64::from(offset.0) * w, f64::from(frame.y0) + h / 2.0 + f64::from(offset.1) * h);
|
||||
// Unscaled chord length along `a` (radians), as `effects::gradient_t`.
|
||||
let chord_of = |a: f64| {
|
||||
let (s, c) = a.sin_cos();
|
||||
(w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0)
|
||||
};
|
||||
let a = f64::from(angle).to_radians();
|
||||
let scale64 = f64::from(scale.max(1e-3));
|
||||
let half = chord_of(a) * scale64 / 2.0;
|
||||
let (s, c) = a.sin_cos();
|
||||
let (dx, dy) = (c * half, -s * half);
|
||||
// Truncate to the pixel grid (with a little slack for rounding error in exact cases).
|
||||
let snap = |v: f64| (v + 1e-4).floor();
|
||||
let end = (snap(cx + dx), snap(cy + dy));
|
||||
let (start, mid) = match style {
|
||||
GradientStyle::Reflected => ((cx, cy), (cx, cy)),
|
||||
_ => {
|
||||
let s0 = (snap(cx - dx), snap(cy - dy));
|
||||
(s0, ((s0.0 + end.0) / 2.0, (s0.1 + end.1) / 2.0))
|
||||
}
|
||||
};
|
||||
let v = (end.0 - start.0, end.1 - start.1);
|
||||
let len = v.0.hypot(v.1);
|
||||
if len < 0.5 {
|
||||
return unchanged;
|
||||
}
|
||||
let a2 = (-v.1).atan2(v.0);
|
||||
// Reflected spans half the chord from the centre; Linear the whole chord.
|
||||
let span = if style == GradientStyle::Reflected { 2.0 * len } else { len };
|
||||
let scale2 = span / chord_of(a2);
|
||||
let shift = (((mid.0 - cx) / w) as f32, ((mid.1 - cy) / h) as f32);
|
||||
(a2.to_degrees() as f32, scale2 as f32, (offset.0 + shift.0, offset.1 + shift.1))
|
||||
}
|
||||
|
||||
/// Gradient parameter `t` of a gradient fill at pixel centre `(x, y)` (see [`fill_gradient_layout`]).
|
||||
pub fn fill_gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, frame: Rect, x: f32, y: f32) -> f32 {
|
||||
let (a, s, o) = fill_gradient_layout(style, angle, scale, frame);
|
||||
effects::gradient_t(style, a, s, reverse, o, frame, x, y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grid(style: GradientStyle, angle: f32, frame: Rect) -> Vec<f32> {
|
||||
let mut v = Vec::new();
|
||||
for y in frame.y0..frame.y1 {
|
||||
for x in frame.x0..frame.x1 {
|
||||
v.push(fill_gradient_t(style, angle, 1.0, false, frame, x as f32 + 0.5, y as f32 + 0.5));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_reflected_30_degrees_snaps_to_the_corner() {
|
||||
// Photoshop's 4 × 4 30° Reflected fill: t = |x − y| / 4.
|
||||
let t = grid(GradientStyle::Reflected, 30.0, Rect::new(0, 0, 4, 4));
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||||
assert!((t[y * 4 + x] - want).abs() < 1e-4, "({x},{y}) {} vs {want}", t[y * 4 + x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_layouts_are_unchanged() {
|
||||
for (style, angle, frame) in [
|
||||
(GradientStyle::Linear, 90.0, Rect::new(0, 0, 33, 20)),
|
||||
(GradientStyle::Linear, 0.0, Rect::new(-5, 3, 40, 17)),
|
||||
(GradientStyle::Reflected, 45.0, Rect::new(0, 0, 32, 32)),
|
||||
(GradientStyle::Linear, -90.0, Rect::new(10, 10, 210, 210)),
|
||||
] {
|
||||
for y in [frame.y0, (frame.y0 + frame.y1) / 2, frame.y1 - 1] {
|
||||
for x in [frame.x0, (frame.x0 + frame.x1) / 2, frame.x1 - 1] {
|
||||
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
|
||||
let a = fill_gradient_t(style, angle, 1.0, false, frame, px, py);
|
||||
let b = effects::gradient_t(style, angle, 1.0, false, (0.0, 0.0), frame, px, py);
|
||||
assert!((a - b).abs() < 1e-3, "{style:?} {angle} {frame:?} ({x},{y}): {a} vs {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_frames_barely_move_and_bad_input_is_harmless() {
|
||||
let f = Rect::new(0, 0, 3000, 2000);
|
||||
let (a, s, o) = fill_gradient_layout(GradientStyle::Linear, 30.0, 1.0, f);
|
||||
assert!((a - 30.0).abs() < 0.05 && (s - 1.0).abs() < 1e-3 && o.0.abs() < 1e-3 && o.1.abs() < 1e-3);
|
||||
for (ang, sc) in [(f32::NAN, 1.0), (30.0, f32::INFINITY), (30.0, 0.0), (1e30, 1.0)] {
|
||||
let t = fill_gradient_t(GradientStyle::Linear, ang, sc, false, Rect::new(0, 0, 4, 4), 1.5, 1.5);
|
||||
assert!(t.is_nan() || (0.0..=1.0).contains(&t));
|
||||
}
|
||||
assert_eq!(fill_gradient_layout(GradientStyle::Radial, 30.0, 1.0, f), (30.0, 1.0, (0.0, 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
pub mod adjust;
|
||||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod fill_layout;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod pattern;
|
||||
@@ -515,19 +516,28 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
}
|
||||
|
||||
/// The frame layer-effect gradients and linked patterns are laid out in, when it isn't the
|
||||
/// layer's pixel bounds: a shape layer's path bounds (rounded out to whole pixels). Its rendered
|
||||
/// layer's pixel bounds: a shape layer's path bounds, truncated to whole pixels. Its rendered
|
||||
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
|
||||
/// psd-tools shape-fx2's 45° overlay spans the 29 px path, not the 32 px of pixels.
|
||||
/// psd-tools shape-fx2's 45° overlay spans the 0.92–29.61 path as 0–29 (29 px, centred at 14.5),
|
||||
/// not the 32 px of pixels.
|
||||
pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
let (x0, y0, x1, y1) = sh.path.control_bounds()?;
|
||||
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32);
|
||||
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.floor() as i32, y1.floor() as i32);
|
||||
(!r.is_empty()).then_some(r)
|
||||
}
|
||||
|
||||
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
|
||||
/// times the rasterized vector mask.
|
||||
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
|
||||
// Feathers blur across tiles: read the cached, canvas-wide combined mask.
|
||||
if masks::has_feather(layer)
|
||||
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(rect, &mut v);
|
||||
return Some(v);
|
||||
}
|
||||
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
|
||||
let Some(m) = layer.mask.as_ref() else { return vector };
|
||||
let mut v = Vec::new();
|
||||
@@ -637,10 +647,11 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
|
||||
})
|
||||
.collect();
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
let (angle, scale, offset) = fill_layout::fill_gradient_layout(*style, *angle, *scale, canvas);
|
||||
let mut b = Buffer::transparent(rect);
|
||||
for y in rect.y0..rect.y1 {
|
||||
for x in rect.x0..rect.x1 {
|
||||
let t = effects::gradient_t(*style, *angle, *scale, *reverse, (0.0, 0.0), canvas, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let t = effects::gradient_t(*style, angle, scale, *reverse, offset, canvas, x as f32 + 0.5, y as f32 + 0.5);
|
||||
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
b.px[i] = sample_stops(&stops, t);
|
||||
}
|
||||
@@ -726,6 +737,55 @@ fn restore_channels(out: &mut Buffer, before: &Buffer, w: [f32; 3]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `layer`'s composite (its pixels and effects; a group's visible children with theirs) can
|
||||
/// be non-transparent, or `None` when it may draw anywhere (a surface with an opaque default
|
||||
/// pixel). Outside it, compositing the layer leaves the backdrop unchanged in every blend mode.
|
||||
/// Adjustment layers count as empty: they only change pixels already there (which is what an
|
||||
/// isolated group needs; see [`change_bounds`] for the document-level question).
|
||||
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
if !transparent_outside(layer) {
|
||||
return None;
|
||||
}
|
||||
let own = match &layer.content {
|
||||
LayerContent::Group(g) if g.artboard.is_none() => {
|
||||
let mut acc = Rect::EMPTY;
|
||||
for c in g.children.iter().filter(|c| c.visible) {
|
||||
let b = composite_bounds(c, canvas)?;
|
||||
if !b.is_empty() {
|
||||
acc = if acc.is_empty() { b } else { acc.union(&b) };
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
LayerContent::Adjustment(_) => Rect::EMPTY,
|
||||
_ => layer_bounds(layer, canvas),
|
||||
};
|
||||
if own.is_empty() {
|
||||
return Some(Rect::EMPTY);
|
||||
}
|
||||
let m = if effects::has_effects(layer) { effects::margin(layer) } else { 0 };
|
||||
Some(own.inflate(m).intersect(&canvas))
|
||||
}
|
||||
|
||||
/// The document pixels that showing, hiding, moving or restyling `layer` can change, or `None`
|
||||
/// for anywhere: an adjustment layer (and a pass-through group holding one) reaches everything
|
||||
/// beneath it. Clipped layers above stay within their base's bounds, so they are covered too;
|
||||
/// effects of groups around the layer can reach further (callers grow the rect by that reach).
|
||||
pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
fn reaches_below(l: &Layer) -> bool {
|
||||
match &l.content {
|
||||
// Clipped, it changes only its base's pixels.
|
||||
LayerContent::Adjustment(_) => !l.clipped,
|
||||
LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().any(reaches_below),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if matches!(layer.content, LayerContent::Adjustment(_)) || reaches_below(layer) {
|
||||
return None;
|
||||
}
|
||||
composite_bounds(layer, canvas)
|
||||
}
|
||||
|
||||
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
|
||||
/// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown
|
||||
/// by their reach.
|
||||
@@ -865,9 +925,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
}
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
|
||||
// Pass-through groups composite their children straight into the backdrop.
|
||||
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
|
||||
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
|
||||
// adjustment inside no longer reaches the layers beneath).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !effects::has_effects(layer)
|
||||
{
|
||||
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
|
||||
@@ -924,7 +987,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
// Adjustment layers transform the backdrop, then blend the result back in.
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = backdrop.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
|
||||
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut adjusted, cx);
|
||||
@@ -1189,7 +1252,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
let rect = base.rect;
|
||||
if let LayerContent::Adjustment(adj) = &layer.content {
|
||||
let mut adjusted = base.clone();
|
||||
adjust::apply_with(adj, &mut adjusted, cx.transfer);
|
||||
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
for (i, p) in base.px.iter_mut().enumerate() {
|
||||
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
|
||||
|
||||
+82
-17
@@ -1,7 +1,8 @@
|
||||
//! A layer's effective mask (pixel mask × vector mask) as one surface, for backends that sample
|
||||
//! masks from textures (the GPU compositor). Rasterising a vector mask costs a path coverage
|
||||
//! pass, so results are cached per mask state; unchanged masks return the same tiles (cheap to
|
||||
//! clone, and the GPU sees no change).
|
||||
//! A layer's effective mask (pixel mask × vector mask, each feathered) as one surface, for
|
||||
//! backends that sample masks from textures (the GPU compositor) and for feathered masks on the
|
||||
//! CPU. Rasterising a vector mask costs a path coverage pass and feathering a blur, so results
|
||||
//! are cached per mask state; unchanged masks return the same tiles (cheap to clone, and the
|
||||
//! GPU sees no change).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -33,7 +34,7 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
format!("{:?}", layer.vector_mask).hash(&mut h);
|
||||
if let Some(m) = &layer.mask {
|
||||
(m.enabled, m.density.to_bits()).hash(&mut h);
|
||||
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h);
|
||||
format!("{:?}", m.surface.default_pixel()).hash(&mut h);
|
||||
for (c, t) in m.surface.tiles() {
|
||||
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
@@ -42,12 +43,66 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, exactly as the CPU
|
||||
/// compositor applies them) as a single-channel f32 surface over `canvas`, or `None` when the
|
||||
/// layer has no enabled vector mask (use the pixel mask directly). Values outside the computed
|
||||
/// area are the surface's default.
|
||||
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
|
||||
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
|
||||
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
|
||||
pub fn feather_sigma(px: f32) -> f32 {
|
||||
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
|
||||
}
|
||||
|
||||
const FEATHER_SIGMA: f32 = 1.0;
|
||||
/// Photoshop's feather tops out at 1000 px.
|
||||
const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA;
|
||||
|
||||
/// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]).
|
||||
pub fn has_feather(layer: &Layer) -> bool {
|
||||
layer.mask.as_ref().is_some_and(|m| m.enabled && feather_sigma(m.feather) > 0.0)
|
||||
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
|
||||
}
|
||||
|
||||
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
|
||||
/// is exact here since the planes extend into constant mask regions).
|
||||
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
|
||||
if sigma <= 0.0 || w == 0 || h == 0 {
|
||||
return;
|
||||
}
|
||||
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
|
||||
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
|
||||
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
|
||||
if r == 0 {
|
||||
return;
|
||||
}
|
||||
let mut line = Vec::new();
|
||||
let mut pass = |v: &mut [f32], len: usize, count: usize, at: &dyn Fn(usize, usize) -> usize| {
|
||||
for k in 0..count {
|
||||
for _ in 0..3 {
|
||||
line.clear();
|
||||
line.extend((0..len).map(|i| v[at(k, i)]));
|
||||
let get = |i: isize| line[i.clamp(0, len as isize - 1) as usize];
|
||||
let mut acc: f32 = (-(r as isize)..=r as isize).map(get).sum();
|
||||
let n = (2 * r + 1) as f32;
|
||||
for i in 0..len {
|
||||
v[at(k, i)] = acc / n;
|
||||
acc += get(i as isize + r as isize + 1) - get(i as isize - r as isize);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
pass(v, w, h, &|row, i| row * w + i);
|
||||
pass(v, h, w, &|col, i| i * w + col);
|
||||
}
|
||||
|
||||
/// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather,
|
||||
/// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or
|
||||
/// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel
|
||||
/// mask directly). Values outside the computed area are the surface's default.
|
||||
pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled)?;
|
||||
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled);
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let (sv, sp) = (vm.map_or(0.0, |v| feather_sigma(v.feather)), pixel.map_or(0.0, |m| feather_sigma(m.feather)));
|
||||
if vm.is_none() && sp <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let k = key(layer, canvas);
|
||||
{
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -60,22 +115,26 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
}
|
||||
// Far outside the path the vector mask is constant.
|
||||
let far = Rect::from_xywh(canvas.x0 - 1_000_000, canvas.y0 - 1_000_000, 1, 1);
|
||||
let v_out = photocraft_vector::vector_mask_values(vm, far)[0];
|
||||
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
|
||||
let v_out = vm.map_or(1.0, |vm| photocraft_vector::vector_mask_values(vm, far)[0]);
|
||||
let p_def = pixel.map_or(1.0, |m| {
|
||||
let d = m.surface.default_pixel().first().copied().unwrap_or(1.0);
|
||||
1.0 - m.density * (1.0 - d)
|
||||
});
|
||||
let grow = |r: Rect, sigma: f32| {
|
||||
let m = (sigma * 3.0).ceil() as i32 + 2;
|
||||
if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) }
|
||||
};
|
||||
// Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel
|
||||
// mask's painted tiles when the vector mask lets them through outside the path.
|
||||
let mut area = match vm.path.control_bounds() {
|
||||
Some((x0, y0, x1, y1)) => Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2),
|
||||
// mask's painted tiles when the vector mask lets them through outside the path, each grown
|
||||
// by its feather.
|
||||
let mut area = match vm.and_then(|vm| vm.path.control_bounds()) {
|
||||
Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv),
|
||||
None => Rect::EMPTY,
|
||||
};
|
||||
if let Some(m) = pixel
|
||||
&& v_out > 0.0
|
||||
{
|
||||
let b = m.surface.content_bounds();
|
||||
let b = grow(m.surface.content_bounds(), sp);
|
||||
area = if area.is_empty() {
|
||||
b
|
||||
} else if b.is_empty() {
|
||||
@@ -87,10 +146,16 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
|
||||
let area = area.intersect(&canvas);
|
||||
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
|
||||
if !area.is_empty() {
|
||||
let mut v = photocraft_vector::vector_mask_values(vm, area);
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let mut v = match vm {
|
||||
Some(vm) => photocraft_vector::vector_mask_values(vm, area),
|
||||
None => vec![1.0; w * h],
|
||||
};
|
||||
gaussian(&mut v, w, h, sv);
|
||||
if let Some(m) = pixel {
|
||||
let mut pm = Vec::new();
|
||||
m.values_into(area, &mut pm);
|
||||
gaussian(&mut pm, w, h, sp);
|
||||
for (a, b) in v.iter_mut().zip(pm) {
|
||||
*a *= b;
|
||||
}
|
||||
|
||||
@@ -131,17 +131,17 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
|
||||
}
|
||||
|
||||
/// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by
|
||||
/// default): type layers mix their colour with the backdrop in a gamma-1.45 space, so
|
||||
/// anti-aliased glyph edges come out lighter over dark backdrops than a linear mix (fitted on
|
||||
/// psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay over grey is 57/255 in red,
|
||||
/// a linear mix gives 40).
|
||||
/// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45
|
||||
/// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a
|
||||
/// mix of the encoded values (psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay
|
||||
/// over grey is 57/255 in red, an encoded mix gives 40).
|
||||
pub const TEXT_GAMMA: f32 = 1.45;
|
||||
|
||||
static TEXT_GAMMA_SETTING: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x3fb9_999a); // 1.45f32
|
||||
|
||||
/// Sets Color Settings › "Blend Text Colors Using Gamma" (1 = off). It is an application
|
||||
/// setting, not stored in documents: files saved with it off (ag-psd float-color) mix type
|
||||
/// linearly. Values are clamped to Photoshop's 1.00–2.20.
|
||||
/// setting, not stored in documents (1 here means switched off: a plain mix of the encoded
|
||||
/// values). Values are clamped to Photoshop's 1.00–2.20.
|
||||
///
|
||||
/// The value is process-wide: a test that changes it races every concurrently running test
|
||||
/// that composites a type layer, so such tests belong in their own test binary (see
|
||||
@@ -156,7 +156,24 @@ pub fn text_gamma() -> f32 {
|
||||
f32::from_bits(TEXT_GAMMA_SETTING.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// [`composite`] with the coverage mix done in a `gamma` space (`gamma == 1` is [`composite`]).
|
||||
/// Into the text blending space: linear light raised to `1 / gamma`. Gamma 2.2 is close to
|
||||
/// mixing the encoded values, lower settings approach linear light. Fitted on psd-tools
|
||||
/// layer_effects.psd (a blue colour overlay's glyph edges over grey, every coverage level within
|
||||
/// 0.6/255; a plain `v^1.45` power mix is 6/255 too light at 94 % coverage) and ag-psd
|
||||
/// float-color.
|
||||
#[inline]
|
||||
pub fn text_encode(v: f32, gamma: f32) -> f32 {
|
||||
photocraft_color::convert::srgb_to_linear(v.max(0.0)).powf(1.0 / gamma)
|
||||
}
|
||||
|
||||
/// Inverse of [`text_encode`].
|
||||
#[inline]
|
||||
pub fn text_decode(v: f32, gamma: f32) -> f32 {
|
||||
photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma))
|
||||
}
|
||||
|
||||
/// [`composite`] with the coverage mix done in the text blending space of `gamma`
|
||||
/// ([`text_encode`]; `gamma == 1` is [`composite`], the setting switched off).
|
||||
pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] {
|
||||
if gamma == 1.0 {
|
||||
return composite(mode, backdrop, source, opacity);
|
||||
@@ -174,10 +191,11 @@ pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], op
|
||||
let cs = [source[0], source[1], source[2]];
|
||||
let b = blend_rgb(mode, cb, cs);
|
||||
let (wb, ws, wbs) = ((1.0 - as_) * ab / ao, (1.0 - ab) * as_ / ao, as_ * ab / ao);
|
||||
let g = |v: f32| v.max(0.0).powf(gamma);
|
||||
let g = |v: f32| text_encode(v, gamma);
|
||||
let ginv = |v: f32| text_decode(v, gamma);
|
||||
let mut out = [0.0f32; 4];
|
||||
for i in 0..3 {
|
||||
out[i] = (wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i])).powf(1.0 / gamma);
|
||||
out[i] = ginv(wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i]));
|
||||
}
|
||||
out[3] = ao;
|
||||
out
|
||||
@@ -189,7 +207,10 @@ mod tests {
|
||||
fn text_gamma_lightens_dark_edges() {
|
||||
// 69 % blue (0, 51, 153) over grey 129: Photoshop shows (57, 80, 146).
|
||||
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.686], 1.0, TEXT_GAMMA);
|
||||
assert!((r[0] * 255.0 - 57.0).abs() < 2.0 && (r[1] * 255.0 - 80.0).abs() < 2.0 && (r[2] * 255.0 - 146.0).abs() < 2.0, "{r:?}");
|
||||
assert!((r[0] * 255.0 - 57.0).abs() < 1.0 && (r[1] * 255.0 - 80.0).abs() < 1.0 && (r[2] * 255.0 - 146.0).abs() < 1.0, "{r:?}");
|
||||
// Near-opaque edges stay dark: 94 % coverage shows (13, 57, 152) (a v^1.45 mix gives 19 red).
|
||||
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.937], 1.0, TEXT_GAMMA);
|
||||
assert!((r[0] * 255.0 - 13.0).abs() < 1.0 && (r[1] * 255.0 - 57.0).abs() < 1.0 && (r[2] * 255.0 - 152.0).abs() < 1.0, "{r:?}");
|
||||
assert_eq!(
|
||||
composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0),
|
||||
composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8)
|
||||
|
||||
@@ -153,6 +153,41 @@ fn isolated_group_differs_from_pass_through() {
|
||||
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
|
||||
}
|
||||
|
||||
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
|
||||
/// so an adjustment inside it no longer changes the layers beneath.
|
||||
#[test]
|
||||
fn pass_through_group_below_full_fill_is_isolated() {
|
||||
let make = |fill: f32| {
|
||||
let mut d = doc_white(2, 2);
|
||||
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
|
||||
g.blend = BlendMode::PassThrough;
|
||||
g.fill_opacity = fill;
|
||||
d.layers.push(g);
|
||||
px(&d, 0, 0)
|
||||
};
|
||||
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
|
||||
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
|
||||
/// combined mask share `masks::combined_mask`).
|
||||
#[test]
|
||||
fn mask_feather_blurs_the_edge() {
|
||||
let mut d = doc_white(64, 8);
|
||||
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::hide_all();
|
||||
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
|
||||
l.mask = Some(m);
|
||||
d.layers.push(l.clone());
|
||||
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
|
||||
if let Some(m) = &mut d.layers[1].mask {
|
||||
m.feather = 8.0;
|
||||
}
|
||||
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
|
||||
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
|
||||
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_opacity_applies_once() {
|
||||
let mut d = doc_white(2, 2);
|
||||
@@ -748,6 +783,53 @@ fn inside_stroke_respects_partial_edge_coverage() {
|
||||
assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emboss_shades_the_composited_layer() {
|
||||
// Emboss styles paint over the layer as composited (for type: mixed at the text gamma), so a
|
||||
// Normal white highlight `k` on an edge pixel keeps (1 − R) = (1 − k)(1 − C) whatever C is:
|
||||
// the ratio of type to raster results equals that of their plain composites.
|
||||
let emboss = Effect::BevelEmboss(photocraft_doc::Bevel {
|
||||
enabled: true,
|
||||
style: photocraft_doc::BevelStyle::Emboss,
|
||||
technique: photocraft_doc::BevelTechnique::Smooth,
|
||||
depth: 1.0,
|
||||
up: true,
|
||||
size: 6.0,
|
||||
soften: 0.0,
|
||||
angle: 90.0,
|
||||
altitude: 30.0,
|
||||
use_global_light: false,
|
||||
gloss_contour: photocraft_doc::Contour::Linear,
|
||||
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
|
||||
highlight_color: Color::WHITE,
|
||||
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
|
||||
shadow_color: Color::BLACK,
|
||||
contour: None,
|
||||
texture: None,
|
||||
});
|
||||
let make = |text: bool, fx: bool| {
|
||||
let mut d = doc_white(40, 40);
|
||||
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [0.2, 0.1, 0.6, 1.0]);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 11), &[0.2, 0.1, 0.6, 0.5]);
|
||||
if text {
|
||||
let t = photocraft_doc::TextLayer { cache: l.surface().cloned(), ..Default::default() };
|
||||
l = Layer::new("t", LayerContent::Text(t));
|
||||
}
|
||||
if fx {
|
||||
l.effects.items = vec![emboss.clone()];
|
||||
}
|
||||
d.layers.push(l);
|
||||
px(&d, 20, 10)
|
||||
};
|
||||
let (ct, cr, rt, rr) = (make(true, false), make(false, false), make(true, true), make(false, true));
|
||||
assert!(rr[1] > cr[1] + 0.05, "the top edge is lit: {rr:?} vs {cr:?}");
|
||||
assert!((ct[1] - cr[1]).abs() > 0.02, "text gamma changes the edge: {ct:?} {cr:?}");
|
||||
for c in 0..3 {
|
||||
let want = (1.0 - ct[c]) / (1.0 - cr[c]);
|
||||
assert!(((1.0 - rt[c]) / (1.0 - rr[c]) - want).abs() < 2e-3, "channel {c}: {rt:?} {rr:?} {ct:?} {cr:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interior_effects_keep_the_layer_alpha() {
|
||||
// A colour overlay replaces a half-transparent pixel's colour without adding coverage.
|
||||
@@ -1042,6 +1124,70 @@ fn levels_matches_photoshop() {
|
||||
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// Photoshop (psd-tools levels_grayscale, 8-bit): input range 44..214, gamma 1.78. The stretch
|
||||
// is rounded to whole levels before the gamma (45 → 1.5 → 2, 47 → 4.5 → 5), so the shadows
|
||||
// step unevenly: 45, 46, 47, 48, 52, 69 → 7, 10, 17, 20, 37, 88.
|
||||
#[test]
|
||||
fn levels_work_on_whole_levels() {
|
||||
let ch = LevelsChannel { in_black: 44.0 / 255.0, in_white: 214.0 / 255.0, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
|
||||
let adj = Adjustment::Levels {
|
||||
master: LevelsChannel::default(),
|
||||
per_channel: [ch.clone(), ch.clone(), ch.clone()],
|
||||
space: Default::default(),
|
||||
black: LevelsChannel::default(),
|
||||
};
|
||||
let luts = adjust::tone_luts_q(&adj, Some(255.0));
|
||||
let at = |v: u8| {
|
||||
let x = f32::from(v) / 255.0 * 4095.0;
|
||||
let (i, f) = (x.floor() as usize, x.fract());
|
||||
let y = luts[0][i] * (1.0 - f) + luts[0][(i + 1).min(4095)] * f;
|
||||
(y * 255.0).round()
|
||||
};
|
||||
for (v, ps) in [(44u8, 0.0f32), (45, 7.0), (46, 10.0), (47, 17.0), (48, 20.0), (52, 37.0), (69, 88.0), (100, 137.0), (214, 255.0)] {
|
||||
assert!((at(v) - ps).abs() <= 1.0, "levels {v}: got {} want {ps}", at(v));
|
||||
}
|
||||
// 16-bit and float stay smooth (no 8-bit staircase).
|
||||
for q in [Some(32768.0), None] {
|
||||
let a = adjust::levels_q(&ch, 0.2, q);
|
||||
let b = adjust::levels_q(&ch, 0.2 + 1.0 / 2048.0, q);
|
||||
assert!(b > a && b - a < 2.0 / 255.0, "{q:?}: {a} {b}");
|
||||
}
|
||||
// Hostile quantum values fall back to the continuous curve.
|
||||
for q in [Some(0.0), Some(f32::NAN), Some(-3.0)] {
|
||||
assert_eq!(adjust::levels_q(&ch, 0.4, q), adjust::levels(&ch, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
// Exposure linearises RGB documents through a 2.2 power, not the sRGB curve: Photoshop lifts 76
|
||||
// to 134 with an offset of 0.1738 (psd-tools adjustment_nested_composition_4).
|
||||
#[test]
|
||||
fn exposure_offset_uses_gamma_2_2_in_rgb() {
|
||||
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [76.0 / 255.0, 76.0 / 255.0, 76.0 / 255.0, 1.0]);
|
||||
adjust::apply_with(&Adjustment::Exposure { exposure: 0.0, offset: 0.1738, gamma: 1.0 }, &mut b, adjust::Transfer::Srgb);
|
||||
assert_eq!((b.px[0][0] * 255.0).round(), 134.0);
|
||||
assert_eq!(adjust::Transfer::Gamma(1.732).for_exposure(), adjust::Transfer::Gamma(1.732));
|
||||
}
|
||||
|
||||
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
|
||||
// the corner: t = |x − y| / 4) at every depth.
|
||||
#[test]
|
||||
fn small_gradient_fill_matches_photoshop_at_all_depths() {
|
||||
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
|
||||
let fill = Fill::Gradient { stops: stops.clone(), angle: 30.0, scale: 1.0, style: photocraft_doc::GradientStyle::Reflected, reverse: false };
|
||||
d.layers.push(Layer::new("g", LayerContent::Fill(fill)));
|
||||
let out = flatten(&d);
|
||||
for y in 0..4usize {
|
||||
for x in 0..4usize {
|
||||
let want = (x as f32 - y as f32).abs() / 4.0;
|
||||
let got = out.px[y * 4 + x][0];
|
||||
assert!((got - want).abs() < 1.0 / 255.0, "{depth:?} ({x},{y}): {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tall document with soft content, a translucent region and an adjustment.
|
||||
fn tall_doc(w: u32, h: u32) -> Document {
|
||||
let mut d = doc_white(w, h);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! "Scripts" are PhotoCraft action scripts: JSON (`[[id, params], …]`, `{"steps": …}` or a
|
||||
//! droplet) or a plain-text list with one `command.id {json params}` per line (`#` comments).
|
||||
//! Droplets are JSON files (`.pcdroplet`) holding an action plus batch options; `photocraft-cli
|
||||
//! droplet <file> <inputs…>` runs one, and on macOS/Linux a `.command` shim makes it
|
||||
//! droplet <file> <inputs…>` runs one, and on Unix a `.command` shim makes it
|
||||
//! double-clickable / drop-target-able from the shell.
|
||||
//!
|
||||
//! Script events (Start Application, New / Open / Save / Close Document, Print, Export) are
|
||||
@@ -291,7 +291,7 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let droplet = json!({"photocraftDroplet": 1, "name": name, "action": {"name": name, "steps": steps_json}, "options": options});
|
||||
let text = serde_json::to_string_pretty(&droplet).unwrap_or_default();
|
||||
write_file(&path, text.as_bytes())?;
|
||||
let shim = p.get("shim").and_then(Value::as_bool).unwrap_or(cfg!(any(target_os = "macos", target_os = "linux")));
|
||||
let shim = p.get("shim").and_then(Value::as_bool).unwrap_or(cfg!(unix));
|
||||
let shim_path = if shim { Some(write_shim(&path)?) } else { None };
|
||||
let _ = s;
|
||||
Ok(json!({"path": path, "shim": shim_path, "steps": steps.len()}))
|
||||
@@ -526,7 +526,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"file.automate.createDroplet",
|
||||
"Create Droplet…",
|
||||
&["File", "Automate"],
|
||||
r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on macOS/Linux (writes <name>.command calling `photocraft-cli droplet`)} → {path, shim}"##,
|
||||
r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes <name>.command calling `photocraft-cli droplet`)} → {path, shim}"##,
|
||||
native,
|
||||
create_droplet
|
||||
),
|
||||
|
||||
@@ -337,8 +337,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "paint.mixerBrush";
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let brush = resolve_brush(s, p, cmd)?;
|
||||
let m = MixerSettings { wet: pct(p, "wet", 50.0), load: pct(p, "load", 50.0), mix: pct(p, "mix", 50.0), flow: pct(p, "flow", 100.0) };
|
||||
let sample_all = flag(p, "sampleAllLayers", false);
|
||||
// The brush's Mixer Brush options, overridden by the scalar params.
|
||||
let bm = &brush.mixer;
|
||||
let m = MixerSettings {
|
||||
wet: pct(p, "wet", bm.wet * 100.0),
|
||||
load: pct(p, "load", bm.load * 100.0),
|
||||
mix: pct(p, "mix", bm.mix * 100.0),
|
||||
flow: pct(p, "flow", bm.flow * 100.0),
|
||||
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
|
||||
};
|
||||
let sample_all = m.sample_all_layers;
|
||||
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
|
||||
let mut state = s.tools.mixer.clone();
|
||||
if load_after || state.reservoir.is_none() {
|
||||
@@ -543,10 +551,89 @@ fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
b = merge_brush(&b, &patch, cmd)?;
|
||||
s.tools.brush = b;
|
||||
let before = std::mem::replace(&mut s.tools.brush, b);
|
||||
// A coalesced gesture (one slider drag) journals as one call: remember the brush it started from.
|
||||
let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none());
|
||||
let continuing = |s: &Session, k: &str| {
|
||||
s.tools.brush_gesture.as_ref().is_some_and(|(g, _)| g == k)
|
||||
&& s.journal.last().is_some_and(|(id, lp)| id == cmd && lp.get("coalesce").and_then(Value::as_str) == Some(k))
|
||||
};
|
||||
match key {
|
||||
Some(k) if continuing(s, k) => {}
|
||||
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
|
||||
None => s.tools.brush_gesture = None,
|
||||
}
|
||||
Ok(brush_json(&s.tools.brush))
|
||||
}
|
||||
|
||||
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
|
||||
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
|
||||
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
|
||||
let mut c = b.clone();
|
||||
if skip.0 {
|
||||
c.tip = TipShape::Round;
|
||||
}
|
||||
if skip.1 {
|
||||
c.dual_brush.tip = TipShape::Round;
|
||||
}
|
||||
if skip.2 {
|
||||
c.texture.pattern = photocraft_paint::Pattern::default();
|
||||
}
|
||||
serde_json::to_value(&c).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Fields of `new` that differ from `old`, nested objects diffed key by key: the minimal
|
||||
/// `tools.setBrush` `brush` patch that turns `old` into `new` (`{}` when nothing changed).
|
||||
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
|
||||
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
|
||||
fn diff(a: &Value, b: &Value) -> Option<Value> {
|
||||
match (a, b) {
|
||||
(Value::Object(ao), Value::Object(bo)) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, bv) in bo {
|
||||
match ao.get(k) {
|
||||
Some(av) => {
|
||||
if let Some(d) = diff(av, bv) {
|
||||
out.insert(k.clone(), d);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k.clone(), bv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
// Enums with payloads (tips, patterns) are replaced whole.
|
||||
_ => (a != b).then(|| b.clone()),
|
||||
}
|
||||
}
|
||||
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
/// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same
|
||||
/// `coalesce` key (one drag in the options bar or the Brush Settings panel) are one journal
|
||||
/// entry, the patch from the brush before the gesture to the brush now. Returns true when the
|
||||
/// call was folded into the previous entry.
|
||||
pub fn coalesce_journal(s: &mut Session, id: &str, params: &Value) -> bool {
|
||||
if id != "tools.setBrush" {
|
||||
return false;
|
||||
}
|
||||
let Some(key) = params.get("coalesce").and_then(Value::as_str) else { return false };
|
||||
let Some((gk, start)) = s.tools.brush_gesture.as_ref() else { return false };
|
||||
if gk != key {
|
||||
return false;
|
||||
}
|
||||
let patch = brush_patch(start, &s.tools.brush);
|
||||
match s.journal.last_mut() {
|
||||
Some((last, lp)) if last == id && lp.get("coalesce").and_then(Value::as_str) == Some(key) => {
|
||||
*lp = json!({ "brush": patch, "coalesce": key });
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
($id:literal, $label:literal, $params:literal, $en:expr, $run:expr, $journal:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal }
|
||||
@@ -567,7 +654,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"paint.mixerBrush",
|
||||
"Mixer Brush",
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
|
||||
has_paintable,
|
||||
mixer_brush,
|
||||
true
|
||||
|
||||
@@ -311,3 +311,138 @@ fn live_stroke_matches_the_committed_stroke() {
|
||||
assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err());
|
||||
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() {
|
||||
// #189: the preview while dragging must be the committed stroke (within 1/255), with a soft
|
||||
// brush on an opaque Background with content, at any zoom (the smoothing string scales with
|
||||
// it), with smoothing on, pressure, 8 and 16 bits, and points fed one pointer move at a time.
|
||||
let pts: Vec<[f64; 3]> = (0..40)
|
||||
.map(|i| {
|
||||
let t = f64::from(i) / 39.0;
|
||||
[40.0 + 120.0 * t, 60.0 + 30.0 * (t * 7.0).sin(), 0.3 + 0.7 * t]
|
||||
})
|
||||
.collect();
|
||||
for depth in [8, 16] {
|
||||
for zoom in [0.25, 1.0, 4.0, 16.0] {
|
||||
for (amount, pulled) in [(0.1, false), (0.5, false), (0.3, true)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 200, "height": 120, "depth": depth, "background": "white"})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#1a2550"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[20, 20], [180, 100]], "size": 8, "hardness": 1.0, "color": "#ffffff"})).unwrap();
|
||||
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
let brush = json!({"size": 40, "hardness": 0.0, "pressureSize": true, "smoothing": {"amount": amount, "pulledString": pulled}});
|
||||
let p = json!({"points": [pts[0]], "brush": brush, "zoom": zoom, "target": "pixels"});
|
||||
let mut live = LiveStroke::begin(&s, &p).unwrap();
|
||||
for q in &pts[1..] {
|
||||
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
|
||||
}
|
||||
let mut commit = p.clone();
|
||||
commit["points"] = json!(pts);
|
||||
commit["seed"] = json!(live.seed);
|
||||
s.execute("paint.stroke", commit).unwrap();
|
||||
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
let done = surface(&s);
|
||||
let worst = (0..120).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| {
|
||||
let (a, b) = (shown.rgba(x, y), done.rgba(x, y));
|
||||
(0..4).map(|i| (a[i] - b[i]).abs()).fold(0.0f32, f32::max)
|
||||
});
|
||||
let worst = worst.fold(0.0f32, f32::max);
|
||||
let label = format!("depth {depth}, zoom {zoom}, smoothing {amount} (pulled string {pulled})");
|
||||
assert!(worst <= 1.0 / 255.0, "{label}: preview differs from the commit by {worst}");
|
||||
// Not two blank canvases: the soft stroke is there, edge included.
|
||||
assert!(done.rgba(40, 60)[0] < 0.1 && (0.05..0.9).contains(&done.rgba(40, 60 - 14)[2]), "{label}: soft stroke");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_brush_controls_round_trip_through_set_brush() {
|
||||
let mut s = session(40, 40);
|
||||
let patch = json!({
|
||||
"spacingEnabled": false,
|
||||
"shapeDynamics": {"tiltScale": 1.5, "brushProjection": true},
|
||||
"transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}},
|
||||
"locks": {"shapeDynamics": true, "texture": true, "noise": true},
|
||||
"mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true},
|
||||
});
|
||||
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
|
||||
let b = &s.tools.brush;
|
||||
assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5);
|
||||
assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure);
|
||||
assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2));
|
||||
assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering);
|
||||
assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true));
|
||||
// brush.get reports them, and a fresh session gets the same brush from the reported JSON.
|
||||
let got = s.execute("brush.get", json!({})).unwrap();
|
||||
for (path, want) in [
|
||||
("/spacingEnabled", json!(false)),
|
||||
("/shapeDynamics/tiltScale", json!(1.5)),
|
||||
("/shapeDynamics/brushProjection", json!(true)),
|
||||
("/transfer/wetness/control", json!("penPressure")),
|
||||
("/locks/texture", json!(true)),
|
||||
("/mixer/sampleAllLayers", json!(true)),
|
||||
("/mixer/load", json!(0.25)),
|
||||
] {
|
||||
assert_eq!(got.pointer(path), Some(&want), "{path}");
|
||||
}
|
||||
let mut t = session(40, 40);
|
||||
t.execute("tools.setBrush", json!({ "brush": got })).unwrap();
|
||||
assert_eq!(t.tools.brush, s.tools.brush);
|
||||
// Locked sections survive picking a preset through the command.
|
||||
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
|
||||
assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture);
|
||||
// Wrong types are errors and leave the brush alone.
|
||||
let before = s.tools.brush.clone();
|
||||
for bad in
|
||||
[json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})]
|
||||
{
|
||||
assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err());
|
||||
}
|
||||
assert_eq!(s.tools.brush, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixer_brush_uses_the_brush_mixer_settings() {
|
||||
let mut s = session(200, 30);
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
|
||||
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
|
||||
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
|
||||
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
|
||||
// A param still overrides the brush.
|
||||
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap();
|
||||
assert!(rgba(&s, 190, 5)[3] > 0.95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_set_brush_calls_journal_once_per_gesture() {
|
||||
let mut s = session(20, 20);
|
||||
let n = s.journal.len();
|
||||
for k in 1..=10 {
|
||||
s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap();
|
||||
s.execute("brush.get", json!({})).unwrap();
|
||||
}
|
||||
s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 1, "one gesture, one entry");
|
||||
let (id, p) = s.journal.last().unwrap();
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}}));
|
||||
// A new gesture is a new entry; so is an uncoalesced call.
|
||||
s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 3);
|
||||
// Another command in between ends the gesture.
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap();
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap();
|
||||
assert_eq!(s.journal.len(), n + 6);
|
||||
// Replay gives the same brush.
|
||||
let mut t = session(20, 20);
|
||||
for (id, p) in s.journal.iter().skip(1) {
|
||||
t.execute(id, p.clone()).unwrap();
|
||||
}
|
||||
assert_eq!(t.tools.brush, s.tools.brush);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Organising brush presets (the Brushes panel's drag and drop and context menus): rename, move a
|
||||
//! preset within or between groups, move, rename and delete whole groups.
|
||||
//!
|
||||
//! The library order is the panel order: groups appear in order of their first preset. Every
|
||||
//! change syncs to the preset store ([`crate::preset_store`]), whose index keeps the group order
|
||||
//! and the order of every preset, built-ins included. A built-in that is renamed or moved to
|
||||
//! another group becomes the user's preset (built-ins are regenerated with their own group).
|
||||
|
||||
use photocraft_paint::BrushPreset;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Longest preset or group name accepted.
|
||||
pub const MAX_NAME: usize = 255;
|
||||
|
||||
fn text(p: &Value, k: &str, cmd: &str) -> Result<String> {
|
||||
let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
|
||||
if v.chars().count() > MAX_NAME {
|
||||
return Err(bad(cmd, format!("`{k}` is longer than {MAX_NAME} characters")));
|
||||
}
|
||||
Ok(v.to_string())
|
||||
}
|
||||
|
||||
/// A group name param: may be empty (the ungrouped presets), never absurdly long.
|
||||
fn group_param(p: &Value, k: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(g)) if g.chars().count() <= MAX_NAME => Ok(Some(g.trim().to_string())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a group name (string, at most {MAX_NAME} characters)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn index_of(presets: &[BrushPreset], name: &str) -> Option<usize> {
|
||||
presets.iter().position(|x| x.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
let name = text(p, k, cmd)?;
|
||||
index_of(&s.tools.presets, &name).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`")))
|
||||
}
|
||||
|
||||
/// Groups in panel order (first appearance).
|
||||
pub fn group_order(presets: &[BrushPreset]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for p in presets {
|
||||
if !out.contains(&p.group) {
|
||||
out.push(p.group.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn position_in_group(presets: &[BrushPreset], i: usize) -> usize {
|
||||
presets.get(i).map_or(0, |pr| presets[..i].iter().filter(|x| x.group == pr.group).count())
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.rename";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
|
||||
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
|
||||
}
|
||||
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
|
||||
if pr.name != new {
|
||||
pr.name = new.clone();
|
||||
pr.builtin = false;
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "name": new }))
|
||||
}
|
||||
|
||||
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.move";
|
||||
let i = find(s, p, "name", cmd)?;
|
||||
let before = match p.get("before") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let b = find(s, p, "before", cmd)?;
|
||||
if b == i {
|
||||
return Err(bad(cmd, "`before` names the preset being moved"));
|
||||
}
|
||||
Some(b)
|
||||
}
|
||||
};
|
||||
let index = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
|
||||
};
|
||||
let lib = &mut s.tools.presets;
|
||||
let target = match (group_param(p, "group", cmd)?, before) {
|
||||
(Some(g), _) => g,
|
||||
(None, Some(b)) => lib[b].group.clone(),
|
||||
(None, None) => lib[i].group.clone(),
|
||||
};
|
||||
if let Some(b) = before
|
||||
&& lib[b].group != target
|
||||
{
|
||||
return Err(bad(cmd, format!("`before` is not in group `{target}`")));
|
||||
}
|
||||
let before_name = before.map(|b| lib[b].name.clone());
|
||||
let mut pr = lib.remove(i);
|
||||
if pr.group != target {
|
||||
pr.group = target.clone();
|
||||
// A built-in moved elsewhere is the user's now (built-ins regenerate in their own group).
|
||||
pr.builtin = false;
|
||||
}
|
||||
let members: Vec<usize> = lib.iter().enumerate().filter(|(_, x)| x.group == target).map(|(k, _)| k).collect();
|
||||
let pos = match (before_name, index) {
|
||||
(Some(n), _) => index_of(lib, &n).unwrap_or(lib.len()),
|
||||
(None, Some(n)) => match members.get(n.min(usize::MAX as u64) as usize) {
|
||||
Some(&k) => k,
|
||||
None => members.last().map_or(lib.len(), |k| k + 1),
|
||||
},
|
||||
(None, None) => members.last().map_or(lib.len(), |k| k + 1),
|
||||
};
|
||||
let pos = pos.min(lib.len());
|
||||
let name = pr.name.clone();
|
||||
lib.insert(pos, pr);
|
||||
let at = position_in_group(lib, pos);
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "name": name, "group": target, "index": at }))
|
||||
}
|
||||
|
||||
fn group_block(s: &Session, g: &str, cmd: &str) -> Result<()> {
|
||||
if s.tools.presets.iter().any(|x| x.group == g) { Ok(()) } else { Err(bad(cmd, format!("no brush preset group named `{g}`"))) }
|
||||
}
|
||||
|
||||
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.moveGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let before = group_param(p, "before", cmd)?;
|
||||
if let Some(b) = &before {
|
||||
group_block(s, b, cmd)?;
|
||||
if *b == g {
|
||||
return Err(bad(cmd, "`before` names the group being moved"));
|
||||
}
|
||||
}
|
||||
let index = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
|
||||
};
|
||||
let lib = std::mem::take(&mut s.tools.presets);
|
||||
let (block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| x.group == g);
|
||||
let order = group_order(&rest);
|
||||
let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| x.group == name).unwrap_or(rest.len());
|
||||
let pos = match (&before, index) {
|
||||
(Some(b), _) => first(b, &rest),
|
||||
(None, Some(n)) => order.get(n.min(usize::MAX as u64) as usize).map_or(rest.len(), |name| first(name, &rest)),
|
||||
(None, None) => rest.len(),
|
||||
};
|
||||
let tail = rest.split_off(pos.min(rest.len()));
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
s.tools.presets = rest;
|
||||
s.brush_presets_changed();
|
||||
let at = group_order(&s.tools.presets).iter().position(|x| *x == g).unwrap_or(0);
|
||||
Ok(json!({ "group": g, "index": at }))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.renameGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let new = text(p, "newName", cmd)?;
|
||||
if new != g && s.tools.presets.iter().any(|x| x.group == new) {
|
||||
return Err(bad(cmd, format!("a group named `{new}` already exists")));
|
||||
}
|
||||
if new != g {
|
||||
for x in s.tools.presets.iter_mut().filter(|x| x.group == g) {
|
||||
x.group = new.clone();
|
||||
x.builtin = false;
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
}
|
||||
Ok(json!({ "group": new }))
|
||||
}
|
||||
|
||||
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "brush.presets.deleteGroup";
|
||||
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
|
||||
group_block(s, &g, cmd)?;
|
||||
let before = s.tools.presets.len();
|
||||
s.tools.presets.retain(|x| x.group != g);
|
||||
s.brush_presets_changed();
|
||||
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
|
||||
}
|
||||
|
||||
macro_rules! spec {
|
||||
($id:literal, $label:literal, $params:literal, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: true }
|
||||
};
|
||||
}
|
||||
|
||||
/// Brush preset organisation specs.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("brush.presets.rename", "Rename Brush", r##"{"name":string,"newName":string} → {name}"##, rename),
|
||||
spec!(
|
||||
"brush.presets.move",
|
||||
"Move Brush",
|
||||
r##"{"name":string,"group":string?=its group ("" = ungrouped),"before":name? (preset to land before) | "index":n? (position in the group)=end} → {name, group, index}"##,
|
||||
move_preset
|
||||
),
|
||||
spec!(
|
||||
"brush.presets.moveGroup",
|
||||
"Move Brush Group",
|
||||
r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, index}"##,
|
||||
move_group
|
||||
),
|
||||
spec!("brush.presets.renameGroup", "Rename Brush Group", r##"{"group":string,"newName":string} → {group}"##, rename_group),
|
||||
spec!("brush.presets.deleteGroup", "Delete Brush Group", r##"{"group":string} → {deleted, count}"##, delete_group),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn names(s: &Session, g: &str) -> Vec<String> {
|
||||
s.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn with_groups() -> Session {
|
||||
let mut s = Session::new();
|
||||
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] {
|
||||
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_within_and_between_groups() {
|
||||
let mut s = with_groups();
|
||||
let r = s.execute("brush.presets.move", json!({"name": "A3", "before": "A1"})).unwrap();
|
||||
assert_eq!(r, json!({"name": "A3", "group": "Alpha", "index": 0}));
|
||||
assert_eq!(names(&s, "Alpha"), ["A3", "A1", "A2"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A3", "index": 99})).unwrap();
|
||||
assert_eq!(names(&s, "Alpha"), ["A1", "A2", "A3"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "index": 1})).unwrap();
|
||||
assert_eq!(names(&s, "Alpha"), ["A2", "A1", "A3"]);
|
||||
// Into another group, before a member (the group comes from `before`).
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "before": "B2"})).unwrap();
|
||||
assert_eq!((names(&s, "Alpha"), names(&s, "Beta")), (vec!["A2".to_string(), "A3".into()], vec!["B1".to_string(), "A1".into(), "B2".into()]));
|
||||
// Into a group by name (appended), and into a new group.
|
||||
s.execute("brush.presets.move", json!({"name": "A2", "group": "Beta"})).unwrap();
|
||||
assert_eq!(names(&s, "Beta"), ["B1", "A1", "B2", "A2"]);
|
||||
s.execute("brush.presets.move", json!({"name": "A3", "group": "Gamma"})).unwrap();
|
||||
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Gamma"));
|
||||
assert!(names(&s, "Alpha").is_empty());
|
||||
// A built-in moved to another group becomes the user's.
|
||||
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
|
||||
s.execute("brush.presets.move", json!({"name": b, "group": "Beta", "index": 0})).unwrap();
|
||||
let moved = photocraft_paint::presets::find(&s.tools.presets, &b).unwrap();
|
||||
assert!(!moved.builtin && moved.group == "Beta");
|
||||
assert_eq!(names(&s, "Beta")[0], b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_move_rename_and_delete() {
|
||||
let mut s = with_groups();
|
||||
let order = group_order(&s.tools.presets);
|
||||
let n = order.len();
|
||||
assert_eq!(&order[n - 2..], ["Alpha", "Beta"]);
|
||||
let r = s.execute("brush.presets.moveGroup", json!({"group": "Beta", "index": 0})).unwrap();
|
||||
assert_eq!(r["index"], 0);
|
||||
assert_eq!(group_order(&s.tools.presets)[0], "Beta");
|
||||
assert_eq!(names(&s, "Beta"), ["B1", "B2"], "a group moves as a block, in order");
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Beta"})).unwrap();
|
||||
assert_eq!(&group_order(&s.tools.presets)[..2], ["Alpha", "Beta"]);
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha"})).unwrap();
|
||||
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Alpha"));
|
||||
s.execute("brush.presets.renameGroup", json!({"group": "Alpha", "newName": "First"})).unwrap();
|
||||
assert_eq!(names(&s, "First"), ["A1", "A2", "A3"]);
|
||||
assert!(s.execute("brush.presets.renameGroup", json!({"group": "First", "newName": "Beta"})).is_err());
|
||||
let r = s.execute("brush.presets.deleteGroup", json!({"group": "First"})).unwrap();
|
||||
assert_eq!(r["deleted"], 3);
|
||||
assert!(names(&s, "First").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_checks_names() {
|
||||
let mut s = with_groups();
|
||||
s.execute("brush.presets.rename", json!({"name": "a1", "newName": "Alpha One"})).unwrap();
|
||||
assert!(photocraft_paint::presets::find(&s.tools.presets, "Alpha One").is_some());
|
||||
assert!(s.execute("brush.presets.rename", json!({"name": "A2", "newName": "alpha one"})).is_err());
|
||||
// Case-only renames of itself are fine.
|
||||
s.execute("brush.presets.rename", json!({"name": "Alpha One", "newName": "ALPHA ONE"})).unwrap();
|
||||
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
|
||||
s.execute("brush.presets.rename", json!({"name": b, "newName": "My Round"})).unwrap();
|
||||
assert!(!photocraft_paint::presets::find(&s.tools.presets, "My Round").unwrap().builtin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_fail_without_changes() {
|
||||
let mut s = with_groups();
|
||||
let before = s.tools.presets.clone();
|
||||
let long = "x".repeat(MAX_NAME + 1);
|
||||
for (id, p) in [
|
||||
("brush.presets.move", json!({})),
|
||||
("brush.presets.move", json!({"name": "Nope"})),
|
||||
("brush.presets.move", json!({"name": "A1", "before": "A1"})),
|
||||
("brush.presets.move", json!({"name": "A1", "before": "Nope"})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": "Alpha", "before": "B1"})),
|
||||
("brush.presets.move", json!({"name": "A1", "index": -1})),
|
||||
("brush.presets.move", json!({"name": "A1", "index": "first"})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": 7})),
|
||||
("brush.presets.move", json!({"name": "A1", "group": long})),
|
||||
("brush.presets.rename", json!({"name": "A1"})),
|
||||
("brush.presets.rename", json!({"name": "A1", "newName": " "})),
|
||||
("brush.presets.rename", json!({"name": "A1", "newName": long})),
|
||||
("brush.presets.moveGroup", json!({"group": "Nope"})),
|
||||
("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Alpha"})),
|
||||
("brush.presets.moveGroup", json!({"group": "Alpha", "index": 1.5})),
|
||||
("brush.presets.renameGroup", json!({"group": "Alpha"})),
|
||||
("brush.presets.deleteGroup", json!({"group": null})),
|
||||
("brush.presets.deleteGroup", json!({"group": "Nope"})),
|
||||
] {
|
||||
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
|
||||
}
|
||||
assert_eq!(s.tools.presets, before);
|
||||
// Huge indices clamp to the end.
|
||||
s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" };
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
// Clipping and channel changes reach the layers around it: recomposite everything.
|
||||
let local = p.get("clipped").is_none() && p.get("channels").is_none();
|
||||
s.edit(label, |doc, _| {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if let Some(v) = p.get("name").and_then(Value::as_str) {
|
||||
@@ -510,6 +513,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if local {
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
@@ -940,6 +946,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::edit_cmds::specs());
|
||||
v.extend(crate::color_cmds::specs());
|
||||
v.extend(crate::brush_cmds::specs());
|
||||
v.extend(crate::brush_preset_cmds::specs());
|
||||
v.extend(crate::eraser_cmds::specs());
|
||||
v.extend(crate::preset_import_cmds::specs());
|
||||
v.extend(crate::retouch_cmds::specs());
|
||||
@@ -1070,7 +1077,7 @@ fn combine(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) ->
|
||||
/// Move a layer's pixels, linked mask and type by whole pixels (vectors move via
|
||||
/// `vector_cmds::translate_vectors`).
|
||||
pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
use photocraft_algo::resample::translate_surface;
|
||||
use crate::layer_multi_cmds::shift_surface as translate_surface;
|
||||
// Linked patterns in the layer's effects move with it.
|
||||
if let Some(r) = &mut l.effects.reference {
|
||||
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
|
||||
|
||||
@@ -246,17 +246,125 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("no active layer".into()));
|
||||
}
|
||||
s.edit("Move", |doc, _| {
|
||||
let ids = top_level(doc, &with_links(doc, &roots));
|
||||
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||||
let ids = s.edit("Move", |doc, _| {
|
||||
let ids = move_targets(doc, &roots);
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::NoLayer(roots[0]));
|
||||
}
|
||||
let moves: Vec<_> = ids.into_iter().map(|id| (id, dx, dy)).collect();
|
||||
move_layers(doc, &moves)
|
||||
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
|
||||
move_layers(doc, &moves)?;
|
||||
Ok(ids)
|
||||
})?;
|
||||
note_damage(s, &before, &ids);
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The layers a move of `roots` takes along: their linked layers, without layers whose group
|
||||
/// is also moving (the group carries them).
|
||||
pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
|
||||
top_level(doc, &with_links(doc, roots))
|
||||
}
|
||||
|
||||
/// `doc` with the layers `ids` ([`move_targets`]) moved by whole pixels, as `layer.translate`
|
||||
/// leaves them: the Move tool's live preview while it drags. Type, shape and smart-object pixels
|
||||
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
|
||||
/// copying the moving pixels only.
|
||||
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
|
||||
let mut out = doc.clone();
|
||||
for &id in ids {
|
||||
let l = out.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if l.locks.position || l.locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
|
||||
}
|
||||
shift_shown(doc, l, dx, dy);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `photocraft_algo::resample::translate_surface` with the content scan cached per tile (a Move
|
||||
/// drag shifts the same layers every frame; scanning them each time cost more than the copy).
|
||||
pub(crate) fn shift_surface(s: &photocraft_raster::Surface, dx: i32, dy: i32) -> photocraft_raster::Surface {
|
||||
let mut out = photocraft_raster::Surface::with_default(s.format(), &s.default_pixel());
|
||||
let r = photocraft_compose::bounds::content_bounds(s);
|
||||
if !r.is_empty() {
|
||||
out.write_interleaved(r.translate(dx, dy), &s.to_interleaved(r));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`crate::commands::translate_layer`] plus the vector side, without re-rendering anything.
|
||||
fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
|
||||
use self::shift_surface as translate_surface;
|
||||
let a = photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy));
|
||||
if let Some(r) = &mut l.effects.reference {
|
||||
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
|
||||
}
|
||||
if let Some(m) = l.mask.as_mut().filter(|m| m.linked) {
|
||||
m.surface = translate_surface(&m.surface, dx, dy);
|
||||
}
|
||||
if let Some(vm) = l.vector_mask.as_mut().filter(|v| v.linked) {
|
||||
vm.path = vm.path.transform(&a);
|
||||
}
|
||||
match &mut l.content {
|
||||
LayerContent::Raster(s) => *s = translate_surface(s, dx, dy),
|
||||
LayerContent::Text(t) => {
|
||||
t.transform = a.mul(&t.transform);
|
||||
if let Some(c) = &mut t.cache {
|
||||
*c = translate_surface(c, dx, dy);
|
||||
}
|
||||
}
|
||||
LayerContent::Shape(sh) => {
|
||||
// The rendered shape is cut at the canvas: one that reaches past it renders again.
|
||||
let canvas = doc.bounds();
|
||||
let inside = sh.cache.as_ref().is_some_and(|c| {
|
||||
let b = photocraft_compose::bounds::content_bounds(c);
|
||||
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
|
||||
});
|
||||
crate::vector_cmds::transform_shape(sh, &a);
|
||||
match &mut sh.cache {
|
||||
Some(c) if inside => *c = translate_surface(c, dx, dy),
|
||||
_ => crate::vector_cmds::refresh_shape(doc, sh),
|
||||
}
|
||||
}
|
||||
LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy),
|
||||
LayerContent::Group(g) => {
|
||||
if let Some(ab) = &mut g.artboard {
|
||||
ab.rect = ab.rect.translate(dx, dy);
|
||||
}
|
||||
g.children.iter_mut().for_each(|c| shift_shown(doc, c, dx, dy));
|
||||
}
|
||||
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The document pixels an edit of the layers `ids` (moved, shown, hidden or restyled; contents
|
||||
/// otherwise as they were) can have changed from `before` to `after`, or `None` for anywhere.
|
||||
pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Option<Rect> {
|
||||
let mut out = Rect::EMPTY;
|
||||
for d in [before, after] {
|
||||
let canvas = d.bounds();
|
||||
for id in ids {
|
||||
// A layer gone from one side (never for a move) changes who knows what.
|
||||
let b = photocraft_compose::change_bounds(d.layer(*id)?, canvas)?;
|
||||
if !b.is_empty() {
|
||||
out = if out.is_empty() { b } else { out.union(&b) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Report [`layers_damage`] as the active document's last change, so the canvas recomposites
|
||||
/// only that (grown by the effect reach around it) instead of everything.
|
||||
pub(crate) fn note_damage(s: &mut Session, before: &Document, ids: &[LayerId]) {
|
||||
let Some(st) = s.active_mut() else { return };
|
||||
if before.size != st.doc.size {
|
||||
return;
|
||||
}
|
||||
st.last_damage = layers_damage(before, &st.doc, ids);
|
||||
}
|
||||
|
||||
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
|
||||
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
|
||||
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
|
||||
@@ -734,6 +842,54 @@ mod tests {
|
||||
selected(s)
|
||||
}
|
||||
|
||||
fn damage(s: &Session) -> Option<Rect> {
|
||||
s.active().unwrap().last_damage
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_layer_props_report_only_their_area() {
|
||||
let mut s = session(8);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.translate", json!({"dx": 5, "dy": -3})).unwrap();
|
||||
assert_eq!(damage(&s), Some(Rect::new(10, 7, 25, 20)), "where the layer was and is");
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "visible": false})).unwrap();
|
||||
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "opacity": 0.5, "blend": "Multiply"})).unwrap();
|
||||
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
|
||||
// Clipping changes reach the layers around: everything.
|
||||
s.execute("layer.setProps", json!({"layer": a.0, "clipped": true})).unwrap();
|
||||
assert_eq!(damage(&s), None);
|
||||
// An adjustment layer changes everything beneath it.
|
||||
let adj = s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": adj, "visible": false})).unwrap();
|
||||
assert_eq!(damage(&s), None);
|
||||
// The Background (opaque everywhere): everything.
|
||||
let bg = doc(&s).layers[0].id;
|
||||
s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 0.5})).unwrap();
|
||||
let all = doc(&s).bounds();
|
||||
assert!(damage(&s).is_none_or(|r| r.contains_rect(&all)), "{:?}", damage(&s));
|
||||
// Bad params still fail cleanly.
|
||||
assert!(s.execute("layer.setProps", json!({"layer": 999_999, "visible": true})).is_err());
|
||||
assert!(s.execute("layer.translate", json!({"layer": 999_999, "dx": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_move_preview_matches_the_move() {
|
||||
let mut s = session(8);
|
||||
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let ids = move_targets(&before, &[a]);
|
||||
let shown = moved(&before, &ids, 7, 4).unwrap();
|
||||
s.execute("layer.translate", json!({"layer": a.0, "dx": 7, "dy": 4})).unwrap();
|
||||
assert_eq!(layer_bounds(shown.layer(a).unwrap()), Some(bounds(&s, a)));
|
||||
// Locked layers don't move in the preview either.
|
||||
let mut locked = (*before).clone();
|
||||
locked.layer_mut(a).unwrap().locks.position = true;
|
||||
assert!(moved(&locked, &ids, 1, 1).is_err());
|
||||
assert!(moved(&before, &[LayerId(999_999)], 1, 1).is_err());
|
||||
}
|
||||
|
||||
fn select_all(s: &mut Session, ids: &[LayerId]) {
|
||||
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
|
||||
for id in &ids[1..] {
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod analysis_cmds;
|
||||
pub mod artboard_cmds;
|
||||
pub mod automate_cmds;
|
||||
pub mod brush_cmds;
|
||||
pub mod brush_preset_cmds;
|
||||
pub mod build_info;
|
||||
mod canvas_geom;
|
||||
pub mod channel_cmds;
|
||||
@@ -198,6 +199,9 @@ pub struct ToolState {
|
||||
pub presets_rev: u64,
|
||||
/// Mixer Brush paint carried between strokes.
|
||||
pub mixer: photocraft_paint::mixer::MixerState,
|
||||
/// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the
|
||||
/// gesture journals as one call ([`brush_cmds::coalesce_journal`]).
|
||||
pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>,
|
||||
}
|
||||
|
||||
impl Default for ToolState {
|
||||
@@ -213,6 +217,7 @@ impl Default for ToolState {
|
||||
presets: photocraft_paint::presets::builtin(),
|
||||
presets_rev: 0,
|
||||
mixer: Default::default(),
|
||||
brush_gesture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -336,7 +341,7 @@ impl Session {
|
||||
edit_menu_cmds::after_command(self, id);
|
||||
automate_cmds::after_command(self, id);
|
||||
self.sync_preset_store();
|
||||
if spec.journal {
|
||||
if spec.journal && !brush_cmds::coalesce_journal(self, id, ¶ms) {
|
||||
self.journal.push((id.to_string(), params));
|
||||
}
|
||||
Ok(r)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! - `tips/<hash>.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when
|
||||
//! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only
|
||||
//! rewrites a small JSON file; a tip no group references any more is deleted;
|
||||
//! - `index.json`: group order and the built-in presets the user deleted.
|
||||
//! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop
|
||||
//! in the Brushes panel persists) and the built-in presets the user deleted.
|
||||
//!
|
||||
//! Built-in presets are never written (they are regenerated on start). The store syncs after
|
||||
//! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups
|
||||
@@ -324,6 +325,8 @@ struct IndexFile {
|
||||
groups: Vec<String>,
|
||||
/// Built-in presets the user deleted (by name).
|
||||
hidden_builtins: Vec<String>,
|
||||
/// Every preset's name in library (panel) order; empty when it is the default order.
|
||||
order: Vec<String>,
|
||||
}
|
||||
|
||||
/// The group's file name: a readable, sanitised prefix plus a hash of the exact name, so
|
||||
@@ -403,6 +406,8 @@ pub struct Opened {
|
||||
pub presets: Vec<BrushPreset>,
|
||||
/// Built-in presets the user deleted.
|
||||
pub hidden_builtins: Vec<String>,
|
||||
/// The saved library order (preset names), empty when none was saved.
|
||||
pub order: Vec<String>,
|
||||
/// Files that were skipped (corrupt, oversized, missing tips).
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -515,8 +520,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
|
||||
}
|
||||
}
|
||||
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
|
||||
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
|
||||
store.index = index;
|
||||
Opened { store, presets, hidden_builtins, warnings }
|
||||
Opened { store, presets, hidden_builtins, order, warnings }
|
||||
}
|
||||
|
||||
/// Read and decode tips, in parallel on native targets.
|
||||
@@ -615,10 +621,24 @@ impl PresetStore {
|
||||
// Index: group order and deleted built-ins.
|
||||
let hidden: Vec<String> = builtin_names().iter().filter(|n| !presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(|n| n.to_string()).collect();
|
||||
let groups: Vec<String> = files.into_iter().filter(|f| self.groups.contains_key(f)).collect();
|
||||
let want = IndexFile { version: 1, groups, hidden_builtins: hidden };
|
||||
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins);
|
||||
// The full order is only saved once it differs from the default (built-ins, then the rest).
|
||||
let order: Vec<String> = presets.iter().map(|p| p.name.clone()).collect();
|
||||
let default_order: Vec<String> = presets.iter().filter(|p| p.builtin).chain(presets.iter().filter(|p| !p.builtin)).map(|p| p.name.clone()).collect();
|
||||
let builtins_in_order = {
|
||||
let names = builtin_names();
|
||||
let mut it = presets.iter().filter(|p| p.builtin).map(|p| names.iter().position(|n| *n == p.name));
|
||||
let first = it.next().flatten();
|
||||
it.try_fold(first, |prev, cur| match (prev, cur) {
|
||||
(Some(a), Some(b)) if b > a => Some(Some(b)),
|
||||
_ => None,
|
||||
})
|
||||
.is_some()
|
||||
};
|
||||
let order = if builtins_in_order && order == default_order { Vec::new() } else { order };
|
||||
let want = IndexFile { version: 1, groups, hidden_builtins: hidden, order };
|
||||
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins && i.order == want.order);
|
||||
if !same {
|
||||
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty();
|
||||
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty() && want.order.is_empty();
|
||||
let r = if empty && self.index.is_none() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -693,7 +713,7 @@ impl Session {
|
||||
/// built-in of the same name; presets created before the store finished loading win), hide
|
||||
/// deleted built-ins, then sync. Returns the load warnings.
|
||||
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
|
||||
let Opened { store, presets, hidden_builtins, mut warnings } = opened;
|
||||
let Opened { store, presets, hidden_builtins, order, mut warnings } = opened;
|
||||
let lib = &mut self.tools.presets;
|
||||
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
|
||||
for p in presets {
|
||||
@@ -703,6 +723,11 @@ impl Session {
|
||||
None => lib.push(p),
|
||||
}
|
||||
}
|
||||
// The saved panel order (stable: presets it doesn't name keep their order, at the end).
|
||||
if !order.is_empty() {
|
||||
let pos: HashMap<String, usize> = order.iter().enumerate().map(|(i, n)| (n.to_lowercase(), i)).collect();
|
||||
lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
self.preset_store = Some(store);
|
||||
self.brush_presets_changed();
|
||||
self.sync_preset_store();
|
||||
|
||||
@@ -239,6 +239,41 @@ fn startup_with_many_groups_loads_them_all() {
|
||||
assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::<Vec<_>>(), "group order survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_presets_and_groups_persists() {
|
||||
let names = |s: &Session| s.tools.presets.iter().map(|p| (p.name.clone(), p.group.clone())).collect::<Vec<_>>();
|
||||
let dir = TempDir::new("reorder");
|
||||
let (mut s, _) = session(&dir);
|
||||
for (n, g) in [("Ink 1", "Inks"), ("Ink 2", "Inks"), ("Wash", "Washes")] {
|
||||
s.execute("brush.presets.save", json!({"name": n})).unwrap();
|
||||
s.tools.presets.iter_mut().find(|p| p.name == n).unwrap().group = g.into();
|
||||
}
|
||||
s.brush_presets_changed();
|
||||
s.sync_preset_store();
|
||||
// Untouched order: no order list is written (the default order is implied).
|
||||
let (t, _) = session(&dir);
|
||||
assert_eq!(names(&t), names(&s));
|
||||
// Reorder a built-in within its group, a user preset across groups, and whole groups.
|
||||
let builtin_group = s.tools.presets[0].group.clone();
|
||||
let last_builtin = s.tools.presets.iter().rfind(|p| p.builtin && p.group == builtin_group).unwrap().name.clone();
|
||||
let first = s.tools.presets[0].name.clone();
|
||||
s.execute("brush.presets.move", json!({"name": last_builtin, "before": first})).unwrap();
|
||||
s.execute("brush.presets.move", json!({"name": "Ink 2", "group": "Washes", "index": 0})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Washes", "index": 0})).unwrap();
|
||||
s.execute("brush.presets.moveGroup", json!({"group": "Inks", "before": builtin_group})).unwrap();
|
||||
assert_eq!(s.tools.presets[0].name, "Ink 2");
|
||||
let (t, w) = session(&dir);
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(names(&t), names(&s), "the panel order survives a restart");
|
||||
assert!(t.tools.presets.iter().find(|p| p.name == last_builtin).unwrap().builtin, "reordering keeps a built-in built-in");
|
||||
// Renaming and deleting a group persist too.
|
||||
s.execute("brush.presets.renameGroup", json!({"group": "Inks", "newName": "Pens"})).unwrap();
|
||||
s.execute("brush.presets.deleteGroup", json!({"group": "Washes"})).unwrap();
|
||||
let (t, _) = session(&dir);
|
||||
assert_eq!(names(&t), names(&s));
|
||||
assert!(t.tools.presets.iter().all(|p| p.group != "Washes" && p.group != "Inks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_and_oversized_files_are_skipped_with_a_warning() {
|
||||
let dir = TempDir::new("corrupt");
|
||||
|
||||
@@ -403,10 +403,10 @@ pub(crate) fn snap_affine(a: Affine) -> Affine {
|
||||
pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
|
||||
sm.transform = Affine::translate(dx as f64, dy as f64).mul(&sm.transform);
|
||||
if let Some(c) = &mut sm.cache {
|
||||
*c = translate_surface(c, dx, dy);
|
||||
*c = crate::layer_multi_cmds::shift_surface(c, dx, dy);
|
||||
}
|
||||
if let Some(m) = &mut sm.filter_mask {
|
||||
m.surface = translate_surface(&m.surface, dx, dy);
|
||||
m.surface = crate::layer_multi_cmds::shift_surface(&m.surface, dx, dy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,10 @@ pub struct SnapTargets {
|
||||
/// Bounds of a layer for snapping: what Free Transform would show (`None` for empty layers,
|
||||
/// groups and maskless adjustment layers).
|
||||
pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> {
|
||||
let l = doc.layer(id)?;
|
||||
rect_of(doc, doc.layer(id)?)
|
||||
}
|
||||
|
||||
fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
|
||||
if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) {
|
||||
return None;
|
||||
}
|
||||
@@ -118,7 +121,7 @@ impl SnapTargets {
|
||||
if !l.visible || exclude.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = layer_rect(doc, l.id) {
|
||||
if let Some(r) = rect_of(doc, l) {
|
||||
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,14 @@ fn bad(msg: impl Into<String>) -> EngineError {
|
||||
}
|
||||
|
||||
/// Document-space bounds a transform of `layer` starts from (what Free Transform frames).
|
||||
/// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag.
|
||||
pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
|
||||
let content = match &layer.content {
|
||||
LayerContent::Group(g) => g.children.iter().map(|l| transform_bounds(doc, l)).fold(Rect::EMPTY, |a, b| a.union(&b)),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, Surface::content_bounds),
|
||||
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
|
||||
};
|
||||
let content = if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| m.surface.content_bounds()) } else { content };
|
||||
let content =
|
||||
if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content };
|
||||
match &doc.selection {
|
||||
Some(sel) if !layer.is_group() => content.intersect(&sel.content_bounds()),
|
||||
_ => content,
|
||||
|
||||
@@ -26,6 +26,11 @@ pub fn transparent_outside(layer: &Layer) -> bool {
|
||||
photocraft_compose::transparent_outside(layer)
|
||||
}
|
||||
|
||||
/// `photocraft_compose::composite_bounds`: where compositing the layer can change anything.
|
||||
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
|
||||
photocraft_compose::composite_bounds(layer, canvas)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+41
-14
@@ -38,6 +38,7 @@ const F_MASK_TEX: u32 = 2u; // mask pixels live in `mask_tex`
|
||||
const F_TEX: u32 = 4u; // layer pixels live in `layer_tex`
|
||||
const F_GRADIENT: u32 = 8u; // gradient fill (stops in `lut_tex`)
|
||||
const F_KNOCKOUT: u32 = 16u; // effect paint: the layer knocks out the coverage (drop shadow)
|
||||
const F_NO_LAYER: u32 = 32u; // effect merge: A already holds the layer (only the opacity mix)
|
||||
const F_VECTOR: u32 = 64u; // effect paint: shape layer (outside stroke never inside)
|
||||
const F_ATOP: u32 = 128u; // effect merge: clipped layer over an opaque base
|
||||
const F_GATE: u32 = 256u; // effect paint: coverage only inside the layer's shape
|
||||
@@ -240,7 +241,17 @@ fn composite(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32) -> vec4<f32> {
|
||||
return vec4(rgb, ao);
|
||||
}
|
||||
|
||||
// `psblend::composite_gamma`: coverage mixed in a gamma space (type layers, gamma 1.45).
|
||||
// `psblend::text_encode` / `text_decode`: linear light raised to 1 / gamma.
|
||||
fn text_enc(c: vec3<f32>, g: f32) -> vec3<f32> {
|
||||
let l = vec3(srgb_to_linear(max(c.r, 0.0)), srgb_to_linear(max(c.g, 0.0)), srgb_to_linear(max(c.b, 0.0)));
|
||||
return pow(l, vec3(1.0 / g));
|
||||
}
|
||||
fn text_dec(v: vec3<f32>, g: f32) -> vec3<f32> {
|
||||
let l = pow(max(v, vec3(0.0)), vec3(g));
|
||||
return vec3(linear_to_srgb(l.r), linear_to_srgb(l.g), linear_to_srgb(l.b));
|
||||
}
|
||||
|
||||
// `psblend::composite_gamma`: coverage mixed in the text blending space (type layers, 1.45).
|
||||
fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bool) -> vec4<f32> {
|
||||
if (!gamma_on) { return composite(mode, b, s, opacity); }
|
||||
let ab = b.a;
|
||||
@@ -250,8 +261,8 @@ fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bo
|
||||
let ao = as_ + ab * (1.0 - as_);
|
||||
if (ao <= 0.0) { return vec4(0.0); }
|
||||
let g = op.p4.w; // psblend::text_gamma
|
||||
let pw = (1.0 - as_) * ab * pow(max(b.rgb, vec3(0.0)), vec3(g)) + (1.0 - ab) * as_ * pow(max(s.rgb, vec3(0.0)), vec3(g)) + as_ * ab * pow(max(bl, vec3(0.0)), vec3(g));
|
||||
return vec4(pow(pw / ao, vec3(1.0 / g)), ao);
|
||||
let pw = (1.0 - as_) * ab * text_enc(b.rgb, g) + (1.0 - ab) * as_ * text_enc(s.rgb, g) + as_ * ab * text_enc(bl, g);
|
||||
return vec4(text_dec(pw / ao, g), ao);
|
||||
}
|
||||
|
||||
// photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB).
|
||||
@@ -593,13 +604,15 @@ fn gradient_t(d: vec2<i32>) -> f32 {
|
||||
let len = max(sqrt((c * w) * (c * w) + (s * h) * (s * h)), 1.0) * max(op.p0.y, 1e-3);
|
||||
// effects::gradient_t: Linear / Reflected span the bounds' chord along the angle.
|
||||
let chord = max(min(w / max(abs(c), 1e-6), h / max(abs(s), 1e-6)), 1.0) * max(op.p0.y, 1e-3);
|
||||
// Linear / Reflected sample the pixel's top-left corner (effects::gradient_t).
|
||||
let corner = 0.5 * (c - s);
|
||||
var t: f32;
|
||||
switch i32(op.p0.w) {
|
||||
case 1: { t = sqrt(dx * dx + dy * dy) / (len / 2.0); } // Radial
|
||||
case 2: { t = rem_euclid((a - atan2(-dy, dx)) / 6.28318530718, 1.0); } // Angle
|
||||
case 3: { t = abs(along / (chord / 2.0)); } // Reflected
|
||||
case 3: { t = abs((along - corner) / (chord / 2.0)); } // Reflected
|
||||
case 4: { t = (abs(along) + abs(across)) / (len / 2.0); } // Diamond
|
||||
default: { t = along / chord + 0.5; } // Linear
|
||||
default: { t = (along - corner) / chord + 0.5; } // Linear
|
||||
}
|
||||
t = clamp(t, 0.0, 1.0);
|
||||
if (op.p0.z > 0.5) { t = 1.0 - t; }
|
||||
@@ -880,7 +893,7 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4<f32> {
|
||||
var w = textureLoad(tex_a, p, 0);
|
||||
let l = textureLoad(tex_b, p, 0);
|
||||
let c = textureLoad(tex_c, p, 0);
|
||||
if (l.a > 0.0) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
|
||||
if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
|
||||
let atop = (op.flags & F_ATOP) != 0u;
|
||||
var before = c;
|
||||
if (atop) { before = vec4(c.rgb, 1.0); }
|
||||
@@ -1000,19 +1013,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
|
||||
@fragment
|
||||
fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
|
||||
let p = local(in.pos);
|
||||
let depth = op.p1.x;
|
||||
let s = textureLoad(layer_tex, p, 0).r;
|
||||
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
|
||||
// 4-neighbour's).
|
||||
var hmax = ra(p);
|
||||
if (op.p1.y > 1.5) {
|
||||
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
|
||||
}
|
||||
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
|
||||
// p1.y: 0 inside (× alpha), 1 under the edge and outside (outer bevel), 2 / 3 emboss / pillow
|
||||
// emboss (inside where the shape is, plus the outside half, flipped for pillow, × 1 − alpha).
|
||||
if (op.p1.y < 0.5) { return mout(bevel_part(p, op.p1.x, s)); }
|
||||
if (op.p1.y < 1.5) { return mout(bevel_part(p, op.p1.x, outside)); }
|
||||
var inner = 0.0;
|
||||
if (s > INSIDE_EPS) { inner = bevel_part(p, op.p1.x, s); }
|
||||
let od = select(op.p1.x, -op.p1.x, op.p1.y > 2.5);
|
||||
return mout(inner + bevel_part(p, od, outside) * (1.0 - min(s, 1.0)));
|
||||
}
|
||||
|
||||
// One bevel shading value (`bevel_maps`' shade_into): highlight (p1.z = 0) or shadow amount for
|
||||
// height-gradient scale `depth`, times `region`, through the gloss contour.
|
||||
fn bevel_part(p: vec2<i32>, depth: f32, region: f32) -> f32 {
|
||||
let gx = (ra(p + vec2(1, 0)) - ra(p - vec2(1, 0))) * 0.5 * depth;
|
||||
let gy = (ra(p + vec2(0, 1)) - ra(p - vec2(0, 1))) * 0.5 * depth;
|
||||
let n = vec3(-gx, -gy, 1.0);
|
||||
let len = sqrt(n.x * n.x + n.y * n.y + 1.0);
|
||||
let shade = (n.x * op.p0.x + n.y * op.p0.y + n.z * op.p0.z) / len;
|
||||
let se = op.p0.w;
|
||||
let s = textureLoad(layer_tex, p, 0).r;
|
||||
var region = s;
|
||||
if (op.p1.y > 0.5) {
|
||||
// Outside parts paint at full strength wherever the shape isn't opaque (edge pixels too).
|
||||
region = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && textureLoad(tex_a, p, 0).r > 1e-5); // effects::BEVEL_H_EPS
|
||||
}
|
||||
let k = shade - se;
|
||||
var v = 0.0;
|
||||
if (op.p1.z > 0.5) {
|
||||
@@ -1021,7 +1048,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
|
||||
v = clamp(k / max(1.0 - se, 1e-3), 0.0, 1.0) * region;
|
||||
}
|
||||
if (op.p1.w > 0.5) { v = lut(0, v); }
|
||||
return mout(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
// Bevel texture (`effects::bevel_height`): A + k × luminance of the pattern (× its alpha; 1 −
|
||||
|
||||
+35
-25
@@ -92,15 +92,7 @@ impl MapProgram {
|
||||
/// the reach each field was computed with.
|
||||
pub fn windows(&self, d: Rect, region: Rect, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec<Rect> {
|
||||
let n = self.stages.len();
|
||||
let reach_of = |reach: &[i32], i: Option<In>| match i {
|
||||
Some(In::Val(k)) => reach[k],
|
||||
Some(In::Field(f)) => field_radius(f, field_reach(f)),
|
||||
_ => 0,
|
||||
};
|
||||
let mut reach = vec![0i32; n];
|
||||
for (i, s) in self.stages.iter().enumerate() {
|
||||
reach[i] = reach_of(&reach, s.a).max(reach_of(&reach, s.b)).max(reach_of(&reach, s.s)) + s.radius;
|
||||
}
|
||||
let reach = self.stage_reach(field_reach);
|
||||
let mut need = vec![Rect::EMPTY; n];
|
||||
for (i, s) in self.stages.iter().enumerate() {
|
||||
if s.out.is_some() {
|
||||
@@ -123,6 +115,27 @@ impl MapProgram {
|
||||
need
|
||||
}
|
||||
|
||||
/// Per stage, how far (px) its output depends on the shape.
|
||||
fn stage_reach(&self, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec<i32> {
|
||||
let mut reach: Vec<i32> = Vec::with_capacity(self.stages.len());
|
||||
for s in &self.stages {
|
||||
let of = |i: Option<In>| match i {
|
||||
Some(In::Val(k)) => reach.get(k).copied().unwrap_or(0),
|
||||
Some(In::Field(f)) => field_radius(f, field_reach(f)),
|
||||
_ => 0,
|
||||
};
|
||||
let r = of(s.a).max(of(s.b)).max(of(s.s)) + s.radius;
|
||||
reach.push(r);
|
||||
}
|
||||
reach
|
||||
}
|
||||
|
||||
/// How far (px) any final map depends on the shape: maps computed over a window are exact
|
||||
/// at least this far inside it.
|
||||
pub fn reach(&self, field_reach: &dyn Fn(FieldKind) -> i32) -> i32 {
|
||||
self.stage_reach(field_reach).into_iter().zip(&self.stages).filter(|(_, s)| s.out.is_some()).map(|(r, _)| r).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Physical temporary index per stage (None for final maps), reusing temporaries after
|
||||
/// their last read; returns (assignment, temporaries needed).
|
||||
pub fn temps(&self) -> (Vec<Option<usize>>, usize) {
|
||||
@@ -277,7 +290,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
if r > 0.0 {
|
||||
// dist_outside of the shifted map is the shifted field (integer offsets; shifted-in
|
||||
// pixels are far away for a drop shadow, inside for an inner shadow).
|
||||
let f = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
|
||||
let f = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r);
|
||||
let d = if dx == 0.0 && dy == 0.0 {
|
||||
f
|
||||
} else {
|
||||
@@ -308,7 +321,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
let (r, bw) = photocraft_compose::effects::spread_split(g.size, g.spread);
|
||||
let mut m = src;
|
||||
if r > 0.0 {
|
||||
let d = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
|
||||
let d = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r);
|
||||
m = b.dilate(src, d, r);
|
||||
}
|
||||
let m = b.blur(m, bw);
|
||||
@@ -372,16 +385,18 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
|
||||
let (sa, ca) = angle.to_radians().sin_cos();
|
||||
let (se, ce) = altitude.to_radians().sin_cos();
|
||||
let lut = contour_lut(&bv.gloss_contour).map(Arc::new);
|
||||
let passes: Vec<(f32, bool)> = match g.paint {
|
||||
BevelPaint::Inner => vec![(g.depth, false)],
|
||||
BevelPaint::Outer => vec![(g.depth, true)],
|
||||
BevelPaint::Both => vec![(g.depth, false), (if g.pillow { -g.depth } else { g.depth }, true)],
|
||||
// Region (`fs_mbevelshade`): 0 inside, 1 under the edge and outside, 2 / 3 emboss /
|
||||
// pillow emboss (both halves in one map).
|
||||
let passes: Vec<(f32, f32)> = match g.paint {
|
||||
BevelPaint::Inner => vec![(g.depth, 0.0)],
|
||||
BevelPaint::Outer => vec![(g.depth, 1.0)],
|
||||
BevelPaint::Both => vec![(g.depth, if g.pillow { 3.0 } else { 2.0 })],
|
||||
};
|
||||
let mut out = 0;
|
||||
for (depth, outer) in &passes {
|
||||
for (depth, region) in &passes {
|
||||
for which in [0.0, 1.0] {
|
||||
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
|
||||
s.p1 = [*depth, f32::from(u8::from(*outer)), which, f32::from(u8::from(lut.is_some()))];
|
||||
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
|
||||
s.lut = lut.clone();
|
||||
s.s = Some(In::Shape);
|
||||
s.out = Some(out);
|
||||
@@ -668,14 +683,9 @@ mod tests {
|
||||
shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 };
|
||||
}
|
||||
}
|
||||
for kind in [
|
||||
FieldKind::Outside,
|
||||
FieldKind::Inside,
|
||||
FieldKind::OutsideInverse,
|
||||
FieldKind::StrokeOutside,
|
||||
FieldKind::StrokeInside,
|
||||
FieldKind::StrokeOutsideVector,
|
||||
] {
|
||||
for kind in
|
||||
[FieldKind::Outside, FieldKind::Inside, FieldKind::ChokeInside, FieldKind::StrokeOutside, FieldKind::StrokeInside, FieldKind::StrokeOutsideVector]
|
||||
{
|
||||
let reach = 9;
|
||||
let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h);
|
||||
let banded = field(kind, reach, &shape, region, region);
|
||||
|
||||
+571
-155
File diff suppressed because it is too large.
Load diff
+152
-42
@@ -338,6 +338,8 @@ enum Cov {
|
||||
/// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`;
|
||||
/// stroke-out `inside ? (vector ? 0 : 1) : m`.
|
||||
pub const F_KNOCKOUT: u32 = 16;
|
||||
/// Effect merge: A already holds the layer; only mix with the backdrop by the layer's opacity.
|
||||
pub const F_NO_LAYER: u32 = 32;
|
||||
/// Shape layer (outside strokes never show inside it).
|
||||
pub const F_VECTOR: u32 = 64;
|
||||
/// Merge onto an opaque clipping base, keeping its alpha.
|
||||
@@ -539,8 +541,10 @@ impl<'a> Planner<'a> {
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect();
|
||||
|
||||
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does).
|
||||
if let LayerContent::Group(g) = &layer.content
|
||||
&& layer.blend == BlendMode::PassThrough
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !has_effects(layer)
|
||||
{
|
||||
let before = self.retain(backdrop);
|
||||
@@ -635,18 +639,72 @@ impl<'a> Planner<'a> {
|
||||
return Ok(self.emit(p));
|
||||
}
|
||||
|
||||
// A layer that covers little of the canvas composites only over its bounds and is
|
||||
// copied back into the backdrop (#125): a layout with hundreds of small text and shape
|
||||
// layers otherwise shades every layer over every chunk.
|
||||
let clip = self.local_clip(layer);
|
||||
if clip.is_some_and(|c| c.is_empty()) {
|
||||
// Nothing to draw (an empty layer, or a group of them).
|
||||
for c in &visible_clipped {
|
||||
self.check_blend_if(c)?;
|
||||
}
|
||||
return Ok(backdrop);
|
||||
}
|
||||
let start = self.passes.len();
|
||||
let mut content = self.content(layer)?;
|
||||
for c in visible_clipped {
|
||||
content = self.atop(c, content)?;
|
||||
}
|
||||
if let Some(clip) = clip {
|
||||
// Everything that built the layer's content only matters inside its bounds.
|
||||
for p in &mut self.passes[start..] {
|
||||
if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) {
|
||||
p.clip = Some(clip);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut p = Pass::new(Kernel::Blend, 0);
|
||||
p.a = Some(backdrop);
|
||||
p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop });
|
||||
p.b = Some(content);
|
||||
p.mode = layer.blend;
|
||||
p.opacity = opacity;
|
||||
p.flags = gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
Ok(self.emit(p))
|
||||
p.clip = clip;
|
||||
let merged = self.emit(p);
|
||||
match clip {
|
||||
Some(clip) => Ok(self.write_back(backdrop, merged, clip)),
|
||||
None => Ok(merged),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rect a plain layer composites over: its composite bounds when they cover at most half
|
||||
/// the canvas (beyond that, compositing everywhere costs less than the copy back).
|
||||
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
|
||||
let canvas = self.cx.canvas;
|
||||
let b = bounds::composite_bounds(layer, canvas)?;
|
||||
(b.width() as u64 * b.height() as u64 * 2 <= canvas.width() as u64 * canvas.height() as u64).then_some(b)
|
||||
}
|
||||
|
||||
/// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop);
|
||||
/// returns the new backdrop. Consumes both.
|
||||
fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot {
|
||||
let dst = if self.refs[backdrop as usize] == 1 {
|
||||
backdrop
|
||||
} else {
|
||||
let n = self.alloc();
|
||||
let mut p = Pass::new(Kernel::CopyFull, n);
|
||||
p.a = Some(backdrop);
|
||||
self.passes.push(p);
|
||||
self.release(backdrop);
|
||||
n
|
||||
};
|
||||
let mut p = Pass::new(Kernel::CopyRect, dst);
|
||||
p.a = Some(merged);
|
||||
p.clip = Some(clip);
|
||||
self.passes.push(p);
|
||||
self.release(merged);
|
||||
dst
|
||||
}
|
||||
|
||||
/// render_content for non-adjustment layers.
|
||||
@@ -724,9 +782,13 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
Fill::Gradient { stops, angle, scale, style, reverse } => {
|
||||
p.gradient = true;
|
||||
p.params[0] = [*angle, *scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
|
||||
// compose::render_fill: whole-pixel end points (fill_layout).
|
||||
let (angle, scale, offset) = photocraft_compose::fill_layout::fill_gradient_layout(*style, *angle, *scale, frame);
|
||||
p.params[0] = [angle, scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
|
||||
let c = frame;
|
||||
p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32];
|
||||
p.params[2][0] = offset.0;
|
||||
p.params[2][1] = offset.1;
|
||||
let conv: Vec<(f32, [f32; 4])> = stops
|
||||
.iter()
|
||||
.map(|(t, c)| {
|
||||
@@ -796,7 +858,7 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
let mut p = Pass::new(Kernel::Adjust, 0);
|
||||
p.a = Some(src);
|
||||
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer);
|
||||
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, photocraft_compose::adjustment_quantum(self.cx.depth));
|
||||
p.adjust_kind = kind;
|
||||
p.params = params;
|
||||
p.lut = lut;
|
||||
@@ -920,11 +982,11 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint;
|
||||
for &(i, e) in &rev {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& b.style != photocraft_doc::BevelStyle::OuterBevel
|
||||
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Inner
|
||||
{
|
||||
// Inner part (emboss styles: maps 0–1 inside, 2–3 outside).
|
||||
l = self.paint(
|
||||
l,
|
||||
content,
|
||||
@@ -984,10 +1046,9 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
for &(i, e) in &rev {
|
||||
if let Effect::BevelEmboss(b) = e
|
||||
&& let paint = photocraft_compose::effects::bevel_geom(b).paint
|
||||
&& paint != photocraft_compose::effects::BevelPaint::Inner
|
||||
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer
|
||||
{
|
||||
let k = if paint == photocraft_compose::effects::BevelPaint::Both { 2 } else { 0 };
|
||||
let k = 0;
|
||||
w = self.paint(
|
||||
w,
|
||||
content,
|
||||
@@ -1003,35 +1064,55 @@ impl<'a> Planner<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||||
p.a = Some(w);
|
||||
p.b = Some(l);
|
||||
p.c = Some(self.retain(backdrop));
|
||||
p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||||
p.opacity = layer.opacity;
|
||||
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
p.clip = Some(clip);
|
||||
let merged = self.emit(p);
|
||||
let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
|
||||
let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev
|
||||
.iter()
|
||||
.filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None })
|
||||
.filter(|(_, b)| bevel_paint(b) == photocraft_compose::effects::BevelPaint::Both)
|
||||
.collect();
|
||||
let merged = if late.is_empty() {
|
||||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||||
p.a = Some(w);
|
||||
p.b = Some(l);
|
||||
p.c = Some(self.retain(backdrop));
|
||||
p.mode = mode;
|
||||
p.opacity = layer.opacity;
|
||||
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
p.clip = Some(clip);
|
||||
self.emit(p)
|
||||
} else {
|
||||
// Emboss styles shade the composited layer (`composite_with_effects`): merge at full
|
||||
// opacity, paint their maps (inside and outside halves), then mix.
|
||||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||||
p.a = Some(w);
|
||||
p.b = Some(l);
|
||||
p.c = Some(self.retain(w));
|
||||
p.mode = mode;
|
||||
p.opacity = 1.0;
|
||||
p.flags = gamma_flag(layer);
|
||||
p.extra[3] = photocraft_compose::text_gamma(layer);
|
||||
p.clip = Some(clip);
|
||||
let mut m = self.emit(p);
|
||||
for (i, b) in late {
|
||||
for (k, color, fxc) in [(0, &b.highlight_color, &b.highlight), (1, &b.shadow_color, &b.shadow)] {
|
||||
m = self.paint(m, content, Cov::Map(map(i, k), 0.0), &Paint::Color(color.to_rgb()), fxc.blend, fxc.opacity, 0, clip, sb);
|
||||
}
|
||||
}
|
||||
let mut p = Pass::new(Kernel::FxMerge, 0);
|
||||
p.b = Some(self.retain(m));
|
||||
p.a = Some(m);
|
||||
p.c = Some(self.retain(backdrop));
|
||||
p.mode = mode;
|
||||
p.opacity = layer.opacity;
|
||||
p.flags = if atop { F_ATOP } else { 0 } | F_NO_LAYER;
|
||||
p.clip = Some(clip);
|
||||
self.emit(p)
|
||||
};
|
||||
self.release(content);
|
||||
|
||||
// Write the clipped result back into the backdrop (in place when nothing else holds it).
|
||||
let dst = if self.refs[backdrop as usize] == 1 {
|
||||
backdrop
|
||||
} else {
|
||||
let n = self.alloc();
|
||||
let mut p = Pass::new(Kernel::CopyFull, n);
|
||||
p.a = Some(backdrop);
|
||||
self.passes.push(p);
|
||||
self.release(backdrop);
|
||||
n
|
||||
};
|
||||
let mut p = Pass::new(Kernel::CopyRect, dst);
|
||||
p.a = Some(merged);
|
||||
p.clip = Some(clip);
|
||||
self.passes.push(p);
|
||||
self.release(merged);
|
||||
Ok(dst)
|
||||
Ok(self.write_back(backdrop, merged, clip))
|
||||
}
|
||||
|
||||
fn fx_paint(&self, p: &'a FxPaint, anchor: (f64, f64)) -> Paint<'a> {
|
||||
@@ -1076,10 +1157,12 @@ impl<'a> Planner<'a> {
|
||||
Paint::None => {}
|
||||
Paint::Color(c) => p.color = [c[0], c[1], c[2], 1.0],
|
||||
Paint::Gradient(g) => {
|
||||
p.params[0] = [g.angle, g.scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
|
||||
// effects::paint_fx: whole-pixel end points (fill_layout::gradient_layout).
|
||||
let (angle, scale, offset) = photocraft_compose::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, sb);
|
||||
p.params[0] = [angle, scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
|
||||
p.params[1] = [sb.x0 as f32, sb.y0 as f32, sb.width() as f32, sb.height() as f32];
|
||||
p.params[2][0] = g.offset.0;
|
||||
p.params[2][1] = g.offset.1;
|
||||
p.params[2][0] = offset.0;
|
||||
p.params[2][1] = offset.1;
|
||||
p.params[2][2] = 1.0;
|
||||
let mut rows = vec![[0.0f32; 4096]; 4];
|
||||
for k in 0..4096 {
|
||||
@@ -1235,7 +1318,7 @@ pub fn adjustment_on_gpu(adj: &Adjustment) -> bool {
|
||||
}
|
||||
|
||||
/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
|
||||
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
|
||||
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, quantum: Option<f32>) -> Program {
|
||||
let mut p = [[0.0f32; 4]; 4];
|
||||
match adj {
|
||||
Adjustment::Invert => (1, p, None),
|
||||
@@ -1260,7 +1343,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
|
||||
(5, p, None)
|
||||
}
|
||||
Adjustment::Exposure { exposure, offset, gamma } => {
|
||||
let g = match transfer {
|
||||
let g = match transfer.for_exposure() {
|
||||
Transfer::Srgb => 0.0,
|
||||
Transfer::Gamma(g) => g,
|
||||
};
|
||||
@@ -1268,7 +1351,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
|
||||
(6, p, None)
|
||||
}
|
||||
// RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
|
||||
Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts(adj).iter().take(3).map(|t| to_row(t)).collect())),
|
||||
Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts_q(adj, quantum).iter().take(3).map(|t| to_row(t)).collect())),
|
||||
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
|
||||
p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }];
|
||||
if !*colorize && ranges.iter().any(|r| !r.is_neutral()) {
|
||||
@@ -1393,8 +1476,35 @@ mod tests {
|
||||
d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format()));
|
||||
}
|
||||
let p = plan(&d).unwrap();
|
||||
// Empty layers draw nothing.
|
||||
assert_eq!(p.passes.len(), 1 + 2);
|
||||
for (i, l) in d.layers.iter_mut().skip(1).enumerate() {
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]);
|
||||
}
|
||||
let p = plan(&d).unwrap();
|
||||
assert!(p.slots <= 3, "{} slots", p.slots);
|
||||
assert_eq!(p.passes.len(), 1 + 11 * 2);
|
||||
// The background, then each small layer over its bounds and copied back.
|
||||
assert_eq!(p.passes.len(), 1 + 2 + 10 * 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_layers_composite_only_over_their_bounds() {
|
||||
let mut d = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
||||
let mut l = Layer::raster("small", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 12, 20, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let mut group = Layer::group("g", vec![l]);
|
||||
group.blend = BlendMode::Normal;
|
||||
d.layers.push(group);
|
||||
let p = plan(&d).unwrap();
|
||||
let r = Rect::new(10, 12, 20, 30);
|
||||
// The group's content, its blend and the copy back are all limited to the layer.
|
||||
assert!(p.passes.iter().filter(|p| matches!(p.kernel, Kernel::Content | Kernel::Blend)).skip(2).all(|p| p.clip == Some(r)), "{:?}", p.passes);
|
||||
let last = p.passes.last().unwrap();
|
||||
assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(r)));
|
||||
// A layer that covers most of the canvas composites everywhere.
|
||||
d.layers[1].children_mut().unwrap()[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 60, 60), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let p = plan(&d).unwrap();
|
||||
assert!(p.passes.iter().all(|p| p.clip.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+261
-31
@@ -9,13 +9,25 @@ use photocraft_gpu::{Compositor, render_to_vec};
|
||||
|
||||
const TOL: f32 = 1.0 / 255.0;
|
||||
|
||||
/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so the GPU tests
|
||||
/// take this lock and hold it until their device is dropped.
|
||||
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
comp: Compositor,
|
||||
/// The same device with a simulated texture limit of [`PAGED_LIMIT`]: every check also runs
|
||||
/// through layer pages and per-cell effect maps.
|
||||
paged: Compositor,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
/// Simulated texture limit (pages and chunks of 256 px).
|
||||
const PAGED_LIMIT: u32 = 256;
|
||||
|
||||
fn gpu() -> Option<Gpu> {
|
||||
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let instance = wgpu::Instance::default();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
|
||||
Ok(a) => a,
|
||||
@@ -38,14 +50,18 @@ fn gpu() -> Option<Gpu> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Gpu { device, queue, comp })
|
||||
let mut paged = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
|
||||
paged.set_texture_limit(PAGED_LIMIT);
|
||||
Some(Gpu { device, queue, comp, paged, _lock: lock })
|
||||
}
|
||||
|
||||
/// Any adapter and device, for the fallback-path tests (which don't need 32-bit float targets).
|
||||
fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
/// The returned guard holds [`GPU_LOCK`] until the device is dropped.
|
||||
fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue, std::sync::MutexGuard<'static, ()>)> {
|
||||
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
|
||||
Some((adapter, device, queue))
|
||||
Some((adapter, device, queue, lock))
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random values in 0..1.
|
||||
@@ -88,11 +104,10 @@ fn base_doc(w: u32, h: u32) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
fn check(g: &mut Gpu, doc: &Document, what: &str) {
|
||||
let cpu = photocraft_compose::flatten(doc);
|
||||
let out = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
/// Largest premultiplied difference between `cpu` and `gpu` pixels, and its index.
|
||||
fn worst_diff(cpu: &[[f32; 4]], gpu: &[[f32; 4]]) -> (f32, usize) {
|
||||
let mut worst = (0.0f32, 0usize);
|
||||
for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() {
|
||||
for (i, (c, o)) in cpu.iter().zip(gpu).enumerate() {
|
||||
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
|
||||
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
|
||||
for k in 0..4 {
|
||||
@@ -102,9 +117,37 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = doc.size.width as usize;
|
||||
let (x, y) = (worst.1 % w, worst.1 / w);
|
||||
assert!(worst.0 <= TOL, "{what}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]);
|
||||
worst
|
||||
}
|
||||
|
||||
/// GPU (whole textures, then pages) vs CPU over `rect`: Err with the worst pixel if over the
|
||||
/// tolerance, else the stats of the unpaged render.
|
||||
fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result<photocraft_gpu::Stats, String> {
|
||||
let cpu = photocraft_compose::render(doc, rect);
|
||||
let mut first = None;
|
||||
for (label, comp) in [("", &mut g.comp), (" (paged)", &mut g.paged)] {
|
||||
let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect).map_err(|e| format!("{what}{label}: {e}"))?;
|
||||
let worst = worst_diff(&cpu.px, &out);
|
||||
let w = rect.width() as usize;
|
||||
let (x, y) = (rect.x0 + (worst.1 % w) as i32, rect.y0 + (worst.1 / w) as i32);
|
||||
if worst.0 > TOL {
|
||||
return Err(format!("{what}{label}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
|
||||
}
|
||||
first.get_or_insert(stats);
|
||||
}
|
||||
first.ok_or_else(|| format!("{what}: no render"))
|
||||
}
|
||||
|
||||
fn check(g: &mut Gpu, doc: &Document, what: &str) {
|
||||
diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_frame_renders() {
|
||||
// Regression: the first submission after device creation is dropped on some drivers
|
||||
// (RADV), which used to make the very first render come back all zeroes.
|
||||
let Some(mut g) = gpu() else { return };
|
||||
check(&mut g, &base_doc(64, 48), "first frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,6 +246,11 @@ fn adjustment_layers() {
|
||||
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 40, 30), 71, 0.5));
|
||||
d.layers.push(Layer::new("ex", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.01, gamma: 1.1 })));
|
||||
check(&mut g, &d, &format!("adjustment {depth:?}"));
|
||||
// Levels on whole levels of the document's depth (compose::adjust::levels_q).
|
||||
let lc = LevelsChannel { in_black: 0.17, in_white: 0.84, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
|
||||
let lv = Adjustment::Levels { master: lc, per_channel: Default::default(), space: Default::default(), black: LevelsChannel::default() };
|
||||
d.layers.push(Layer::new("lv", LayerContent::Adjustment(lv)));
|
||||
check(&mut g, &d, &format!("levels {depth:?}"));
|
||||
}
|
||||
for adj in adjustments() {
|
||||
for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] {
|
||||
@@ -307,6 +355,14 @@ fn fills_and_dissolve() {
|
||||
d.layers.push(l);
|
||||
check(&mut g, &d, &format!("gradient {style:?}"));
|
||||
}
|
||||
// Tiny frames, where the whole-pixel end points (compose::fill_layout) change the angle
|
||||
// and centre.
|
||||
for (w, h, style, angle) in [(4, 4, GradientStyle::Reflected, 30.0), (7, 5, GradientStyle::Linear, 30.0), (9, 4, GradientStyle::Linear, -60.0)] {
|
||||
let mut d = base_doc(w, h);
|
||||
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.7)), (0.5, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 0.0))];
|
||||
d.layers.push(Layer::new("grad", LayerContent::Fill(Fill::Gradient { stops, angle, scale: 1.0, style, reverse: false })));
|
||||
check(&mut g, &d, &format!("small gradient {w}x{h} {style:?} {angle}"));
|
||||
}
|
||||
let mut d = base_doc(64, 48);
|
||||
let mut l = noise_layer("dis", PixelFormat::RGBA8, Rect::new(0, 0, 64, 48), 41, 0.2);
|
||||
l.blend = BlendMode::Dissolve;
|
||||
@@ -373,6 +429,60 @@ use photocraft_doc::{
|
||||
|
||||
/// An anti-aliased blob (disc plus a soft-edged bar and a hole) with partially transparent parts,
|
||||
/// so effects see real edge coverage, concavities and interior alpha.
|
||||
/// Small layers composite only over their bounds and are copied back into the backdrop (#125):
|
||||
/// every blend mode, clipped layers, isolated and pass-through groups (with adjustments, masks,
|
||||
/// opacity), layers partly off the canvas and over several chunks must still match the CPU.
|
||||
#[test]
|
||||
fn small_layers_composite_over_their_bounds() {
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let small = |seed: u32, x: i32, y: i32, mode: BlendMode| {
|
||||
let mut l = noise_layer("s", PixelFormat::RGBA8, Rect::from_xywh(x, y, 23, 17), seed, 0.0);
|
||||
l.blend = mode;
|
||||
l.opacity = 0.85;
|
||||
l
|
||||
};
|
||||
let mut d = base_doc(200, 150);
|
||||
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
|
||||
let i = i as i32;
|
||||
d.layers.push(small(100 + i as u32, (i * 29) % 190 - 8, (i * 37) % 140 - 5, mode));
|
||||
}
|
||||
// A clipping base with a raster and an adjustment clipped to it.
|
||||
let base = small(200, 120, 90, BlendMode::Normal);
|
||||
let mut c1 = noise_layer("c1", PixelFormat::RGBA8, Rect::new(100, 80, 190, 140), 201, 0.0);
|
||||
c1.clipped = true;
|
||||
c1.blend = BlendMode::Multiply;
|
||||
let mut c2 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
|
||||
c2.clipped = true;
|
||||
d.layers.extend([base, c1, c2]);
|
||||
// An isolated group with a mask, an adjustment and a nested pass-through group.
|
||||
let inner = Layer::group("inner", vec![small(300, 40, 100, BlendMode::Screen), small(301, 55, 110, BlendMode::Normal)]);
|
||||
let mut iso = Layer::group(
|
||||
"iso",
|
||||
vec![
|
||||
small(302, 30, 95, BlendMode::Normal),
|
||||
Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)),
|
||||
inner,
|
||||
small(303, 70, 120, BlendMode::Overlay),
|
||||
],
|
||||
);
|
||||
iso.blend = BlendMode::Normal;
|
||||
iso.opacity = 0.75;
|
||||
iso.mask = Some(mask(Rect::new(25, 90, 100, 145), 304, 1.0));
|
||||
d.layers.push(iso);
|
||||
// A pass-through group with opacity around small layers.
|
||||
let mut pt = Layer::group("pt", vec![small(305, 150, 10, BlendMode::Normal), small(306, 160, 20, BlendMode::Difference)]);
|
||||
pt.opacity = 0.6;
|
||||
d.layers.push(pt);
|
||||
check(&mut g, &d, "small layers");
|
||||
// The same over several compositor chunks.
|
||||
let mut big = base_doc(1500, 1200);
|
||||
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
|
||||
let i = i as i32;
|
||||
big.layers.push(small(400 + i as u32, 1010 + (i % 4) * 5, 1010 + (i / 4) * 5, mode));
|
||||
}
|
||||
check(&mut g, &big, "small layers across chunks");
|
||||
}
|
||||
|
||||
fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer {
|
||||
let mut l = Layer::raster(name, fmt);
|
||||
let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32);
|
||||
@@ -673,25 +783,7 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
|
||||
|
||||
/// GPU vs CPU over the whole document: Err with the worst pixel if over the tolerance.
|
||||
fn fx_diff(g: &mut Gpu, doc: &Document, what: &str) -> Result<photocraft_gpu::Stats, String> {
|
||||
let cpu = photocraft_compose::flatten(doc);
|
||||
let (out, stats) = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).map_err(|e| format!("{what}: {e}"))?;
|
||||
let mut worst = (0.0f32, 0usize);
|
||||
for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() {
|
||||
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
|
||||
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
|
||||
for k in 0..4 {
|
||||
let d = (pc[k] - po[k]).abs();
|
||||
if d.is_nan() || d > worst.0 {
|
||||
worst = (if d.is_nan() { 9.0 } else { d }, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let w = doc.size.width as usize;
|
||||
let (x, y) = (worst.1 % w, worst.1 / w);
|
||||
if worst.0 > TOL {
|
||||
return Err(format!("{what}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
|
||||
}
|
||||
Ok(stats)
|
||||
diff_rect(g, doc, doc.bounds(), what)
|
||||
}
|
||||
|
||||
fn fx_check(g: &mut Gpu, doc: &Document, what: &str) -> photocraft_gpu::Stats {
|
||||
@@ -927,8 +1019,13 @@ fn as_text(src: Layer) -> Layer {
|
||||
l
|
||||
}
|
||||
|
||||
/// Held by tests that change the process-wide text gamma, and by tests with type layers that
|
||||
/// run long enough to see such a change.
|
||||
static TEXT_GAMMA: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn type_layers_blend_with_text_gamma() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(mut g) = gpu() else { return };
|
||||
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Color] {
|
||||
let mut d = base_doc(64, 48);
|
||||
@@ -958,6 +1055,24 @@ fn type_layers_blend_with_text_gamma() {
|
||||
];
|
||||
d.layers.push(l);
|
||||
fx_check(&mut g, &d, "text fx");
|
||||
// Emboss styles shade the composited layer (merge, shade, then the opacity mix), also as a
|
||||
// clipped layer over an opaque base.
|
||||
let mut e = fx_doc(96, 80, SampleType::U8);
|
||||
let mut l = as_text(blob("emboss", e.pixel_format(), 46.0, 40.0, 20.0, [0.4, 0.1, 0.3]));
|
||||
l.effects.items = vec![
|
||||
Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 9.0, 0.0)),
|
||||
Effect::BevelEmboss(bevel(BevelStyle::PillowEmboss, false, 5.0, 1.0)),
|
||||
Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0)),
|
||||
];
|
||||
l.opacity = 0.7;
|
||||
l.fill_opacity = 0.6;
|
||||
e.layers.push(l.clone());
|
||||
fx_check(&mut g, &e, "text emboss");
|
||||
let mut c = l;
|
||||
c.clipped = true;
|
||||
c.blend = BlendMode::Multiply;
|
||||
e.layers.push(c);
|
||||
fx_check(&mut g, &e, "clipped text emboss");
|
||||
// The gamma changes edge pixels against a linear mix.
|
||||
let flat = photocraft_compose::flatten(&d);
|
||||
let mut lin = d.clone();
|
||||
@@ -1129,7 +1244,7 @@ fn rgba16f_fallback_path_renders() {
|
||||
// Rgba16Float. Force that path here (even on a 32f-capable GPU) to prove it works end to end —
|
||||
// pipeline creation, the accumulation texture, and the half-float readback — within display
|
||||
// tolerance of the CPU reference. This is the path Intel-Vulkan / limited GPUs take.
|
||||
let Some((adapter, device, queue)) = any_device() else { return };
|
||||
let Some((adapter, device, queue, _lock)) = any_device() else { return };
|
||||
let mut comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba16Float) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return eprintln!("skipping: {e}"),
|
||||
@@ -1159,7 +1274,7 @@ fn rgba16f_fallback_path_renders() {
|
||||
fn unbuildable_pipelines_are_an_error_not_a_panic() {
|
||||
// The app falls back to the CPU compositor on Err; a panic here would crash it (as FXC once
|
||||
// did on D3D12). A depth format can't be a colour target, so its pipelines fail to build.
|
||||
let Some((adapter, device, _)) = any_device() else { return };
|
||||
let Some((adapter, device, _, _lock)) = any_device() else { return };
|
||||
let e = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Depth32Float).err().expect("depth target must fail");
|
||||
assert!(e.0.contains("pipelines"), "{e}");
|
||||
// The format the app picks builds (effect maps the adapter can't render are left out, not fatal).
|
||||
@@ -1168,3 +1283,118 @@ fn unbuildable_pipelines_are_an_error_not_a_panic() {
|
||||
assert!(Compositor::try_new_with_format(&device, f).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- documents larger than the texture limit ------------------------------------------------
|
||||
|
||||
/// A document several pages wide under [`PAGED_LIMIT`]: pixel layers crossing page borders (one
|
||||
/// masked, one off-canvas), a big effect layer whose region exceeds the limit (maps built per
|
||||
/// page cell), a type layer with a shadow, an adjustment, a clipped group and a gradient fill.
|
||||
fn big_doc() -> Document {
|
||||
let (w, h) = (1100, 900);
|
||||
let mut d = fx_doc(w, h, SampleType::U8);
|
||||
// Right over the background: adjustment results round to 8 bits, which would turn sub-LSB
|
||||
// float differences of blends beneath into whole steps.
|
||||
let mut adj = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
|
||||
adj.opacity = 0.6;
|
||||
adj.mask = Some(mask(Rect::new(240, 0, 800, 520), 83, 0.0));
|
||||
d.layers.push(adj);
|
||||
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(-30, 200, 700, 820), 81, 0.0);
|
||||
top.blend = BlendMode::Multiply;
|
||||
top.mask = Some(mask(Rect::new(100, 240, 600, 700), 82, 1.0));
|
||||
d.layers.push(top);
|
||||
let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1;
|
||||
let mut big = blob("big", d.pixel_format(), 520.0, 450.0, 330.0, [0.9, 0.4, 0.2]);
|
||||
big.effects.items = stack;
|
||||
big.effects.items.push(Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.6)), GlowTechnique::Softer, 40.0, 0.2, GlowSource::Edge)));
|
||||
d.layers.push(big);
|
||||
let mut text = as_text(blob("type", d.pixel_format(), 900.0, 150.0, 90.0, [0.1, 0.2, 0.9]));
|
||||
text.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 135.0, 12.0, 20.0, 0.0))];
|
||||
d.layers.push(text);
|
||||
let a = noise_layer("ga", PixelFormat::RGBA8, Rect::new(200, 500, 1000, 880), 84, 0.6);
|
||||
let mut c = noise_layer("gc", PixelFormat::RGBA8, Rect::new(0, 600, 1100, 700), 85, 0.0);
|
||||
c.clipped = true;
|
||||
c.blend = BlendMode::Screen;
|
||||
let mut grp = Layer::group("g", vec![a, c]);
|
||||
grp.opacity = 0.9;
|
||||
d.layers.push(grp);
|
||||
let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.6, Color::rgb(0.0, 1.0, 0.2)), (1.0, Color::rgb(0.1, 0.1, 0.9))];
|
||||
let mut grad = Layer::new("grad", LayerContent::Fill(Fill::Gradient { stops, angle: 30.0, scale: 0.8, style: GradientStyle::Radial, reverse: false }));
|
||||
grad.opacity = 0.3;
|
||||
grad.blend = BlendMode::Overlay;
|
||||
d.layers.push(grad);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_larger_than_the_texture_limit() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = big_doc();
|
||||
assert!(g.paged.supports(&d).is_ok());
|
||||
// Every pass sees page borders, and effect maps and blurs crossing them.
|
||||
check(&mut g, &d, "big document");
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
|
||||
assert!(s.cells >= 20 && s.chunks == s.cells, "{s:?}");
|
||||
// A viewport off the page grid covers only its cells.
|
||||
let view = Rect::new(300, 330, 790, 610);
|
||||
diff_rect(&mut g, &d, view, "viewport").unwrap_or_else(|e| panic!("{e}"));
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, view).unwrap().1;
|
||||
assert_eq!(s.cells, 6, "{s:?}");
|
||||
// A dab across a page corner on the effect layer, and an edit elsewhere.
|
||||
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(500, 490, 530, 530), &[0.1, 0.1, 0.9, 1.0]);
|
||||
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(250, 250, 270, 270), &[0.0, 0.0, 0.0, 0.0]);
|
||||
check(&mut g, &d, "after dabs");
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
|
||||
assert_eq!((s.tiles_uploaded, s.fx_shapes), (0, 0), "nothing changed since the last render: {s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_are_evicted_under_the_budget() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(mut g) = gpu() else { return };
|
||||
let mut d = big_doc();
|
||||
check(&mut g, &d, "warm");
|
||||
let all = g.paged.resident_bytes();
|
||||
// A budget of one page: everything but the cell being drawn is evicted as the render goes.
|
||||
let page = u64::from(g.paged.page_size()).pow(2) * 4;
|
||||
g.paged.set_resident_budget(page);
|
||||
for round in 0..3 {
|
||||
let cpu = photocraft_compose::flatten(&d);
|
||||
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap();
|
||||
let worst = worst_diff(&cpu.px, &out);
|
||||
assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1);
|
||||
assert!(s.evicted > 0, "round {round}: {s:?}");
|
||||
assert!(g.paged.resident_bytes() < all / 4, "{} of {all}", g.paged.resident_bytes());
|
||||
// Evicted pages come back with the edits made meanwhile.
|
||||
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(10 + round * 300, 300, 60 + round * 300, 340), &[0.9, 0.9, 0.1, 1.0]);
|
||||
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(200 + round * 200, 420, 240 + round * 200, 470), &[0.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
check(&mut g, &d, "after eviction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_view_stays_resident_after_a_full_refresh() {
|
||||
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let Some(g) = gpu() else { return };
|
||||
let d = big_doc();
|
||||
let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
|
||||
comp.set_texture_limit(PAGED_LIMIT);
|
||||
let view = Rect::new(520, 300, 760, 500);
|
||||
photocraft_gpu::render_to_vec(&mut comp, &g.device, &g.queue, &d, view).unwrap();
|
||||
// A budget holding just the view's pages: a full refresh evicts as it goes, and draws the
|
||||
// view's cells last, so the next edit in the view uploads nothing.
|
||||
// The working set of a region is what rendering it makes resident.
|
||||
assert_eq!(comp.working_set(&d, view), comp.resident_bytes());
|
||||
assert!(comp.working_set(&d, d.bounds()) > 4 * comp.resident_bytes());
|
||||
comp.set_resident_budget(comp.resident_bytes());
|
||||
assert!(comp.fits_budget(&d, view) && !comp.fits_budget(&d, d.bounds()));
|
||||
comp.set_focus(Some(view));
|
||||
for _ in 0..2 {
|
||||
let s = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
|
||||
assert!(s.evicted > 0, "{s:?}");
|
||||
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, view).unwrap();
|
||||
assert_eq!(s.tiles_uploaded, 0, "{s:?}");
|
||||
let cpu = photocraft_compose::render(&d, view);
|
||||
assert!(worst_diff(&cpu.px, &out).0 <= TOL);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ photocraft-codecs = { workspace = true }
|
||||
photocraft-raw = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-paint = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
photocraft-format = { path = "../format" }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -28,4 +29,3 @@ thiserror = { workspace = true }
|
||||
photocraft-psd = { workspace = true, features = ["testgen"] }
|
||||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
proptest = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
@@ -10,7 +10,9 @@
|
||||
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 layerpx x y # each layer's pixel
|
||||
//! cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd # every file: max err, bad %, PASS/DIFF
|
||||
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 dump prefix # raw f32 planes for offline fitting
|
||||
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 bylayer # max error around each layer
|
||||
//! HIDE_ADJ=1 … # adjustment layers hidden
|
||||
//! ONLY="name,name" … # only these top-level layers (+ the bottom one)
|
||||
//! DUMP_FX=1 … # raw effects descriptors
|
||||
//! ```
|
||||
fn main() {
|
||||
@@ -36,6 +38,14 @@ fn main() {
|
||||
}
|
||||
hide(&mut imp.document.layers);
|
||||
}
|
||||
if let Some(keep) = std::env::var_os("ONLY") {
|
||||
// Hide every top-level layer but the bottom one and those named in ONLY (comma-separated).
|
||||
let keep = keep.to_string_lossy().to_string();
|
||||
let names: Vec<&str> = keep.split(',').collect();
|
||||
for l in imp.document.layers.iter_mut().skip(1) {
|
||||
l.visible = names.contains(&l.name.as_str());
|
||||
}
|
||||
}
|
||||
let doc = &imp.document;
|
||||
println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light);
|
||||
for l in doc.walk() {
|
||||
@@ -173,7 +183,7 @@ fn main() {
|
||||
}
|
||||
}
|
||||
for rec in file.layers() {
|
||||
println!("-- layer {:?}", String::from_utf8_lossy(&rec.name));
|
||||
println!("-- layer {:?} rect {:?}", String::from_utf8_lossy(&rec.name), rec.rect);
|
||||
if let Some(b) = rec.block(b"lmfx").or(rec.block(b"lfx2")).or(rec.block(b"lfxs"))
|
||||
&& let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..])
|
||||
{
|
||||
@@ -264,6 +274,46 @@ fn main() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if std::env::args().nth(3).as_deref() == Some("bylayer") {
|
||||
// Max error and bad-pixel count inside each layer's bounds grown by its effects' reach
|
||||
// (regions overlap; a quick way to tell which layer or effect is off).
|
||||
let (w, h) = (doc.size.width as i32, doc.size.height as i32);
|
||||
for (_, _, l) in doc.walk() {
|
||||
let Some(sf) = l.surface() else { continue };
|
||||
let cb = sf.content_bounds();
|
||||
let mut b = photocraft_geom::Rect::new(i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||||
for y in cb.y0..cb.y1 {
|
||||
for x in cb.x0..cb.x1 {
|
||||
let p = sf.pixel(x, y);
|
||||
if p[p.len() - 1] > 0.0 {
|
||||
b = photocraft_geom::Rect::new(b.x0.min(x), b.y0.min(y), b.x1.max(x + 1), b.y1.max(y + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if b.x0 > b.x1 {
|
||||
continue;
|
||||
}
|
||||
let m = photocraft_compose::effects::margin(l);
|
||||
let r = photocraft_geom::Rect::new((b.x0 - m).max(0), (b.y0 - m).max(0), (b.x1 + m).min(w), (b.y1 + m).min(h));
|
||||
let (mut worst, mut at, mut bad) = (0.0f32, (0, 0), 0usize);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let i = (y * w + x) as usize;
|
||||
let (a, b) = (ours[i], merged[i]);
|
||||
let d = (0..3).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max);
|
||||
if d > 2.0 / 255.0 {
|
||||
bad += 1;
|
||||
}
|
||||
if d > worst {
|
||||
(worst, at) = (d, (x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
let fx: Vec<&str> = l.effects.items.iter().filter(|e| e.enabled()).map(|e| e.label()).collect();
|
||||
println!("{:<32} {:>6.1} at {:?} bad {:>6} {:?} {:?}", l.name, worst * 255.0, at, bad, l.blend, fx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if std::env::args().nth(3).as_deref() == Some("worst") {
|
||||
// The N worst pixels (premultiplied max channel error), with coordinates.
|
||||
let w = doc.size.width as usize;
|
||||
|
||||
+33
-15
@@ -10,7 +10,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use photocraft_paint::{
|
||||
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
|
||||
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, MixerSettings, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
|
||||
};
|
||||
use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip};
|
||||
use photocraft_psd::descriptor::{Descriptor, Value};
|
||||
@@ -281,6 +281,25 @@ impl Mapper<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixer Brush tool options captured with a preset (Wet, Load, Mix, Flow, Sample All Layers).
|
||||
/// Key names are best effort (Photoshop doesn't document them); several spellings are read.
|
||||
fn mixer_options(&mut self, to: &Descriptor, m: &mut MixerSettings) {
|
||||
let first = |keys: &[&str]| keys.iter().find_map(|k| pct(to, k));
|
||||
let wet = first(&["wetness", "Wtns", "wet"]);
|
||||
let load = first(&["dryness", "load", "Ld "]);
|
||||
let mix = first(&["mix", "mixRatio", "Mx "]);
|
||||
if wet.is_none() && load.is_none() && mix.is_none() {
|
||||
return;
|
||||
}
|
||||
m.wet = wet.unwrap_or(m.wet).clamp(0.0, 1.0);
|
||||
m.load = load.unwrap_or(m.load).clamp(0.0, 1.0);
|
||||
m.mix = mix.unwrap_or(m.mix).clamp(0.0, 1.0);
|
||||
m.flow = pct(to, "flow").unwrap_or(m.flow).clamp(0.0, 1.0);
|
||||
if let Some(v) = ["sampleMerged", "useAllLayers", "sampleAllLayers", "Mrgd"].iter().find_map(|k| boolean(to, k)) {
|
||||
m.sample_all_layers = v;
|
||||
}
|
||||
}
|
||||
|
||||
fn preset(&mut self, d: &Descriptor) -> Option<BrushSettings> {
|
||||
for (k, _) in &d.items {
|
||||
let k = String::from_utf8_lossy(k.as_bytes()).to_string();
|
||||
@@ -298,8 +317,9 @@ impl Mapper<'_> {
|
||||
}
|
||||
b.angle = num(t, "Angl").unwrap_or(0.0) as f32;
|
||||
b.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0);
|
||||
// `Intr` off = spacing unchecked (Photoshop spaces by pointer speed): keep it tight.
|
||||
b.spacing = if boolean(t, "Intr") == Some(false) { 0.05 } else { pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0) };
|
||||
// `Intr` off = the Spacing checkbox is off: the pointer's speed sets the spacing.
|
||||
b.spacing = pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0);
|
||||
b.spacing_enabled = boolean(t, "Intr") != Some(false);
|
||||
b.flip_x = boolean(t, "flipX").unwrap_or(false);
|
||||
b.flip_y = boolean(t, "flipY").unwrap_or(false);
|
||||
}
|
||||
@@ -316,13 +336,9 @@ impl Mapper<'_> {
|
||||
roundness,
|
||||
flip_x_jitter: boolean(d, "flipX").unwrap_or(false),
|
||||
flip_y_jitter: boolean(d, "flipY").unwrap_or(false),
|
||||
tilt_scale: pct(d, "tiltScale").unwrap_or(0.0).clamp(0.0, 2.0),
|
||||
brush_projection: boolean(d, "brushProjection").unwrap_or(false),
|
||||
};
|
||||
if pct(d, "tiltScale").is_some_and(|v| v > 0.0) {
|
||||
self.warnings.insert("Tilt Scale (Shape Dynamics) is not supported".into());
|
||||
}
|
||||
if boolean(d, "brushProjection") == Some(true) {
|
||||
self.warnings.insert("Brush Projection is not supported".into());
|
||||
}
|
||||
}
|
||||
// Scattering.
|
||||
if boolean(d, "useScatter") == Some(true) {
|
||||
@@ -393,12 +409,13 @@ impl Mapper<'_> {
|
||||
}
|
||||
// Transfer.
|
||||
if boolean(d, "usePaintDynamics") == Some(true) {
|
||||
b.transfer = Transfer { enabled: true, opacity: self.dynamic(obj(d, "opVr")), flow: self.dynamic(obj(d, "prVr")) };
|
||||
for (k, what) in [("wtVr", "Wetness"), ("mxVr", "Mix")] {
|
||||
if self.dynamic(obj(d, k)).is_active() {
|
||||
self.warnings.insert(format!("Transfer › {what} jitter (Mixer Brush) is not supported"));
|
||||
}
|
||||
}
|
||||
b.transfer = Transfer {
|
||||
enabled: true,
|
||||
opacity: self.dynamic(obj(d, "opVr")),
|
||||
flow: self.dynamic(obj(d, "prVr")),
|
||||
wetness: self.dynamic(obj(d, "wtVr")),
|
||||
mix: self.dynamic(obj(d, "mxVr")),
|
||||
};
|
||||
}
|
||||
// Brush Pose (Photoshop's tilt is ±100 %; ours is ±90°).
|
||||
if boolean(d, "useBrushPose") == Some(true) {
|
||||
@@ -443,6 +460,7 @@ impl Mapper<'_> {
|
||||
if let Some(v) = boolean(to, "smoothingZoomCompensation") {
|
||||
b.smoothing.adjust_for_zoom = v;
|
||||
}
|
||||
self.mixer_options(to, &mut b.mixer);
|
||||
if mask_blend_is_set(to) {
|
||||
self.warnings.insert("tool blend modes stored with presets are not applied".into());
|
||||
}
|
||||
|
||||
@@ -226,6 +226,51 @@ fn big_tips_are_downsampled() {
|
||||
assert!(imp.warnings.iter().any(|w| w.contains("downsampled")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_scale_projection_spacing_and_mixer_fields_map() {
|
||||
let tip = Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 30.0)).with("Spcn", prc(35.0)).with("Intr", Value::Boolean(false));
|
||||
let preset = Descriptor::new("brushPreset")
|
||||
.with("Nm ", t("Wet Pen"))
|
||||
.with("Brsh", Value::Descriptor(tip))
|
||||
.with("useTipDynamics", Value::Boolean(true))
|
||||
.with("szVr", var(3, 0.0, 0.0))
|
||||
.with("tiltScale", prc(150.0))
|
||||
.with("brushProjection", Value::Boolean(true))
|
||||
.with("usePaintDynamics", Value::Boolean(true))
|
||||
.with("wtVr", var(2, 30.0, 10.0))
|
||||
.with("mxVr", var(1, 45.0, 0.0))
|
||||
.with(
|
||||
"toolOptions",
|
||||
Value::Descriptor(
|
||||
Descriptor::new("currentToolOptions")
|
||||
.with("wetness", prc(80.0))
|
||||
.with("dryness", prc(20.0))
|
||||
.with("mix", prc(65.0))
|
||||
.with("flow", prc(70.0))
|
||||
.with("sampleMerged", Value::Boolean(true)),
|
||||
),
|
||||
);
|
||||
let plain =
|
||||
Descriptor::new("brushPreset").with("Nm ", t("Plain")).with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 9.0))));
|
||||
let bytes = write_v6(2, &[], &[], &[preset, plain], true).unwrap();
|
||||
let imp = read_abr(&bytes, "Mixer").unwrap();
|
||||
let b = &imp.presets[0].brush;
|
||||
assert!(!b.spacing_enabled && (b.spacing - 0.35).abs() < 1e-6, "Intr off = Spacing unchecked, the value kept");
|
||||
let sd = &b.shape_dynamics;
|
||||
assert!(sd.brush_projection && (sd.tilt_scale - 1.5).abs() < 1e-6 && sd.size.control == Control::PenTilt);
|
||||
let tr = &b.transfer;
|
||||
assert_eq!((tr.wetness.control, tr.wetness.jitter, tr.wetness.minimum), (Control::PenPressure, 0.3, 0.1));
|
||||
assert_eq!((tr.mix.control, tr.mix.jitter), (Control::Fade, 0.45));
|
||||
let m = &b.mixer;
|
||||
assert_eq!((m.wet, m.load, m.mix, m.flow, m.sample_all_layers), (0.8, 0.2, 0.65, 0.7, true));
|
||||
assert!(!imp.warnings.iter().any(|w| w.contains("Tilt Scale") || w.contains("Projection") || w.contains("Mixer")), "{:?}", imp.warnings);
|
||||
// Defaults when the file says nothing.
|
||||
let p = &imp.presets[1].brush;
|
||||
assert!(p.spacing_enabled && !p.shape_dynamics.brush_projection && p.shape_dynamics.tilt_scale == 0.0);
|
||||
assert_eq!(p.mixer, photocraft_paint::MixerSettings::default());
|
||||
assert_eq!((p.transfer.wetness, p.transfer.mix), (Dynamic::default(), Dynamic::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error() {
|
||||
assert!(read_abr(b"", "g").is_err());
|
||||
|
||||
@@ -104,6 +104,8 @@ pub fn color_from_desc(d: &Descriptor) -> Option<Color> {
|
||||
b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0),
|
||||
b"CMYC" => Color { mode: ColorMode::Cmyk, c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0], alpha: 1.0 },
|
||||
b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0),
|
||||
// CIE L*a*b*: L 0..100, a / b −128..127 (stored like Lab pixels: offset by 128).
|
||||
b"LbCl" => Color { mode: ColorMode::Lab, c: [g("Lmnc")? / 100.0, (g("A ")? + 128.0) / 255.0, (g("B ")? + 128.0) / 255.0, 0.0], alpha: 1.0 },
|
||||
// Newer Photoshop writes 0..1 floats.
|
||||
_ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?),
|
||||
_ => {
|
||||
@@ -123,6 +125,9 @@ pub fn color_to_desc(c: &Color) -> Descriptor {
|
||||
.with("Ylw ", d(c.c[2] * 100.0))
|
||||
.with("Blck", d(c.c[3] * 100.0)),
|
||||
ColorMode::Grayscale => Descriptor::new("Grsc").with("Gry ", d((1.0 - c.c[0]) * 100.0)),
|
||||
ColorMode::Lab => {
|
||||
Descriptor::new("LbCl").with("Lmnc", d(c.c[0] * 100.0)).with("A ", d(c.c[1] * 255.0 - 128.0)).with("B ", d(c.c[2] * 255.0 - 128.0))
|
||||
}
|
||||
_ => {
|
||||
let rgb = c.to_rgb();
|
||||
Descriptor::new("RGBC").with("Rd ", d(rgb[0] * 255.0)).with("Grn ", d(rgb[1] * 255.0)).with("Bl ", d(rgb[2] * 255.0))
|
||||
@@ -244,6 +249,70 @@ pub(crate) fn gradient_desc(stops: &[(f32, Color)], opacity: &[(f32, f32)]) -> D
|
||||
.with("Trns", Value::List(trns))
|
||||
}
|
||||
|
||||
/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness and midpoints applied (the
|
||||
/// same curve as the colour stops, Classic interpolation), densely sampled.
|
||||
fn smoothed_opacity_stops(grad: &Descriptor) -> Vec<(f32, f32)> {
|
||||
let mut stops = Vec::new();
|
||||
let mut mids = Vec::new();
|
||||
if let Some(Value::List(items)) = grad.get("Trns") {
|
||||
for it in items {
|
||||
if let Value::Descriptor(s) = it {
|
||||
let a = num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0;
|
||||
stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, Color::rgba(a, a, a, 1.0)));
|
||||
mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut order: Vec<usize> = (0..stops.len()).collect();
|
||||
order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0));
|
||||
let mids: Vec<f32> = order.iter().skip(1).filter_map(|i| mids.get(*i).copied()).collect();
|
||||
let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32);
|
||||
let baked = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::Classic) } else { stops };
|
||||
baked.into_iter().map(|(t, c)| (t, c.to_rgb()[0])).collect()
|
||||
}
|
||||
|
||||
/// Piecewise-linear value of sorted `(location, v)` stops at `t` (clamped at the ends).
|
||||
fn lerp_stops<T: Copy>(stops: &[(f32, T)], t: f32, mix: impl Fn(T, T, f32) -> T) -> Option<T> {
|
||||
let first = stops.first()?;
|
||||
if t <= first.0 {
|
||||
return Some(first.1);
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
if t <= b.0 {
|
||||
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
|
||||
return Some(mix(a.1, b.1, k));
|
||||
}
|
||||
}
|
||||
stops.last().map(|s| s.1)
|
||||
}
|
||||
|
||||
/// Folds a gradient's opacity stops into its colour stops' alpha (the model keeps one stop
|
||||
/// list): stops at every colour and opacity location, colours and opacities each interpolated
|
||||
/// linearly. Without opacity stops (or all opaque) the colour stops are returned unchanged.
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels).
|
||||
pub(crate) fn with_opacity_stops(stops: Vec<(f32, Color)>, opacity: &[(f32, f32)]) -> Vec<(f32, Color)> {
|
||||
let mut op: Vec<(f32, f32)> = opacity.iter().copied().filter(|(t, a)| t.is_finite() && a.is_finite()).collect();
|
||||
if op.iter().all(|(_, a)| *a >= 1.0) || stops.is_empty() {
|
||||
return stops;
|
||||
}
|
||||
op.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut sorted = stops;
|
||||
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut locs: Vec<f32> = sorted.iter().map(|s| s.0).chain(op.iter().map(|s| s.0)).collect();
|
||||
locs.sort_by(f32::total_cmp);
|
||||
locs.dedup();
|
||||
let mix_color =
|
||||
|a: Color, b: Color, k: f32| Color { c: std::array::from_fn(|i| a.c[i] + (b.c[i] - a.c[i]) * k), alpha: a.alpha + (b.alpha - a.alpha) * k, ..a };
|
||||
locs.into_iter()
|
||||
.filter_map(|t| {
|
||||
let c = lerp_stops(&sorted, t, mix_color)?;
|
||||
let a = lerp_stops(&op, t, |a, b, k| a + (b - a) * k)?;
|
||||
Some((t, Color { alpha: (c.alpha * a).clamp(0.0, 1.0), ..c }))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a fill block (`SoCo`, `GdFl`, `PtFl`).
|
||||
pub fn parse_fill(key: &[u8; 4], data: &[u8]) -> Option<Fill> {
|
||||
// Block data may carry trailing padding after the descriptor.
|
||||
@@ -260,9 +329,11 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option<Fill> {
|
||||
let angle = num(d.get("Angl")).unwrap_or(90.0) as f32;
|
||||
let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0);
|
||||
let (mut stops, _) = get_desc(d, "Grad").map(|g| gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default();
|
||||
let opacity = get_desc(d, "Grad").map(smoothed_opacity_stops).unwrap_or_default();
|
||||
if stops.is_empty() {
|
||||
stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)];
|
||||
}
|
||||
let stops = with_opacity_stops(stops, &opacity);
|
||||
Some(Fill::Gradient { stops, angle, scale, style: gradient_style(d), reverse: bool_of(d, "Rvrs") })
|
||||
}
|
||||
b"PtFl" => {
|
||||
@@ -431,6 +502,48 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a
|
||||
/// gradient fill's opacity stops reach the model (as stop alpha) and survive a write.
|
||||
#[test]
|
||||
fn gradient_fill_opacity_stops_become_alpha() {
|
||||
use photocraft_color::Color;
|
||||
use photocraft_doc::{Fill, GradientStyle};
|
||||
let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
|
||||
let f = Fill::Gradient {
|
||||
stops: vec![(0.0, red), (1.0, Color { alpha: 0.0, ..red })],
|
||||
angle: 90.0,
|
||||
scale: 1.0,
|
||||
style: GradientStyle::Linear,
|
||||
reverse: false,
|
||||
};
|
||||
let (k, data) = super::write_fill(&f);
|
||||
let Some(Fill::Gradient { stops, .. }) = super::parse_fill(&k, &data) else { panic!("gradient") };
|
||||
let alpha_at = |t: f32| stops.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map(|s| s.1.alpha).unwrap();
|
||||
assert_eq!(alpha_at(0.0), 1.0);
|
||||
assert!(alpha_at(1.0) < 0.01, "{stops:?}");
|
||||
assert!(stops.iter().all(|s| s.1.c[0] == 1.0 && s.1.c[1] == 0.0));
|
||||
// Opaque gradients keep their colour stops untouched.
|
||||
let opaque = super::with_opacity_stops(vec![(0.0, red), (1.0, Color::WHITE)], &[(0.0, 1.0), (1.0, 1.0)]);
|
||||
assert_eq!(opaque, vec![(0.0, red), (1.0, Color::WHITE)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_descriptor_colours_round_trip() {
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_psd::descriptor::{Descriptor, Value};
|
||||
let d = Descriptor::new("LbCl").with("Lmnc", Value::Double(19.07)).with("A ", Value::Double(52.29)).with("B ", Value::Double(-85.08));
|
||||
let c = super::color_from_desc(&d).unwrap();
|
||||
assert_eq!(c.mode, ColorMode::Lab);
|
||||
assert!((c.c[0] - 0.1907).abs() < 1e-5 && (c.c[1] * 255.0 - 128.0 - 52.29).abs() < 1e-3 && (c.c[2] * 255.0 - 128.0 + 85.08).abs() < 1e-3);
|
||||
let back = super::color_from_desc(&super::color_to_desc(&c)).unwrap();
|
||||
assert_eq!(back.mode, ColorMode::Lab);
|
||||
for i in 0..3 {
|
||||
assert!((back.c[i] - c.c[i]).abs() < 1e-6);
|
||||
}
|
||||
// Missing components are rejected, not defaulted.
|
||||
assert!(super::color_from_desc(&Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_ranges_map_to_blend_if() {
|
||||
use photocraft_doc::{BlendIf, BlendRange};
|
||||
|
||||
@@ -47,8 +47,35 @@ const BLEND_NAMES: [(BlendMode, &str); 28] = [
|
||||
(BlendMode::PassThrough, "passThrough"),
|
||||
];
|
||||
|
||||
/// Long (string-ID) names some writers use instead of the four-character codes, for the modes
|
||||
/// whose code is a short ID (`BlnM` `multiply` = `Mltp`). The others already use their long name.
|
||||
const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
|
||||
(BlendMode::Normal, "normal"),
|
||||
(BlendMode::Dissolve, "dissolve"),
|
||||
(BlendMode::Darken, "darken"),
|
||||
(BlendMode::Multiply, "multiply"),
|
||||
(BlendMode::ColorBurn, "colorBurn"),
|
||||
(BlendMode::Lighten, "lighten"),
|
||||
(BlendMode::Screen, "screen"),
|
||||
(BlendMode::ColorDodge, "colorDodge"),
|
||||
(BlendMode::Overlay, "overlay"),
|
||||
(BlendMode::SoftLight, "softLight"),
|
||||
(BlendMode::HardLight, "hardLight"),
|
||||
(BlendMode::Difference, "difference"),
|
||||
(BlendMode::Exclusion, "exclusion"),
|
||||
(BlendMode::Hue, "hue"),
|
||||
(BlendMode::Saturation, "saturation"),
|
||||
(BlendMode::Color, "color"),
|
||||
(BlendMode::Luminosity, "luminosity"),
|
||||
];
|
||||
|
||||
/// A blend-mode enum from either its four-character code or its long string ID.
|
||||
fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
|
||||
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
|
||||
}
|
||||
|
||||
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
|
||||
enum_of(d, key).and_then(|v| BLEND_NAMES.iter().find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)).unwrap_or(default)
|
||||
enum_of(d, key).and_then(blend_from_id).unwrap_or(default)
|
||||
}
|
||||
|
||||
fn blend_value(m: BlendMode) -> Value {
|
||||
@@ -721,6 +748,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_string_blend_ids_parse() {
|
||||
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
|
||||
// instead of the four-character codes (psd-tools effects/blend-modes.psd).
|
||||
let long = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
|
||||
let d = Descriptor::new("null")
|
||||
.with("masterFXSwitch", Value::Boolean(true))
|
||||
.with("DrSh", Value::Descriptor(Descriptor::new("DrSh").with("enab", Value::Boolean(true)).with("Md ", long("linearBurn"))))
|
||||
.with("ebbl", Value::Descriptor(Descriptor::new("ebbl").with("hglM", long("colorDodge")).with("sdwM", long("colorBurn"))))
|
||||
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("Md ", long("hue"))));
|
||||
let mut data = 0u32.to_be_bytes().to_vec();
|
||||
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
|
||||
let (_, fx) = parse_lfx2(&data).unwrap();
|
||||
let modes: Vec<BlendMode> = fx
|
||||
.iter()
|
||||
.flat_map(|e| match e {
|
||||
Effect::DropShadow(s) => vec![s.common.blend],
|
||||
Effect::BevelEmboss(b) => vec![b.highlight.blend, b.shadow.blend],
|
||||
Effect::ColorOverlay { common, .. } => vec![common.blend],
|
||||
_ => vec![],
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(modes, vec![BlendMode::LinearBurn, BlendMode::ColorDodge, BlendMode::ColorBurn, BlendMode::Hue]);
|
||||
for (m, n) in BLEND_LONG_NAMES {
|
||||
assert_eq!(blend_from_id(n.as_bytes()), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_none() {
|
||||
assert!(parse_lfx2(&[0, 0]).is_none());
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! colours (end tangents at half the chord slope); Perceptual interpolates in Oklab, Linear in
|
||||
//! linear light, Classic in sRGB. Stop midpoints remap each segment piecewise-linearly.
|
||||
|
||||
use photocraft_color::Color;
|
||||
use photocraft_color::{Color, ColorMode};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Method {
|
||||
@@ -117,7 +117,10 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32) -> [f32; 3
|
||||
/// already interpolates linearly in sRGB.
|
||||
pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> {
|
||||
let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3);
|
||||
if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids) {
|
||||
// Classic interpolates in the stops' own model: Lab stops (Lab documents) blend in L*a*b*, which the
|
||||
// compositors' sRGB interpolation can't reproduce (psd-tools 4x4_16bit_lab).
|
||||
let lab = method == Method::Classic && stops.iter().all(|(_, c)| c.mode == ColorMode::Lab);
|
||||
if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids && !lab) {
|
||||
return stops;
|
||||
}
|
||||
let mut s = stops;
|
||||
@@ -126,12 +129,16 @@ pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32,
|
||||
// Stop colours carry no alpha in Photoshop (opacity has its own stops); keep the nearest.
|
||||
s.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map_or(1.0, |x| x.1.alpha)
|
||||
};
|
||||
let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, into_space(method, c.to_rgb()))).collect();
|
||||
let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, if lab { [c.c[0], c.c[1], c.c[2]] } else { into_space(method, c.to_rgb()) })).collect();
|
||||
const N: usize = 96;
|
||||
let mut out: Vec<(f32, Color)> = (0..=N)
|
||||
.map(|k| {
|
||||
let t = k as f32 / N as f32;
|
||||
let rgb = out_of_space(method, eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t));
|
||||
let v = eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t);
|
||||
if lab {
|
||||
return (t, Color { mode: ColorMode::Lab, c: [v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0), 0.0], alpha: alpha(t) });
|
||||
}
|
||||
let rgb = out_of_space(method, v);
|
||||
(t, Color::rgba(rgb[0], rgb[1], rgb[2], alpha(t)))
|
||||
})
|
||||
.collect();
|
||||
@@ -184,6 +191,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_lab_stops_interpolate_in_lab() {
|
||||
// Lab documents blend Classic gradients in L*a*b* (psd-tools 4x4_16bit_lab): the half-way
|
||||
// colour is the Lab average, even without smoothness.
|
||||
let lab = |l: f32, a: f32, b: f32| Color { mode: ColorMode::Lab, c: [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0, 0.0], alpha: 1.0 };
|
||||
let s = bake(vec![(0.0, lab(19.07, 52.29, -85.08)), (1.0, lab(54.29, 80.81, 69.91))], &[0.5], 0.0, Method::Classic);
|
||||
assert!(s.len() > 2 && s.iter().all(|(_, c)| c.mode == ColorMode::Lab));
|
||||
let mid = s.iter().find(|(t, _)| (t - 0.5).abs() < 1e-6).map(|(_, c)| *c).unwrap();
|
||||
let want = lab(36.68, 66.55, -7.585);
|
||||
for i in 0..3 {
|
||||
assert!((mid.c[i] - want.c[i]).abs() < 1e-4, "{mid:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_shifts_the_half_way_colour() {
|
||||
let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.25], 0.0, Method::Classic);
|
||||
|
||||
@@ -181,6 +181,10 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
|
||||
}
|
||||
// Generic path (Lab, CMYK and others) via the raster model conversion.
|
||||
let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() });
|
||||
// Multichannel documents keep their channels apart (no layer): composite them.
|
||||
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
|
||||
return Ok(photocraft_compose::flatten(&doc).px);
|
||||
}
|
||||
let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
|
||||
Ok(photocraft_compose::surface_to_buffer(s, doc.bounds()).px)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Pattern};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_psd::patterns::{PsdPattern, block_key, mode_channels, parse_pattern_block, write_pattern_block};
|
||||
@@ -69,7 +69,10 @@ pub fn from_psd(p: &PsdPattern) -> Option<Pattern> {
|
||||
fill.push(max_sample(s));
|
||||
invert.push(false);
|
||||
}
|
||||
let bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
if mode == ColorMode::Lab && s == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, refs.len(), true);
|
||||
}
|
||||
let surface = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), &bytes);
|
||||
Some(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface })
|
||||
}
|
||||
@@ -83,8 +86,11 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
|
||||
};
|
||||
let fmt = PixelFormat::new(mode, f.sample, f.alpha);
|
||||
let surf = if fmt == f { p.surface.clone() } else { p.surface.convert(fmt) };
|
||||
let bytes = surf.to_interleaved(p.rect());
|
||||
let mut bytes = surf.to_interleaved(p.rect());
|
||||
let ch = fmt.channels();
|
||||
if mode == ColorMode::Lab && fmt.sample == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, ch, false);
|
||||
}
|
||||
let mut invert = vec![mode == ColorMode::Cmyk; ch];
|
||||
if fmt.alpha {
|
||||
invert[ch - 1] = false;
|
||||
|
||||
@@ -104,6 +104,27 @@ pub fn max_sample(s: SampleType) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Top of Photoshop's 16-bit Lab a*/b* scale: it stores `32768 + 256·a` (0..65280 spans −128..127,
|
||||
/// fitted on psd-tools `4x4_16bit_lab`: the merged image holds a stop's `LbCl` a* = 52.29 as 46154),
|
||||
/// while documents keep a*/b* on the 8-bit scale `(a + 128) / 255` at every depth.
|
||||
pub const LAB16_CHROMA_MAX: f32 = 65280.0;
|
||||
|
||||
/// Rescales the a*/b* channels (1 and 2) of interleaved native-endian 16-bit Lab pixels with
|
||||
/// `ch` channels: PSD → document when `to_doc`, else document → PSD. Round-trips exactly.
|
||||
pub fn lab16_chroma(bytes: &mut [u8], ch: usize, to_doc: bool) {
|
||||
if ch < 3 {
|
||||
return;
|
||||
}
|
||||
let k = if to_doc { 65535.0 / f64::from(LAB16_CHROMA_MAX) } else { f64::from(LAB16_CHROMA_MAX) / 65535.0 };
|
||||
for px in bytes.chunks_exact_mut(ch * 2) {
|
||||
for c in 1..3 {
|
||||
let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]);
|
||||
let w = (f64::from(v) * k).round().min(65535.0) as u16;
|
||||
px[2 * c..2 * c + 2].copy_from_slice(&w.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native-endian encoding of zero.
|
||||
pub fn zero_sample(s: SampleType) -> Vec<u8> {
|
||||
vec![0; s.bytes()]
|
||||
@@ -198,6 +219,34 @@ mod tests {
|
||||
assert_eq!(il, vec![9, 5, 9, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab16_chroma_scale_round_trips() {
|
||||
// Two pixels of three channels: L is untouched, a*/b* rescale, and every PSD value
|
||||
// comes back exactly.
|
||||
let mut all = Vec::new();
|
||||
for v in 0..=u16::MAX {
|
||||
all.extend_from_slice(&v.to_ne_bytes());
|
||||
all.extend_from_slice(&v.to_ne_bytes());
|
||||
all.extend_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
let orig = all.clone();
|
||||
lab16_chroma(&mut all, 3, true);
|
||||
let px = |b: &[u8], i: usize, c: usize| u16::from_ne_bytes([b[(i * 3 + c) * 2], b[(i * 3 + c) * 2 + 1]]);
|
||||
// Neutral (32768 in PSD) is 128 / 255 of the document scale; 65280 (a* = 127) is the top.
|
||||
assert_eq!(px(&all, 32768, 0), 32768);
|
||||
assert_eq!(px(&all, 32768, 1), (32768.0f64 * 65535.0 / 65280.0).round() as u16);
|
||||
assert_eq!(px(&all, 65280, 2), 65535);
|
||||
lab16_chroma(&mut all, 3, false);
|
||||
for i in 0..=65280usize {
|
||||
assert_eq!(px(&all, i, 1), px(&orig, i, 1), "{i}");
|
||||
}
|
||||
// Short or odd buffers are left alone.
|
||||
let mut short = vec![1u8, 2, 3];
|
||||
lab16_chroma(&mut short, 3, true);
|
||||
lab16_chroma(&mut short, 1, true);
|
||||
assert_eq!(short, [1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn be_codec() {
|
||||
let mut v = Vec::new();
|
||||
|
||||
+40
-19
@@ -88,7 +88,10 @@ impl Ex {
|
||||
if r.is_empty() {
|
||||
return (PsdRect::default(), self.empty_channels());
|
||||
}
|
||||
let bytes = s.to_interleaved(r);
|
||||
let mut bytes = s.to_interleaved(r);
|
||||
if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
|
||||
}
|
||||
let mut invert = vec![self.cmyk; self.cc];
|
||||
invert.push(false);
|
||||
let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert);
|
||||
@@ -101,7 +104,7 @@ impl Ex {
|
||||
(to_psd_rect(r), ch)
|
||||
}
|
||||
|
||||
fn mask(&self, m: &LayerMask) -> (MaskData, Option<ChannelData>) {
|
||||
fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option<ChannelData>) {
|
||||
let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() };
|
||||
let default = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let r = s.content_bounds();
|
||||
@@ -114,18 +117,10 @@ impl Ex {
|
||||
if !m.enabled {
|
||||
flags |= PsdMask::FLAG_DISABLED;
|
||||
}
|
||||
let density = (m.density < 1.0).then(|| q255(m.density));
|
||||
let feather = (m.feather != 0.0).then_some(f64::from(m.feather));
|
||||
let parameters = (density.is_some() || feather.is_some()).then(|| {
|
||||
let parameters = mask_parameters(Some((m.density, m.feather)), vector);
|
||||
if parameters.is_some() {
|
||||
flags |= PsdMask::FLAG_PARAMETERS;
|
||||
MaskParameters {
|
||||
flags: u8::from(density.is_some()) | u8::from(feather.is_some()) << 1,
|
||||
user_density: density,
|
||||
user_feather: feather,
|
||||
vector_density: None,
|
||||
vector_feather: None,
|
||||
}
|
||||
});
|
||||
}
|
||||
let pm = PsdMask {
|
||||
rect: if r.is_empty() { PsdRect::default() } else { to_psd_rect(r) },
|
||||
default_color: q255(default),
|
||||
@@ -133,6 +128,7 @@ impl Ex {
|
||||
trailing: if parameters.is_none() { vec![0, 0] } else { Vec::new() },
|
||||
parameters,
|
||||
real: None,
|
||||
real_first: true,
|
||||
};
|
||||
(MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)))
|
||||
}
|
||||
@@ -157,10 +153,23 @@ impl Ex {
|
||||
None => (PsdRect::default(), self.empty_channels()),
|
||||
};
|
||||
let mut mask = MaskData::None;
|
||||
// Vector mask density/feather live in the mask parameters, with or without a user mask.
|
||||
let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather));
|
||||
if let Some(m) = &l.mask {
|
||||
let (md, ch) = self.mask(m);
|
||||
let (md, ch) = self.mask(m, vector);
|
||||
mask = md;
|
||||
channels.extend(ch);
|
||||
} else if let Some(parameters) = mask_parameters(None, vector) {
|
||||
// Parameters only: no user mask channel, so nothing hides on re-import.
|
||||
mask = MaskData::Mask(PsdMask {
|
||||
rect: PsdRect::default(),
|
||||
default_color: 255,
|
||||
flags: PsdMask::FLAG_PARAMETERS,
|
||||
parameters: Some(parameters),
|
||||
real: None,
|
||||
trailing: Vec::new(),
|
||||
real_first: true,
|
||||
});
|
||||
}
|
||||
let mut flags = LayerFlags(0);
|
||||
flags.set_hidden(!l.visible);
|
||||
@@ -321,11 +330,6 @@ impl Ex {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(vm) = &l.vector_mask
|
||||
&& (vm.density < 1.0 || vm.feather != 0.0)
|
||||
{
|
||||
self.warnings.push(format!("layer \"{}\": vector mask density/feather are not written to PSD", l.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels for a fill layer: Photoshop's cached rendering while valid,
|
||||
@@ -501,6 +505,7 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) ->
|
||||
let canvas = doc.bounds();
|
||||
let w = canvas.width() as usize;
|
||||
let space = photocraft_compose::cmyk_space(doc);
|
||||
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
|
||||
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
|
||||
// Retain alpha whenever it differs from opaque at the stored precision.
|
||||
has_alpha |= band.px.iter().any(|p| match sample {
|
||||
@@ -522,6 +527,8 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) ->
|
||||
// Matte against white like Photoshop (see `pixels::matte`).
|
||||
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
|
||||
let x = if cmyk && c < cc { 1.0 - m } else { m };
|
||||
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
|
||||
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
|
||||
encode_be(x, sample, &mut out[c]);
|
||||
}
|
||||
}
|
||||
@@ -779,3 +786,17 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
|
||||
};
|
||||
(file, ex.warnings)
|
||||
}
|
||||
|
||||
/// PSD mask parameters for a user mask's and a vector mask's (density, feather); `None` when
|
||||
/// every value is the default (density 1, feather 0).
|
||||
fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option<MaskParameters> {
|
||||
let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d)));
|
||||
let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f)));
|
||||
let p =
|
||||
MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) };
|
||||
let flags = u8::from(p.user_density.is_some())
|
||||
| u8::from(p.user_feather.is_some()) << 1
|
||||
| u8::from(p.vector_density.is_some()) << 2
|
||||
| u8::from(p.vector_feather.is_some()) << 3;
|
||||
(flags != 0).then_some(MaskParameters { flags, ..p })
|
||||
}
|
||||
@@ -73,6 +73,23 @@ fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
|
||||
rec.layer_mask()?.real
|
||||
}
|
||||
|
||||
/// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer
|
||||
/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a
|
||||
/// feather, or when it stores no pixels and fills with a pattern.
|
||||
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<photocraft_doc::Fill> {
|
||||
if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() {
|
||||
return None;
|
||||
}
|
||||
let k = fill_key?;
|
||||
let fill = rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))?;
|
||||
let p = rec.layer_mask().and_then(|m| m.parameters);
|
||||
let soft = p.is_some_and(|p| p.vector_density.is_some_and(|d| d < 255) || p.vector_feather.is_some_and(|f| f > 0.0));
|
||||
// Without stored pixels a pattern-filled shape cannot be rasterized on its own (the
|
||||
// compositor resolves the document's patterns for fill layers).
|
||||
let unrendered_pattern = (rec.rect.is_empty() || rec.rect.size().is_err()) && matches!(fill, photocraft_doc::Fill::Pattern { .. });
|
||||
(soft || unrendered_pattern).then_some(fill)
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn warn(&mut self, s: impl Into<String>) {
|
||||
self.warnings.push(s.into());
|
||||
@@ -106,7 +123,10 @@ impl Ctx<'_> {
|
||||
fill.push(max_sample(s));
|
||||
let mut invert = vec![self.cmyk; self.cc];
|
||||
invert.push(false);
|
||||
let bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
|
||||
if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true);
|
||||
}
|
||||
let mut surf = Surface::from_interleaved(self.fmt, Rect::new(r.left, r.top, r.right, r.bottom), &bytes);
|
||||
surf.prune();
|
||||
surf
|
||||
@@ -247,11 +267,22 @@ impl Ctx<'_> {
|
||||
warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)),
|
||||
stack_mode: None,
|
||||
})
|
||||
} else if let Some(f) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
|
||||
// A shape whose vector mask has a density or feather is a fill layer seen through a
|
||||
// soft vector mask: the fill shows beyond the path, which the stored pixels (the
|
||||
// shape alone) lack. Import it as exactly that.
|
||||
LayerContent::Fill(f)
|
||||
} else if (vector_key.is_some() && (fill_key.is_some() || rec.block(b"vstk").is_some())) || rec.block(b"vscg").is_some() {
|
||||
let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)));
|
||||
let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() };
|
||||
let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone());
|
||||
crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi);
|
||||
// No stored pixels (32-bit documents, files saved without layer pixels): render the
|
||||
// shape from its path and fill, as Photoshop does when it opens the file.
|
||||
if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) {
|
||||
let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32);
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas));
|
||||
}
|
||||
LayerContent::Shape(sh)
|
||||
} else if let Some(k) = fill_key {
|
||||
match rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)) {
|
||||
@@ -499,7 +530,10 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
|
||||
fill.push(max_sample(depth));
|
||||
let mut inv = vec![cx.cmyk; cc];
|
||||
inv.push(false);
|
||||
let bytes = interleave(&planes, &fill, n, depth, &inv);
|
||||
let mut bytes = interleave(&planes, &fill, n, depth, &inv);
|
||||
if fmt.mode == ColorMode::Lab && depth == SampleType::U16 {
|
||||
crate::pixels::lab16_chroma(&mut bytes, cc + 1, true);
|
||||
}
|
||||
let mut s = Surface::from_interleaved(fmt, canvas, &bytes);
|
||||
if alpha_idx.is_some() {
|
||||
// Undo Photoshop's white matting of the merged image.
|
||||
|
||||
+107
-7
@@ -75,30 +75,84 @@ pub fn path_from_resource(data: &[u8], w: u32, h: u32) -> Option<Path> {
|
||||
PathData::from_bytes(data).ok().map(|p| path_from_records(&p, w, h))
|
||||
}
|
||||
|
||||
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags).
|
||||
/// Layer vector data (`vmsk`/`vsms`) → model path, with Photoshop's reading of the records
|
||||
/// (matching its rendering of the psd-tools corpus):
|
||||
/// - without subpaths, the initial-fill record alone decides coverage (1 = everything);
|
||||
/// - with subpaths the initial-fill record is ignored; a first component that subtracts starts
|
||||
/// from a full canvas (the shape is inverted);
|
||||
/// - a subpath with operation -1 continues the previous component (same operation).
|
||||
///
|
||||
/// The model applies the first subpath as "combine" and inverts the final result, so a leading
|
||||
/// subtract becomes `inverted` with every later operation replaced by its complement dual
|
||||
/// (combine ↔ subtract; exclude is self-dual; intersect has no dual and is kept as is).
|
||||
fn layer_path_from_records(p: &PathData, w: u32, h: u32) -> Path {
|
||||
let mut path = path_from_records(p, w, h);
|
||||
if path.subpaths.is_empty() {
|
||||
return path;
|
||||
}
|
||||
let ops: Vec<i16> = p.subpaths().iter().map(|s| s.operation).collect();
|
||||
let mut prev = PathOp::Combine;
|
||||
for (s, &raw) in path.subpaths.iter_mut().zip(&ops) {
|
||||
if raw == -1 {
|
||||
s.op = prev;
|
||||
}
|
||||
prev = s.op;
|
||||
}
|
||||
path.inverted = path.subpaths.first().is_some_and(|s| s.op == PathOp::Subtract);
|
||||
if path.inverted {
|
||||
for s in path.subpaths.iter_mut().skip(1) {
|
||||
s.op = match s.op {
|
||||
PathOp::Combine => PathOp::Subtract,
|
||||
PathOp::Subtract => PathOp::Combine,
|
||||
op => op,
|
||||
};
|
||||
}
|
||||
if let Some(first) = path.subpaths.first_mut() {
|
||||
first.op = PathOp::Combine;
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags). Coverage semantics: an
|
||||
/// empty path covers everything when `inverted` (shape layers; vector masks flip this, see
|
||||
/// [`vector_mask_from_block`]).
|
||||
pub fn path_from_vmsk(data: &[u8], w: u32, h: u32) -> Option<(Path, u32)> {
|
||||
let b = VectorMaskBlock::from_bytes(data).ok()?;
|
||||
let mut p = path_from_records(&b.path, w, h);
|
||||
let mut p = layer_path_from_records(&b.path, w, h);
|
||||
if b.flags & VectorMaskBlock::FLAG_INVERT != 0 {
|
||||
p.inverted = !p.inverted;
|
||||
}
|
||||
Some((p, b.flags))
|
||||
}
|
||||
|
||||
/// A `vmsk` block for `path` with extra flags (not linked / disabled).
|
||||
/// A `vmsk` block for `path` with extra flags (not linked / disabled). An empty path is written
|
||||
/// as the initial-fill record (Photoshop's form); otherwise inversion uses the invert flag.
|
||||
pub fn vmsk_bytes(path: &Path, flags: u32, w: u32, h: u32) -> Vec<u8> {
|
||||
let mut p = path.clone();
|
||||
let mut flags = flags & !VectorMaskBlock::FLAG_INVERT;
|
||||
if p.inverted {
|
||||
if p.inverted && !p.subpaths.is_empty() {
|
||||
flags |= VectorMaskBlock::FLAG_INVERT;
|
||||
p.inverted = false;
|
||||
}
|
||||
// The model draws the first subpath as "combine"; a stored leading subtract would read back
|
||||
// as an inverted shape.
|
||||
if let Some(first) = p.subpaths.first_mut()
|
||||
&& first.op == PathOp::Subtract
|
||||
{
|
||||
first.op = PathOp::Combine;
|
||||
}
|
||||
VectorMaskBlock { version: 3, flags, path: path_to_records(&p, w, h) }.to_bytes()
|
||||
}
|
||||
|
||||
/// A layer's vector mask from its `vmsk`/`vsms` block.
|
||||
/// A layer's vector mask from its `vmsk`/`vsms` block. A vector mask without subpaths reveals
|
||||
/// everything unless inverted (see `photocraft_vector::vector_mask_values`), the opposite of a
|
||||
/// shape's coverage, hence the flip.
|
||||
pub fn vector_mask_from_block(data: &[u8], w: u32, h: u32) -> Option<VectorMask> {
|
||||
let (path, flags) = path_from_vmsk(data, w, h)?;
|
||||
let (mut path, flags) = path_from_vmsk(data, w, h)?;
|
||||
if path.subpaths.is_empty() {
|
||||
path.inverted = !path.inverted;
|
||||
}
|
||||
Some(VectorMask {
|
||||
path,
|
||||
enabled: flags & VectorMaskBlock::FLAG_DISABLED == 0,
|
||||
@@ -117,7 +171,11 @@ pub fn vector_mask_bytes(m: &VectorMask, w: u32, h: u32) -> Vec<u8> {
|
||||
if !m.linked {
|
||||
flags |= VectorMaskBlock::FLAG_NOT_LINKED;
|
||||
}
|
||||
vmsk_bytes(&m.path, flags, w, h)
|
||||
let mut path = m.path.clone();
|
||||
if path.subpaths.is_empty() {
|
||||
path.inverted = !path.inverted;
|
||||
}
|
||||
vmsk_bytes(&path, flags, w, h)
|
||||
}
|
||||
|
||||
/// Compares the parts of a vector mask stored in the block (density/feather live elsewhere).
|
||||
@@ -515,6 +573,48 @@ mod tests {
|
||||
assert_eq!(op_from_psd(-1), PathOp::Combine);
|
||||
}
|
||||
|
||||
fn vmsk_with(initial_fill: bool, ops: &[i16]) -> Vec<u8> {
|
||||
let square = |x: f64| PsdSubpath {
|
||||
closed: true,
|
||||
operation: 0,
|
||||
knots: [(x, 0.25), (x + 0.25, 0.25), (x + 0.25, 0.5), (x, 0.5)].iter().map(|&p| PsdKnot { linked: false, pre: p, anchor: p, post: p }).collect(),
|
||||
};
|
||||
let subs: Vec<PsdSubpath> = ops.iter().enumerate().map(|(i, &op)| PsdSubpath { operation: op, ..square(0.1 * i as f64) }).collect();
|
||||
VectorMaskBlock { version: 3, flags: 0, path: PathData::from_subpaths(&subs, initial_fill) }.to_bytes()
|
||||
}
|
||||
|
||||
/// psd-tools vector-mask.psd / vector-mask2.psd / vector-mask3.psd: Photoshop's initial-fill
|
||||
/// record only matters without subpaths; a leading subtract inverts; -1 continues the
|
||||
/// previous component.
|
||||
#[test]
|
||||
fn layer_path_semantics_follow_photoshop() {
|
||||
// Initial fill with a combine subpath: a plain (not inverted) shape.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(true, &[1]), 100, 100).unwrap();
|
||||
assert!(!p.inverted);
|
||||
// Leading subtract: everything but the shape; later ops become their complements.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(true, &[2, 1, 2, 0]), 100, 100).unwrap();
|
||||
assert!(p.inverted);
|
||||
assert_eq!(p.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), [PathOp::Combine, PathOp::Subtract, PathOp::Combine, PathOp::Exclude]);
|
||||
// -1 continues the previous component's operation.
|
||||
let (p, _) = path_from_vmsk(&vmsk_with(false, &[1, 2, -1]), 100, 100).unwrap();
|
||||
assert_eq!(p.subpaths[2].op, PathOp::Subtract);
|
||||
// No subpaths: a vector mask with initial fill reveals all, without hides all; a shape
|
||||
// with initial fill covers everything.
|
||||
let reveal = vector_mask_from_block(&vmsk_with(true, &[]), 100, 100).unwrap();
|
||||
assert_eq!(photocraft_vector::vector_mask_values(&reveal, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![1.0; 4]);
|
||||
let hide = vector_mask_from_block(&vmsk_with(false, &[]), 100, 100).unwrap();
|
||||
assert_eq!(photocraft_vector::vector_mask_values(&hide, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![0.0; 4]);
|
||||
assert!(path_from_vmsk(&vmsk_with(true, &[]), 100, 100).unwrap().0.inverted);
|
||||
// Our own writer round-trips each case.
|
||||
for m in [reveal, hide] {
|
||||
assert_eq!(vector_mask_from_block(&vector_mask_bytes(&m, 100, 100), 100, 100), Some(m));
|
||||
}
|
||||
let mut lead = sample_path();
|
||||
lead.subpaths[0].op = PathOp::Subtract;
|
||||
let back = path_from_vmsk(&vmsk_bytes(&lead, 0, 64, 64), 64, 64).unwrap().0;
|
||||
assert!(!back.inverted, "a stored leading subtract is drawn as combine by the model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_mask_roundtrip_with_flags() {
|
||||
let mut m = VectorMask::new(sample_path());
|
||||
|
||||
+437
-93
@@ -1,22 +1,49 @@
|
||||
//! Optional real-file corpus: `corpus/psd/**/*.{psd,psb}` at the workspace
|
||||
//! root (gitignored; see `corpus/psd/SOURCES.md`). Skips silently if absent.
|
||||
//! Optional real-file corpora (gitignored, never committed; each test skips
|
||||
//! silently when its corpus is absent):
|
||||
//!
|
||||
//! - `corpus/psd/**/*.{psd,psb}`: a hand-picked mix of small ag-psd and
|
||||
//! psd-tools samples (see `corpus/psd/SOURCES.md`). Enforced with
|
||||
//! `PHOTOCRAFT_CORPUS` (a directory, or `1` for the default location).
|
||||
//! - `corpus/psd-tools/**/*.{psd,psb}`: the complete psd-tools test set
|
||||
//! (MIT) at a pinned commit, fetched and sha256-verified by
|
||||
//! `cargo xtask corpus --psd-tools`. Enforced with
|
||||
//! `PHOTOCRAFT_PSDTOOLS_CORPUS` (a directory, or `1`).
|
||||
//!
|
||||
//! For each file: parse → document → flatten, compared with the file's own
|
||||
//! merged composite (Photoshop's rendering) as the oracle; then document →
|
||||
//! PSD → document → flatten, compared with the first flatten (our own export
|
||||
//! must not change what the document looks like). Prints a per-file table.
|
||||
//! A panic anywhere is caught and reported as CRASH (Rule 9).
|
||||
//!
|
||||
//! Without `PHOTOCRAFT_CORPUS` the comparisons are only reported: differences
|
||||
//! are expected where features are not yet rendered (effects, text engine,
|
||||
//! smart filters). With `PHOTOCRAFT_CORPUS` set (to the corpus directory, or
|
||||
//! to `1` for the default location) the run asserts that the oracle pass
|
||||
//! count and the export round-trip count do not fall below their floors.
|
||||
//! Raise the floors when they improve; never lower them.
|
||||
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to fail on import/export errors.
|
||||
//! Oracles: files without layers (Bitmap, Indexed, plain flat files) compare
|
||||
//! our import with the merged image as decoded by the psd crate. When
|
||||
//! Photoshop wrote no real merged image (Maximize Compatibility off: blank
|
||||
//! image data) or the merged image would decode through our own model code
|
||||
//! (Multichannel), the embedded thumbnail (resource 1036, Photoshop's own
|
||||
//! render, JPEG, ≤ 160 px) is the oracle: our composite is box-downsampled to
|
||||
//! its size and both are lightly blurred; PASS (thumbnail) needs a mean error
|
||||
//! ≤ 3/255 with ≤ 2 % of pixels off by more than 24/255. That bar is strict
|
||||
//! (calibrated on files that match their merged image, many thumbnails being
|
||||
//! stale or resampled differently), so a thumbnail PASS is real evidence while
|
||||
//! a thumbnail DIFF may be the thumbnail's fault. A file SKIPs only when no
|
||||
//! oracle exists (no real composite and no usable thumbnail).
|
||||
//!
|
||||
//! Without the env var the comparisons are only reported: differences are
|
||||
//! expected where features are not yet rendered (effects, text engine, smart
|
||||
//! filters, knockout). With it set the run asserts no crashes, and that the
|
||||
//! oracle pass count and the export round-trip count do not fall below the
|
||||
//! source's floors. Raise the floors when they improve; never lower them.
|
||||
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to also fail on import/export errors
|
||||
//! (files listed in `KNOWN_BAD` excepted).
|
||||
//!
|
||||
//! `*_mutations_never_panic` truncates and corrupts every corpus file and
|
||||
//! asserts import + flatten return (Ok or Err) without panicking.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::PsdFile;
|
||||
@@ -36,111 +63,428 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
const PASS_TOL: f32 = 2.0 / 255.0;
|
||||
/// Export → re-import must render within one 8-bit step of the imported document.
|
||||
const ROUNDTRIP_TOL: f32 = 1.0 / 255.0 + 1e-5;
|
||||
/// Files whose flatten matches Photoshop's merged image (`corpus/psd`, 170 files).
|
||||
const PASS_FLOOR: usize = 113;
|
||||
/// Files whose export → re-import renders the same as the import.
|
||||
const ROUNDTRIP_FLOOR: usize = 169;
|
||||
|
||||
#[test]
|
||||
fn corpus_import_flatten_oracle() {
|
||||
let env = std::env::var_os("PHOTOCRAFT_CORPUS").filter(|v| !v.is_empty());
|
||||
/// Dissolve block size and tolerance (see [`dissolve_matches`]).
|
||||
const DISSOLVE_BLOCK: i32 = 16;
|
||||
const DISSOLVE_TOL: f32 = 0.1;
|
||||
|
||||
/// Oracle for documents with Dissolve layers. Photoshop's dissolve decides each pixel with a
|
||||
/// position-only pseudo-random threshold (observed on psd-tools dissolve.psd: where dissolved
|
||||
/// layers of equal opacity overlap, a pixel shows the top layer or nothing, never a lower one),
|
||||
/// but its generator is not public and can't be recovered from one binary pattern; ours uses
|
||||
/// another hash with the same rule. So outside the dissolve layers' bounds pixels must match
|
||||
/// as usual (≤ 2/255), and inside them the premultiplied colour averaged over 16 × 16 blocks
|
||||
/// (density and colour) must agree within 0.1 (binomial noise of a 50 % dissolve is about
|
||||
/// 0.044 per block difference).
|
||||
fn dissolve_matches(doc: &photocraft_doc::Document, ours: &[[f32; 4]], ps: &[[f32; 4]]) -> bool {
|
||||
let canvas = doc.bounds();
|
||||
let regions: Vec<_> = doc
|
||||
.walk()
|
||||
.into_iter()
|
||||
.filter(|(_, _, l)| l.visible && l.blend == photocraft_color::BlendMode::Dissolve)
|
||||
.map(|(_, _, l)| photocraft_compose::layer_bounds(l, canvas).intersect(&canvas))
|
||||
.filter(|r| !r.is_empty())
|
||||
.collect();
|
||||
let (w, h) = (canvas.width() as i32, canvas.height() as i32);
|
||||
if regions.is_empty() || ours.len() != ps.len() || ours.len() != (w as usize) * (h as usize) {
|
||||
return false;
|
||||
}
|
||||
let pm = |p: &[f32; 4], c: usize| if c < 3 { p[c] * p[3] } else { p[3] };
|
||||
let inside = |x: i32, y: i32| regions.iter().any(|r| r.contains(x + canvas.x0, y + canvas.y0));
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
if !inside(x, y) && (0..4).any(|c| (pm(&ours[i], c) - pm(&ps[i], c)).abs() > PASS_TOL) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for by in (0..h).step_by(DISSOLVE_BLOCK as usize) {
|
||||
for bx in (0..w).step_by(DISSOLVE_BLOCK as usize) {
|
||||
let (x1, y1) = ((bx + DISSOLVE_BLOCK).min(w), (by + DISSOLVE_BLOCK).min(h));
|
||||
let n = ((x1 - bx) * (y1 - by)) as f32;
|
||||
for c in 0..4 {
|
||||
let (mut a, mut b) = (0.0, 0.0);
|
||||
for y in by..y1 {
|
||||
for x in bx..x1 {
|
||||
let i = (y * w + x) as usize;
|
||||
a += pm(&ours[i], c);
|
||||
b += pm(&ps[i], c);
|
||||
}
|
||||
}
|
||||
if ((a - b) / n).abs() > DISSOLVE_TOL {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Files known to be invalid upstream: their parse error is expected.
|
||||
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
|
||||
|
||||
/// One corpus directory with its own floors.
|
||||
struct Source {
|
||||
label: &'static str,
|
||||
/// Enforcing env var: a directory, or `1` for `default_dir`.
|
||||
env: &'static str,
|
||||
/// Default location, relative to the workspace root.
|
||||
default_dir: &'static str,
|
||||
/// Files whose flatten matches their oracle (merged image, or thumbnail when there is none).
|
||||
pass_floor: usize,
|
||||
/// Files whose export → re-import renders the same as the import.
|
||||
roundtrip_floor: usize,
|
||||
}
|
||||
|
||||
/// Hand-picked mix in `corpus/psd` (170 files: 133 vs the merged image + 12 vs the thumbnail).
|
||||
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 145, roundtrip_floor: 169 };
|
||||
|
||||
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
|
||||
/// 218 vs the merged image + 10 vs the thumbnail.
|
||||
const PSD_TOOLS: Source =
|
||||
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 228, roundtrip_floor: 307 };
|
||||
|
||||
/// The corpus directory and whether floors are enforced; `None` when absent.
|
||||
fn locate(src: &Source) -> Option<(PathBuf, bool)> {
|
||||
let env = std::env::var_os(src.env).filter(|v| !v.is_empty());
|
||||
let root = match &env {
|
||||
Some(v) if v != "1" => PathBuf::from(v),
|
||||
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"),
|
||||
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(src.default_dir),
|
||||
};
|
||||
if !root.is_dir() {
|
||||
assert!(env.is_none(), "PHOTOCRAFT_CORPUS is set but {} is not a directory", root.display());
|
||||
return;
|
||||
assert!(env.is_none(), "{} is set but {} is not a directory", src.env, root.display());
|
||||
return None;
|
||||
}
|
||||
Some((root, env.is_some()))
|
||||
}
|
||||
|
||||
fn files_in(root: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
collect(root, &mut files);
|
||||
files.sort();
|
||||
let (mut pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0);
|
||||
let (mut rt_same, mut rt_diff) = (0, Vec::new());
|
||||
files
|
||||
}
|
||||
|
||||
fn panic_message(e: &(dyn std::any::Any + Send)) -> String {
|
||||
e.downcast_ref::<&str>().map(|s| (*s).to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
|
||||
}
|
||||
|
||||
/// Thumbnail oracle: mean absolute error and the share of pixels off by more than
|
||||
/// `THUMB_BAD`, after box-downsampling our composite (over white) to the JPEG thumbnail's size.
|
||||
const THUMB_MEAN_TOL: f32 = 3.0 / 255.0;
|
||||
const THUMB_BAD: f32 = 24.0 / 255.0;
|
||||
const THUMB_BAD_FRAC: f32 = 0.02;
|
||||
|
||||
/// Photoshop's embedded thumbnail (resource 1036: a 28-byte header, then JFIF) as RGB floats.
|
||||
fn thumbnail(file: &PsdFile) -> Option<(u32, u32, Vec<[f32; 3]>)> {
|
||||
let r = file.resource(photocraft_psd::resources::ids::THUMBNAIL)?;
|
||||
let format = u32::from_be_bytes(r.data.get(..4)?.try_into().ok()?);
|
||||
if format != 1 {
|
||||
return None;
|
||||
}
|
||||
let img = photocraft_codecs::decode(r.data.get(28..)?).ok()?;
|
||||
let rgba = img.to_rgba8();
|
||||
Some((img.width(), img.height(), rgba.as_chunks::<4>().0.iter().map(|p| [0, 1, 2].map(|c| f32::from(p[c]) / 255.0)).collect()))
|
||||
}
|
||||
|
||||
/// 3×3 box blur of an RGB plane (edges clamp): absorbs the sub-pixel offsets between our box
|
||||
/// downsampling and Photoshop's thumbnail resampling, and JPEG ringing.
|
||||
fn blur3(v: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
|
||||
let mut out = vec![[0.0; 3]; v.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut acc = [0.0f32; 3];
|
||||
for dy in [-1i64, 0, 1] {
|
||||
for dx in [-1i64, 0, 1] {
|
||||
let (sx, sy) = ((x as i64 + dx).clamp(0, w as i64 - 1) as usize, (y as i64 + dy).clamp(0, h as i64 - 1) as usize);
|
||||
for c in 0..3 {
|
||||
acc[c] += v[sy * w + sx][c];
|
||||
}
|
||||
}
|
||||
}
|
||||
out[y * w + x] = acc.map(|a| a / 9.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compares `ours` (canvas-size straight RGBA) with the thumbnail: our composite over white is
|
||||
/// box-downsampled to the thumbnail's size, both are lightly blurred, and the result is the mean
|
||||
/// absolute error and the share of pixels off by more than `THUMB_BAD`. `None` without a
|
||||
/// thumbnail, or when it is not smaller than the canvas (tiny documents: not a real reduction).
|
||||
fn thumbnail_oracle(file: &PsdFile, ours: &[[f32; 4]]) -> Option<(f32, f32)> {
|
||||
let (tw, th, thumb) = thumbnail(file)?;
|
||||
let (w, h) = (file.header.width as usize, file.header.height as usize);
|
||||
let (tw, th) = (tw as usize, th as usize);
|
||||
if tw < 8 || th < 8 || tw > w || th > h || ours.len() != w * h || thumb.len() != tw * th {
|
||||
return None;
|
||||
}
|
||||
let mut small = Vec::with_capacity(tw * th);
|
||||
for ty in 0..th {
|
||||
for tx in 0..tw {
|
||||
let (x0, x1) = (tx * w / tw, ((tx + 1) * w / tw).max(tx * w / tw + 1).min(w));
|
||||
let (y0, y1) = (ty * h / th, ((ty + 1) * h / th).max(ty * h / th + 1).min(h));
|
||||
let mut acc = [0.0f32; 3];
|
||||
for y in y0..y1 {
|
||||
for p in &ours[y * w + x0..y * w + x1] {
|
||||
for c in 0..3 {
|
||||
acc[c] += p[c] * p[3] + 1.0 - p[3];
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = ((x1 - x0) * (y1 - y0)) as f32;
|
||||
small.push(acc.map(|a| a / n));
|
||||
}
|
||||
}
|
||||
let (a, b) = (blur3(&small, tw, th), blur3(&thumb, tw, th));
|
||||
let (mut sum, mut bad) = (0.0f32, 0usize);
|
||||
for (p, t) in a.iter().zip(&b) {
|
||||
let d: [f32; 3] = std::array::from_fn(|c| (p[c] - t[c]).abs());
|
||||
sum += (d[0] + d[1] + d[2]) / 3.0;
|
||||
bad += usize::from(d.iter().any(|v| *v > THUMB_BAD));
|
||||
}
|
||||
let n = (tw * th) as f32;
|
||||
Some((sum / n, bad as f32 / n))
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
Pass,
|
||||
/// Matched the embedded thumbnail (no real merged image to compare with).
|
||||
ThumbPass,
|
||||
Diff,
|
||||
Skip,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// Oracle + round trip for one file. Returns the oracle outcome and whether the export round trip
|
||||
/// rendered the same (`None` when it could not run).
|
||||
fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
|
||||
let known_bad = KNOWN_BAD.iter().any(|(f, _)| name.ends_with(f));
|
||||
let file = match PsdFile::from_bytes(bytes) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let tag = if known_bad { "KNOWN-BAD" } else { "PARSE-ERROR" };
|
||||
eprintln!("{name:<60} {:>6} {:>9} {:>8} {tag} {e}", "-", "-", "-");
|
||||
return (if known_bad { Outcome::Skip } else { Outcome::Error }, None);
|
||||
}
|
||||
};
|
||||
let imp = match import(name, bytes) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} IMPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
let doc = &imp.document;
|
||||
// Export must always succeed, re-parse and re-import.
|
||||
let reimported = match export(doc, "x.psd", &ExportOptions::default()) {
|
||||
Ok(r) => {
|
||||
if let Err(e) = PsdFile::from_bytes(&r.bytes) {
|
||||
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
match import(name, &r.bytes) {
|
||||
Ok(i) => i.document,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} REIMPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} EXPORT-ERROR {e}");
|
||||
return (Outcome::Error, None);
|
||||
}
|
||||
};
|
||||
let ours = photocraft_compose::flatten(doc).px;
|
||||
// Our own export must not change how the document renders.
|
||||
let again = photocraft_compose::flatten(&reimported).px;
|
||||
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
|
||||
let layers = doc.layer_count();
|
||||
// Oracle choice: the merged image (Photoshop's composite; for files without layers it is the
|
||||
// whole image, decoded independently by the psd crate). When Photoshop wrote no real merged
|
||||
// image (Maximize Compatibility off) or it decodes through our own model code (Multichannel),
|
||||
// its embedded thumbnail is the oracle at thumbnail size.
|
||||
let use_thumb = file.has_real_merged_data() == Some(false) || file.header.color_mode == photocraft_psd::ColorMode::Multichannel;
|
||||
let merged = if use_thumb { None } else { merged_composite(&file).ok() };
|
||||
let Some(merged) = merged else {
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
return match thumbnail_oracle(&file, &ours) {
|
||||
Some((mean, frac)) => {
|
||||
let ok = mean <= THUMB_MEAN_TOL && frac <= THUMB_BAD_FRAC;
|
||||
let status = if ok { "PASS (thumbnail)" } else { "DIFF (thumbnail)" };
|
||||
eprintln!("{name:<60} {layers:>6} {mean:>9.4} {:>7.2}% {status} {}", 100.0 * frac, notes.join(" | "));
|
||||
(if ok { Outcome::ThumbPass } else { Outcome::Diff }, Some(rt))
|
||||
}
|
||||
None => {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no real composite and no thumbnail)", "-", "-");
|
||||
(Outcome::Skip, Some(rt))
|
||||
}
|
||||
};
|
||||
};
|
||||
// Without an alpha channel in the merged image, Photoshop flattened the document over white.
|
||||
let ours = if file.merged_has_alpha() {
|
||||
ours
|
||||
} else {
|
||||
ours.iter()
|
||||
.map(|p| {
|
||||
let w = 1.0 - p[3];
|
||||
[p[0] * p[3] + w, p[1] * p[3] + w, p[2] * p[3] + w, 1.0]
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let m = common::max_diff(&ours, &merged);
|
||||
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
|
||||
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
|
||||
let (outcome, status) = if m <= PASS_TOL {
|
||||
(Outcome::Pass, "PASS")
|
||||
} else if dissolve_matches(doc, &ours, &merged) {
|
||||
(Outcome::Pass, "PASS (dissolve metric)")
|
||||
} else {
|
||||
(Outcome::Diff, "DIFF")
|
||||
};
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
|
||||
if std::env::var_os("CORPUS_THUMB_CALIBRATE").is_some()
|
||||
&& let Some((mean, frac)) = thumbnail_oracle(&file, &ours)
|
||||
{
|
||||
eprintln!("THUMBCAL {status} {mean:.4} {frac:.4} {name}");
|
||||
}
|
||||
(outcome, Some(rt))
|
||||
}
|
||||
|
||||
fn run_oracle(src: &Source) {
|
||||
let Some((root, enforce)) = locate(src) else { return };
|
||||
let files = files_in(&root);
|
||||
let (mut pass, mut thumb_pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0, 0);
|
||||
let (mut rt_same, mut rt_diff, mut crashes) = (0, Vec::new(), Vec::new());
|
||||
eprintln!("{:<60} {:>6} {:>9} {:>8} status", "file", "layers", "max_err", "bad_px%");
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let bytes = std::fs::read(p).unwrap_or_default();
|
||||
let file = match PsdFile::from_bytes(&bytes) {
|
||||
Ok(f) => f,
|
||||
let (outcome, rt) = match catch_unwind(AssertUnwindSafe(|| check_file(&name, &bytes))) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} {:>6} {:>9} {:>8} PARSE-ERROR {e}", "-", "-", "-");
|
||||
errors += 1;
|
||||
let msg = panic_message(e.as_ref());
|
||||
eprintln!("{name:<60} CRASH {msg}");
|
||||
crashes.push(format!("{name}: {msg}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let imp = match import(&name, &bytes) {
|
||||
Ok(i) => i,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} IMPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let doc = &imp.document;
|
||||
// Export must always succeed, re-parse and re-import.
|
||||
let reimported = match export(doc, "x.psd", &ExportOptions::default()) {
|
||||
Ok(r) => {
|
||||
if let Err(e) = PsdFile::from_bytes(&r.bytes) {
|
||||
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
match import(&name, &r.bytes) {
|
||||
Ok(i) => i.document,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} REIMPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} EXPORT-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let ours = photocraft_compose::flatten(doc).px;
|
||||
// Our own export must not change how the document renders.
|
||||
let again = photocraft_compose::flatten(&reimported).px;
|
||||
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
|
||||
if rt <= ROUNDTRIP_TOL {
|
||||
rt_same += 1;
|
||||
} else {
|
||||
rt_diff.push(format!("{name} ({rt:.4})"));
|
||||
match outcome {
|
||||
Outcome::Pass => pass += 1,
|
||||
Outcome::ThumbPass => thumb_pass += 1,
|
||||
Outcome::Diff => diff += 1,
|
||||
Outcome::Skip => skipped += 1,
|
||||
Outcome::Error => errors += 1,
|
||||
}
|
||||
let layers = doc.layer_count();
|
||||
if file.has_real_merged_data() == Some(false) || file.layers().is_empty() {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no layers or no real composite)", "-", "-");
|
||||
skipped += 1;
|
||||
continue;
|
||||
match rt {
|
||||
Some(rt) if rt <= ROUNDTRIP_TOL => rt_same += 1,
|
||||
Some(rt) => rt_diff.push(format!("{name} ({rt:.4})")),
|
||||
None => {}
|
||||
}
|
||||
let Ok(merged) = merged_composite(&file) else {
|
||||
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (merged not decodable)", "-", "-");
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
let m = common::max_diff(&ours, &merged);
|
||||
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
|
||||
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
|
||||
let status = if m <= PASS_TOL {
|
||||
pass += 1;
|
||||
"PASS"
|
||||
} else {
|
||||
diff += 1;
|
||||
"DIFF"
|
||||
};
|
||||
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
|
||||
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
|
||||
}
|
||||
eprintln!("io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", files.len());
|
||||
eprintln!("io corpus: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
|
||||
let label = src.label;
|
||||
eprintln!(
|
||||
"{label}: {} files: {} pass ({pass} vs the merged image <= 2/255, {thumb_pass} vs the thumbnail), {diff} differ, {skipped} skipped, {errors} errors, {} crashes",
|
||||
files.len(),
|
||||
pass + thumb_pass,
|
||||
crashes.len()
|
||||
);
|
||||
let pass = pass + thumb_pass;
|
||||
eprintln!("{label}: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
|
||||
if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() {
|
||||
assert_eq!(errors, 0);
|
||||
}
|
||||
if env.is_some() {
|
||||
assert!(pass >= PASS_FLOOR, "oracle pass count {pass} fell below the floor {PASS_FLOOR}");
|
||||
assert!(rt_same >= ROUNDTRIP_FLOOR, "export round trip {rt_same} fell below the floor {ROUNDTRIP_FLOOR}: {rt_diff:?}");
|
||||
if enforce {
|
||||
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
|
||||
assert!(pass >= src.pass_floor, "{label}: oracle pass count {pass} fell below the floor {}", src.pass_floor);
|
||||
assert!(rt_same >= src.roundtrip_floor, "{label}: export round trip {rt_same} fell below the floor {}: {rt_diff:?}", src.roundtrip_floor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic xorshift for the mutation sweep.
|
||||
fn next(state: &mut u64) -> u64 {
|
||||
*state ^= *state << 13;
|
||||
*state ^= *state >> 7;
|
||||
*state ^= *state << 17;
|
||||
*state
|
||||
}
|
||||
|
||||
/// Truncations and byte corruptions of every corpus file: import (and flatten when the document
|
||||
/// is small) must return without panicking. Files are mutated in their first 64 KB, where the
|
||||
/// header, resources and layer records live.
|
||||
fn run_mutations(src: &Source) {
|
||||
// Minutes of work: only when the corpus is enforced.
|
||||
let Some((root, true)) = locate(src) else { return };
|
||||
let enforce = true;
|
||||
let files = files_in(&root);
|
||||
let (mut runs, mut panics, mut slow) = (0usize, Vec::new(), Vec::new());
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let Ok(bytes) = std::fs::read(p) else { continue };
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ bytes.len() as u64;
|
||||
let span = bytes.len().clamp(1, 64 * 1024);
|
||||
let mut variants: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for k in 1..8 {
|
||||
let cut = bytes.len() * k / 8;
|
||||
variants.push((format!("truncate@{cut}"), bytes[..cut].to_vec()));
|
||||
}
|
||||
for _ in 0..24 {
|
||||
let mut m = bytes.clone();
|
||||
let flips = 1 + (next(&mut seed) % 4) as usize;
|
||||
for _ in 0..flips {
|
||||
let at = (next(&mut seed) as usize) % span;
|
||||
if let Some(b) = m.get_mut(at) {
|
||||
*b = match next(&mut seed) % 3 {
|
||||
0 => 0xFF,
|
||||
1 => 0x00,
|
||||
_ => *b ^ (1 << (next(&mut seed) % 8)),
|
||||
};
|
||||
}
|
||||
}
|
||||
variants.push(("mutate".into(), m));
|
||||
}
|
||||
for (what, data) in variants {
|
||||
runs += 1;
|
||||
let t = Instant::now();
|
||||
let r = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Ok(imp) = import(&name, &data) {
|
||||
let b = imp.document.bounds();
|
||||
if i64::from(b.width()) * i64::from(b.height()) <= 4_000_000 {
|
||||
let _ = photocraft_compose::flatten(&imp.document);
|
||||
}
|
||||
}
|
||||
}));
|
||||
let secs = t.elapsed().as_secs_f32();
|
||||
if secs > 20.0 {
|
||||
slow.push(format!("{name} {what}: {secs:.1}s"));
|
||||
}
|
||||
if let Err(e) = r {
|
||||
let msg = panic_message(e.as_ref());
|
||||
eprintln!("CRASH {name} {what}: {msg}");
|
||||
panics.push(format!("{name} {what}: {msg}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("{}: {runs} mutated imports, {} panics, {} slow (> 20 s): {slow:?}", src.label, panics.len(), slow.len());
|
||||
if enforce {
|
||||
assert!(panics.is_empty(), "{}: mutated files panicked (Rule 9): {panics:?}", src.label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_import_flatten_oracle() {
|
||||
run_oracle(&MIXED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_tools_import_flatten_oracle() {
|
||||
run_oracle(&PSD_TOOLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_tools_mutations_never_panic() {
|
||||
run_mutations(&PSD_TOOLS);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Synthetic reproductions of failures found by the psd-tools corpus
|
||||
//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus
|
||||
//! files themselves are never committed: each test rebuilds the relevant
|
||||
//! structure from our own documents.
|
||||
|
||||
use photocraft_color::{Color, ColorMode, SampleType};
|
||||
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerMask, Path, ShapeLayer, Size, Subpath, VectorMask};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::layer::CHANNEL_REAL_USER_MASK;
|
||||
use photocraft_psd::{ChannelData, Compression, MaskData, PsdFile, RealMask, Version};
|
||||
|
||||
fn doc(depth: SampleType) -> Document {
|
||||
Document::with_background("t", Size::new(32, 32), ColorMode::Rgb, depth, Color::WHITE)
|
||||
}
|
||||
|
||||
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Path {
|
||||
Path::new(vec![Subpath::polygon(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)])])
|
||||
}
|
||||
|
||||
fn reimport(file: &PsdFile) -> ImportResult {
|
||||
import("x.psd", &file.to_bytes().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// psd-tools 32bit.psd, 300dpi.psd, transparentbg.psd, vector-mask2.psd: shape layers whose
|
||||
/// record stores no pixels (empty rect; every 32-bit shape layer) imported as invisible. They
|
||||
/// are now rendered from their path and fill.
|
||||
#[test]
|
||||
fn shape_layer_without_stored_pixels_renders_from_its_path() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let mut d = doc(depth);
|
||||
let sh = ShapeLayer { path: square(8.0, 8.0, 24.0, 24.0), fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
|
||||
let mut sh2 = sh.clone();
|
||||
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||||
d.layers.push(Layer::new("Shape 1", LayerContent::Shape(sh2)));
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Drop the shape record's pixels, as Photoshop does for 32-bit documents.
|
||||
let bits = file.header.depth;
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
assert!(rec.block(b"vmsk").is_some() || rec.block(b"vsms").is_some());
|
||||
rec.rect = Default::default();
|
||||
for ch in &mut rec.channels {
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, bits, Version::Psd).unwrap();
|
||||
}
|
||||
let imp = reimport(&file);
|
||||
let f = photocraft_compose::flatten(&imp.document);
|
||||
assert!(f.get(16, 16)[0] < 0.01, "{depth:?}: shape interior renders black: {:?}", f.get(16, 16));
|
||||
assert!(f.get(2, 2)[0] > 0.99, "{depth:?}: outside stays white");
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools mask-density-vectormask.psd, mask_parameters.psd: vector mask density and feather
|
||||
/// were dropped on export (only the user mask's parameters were written).
|
||||
#[test]
|
||||
fn vector_mask_density_and_feather_survive_export() {
|
||||
for with_user_mask in [false, true] {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::raster("pix", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let mut vm = VectorMask::new(square(0.0, 0.0, 16.0, 32.0));
|
||||
vm.density = 128.0 / 255.0;
|
||||
vm.feather = 1.5;
|
||||
l.vector_mask = Some(vm);
|
||||
if with_user_mask {
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.density = 204.0 / 255.0;
|
||||
m.feather = 2.5;
|
||||
l.mask = Some(m);
|
||||
}
|
||||
d.layers.push(l);
|
||||
let r = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("density")), "{:?}", r.warnings);
|
||||
let b = import("x.psd", &r.bytes).unwrap().document;
|
||||
let vm = b.layers[1].vector_mask.as_ref().unwrap();
|
||||
assert_eq!((vm.density, vm.feather), (128.0 / 255.0, 1.5), "user mask: {with_user_mask}");
|
||||
assert_eq!(b.layers[1].mask.is_some(), with_user_mask);
|
||||
if let Some(m) = &b.layers[1].mask {
|
||||
assert_eq!((m.density, m.feather), (204.0 / 255.0, 2.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd, mask-density-layervectormask.psd: a layer with both a user
|
||||
/// mask and a vector mask stores the "real" user mask (channel -3) and mask parameters; the
|
||||
/// real-mask fields precede the parameters, so the -3 channel failed to decode.
|
||||
#[test]
|
||||
fn real_user_mask_with_parameters_is_decoded() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::raster("pix", d.pixel_format());
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.density = 0.5;
|
||||
l.mask = Some(m);
|
||||
let mut vm = VectorMask::new(square(0.0, 0.0, 32.0, 32.0));
|
||||
vm.density = 0.5;
|
||||
l.vector_mask = Some(vm);
|
||||
d.layers.push(l);
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Add a real user mask hiding the left half (0) inside its rect, revealing elsewhere.
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
let MaskData::Mask(pm) = &mut rec.mask else { panic!("mask record") };
|
||||
assert!(pm.parameters.is_some());
|
||||
let real_rect = photocraft_psd::Rect { top: 0, left: 0, bottom: 32, right: 16 };
|
||||
pm.real = Some(RealMask { flags: 0, background: 255, rect: real_rect });
|
||||
pm.trailing.clear();
|
||||
rec.channels.push(ChannelData::encode(CHANNEL_REAL_USER_MASK, Compression::Rle, &[0u8; 16 * 32], 16, 32, 8, Version::Psd).unwrap());
|
||||
let imp = reimport(&file);
|
||||
assert!(imp.warnings.iter().all(|w| !w.contains("could not be decoded")), "{:?}", imp.warnings);
|
||||
let mask = imp.document.layers[1].mask.as_ref().unwrap();
|
||||
// Hidden at density 0.5: 1 - 0.5 · (1 - 0).
|
||||
assert!((mask.value(4, 4) - 0.5).abs() < 0.01, "real mask hides the left half: {}", mask.value(4, 4));
|
||||
assert_eq!(mask.value(24, 4), 1.0, "and reveals outside its rect");
|
||||
assert_eq!(mask.density, 128.0 / 255.0);
|
||||
assert_eq!(imp.document.layers[1].vector_mask.as_ref().map(|v| v.density), Some(128.0 / 255.0));
|
||||
}
|
||||
|
||||
/// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose
|
||||
/// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the
|
||||
/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask.
|
||||
#[test]
|
||||
fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK)));
|
||||
let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0));
|
||||
vm.density = 0.8;
|
||||
l.vector_mask = Some(vm);
|
||||
d.layers.push(l);
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
// Photoshop stores the shape alone: replace the record's pixels with the path's coverage.
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
assert!(rec.block(b"SoCo").is_some() && rec.block(b"vmsk").is_some());
|
||||
rec.rect = photocraft_psd::Rect { top: 8, left: 8, bottom: 24, right: 24 };
|
||||
for ch in &mut rec.channels {
|
||||
if ch.id >= -1 {
|
||||
let v = if ch.id == -1 { 255 } else { 0 };
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[v; 16 * 16], 16, 16, 8, Version::Psd).unwrap();
|
||||
}
|
||||
}
|
||||
let imp = reimport(&file);
|
||||
let l = &imp.document.layers[1];
|
||||
assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name());
|
||||
assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0));
|
||||
let f = photocraft_compose::flatten(&imp.document);
|
||||
assert!(f.get(16, 16)[0] < 0.01, "inside the path: black");
|
||||
assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2));
|
||||
}
|
||||
|
||||
/// psd-tools adjustment-fillers.psd: a solid-colour shape whose vector path is empty with the
|
||||
/// initial-fill record set (it covers the canvas) and no stored pixels rendered nothing.
|
||||
#[test]
|
||||
fn shape_without_pixels_and_full_initial_fill_covers_the_canvas() {
|
||||
let mut d = doc(SampleType::U8);
|
||||
let mut path = Path::new(Vec::new());
|
||||
path.inverted = true;
|
||||
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
|
||||
let mut sh2 = sh.clone();
|
||||
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
|
||||
d.layers.push(Layer::new("Color Fill 1", LayerContent::Shape(sh2)));
|
||||
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||||
let rec = file.layers_mut().last_mut().unwrap();
|
||||
rec.rect = Default::default();
|
||||
for ch in &mut rec.channels {
|
||||
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, 8, Version::Psd).unwrap();
|
||||
}
|
||||
let f = photocraft_compose::flatten(&reimport(&file).document);
|
||||
assert!(f.get(0, 0)[0] < 0.01 && f.get(31, 31)[0] < 0.01, "covers everything: {:?}", f.get(0, 0));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! 16-bit Lab PSDs: Photoshop stores a*/b* as `32768 + 256·a` (0..65280 spans −128..127), not
|
||||
//! on the full 0..65535 range. Layer pixels, the merged image and patterns use that scale, and
|
||||
//! the documents' a*/b* stay on the 8-bit scale `(a + 128) / 255` at every depth.
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Document, Layer, LayerContent};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_io::*;
|
||||
use photocraft_psd::PsdFile;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
/// a* = 52.29, b* = −85.08 (a gradient stop of psd-tools 4x4_16bit_lab) on the document scale.
|
||||
const A: f32 = (52.29 + 128.0) / 255.0;
|
||||
const B: f32 = (-85.08 + 128.0) / 255.0;
|
||||
|
||||
fn lab_doc(depth: SampleType) -> Document {
|
||||
let mut d = Document::new("lab", Size::new(3, 2), ColorMode::Lab, depth);
|
||||
let fmt = PixelFormat::new(ColorMode::Lab, depth, true);
|
||||
let mut s = Surface::new(fmt);
|
||||
let px: Vec<f32> = (0..6).flat_map(|_| [0.1907, A, B, 1.0]).collect();
|
||||
s.write_region(Rect::new(0, 0, 3, 2), &px);
|
||||
d.layers.push(Layer::new("px", LayerContent::Raster(s)));
|
||||
d
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], i: usize) -> u16 {
|
||||
u16::from_be_bytes([b[2 * i], b[2 * i + 1]])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab16_chroma_uses_photoshops_scale() {
|
||||
let doc = lab_doc(SampleType::U16);
|
||||
let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let file = PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
assert_eq!(file.header.depth, 16);
|
||||
// Merged image: planes L, a, b.
|
||||
let m = file.decode_merged().unwrap();
|
||||
let n = 6;
|
||||
let a = be16(&m, n);
|
||||
let b = be16(&m, 2 * n);
|
||||
assert!((i32::from(a) - (32768 + (256.0f32 * 52.29).round() as i32)).abs() <= 1, "a = {a}");
|
||||
assert!((i32::from(b) - (32768 - (256.0f32 * 85.08).round() as i32)).abs() <= 1, "b = {b}");
|
||||
// Layer channel 1 (a*) the same.
|
||||
let rec = &file.layers()[0];
|
||||
let plane = rec.decode_channel(1, 16, file.header.version).unwrap();
|
||||
assert!((i32::from(be16(&plane, 0)) - i32::from(a)).abs() <= 1);
|
||||
// And back: the document values survive.
|
||||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||||
let s = back.layers[0].surface().unwrap();
|
||||
let p = s.pixel(1, 1);
|
||||
assert!((p[1] - A).abs() < 2e-5 && (p[2] - B).abs() < 2e-5, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lab_round_trips_at_every_depth() {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
let doc = lab_doc(depth);
|
||||
let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
|
||||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||||
let p = back.layers[0].surface().unwrap().pixel(2, 0);
|
||||
let tol = if depth == SampleType::U8 { 0.5 / 255.0 + 1e-6 } else { 2e-5 };
|
||||
assert!((p[1] - A).abs() <= tol && (p[2] - B).abs() <= tol, "{depth:?}: {p:?}");
|
||||
let a = photocraft_compose::flatten(&doc).px;
|
||||
let b = photocraft_compose::flatten(&back).px;
|
||||
for (x, y) in a.iter().zip(&b) {
|
||||
for c in 0..4 {
|
||||
assert!((x[c] - y[c]).abs() <= 1.0 / 255.0, "{depth:?}: {x:?} vs {y:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,11 @@ impl History {
|
||||
self.undo.len()
|
||||
}
|
||||
|
||||
/// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).
|
||||
pub fn set_current_label(&mut self, label: impl Into<String>) {
|
||||
self.current_label = label.into();
|
||||
}
|
||||
|
||||
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
|
||||
/// last step can no longer be undone and its pixels are released.
|
||||
pub fn purge_last(&mut self) -> bool {
|
||||
@@ -118,6 +123,14 @@ impl History {
|
||||
self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum()
|
||||
}
|
||||
|
||||
/// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`]
|
||||
/// bounds by [`History::max_bytes`]).
|
||||
pub fn pixel_bytes(&self, current: &Document) -> usize {
|
||||
let mut seen = HashSet::new();
|
||||
let own = tile_bytes(current, &mut seen);
|
||||
self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
|
||||
}
|
||||
|
||||
/// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the
|
||||
/// tiles only history holds (newest states first). The oldest undo states that don't fit are
|
||||
/// dropped; the most recent one is always kept so the last step can be undone. Returns how
|
||||
@@ -257,6 +270,7 @@ mod tests {
|
||||
assert_eq!(h.trim(&cur), 3);
|
||||
assert_eq!(h.past_len(), 3);
|
||||
assert_eq!(h.unique_bytes(&cur), 3 * tile);
|
||||
assert_eq!(h.pixel_bytes(&cur), 4 * tile, "the current tile plus history's");
|
||||
assert_eq!(h.entries()[0], "paint 2");
|
||||
// A budget smaller than one state still keeps the last step undoable.
|
||||
h.max_bytes = 1;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
use photocraft_color::BlendMode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::mixer::MixerSettings;
|
||||
use crate::tile::GrayTile;
|
||||
|
||||
/// What drives a dynamic parameter (Photoshop's "Control" pop-ups).
|
||||
@@ -80,12 +81,19 @@ pub struct ShapeDynamics {
|
||||
pub enabled: bool,
|
||||
/// Size jitter / control; `minimum` is Photoshop's Minimum Diameter.
|
||||
pub size: Dynamic,
|
||||
/// Tilt Scale, 0..2 (0–200 %): with the size control on Pen Tilt, how much a tilted pen
|
||||
/// squashes the tip's height (before the tip angle is applied). At full tilt the height is
|
||||
/// scaled by `1 − tiltScale / 2`, so 0 leaves the tip alone and 200 % flattens it.
|
||||
pub tilt_scale: f32,
|
||||
/// Angle jitter (fraction of ±180°) / control.
|
||||
pub angle: Dynamic,
|
||||
/// Roundness jitter / control; `minimum` is Minimum Roundness.
|
||||
pub roundness: Dynamic,
|
||||
pub flip_x_jitter: bool,
|
||||
pub flip_y_jitter: bool,
|
||||
/// Brush Projection: the pen's tilt and barrel rotation are applied to the tip as a
|
||||
/// projection (the tip is foreshortened along the tilt direction and turns with the pen).
|
||||
pub brush_projection: bool,
|
||||
}
|
||||
|
||||
/// Scattering.
|
||||
@@ -254,13 +262,36 @@ impl Default for ColorDynamics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transfer: per-dab opacity ceiling and flow.
|
||||
/// Transfer: per-dab opacity ceiling and flow, plus the Mixer Brush's wetness and mix.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Transfer {
|
||||
pub enabled: bool,
|
||||
pub opacity: Dynamic,
|
||||
pub flow: Dynamic,
|
||||
/// Wetness Jitter / control (Mixer Brush only): scales the Wet setting per dab.
|
||||
pub wetness: Dynamic,
|
||||
/// Mix Jitter / control (Mixer Brush only): scales the Mix setting per dab.
|
||||
pub mix: Dynamic,
|
||||
}
|
||||
|
||||
/// The sections a lock can hold (the lock icons in Photoshop's Brush Settings list): a locked
|
||||
/// section keeps its settings when another brush preset is picked.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SectionLocks {
|
||||
pub shape_dynamics: bool,
|
||||
pub scattering: bool,
|
||||
pub texture: bool,
|
||||
pub dual_brush: bool,
|
||||
pub color_dynamics: bool,
|
||||
pub transfer: bool,
|
||||
pub pose: bool,
|
||||
pub noise: bool,
|
||||
pub wet_edges: bool,
|
||||
pub build_up: bool,
|
||||
pub smoothing: bool,
|
||||
pub protect_texture: bool,
|
||||
}
|
||||
|
||||
/// Brush Pose: stylus values used instead of (override) or for missing device data.
|
||||
@@ -327,6 +358,9 @@ pub struct BrushSettings {
|
||||
pub hardness: f32,
|
||||
/// Spacing between dabs as a fraction of the diameter.
|
||||
pub spacing: f32,
|
||||
/// Photoshop's Spacing checkbox. Off: the pointer's speed sets the spacing (one dab every
|
||||
/// [`crate::dynamics::SPEED_SPACING_MS`] of stroke time; without timestamps, one per input point).
|
||||
pub spacing_enabled: bool,
|
||||
/// Maximum coverage for the whole stroke.
|
||||
pub opacity: f32,
|
||||
/// Per-dab coverage.
|
||||
@@ -374,6 +408,10 @@ pub struct BrushSettings {
|
||||
pub smoothing: Smoothing,
|
||||
/// Keep the current texture (pattern + scale) when switching to another textured preset.
|
||||
pub protect_texture: bool,
|
||||
/// Sections whose settings stay when another preset is picked (tool state, like Photoshop's locks).
|
||||
pub locks: SectionLocks,
|
||||
/// Mixer Brush options (Wet, Load, Mix, Flow, Sample All Layers) used by `paint.mixerBrush`.
|
||||
pub mixer: MixerSettings,
|
||||
/// Random seed for jitters (commands derive it from the stroke when not given), so strokes replay.
|
||||
pub seed: u64,
|
||||
}
|
||||
@@ -384,6 +422,7 @@ impl Default for BrushSettings {
|
||||
size: 20.0,
|
||||
hardness: 1.0,
|
||||
spacing: 0.1,
|
||||
spacing_enabled: true,
|
||||
opacity: 1.0,
|
||||
flow: 1.0,
|
||||
pressure_size: true,
|
||||
@@ -411,6 +450,8 @@ impl Default for BrushSettings {
|
||||
build_up_rate: 20.0,
|
||||
smoothing: Smoothing::default(),
|
||||
protect_texture: false,
|
||||
locks: SectionLocks::default(),
|
||||
mixer: MixerSettings::default(),
|
||||
seed: 0,
|
||||
}
|
||||
}
|
||||
@@ -418,10 +459,47 @@ impl Default for BrushSettings {
|
||||
|
||||
impl BrushSettings {
|
||||
/// This brush (a preset) picked while `current` is the tool's brush: Smoothing is a tool
|
||||
/// option, so it stays the tool's (Photoshop), and a protected texture stays too.
|
||||
/// option, so it stays the tool's (Photoshop), a protected texture stays too, and so does every
|
||||
/// section `current` has locked (the locks themselves are tool state and carry over).
|
||||
pub fn picked_over(self, current: &BrushSettings) -> Self {
|
||||
let mut b = self.with_protected_texture(current);
|
||||
b.smoothing = current.smoothing.clone();
|
||||
let l = ¤t.locks;
|
||||
if l.shape_dynamics {
|
||||
b.shape_dynamics = current.shape_dynamics.clone();
|
||||
}
|
||||
if l.scattering {
|
||||
b.scattering = current.scattering.clone();
|
||||
}
|
||||
if l.texture {
|
||||
b.texture = current.texture.clone();
|
||||
}
|
||||
if l.dual_brush {
|
||||
b.dual_brush = current.dual_brush.clone();
|
||||
}
|
||||
if l.color_dynamics {
|
||||
b.color_dynamics = current.color_dynamics.clone();
|
||||
}
|
||||
if l.transfer {
|
||||
b.transfer = current.transfer.clone();
|
||||
}
|
||||
if l.pose {
|
||||
b.pose = current.pose.clone();
|
||||
}
|
||||
if l.noise {
|
||||
b.noise = current.noise;
|
||||
}
|
||||
if l.wet_edges {
|
||||
b.wet_edges = current.wet_edges;
|
||||
}
|
||||
if l.build_up {
|
||||
b.build_up = current.build_up;
|
||||
b.build_up_rate = current.build_up_rate;
|
||||
}
|
||||
if l.protect_texture {
|
||||
b.protect_texture = current.protect_texture;
|
||||
}
|
||||
b.locks = current.locks.clone();
|
||||
b
|
||||
}
|
||||
|
||||
|
||||
@@ -232,6 +232,7 @@ fn jitter_is_deterministic_per_seed() {
|
||||
roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() },
|
||||
flip_x_jitter: true,
|
||||
flip_y_jitter: true,
|
||||
..Default::default()
|
||||
},
|
||||
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.0), both_axes: true, count: 3, count_jitter: Dynamic::jitter(1.0) },
|
||||
..brush()
|
||||
@@ -638,3 +639,177 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
|
||||
r.push(&pts);
|
||||
assert!(r.tail_preview().is_none());
|
||||
}
|
||||
|
||||
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
|
||||
|
||||
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
|
||||
fn extents(b: &BrushSettings, d: &Dab) -> (usize, usize) {
|
||||
let r = raster(b, d);
|
||||
let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32);
|
||||
let w = (r.0.x0..r.0.x1).filter(|&x| at(&r, x, cy) > 0.5).count();
|
||||
let h = (r.0.y0..r.0.y1).filter(|&y| at(&r, cx, y) > 0.5).count();
|
||||
(w, h)
|
||||
}
|
||||
|
||||
fn pen(x: f64, y: f64, tilt_x: f32, tilt_y: f32, rotation: f32) -> StrokePoint {
|
||||
StrokePoint { tilt_x, tilt_y, rotation, ..StrokePoint::new(x, y, 1.0) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilt_scale_squashes_the_tip_height_with_pen_tilt() {
|
||||
// Size control on Pen Tilt with a 100 % minimum, so only Tilt Scale changes the shape.
|
||||
let mk = |scale: f32| BrushSettings {
|
||||
size: 40.0,
|
||||
shape_dynamics: ShapeDynamics {
|
||||
enabled: true,
|
||||
size: Dynamic { control: Control::PenTilt, minimum: 1.0, ..Default::default() },
|
||||
tilt_scale: scale,
|
||||
..Default::default()
|
||||
},
|
||||
..brush()
|
||||
};
|
||||
let upright = [pen(50.0, 50.0, 0.0, 0.0, 0.0)];
|
||||
let tilted = [pen(50.0, 50.0, 45.0, 0.0, 0.0)];
|
||||
// 0 % (the default) and an upright pen leave the tip round.
|
||||
let d = dabs_of(&mk(0.0), &tilted)[0];
|
||||
assert_eq!(d.roundness, 1.0);
|
||||
let d = dabs_of(&mk(2.0), &upright)[0];
|
||||
assert_eq!(d.roundness, 1.0);
|
||||
// 100 % at 45° tilt: height × (1 − 0.5 × 0.5) = 0.75; 200 %: 0.5.
|
||||
let d = dabs_of(&mk(1.0), &tilted)[0];
|
||||
assert!((d.roundness - 0.75).abs() < 1e-4, "{}", d.roundness);
|
||||
let d2 = dabs_of(&mk(2.0), &tilted)[0];
|
||||
assert!((d2.roundness - 0.5).abs() < 1e-4, "{}", d2.roundness);
|
||||
let (w, h) = extents(&mk(2.0), &d2);
|
||||
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
|
||||
// Only with the size control on Pen Tilt (Photoshop greys the slider otherwise).
|
||||
let mut b = mk(2.0);
|
||||
b.shape_dynamics.size.control = Control::PenPressure;
|
||||
assert_eq!(dabs_of(&b, &tilted)[0].roundness, 1.0);
|
||||
// Rendered strokes differ.
|
||||
let a = paint(&mk(0.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
|
||||
let c = paint(&mk(2.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
|
||||
let area = Rect::new(0, 0, 160, 80);
|
||||
assert!(alpha_sum(&c, area) < alpha_sum(&a, area) * 0.75, "{} vs {}", alpha_sum(&c, area), alpha_sum(&a, area));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_projection_foreshortens_along_tilt_and_turns_with_rotation() {
|
||||
let mk = |on: bool| BrushSettings { size: 40.0, shape_dynamics: ShapeDynamics { enabled: true, brush_projection: on, ..Default::default() }, ..brush() };
|
||||
// Tilted 60° towards +x: the tip is foreshortened to cos 60° = ½ along x.
|
||||
let pts = [pen(50.0, 50.0, 60.0, 0.0, 0.0)];
|
||||
let d = dabs_of(&mk(true), &pts)[0];
|
||||
assert!((d.proj_scale - 0.5).abs() < 1e-4 && d.proj_angle.abs() < 1e-5, "{d:?}");
|
||||
let (w, h) = extents(&mk(true), &d);
|
||||
assert!((w as i32 - 20).abs() <= 2 && h >= 38, "{w}×{h}");
|
||||
// Tilted along y instead: foreshortened vertically.
|
||||
let d = dabs_of(&mk(true), &[pen(50.0, 50.0, 0.0, 60.0, 0.0)])[0];
|
||||
let (w, h) = extents(&mk(true), &d);
|
||||
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
|
||||
// Off: the tilt doesn't touch the tip.
|
||||
let d = dabs_of(&mk(false), &pts)[0];
|
||||
assert_eq!((d.proj_scale, d.angle), (1.0, 0.0));
|
||||
assert_eq!(extents(&mk(false), &d), (40, 40));
|
||||
// Barrel rotation turns the tip (an elliptical one shows it).
|
||||
let mut b = mk(true);
|
||||
b.roundness = 0.5;
|
||||
let d = dabs_of(&b, &[pen(50.0, 50.0, 0.0, 0.0, 90.0)])[0];
|
||||
assert!((d.angle.to_degrees() - 90.0).abs() < 1e-3);
|
||||
let (w, h) = extents(&b, &d);
|
||||
assert!(w < h, "{w}×{h}");
|
||||
// Dual-brush dabs are never projected.
|
||||
let ctx = BrushContext::new(&mk(true));
|
||||
let dd = Dab { proj_scale: 0.3, ..Dab::round(Point::new(50.0, 50.0), 20.0, 1.0) };
|
||||
let (r, mut a, mut c) = (ctx.dab_rect(&dd, true), Vec::new(), Vec::new());
|
||||
ctx.rasterize(&dd, true, r, &mut a);
|
||||
ctx.rasterize(&Dab { proj_scale: 1.0, ..dd }, true, r, &mut c);
|
||||
assert_eq!(a, c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_off_spaces_dabs_by_pointer_speed() {
|
||||
let mut b = BrushSettings { size: 20.0, spacing_enabled: false, ..brush() };
|
||||
let timed = |ms: f64| vec![StrokePoint { time: 0.0, ..StrokePoint::new(0.0, 10.0, 1.0) }, StrokePoint { time: ms, ..StrokePoint::new(100.0, 10.0, 1.0) }];
|
||||
// 100 px in 400 ms: a dab every 8 ms = every 2 px; in 40 ms: every 20 px.
|
||||
let slow = dabs_of(&b, &timed(400.0)).len();
|
||||
let fast = dabs_of(&b, &timed(40.0)).len();
|
||||
assert_eq!((slow, fast), (51, 6));
|
||||
// Never denser than half a pixel, however slow.
|
||||
assert!(dabs_of(&b, &timed(1.0e9)).len() <= 201);
|
||||
// No timestamps: one dab per input point.
|
||||
let pts: Vec<StrokePoint> = (0..7).map(|i| StrokePoint::new(f64::from(i) * 15.0, 10.0, 1.0)).collect();
|
||||
assert_eq!(dabs_of(&b, &pts).len(), 7);
|
||||
// Checked: fixed spacing (25 % of 20 px = 5 px) whatever the speed.
|
||||
b.spacing_enabled = true;
|
||||
assert_eq!(dabs_of(&b, &timed(400.0)).len(), dabs_of(&b, &timed(40.0)).len());
|
||||
// Chunked input gives the same dabs.
|
||||
b.spacing_enabled = false;
|
||||
let pts: Vec<StrokePoint> = (0..20).map(|i| StrokePoint { time: f64::from(i) * 13.0, ..StrokePoint::new(f64::from(i) * 7.0, 10.0, 1.0) }).collect();
|
||||
let one = dabs_of(&b, &pts);
|
||||
let mut g = crate::dynamics::DabGenerator::new(&b, 1.0);
|
||||
let (mut out, mut dual) = (Vec::new(), Vec::new());
|
||||
for c in pts.chunks(3) {
|
||||
g.push(c, &mut out, &mut dual);
|
||||
}
|
||||
g.finish(&mut out, &mut dual);
|
||||
assert_eq!(one, out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_sections_survive_picking_a_preset() {
|
||||
let presets = crate::presets::builtin();
|
||||
let chalk = crate::presets::find(&presets, "chalk").unwrap().brush.clone();
|
||||
let mut cur = BrushSettings {
|
||||
scattering: Scattering { enabled: true, count: 5, ..Default::default() },
|
||||
transfer: Transfer { enabled: true, wetness: Dynamic::jitter(0.4), ..Default::default() },
|
||||
noise: true,
|
||||
..brush()
|
||||
};
|
||||
// Unlocked: the preset's sections win.
|
||||
let next = chalk.clone().picked_over(&cur);
|
||||
assert_eq!(next.scattering, chalk.scattering);
|
||||
assert_eq!(next.noise, chalk.noise);
|
||||
// Locked: the current sections stay, and so do the locks.
|
||||
cur.locks = SectionLocks { scattering: true, transfer: true, noise: true, ..Default::default() };
|
||||
let next = chalk.clone().picked_over(&cur);
|
||||
assert_eq!(next.scattering, cur.scattering);
|
||||
assert_eq!(next.transfer, cur.transfer);
|
||||
assert!(next.noise);
|
||||
assert_eq!(next.locks, cur.locks);
|
||||
// Unlocked sections still come from the preset.
|
||||
assert_eq!(next.texture, chalk.texture);
|
||||
assert_eq!(next.tip, chalk.tip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_wetness_and_mix_vary_per_dab() {
|
||||
let b = BrushSettings {
|
||||
transfer: Transfer {
|
||||
enabled: true,
|
||||
wetness: Dynamic::controlled(Control::PenPressure),
|
||||
mix: Dynamic { jitter: 1.0, minimum: 0.25, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
..brush()
|
||||
};
|
||||
let pts = [StrokePoint::new(0.0, 0.0, 0.3), StrokePoint::new(60.0, 0.0, 0.3)];
|
||||
let d = dabs_of(&b, &pts);
|
||||
assert!(d.iter().all(|x| (x.wet - 0.3).abs() < 1e-5));
|
||||
assert!(d.iter().all(|x| (0.25..=1.0).contains(&x.mix)) && d.iter().any(|x| x.mix < 0.9));
|
||||
// Transfer off: as set.
|
||||
let d = dabs_of(&brush(), &pts);
|
||||
assert!(d.iter().all(|x| x.wet == 1.0 && x.mix == 1.0));
|
||||
// New fields round-trip and old JSON loads with neutral values.
|
||||
let full = BrushSettings {
|
||||
spacing_enabled: false,
|
||||
shape_dynamics: ShapeDynamics { tilt_scale: 1.5, brush_projection: true, ..Default::default() },
|
||||
locks: SectionLocks { texture: true, ..Default::default() },
|
||||
mixer: crate::mixer::MixerSettings { wet: 0.9, sample_all_layers: true, ..Default::default() },
|
||||
..b
|
||||
};
|
||||
let back: BrushSettings = serde_json::from_value(serde_json::to_value(&full).unwrap()).unwrap();
|
||||
assert_eq!(back, full);
|
||||
let old: BrushSettings = serde_json::from_str(r#"{"size": 12, "shapeDynamics": {"enabled": true}, "transfer": {"enabled": true}}"#).unwrap();
|
||||
assert!(old.spacing_enabled && !old.shape_dynamics.brush_projection && old.shape_dynamics.tilt_scale == 0.0 && old.locks == SectionLocks::default());
|
||||
assert_eq!(old.mixer, crate::mixer::MixerSettings::default());
|
||||
}
|
||||
@@ -13,6 +13,13 @@ use crate::{Dab, StrokePoint};
|
||||
/// Maximum pulled-string length at 100 % smoothing, in screen pixels.
|
||||
pub const MAX_STRING_PX: f64 = 100.0;
|
||||
|
||||
/// With Spacing unchecked, one dab per this many milliseconds of stroke time, so faster strokes
|
||||
/// space their dabs further apart (Photoshop: "the speed of the cursor determines the spacing").
|
||||
pub const SPEED_SPACING_MS: f64 = 8.0;
|
||||
|
||||
/// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer).
|
||||
const MIN_SPEED_STEP: f64 = 0.5;
|
||||
|
||||
#[inline]
|
||||
fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint {
|
||||
let ff = f as f32;
|
||||
@@ -147,11 +154,32 @@ pub struct PathWalker {
|
||||
/// Airbrush interval in ms (None = off).
|
||||
interval: Option<f64>,
|
||||
time_acc: f64,
|
||||
/// Speed spacing (Spacing unchecked): one step per this many ms of stroke time.
|
||||
speed: Option<f64>,
|
||||
speed_acc: f64,
|
||||
}
|
||||
|
||||
impl PathWalker {
|
||||
pub fn new(build_up_interval_ms: Option<f64>) -> Self {
|
||||
Self { last: None, next_at: 0.0, pending_first: None, initial_dir: None, dir: 0.0, step: 0, interval: build_up_interval_ms, time_acc: 0.0 }
|
||||
Self {
|
||||
last: None,
|
||||
next_at: 0.0,
|
||||
pending_first: None,
|
||||
initial_dir: None,
|
||||
dir: 0.0,
|
||||
step: 0,
|
||||
interval: build_up_interval_ms,
|
||||
time_acc: 0.0,
|
||||
speed: None,
|
||||
speed_acc: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement
|
||||
/// spreads them out. Segments without timestamps get one step at their end point.
|
||||
pub fn speed_spacing(mut self, interval_ms: f64) -> Self {
|
||||
self.speed = Some(interval_ms.max(0.1));
|
||||
self
|
||||
}
|
||||
|
||||
fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {
|
||||
@@ -179,12 +207,36 @@ impl PathWalker {
|
||||
self.emit(f, out);
|
||||
}
|
||||
if len > 1e-9 {
|
||||
while self.next_at <= len + 1e-9 {
|
||||
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
|
||||
self.emit(q, out);
|
||||
self.next_at += step_len(&q).max(0.25);
|
||||
match self.speed {
|
||||
None => {
|
||||
while self.next_at <= len + 1e-9 {
|
||||
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
|
||||
self.emit(q, out);
|
||||
self.next_at += step_len(&q).max(0.25);
|
||||
}
|
||||
self.next_at -= len;
|
||||
}
|
||||
Some(iv) => {
|
||||
let dt = p.time - a.time;
|
||||
if dt > 0.0 && dt.is_finite() {
|
||||
self.speed_acc += dt;
|
||||
let max_n = (len / MIN_SPEED_STEP).ceil().max(1.0) as usize;
|
||||
let mut n = 0;
|
||||
while self.speed_acc >= iv && n < max_n {
|
||||
self.speed_acc -= iv;
|
||||
let f = ((dt - self.speed_acc) / dt).clamp(0.0, 1.0);
|
||||
self.emit(lerp_pt(&a, &p, f), out);
|
||||
n += 1;
|
||||
}
|
||||
if n == max_n {
|
||||
self.speed_acc = self.speed_acc.rem_euclid(iv);
|
||||
}
|
||||
} else {
|
||||
// No timestamps: one dab per input point.
|
||||
self.emit(p, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.next_at -= len;
|
||||
}
|
||||
if let Some(iv) = self.interval {
|
||||
let dt = p.time - a.time;
|
||||
@@ -379,6 +431,22 @@ impl DabBuilder {
|
||||
fy = !fy;
|
||||
}
|
||||
}
|
||||
// Tilt Scale and Brush Projection (Shape Dynamics).
|
||||
let (mut proj_angle, mut proj_scale) = (0.0, 1.0);
|
||||
if sd.enabled {
|
||||
let tilt = s.point.tilt_x.hypot(s.point.tilt_y).clamp(0.0, 90.0);
|
||||
if sd.size.control == Control::PenTilt && sd.tilt_scale > 0.0 {
|
||||
let k = 1.0 - sd.tilt_scale.clamp(0.0, 2.0) * 0.5 * tilt / 90.0;
|
||||
roundness = (roundness * k).clamp(0.01, 1.0);
|
||||
}
|
||||
if sd.brush_projection {
|
||||
angle += s.point.rotation;
|
||||
if tilt > 0.0 {
|
||||
proj_angle = (-s.point.tilt_y).atan2(s.point.tilt_x);
|
||||
proj_scale = tilt.to_radians().cos().max(0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
let radius = (diameter / 2.0).max(0.5);
|
||||
// Scatter.
|
||||
let mut c = Point::new(s.point.x, s.point.y);
|
||||
@@ -403,6 +471,7 @@ impl DabBuilder {
|
||||
opacity = scalar(&tr.opacity, s, r(stream::OPACITY));
|
||||
flow *= scalar(&tr.flow, s, r(stream::FLOW));
|
||||
}
|
||||
let (wet, mix) = if tr.enabled { (scalar(&tr.wetness, s, r(stream::WET)), scalar(&tr.mix, s, r(stream::MIX))) } else { (1.0, 1.0) };
|
||||
if b.pressure_opacity && !(tr.enabled && tr.opacity.control != Control::Off) {
|
||||
opacity *= s.point.pressure.clamp(0.0, 1.0);
|
||||
}
|
||||
@@ -428,6 +497,10 @@ impl DabBuilder {
|
||||
color,
|
||||
depth,
|
||||
index: i,
|
||||
proj_angle,
|
||||
proj_scale,
|
||||
wet,
|
||||
mix,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -479,6 +552,10 @@ impl DualBuilder {
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
index: i,
|
||||
proj_angle: 0.0,
|
||||
proj_scale: 1.0,
|
||||
wet: 1.0,
|
||||
mix: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -519,7 +596,7 @@ impl DabGenerator {
|
||||
let dual_on = brush.dual_brush.enabled;
|
||||
Self {
|
||||
smoother: Smoother::new(&brush.smoothing, zoom),
|
||||
walker: PathWalker::new(interval),
|
||||
walker: if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) },
|
||||
dual_walker: dual_on.then(|| PathWalker::new(None)),
|
||||
builder: DabBuilder::new(brush),
|
||||
dual: dual_on.then(|| DualBuilder::new(brush)),
|
||||
|
||||
+14
-2
@@ -31,9 +31,10 @@ pub mod rng;
|
||||
pub mod tile;
|
||||
|
||||
pub use brush::{
|
||||
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing,
|
||||
Texture, TipShape, Transfer,
|
||||
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, SectionLocks, ShapeDynamics,
|
||||
Smoothing, Texture, TipShape, Transfer,
|
||||
};
|
||||
pub use mixer::MixerSettings;
|
||||
pub use render::{BrushContext, StrokeRenderer, render_stroke};
|
||||
pub use tile::GrayTile;
|
||||
|
||||
@@ -94,6 +95,13 @@ pub struct Dab {
|
||||
pub depth: f32,
|
||||
/// Position in the stroke (0 = first).
|
||||
pub index: u64,
|
||||
/// Brush Projection: direction (radians, counter-clockwise, y up) along which the tip is
|
||||
/// foreshortened, and the factor (1 = no projection).
|
||||
pub proj_angle: f32,
|
||||
pub proj_scale: f32,
|
||||
/// Mixer Brush multipliers of Wet and Mix (Transfer › Wetness/Mix Jitter), 1 = as set.
|
||||
pub wet: f32,
|
||||
pub mix: f32,
|
||||
}
|
||||
|
||||
impl Dab {
|
||||
@@ -111,6 +119,10 @@ impl Dab {
|
||||
color: [0.0, 0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
index: 0,
|
||||
proj_angle: 0.0,
|
||||
proj_scale: 1.0,
|
||||
wet: 1.0,
|
||||
mix: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,13 @@ pub struct MixerSettings {
|
||||
pub load: f32,
|
||||
pub mix: f32,
|
||||
pub flow: f32,
|
||||
/// Pick up colour from all visible layers instead of the target layer only.
|
||||
pub sample_all_layers: bool,
|
||||
}
|
||||
|
||||
impl Default for MixerSettings {
|
||||
fn default() -> Self {
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0 }
|
||||
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +89,8 @@ pub fn apply_mixer_stroke(
|
||||
let mut acc = vec![0.0f32; n];
|
||||
let mut paint = vec![0.0f32; n];
|
||||
let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
|
||||
// Transfer › Wetness / Mix Jitter scale the settings per dab.
|
||||
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
|
||||
// Sample the canvas under the dab.
|
||||
acc.iter_mut().for_each(|v| *v = 0.0);
|
||||
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
|
||||
@@ -165,7 +169,7 @@ mod tests {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
let mut st = MixerState::default();
|
||||
st.load([1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0 }, &mut st, None, false);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
|
||||
let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]);
|
||||
assert!(start > 0.95, "{start}");
|
||||
if expect_dry {
|
||||
@@ -186,7 +190,15 @@ mod tests {
|
||||
s.fill_rect(Rect::new(100, 0, 200, 20), &[1.0, 1.0, 1.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
st.load([1.0, 0.0, 0.0, 1.0]);
|
||||
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0 }, &mut st, None, false);
|
||||
apply_mixer_stroke(
|
||||
&mut s,
|
||||
None,
|
||||
&stroke(10.0),
|
||||
&MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0, ..Default::default() },
|
||||
&mut st,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
// Blue is dragged into the white half.
|
||||
let c = s.rgba(115, 10);
|
||||
assert!(c[2] > c[1] + 0.05 && c[1] < 0.95, "{c:?}");
|
||||
@@ -205,4 +217,24 @@ mod tests {
|
||||
assert!(!dmg.is_empty());
|
||||
assert_eq!(s.rgba(50, 10)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_wetness_control_scales_pickup_per_dab() {
|
||||
// Wetness on Pen Pressure: no pressure means a dry brush (no pickup), full pressure picks up.
|
||||
let run = |pressure: f32| {
|
||||
let mut s = Surface::new(PixelFormat::RGBA8);
|
||||
s.fill_rect(Rect::new(0, 0, 200, 20), &[0.0, 0.0, 1.0, 1.0]);
|
||||
let mut st = MixerState::default();
|
||||
st.load([1.0, 0.0, 0.0, 1.0]);
|
||||
let mut k = stroke(10.0);
|
||||
k.brush.transfer = crate::Transfer { enabled: true, wetness: crate::Dynamic::controlled(crate::Control::PenPressure), ..Default::default() };
|
||||
for p in &mut k.points {
|
||||
p.pressure = pressure;
|
||||
}
|
||||
apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
|
||||
st.pickup
|
||||
};
|
||||
assert!(run(0.0).is_none());
|
||||
assert!(run(1.0).is_some_and(|p| p[2] > 0.2));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ fn size_dyn(size: Dynamic) -> ShapeDynamics {
|
||||
}
|
||||
|
||||
fn transfer(opacity: Dynamic, flow: Dynamic) -> Transfer {
|
||||
Transfer { enabled: true, opacity, flow }
|
||||
Transfer { enabled: true, opacity, flow, ..Default::default() }
|
||||
}
|
||||
|
||||
fn paper(seed: u32, depth: f32) -> Texture {
|
||||
|
||||
@@ -144,6 +144,12 @@ impl BrushContext {
|
||||
let (cx, cy) = (d.center.x as f32, d.center.y as f32);
|
||||
let (sn, cs) = d.angle.sin_cos();
|
||||
let (fx, fy) = (if d.flip_x { -1.0 } else { 1.0 }, if d.flip_y { -1.0 } else { 1.0 });
|
||||
// Brush Projection: stretch the sampling coordinates along the tilt direction, which
|
||||
// foreshortens the tip there by `proj_scale`.
|
||||
let proj = (!dual && d.proj_scale < 0.999).then(|| {
|
||||
let (ps, pc) = d.proj_angle.sin_cos();
|
||||
(pc, ps, 1.0 / d.proj_scale.max(0.05) - 1.0)
|
||||
});
|
||||
let r = d.radius;
|
||||
let ro = d.roundness.max(0.5 / r).min(1.0);
|
||||
let rm = (r * ro).max(0.5);
|
||||
@@ -163,8 +169,13 @@ impl BrushContext {
|
||||
for xx in 0..w {
|
||||
let x = rect.x0 + xx as i32;
|
||||
let dx = x as f32 + 0.5 - cx;
|
||||
// To y-up, rotate by -angle, flip.
|
||||
let (ux, uy) = (dx, -dy);
|
||||
// To y-up, project, rotate by -angle, flip.
|
||||
let (mut ux, mut uy) = (dx, -dy);
|
||||
if let Some((ax, ay, k)) = proj {
|
||||
let t = (ux * ax + uy * ay) * k;
|
||||
ux += t * ax;
|
||||
uy += t * ay;
|
||||
}
|
||||
let u = (ux * cs + uy * sn) * fx;
|
||||
let v = (-ux * sn + uy * cs) * fy;
|
||||
let (mut val, rn) = match mips {
|
||||
|
||||
@@ -53,6 +53,8 @@ pub mod stream {
|
||||
pub const DUAL_X: u64 = 16;
|
||||
pub const DUAL_Y: u64 = 17;
|
||||
pub const DUAL_FLIP: u64 = 18;
|
||||
pub const WET: u64 = 19;
|
||||
pub const MIX: u64 = 20;
|
||||
}
|
||||
|
||||
/// Seed derived from arbitrary bytes (FNV-1a then mixed), for commands without an explicit seed.
|
||||
|
||||
@@ -48,7 +48,7 @@ ChannelData { id, compression: Option<Compression>, data } // encoded bytes ke
|
||||
ChannelData::encode(id, compression, decoded, w, h, depth, version)
|
||||
ChannelData::decode(w, h, depth, version); set_decoded(...)
|
||||
MaskData::{None, Mask(LayerMask), Raw(Vec<u8>)}
|
||||
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing }
|
||||
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing, real_first }
|
||||
BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key()
|
||||
Layer<'_>::rgba8() -> Result<RgbaImage>; user_mask() -> Option<Result<GrayImage>>; channel_bytes(id)
|
||||
|
||||
|
||||
+101
-8
@@ -176,6 +176,21 @@ pub struct LayerMask {
|
||||
pub real: Option<RealMask>,
|
||||
/// Remaining bytes (the 2 padding bytes of the 20-byte form, etc.).
|
||||
pub trailing: Vec<u8>,
|
||||
/// When both are present, the real-mask fields come before the mask parameters. Photoshop
|
||||
/// writes this order (the published spec lists the parameters first); `false` keeps a file
|
||||
/// that uses the spec's order byte-stable.
|
||||
#[cfg_attr(feature = "serde", serde(default = "default_true"))]
|
||||
pub real_first: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "serde")]
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Byte length of the mask parameter values announced by the parameter flags byte `pf`.
|
||||
fn mask_parameter_bytes(pf: u8) -> usize {
|
||||
usize::from(pf & 1 != 0) + 8 * usize::from(pf & 2 != 0) + usize::from(pf & 4 != 0) + 8 * usize::from(pf & 8 != 0)
|
||||
}
|
||||
|
||||
impl LayerMask {
|
||||
@@ -190,19 +205,34 @@ impl LayerMask {
|
||||
|
||||
/// The simple 20-byte form.
|
||||
pub fn new(rect: Rect, default_color: u8, flags: u8) -> Self {
|
||||
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0] }
|
||||
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0], real_first: true }
|
||||
}
|
||||
/// `true` if the disabled flag is set.
|
||||
pub fn disabled(&self) -> bool {
|
||||
self.flags & Self::FLAG_DISABLED != 0
|
||||
}
|
||||
|
||||
/// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before
|
||||
/// the parameters, as Photoshop does. The spec's order wins when it reads as parameters
|
||||
/// followed by padding alone (no real mask), the one layout the two orders can share.
|
||||
fn real_before_parameters(d: &[u8]) -> bool {
|
||||
let fits =
|
||||
|pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len()));
|
||||
!fits(18) && fits(36)
|
||||
}
|
||||
|
||||
fn parse(d: &[u8]) -> Result<Self> {
|
||||
let mut r = Reader::new(d);
|
||||
let rect = Rect::read(&mut r)?;
|
||||
let default_color = r.u8()?;
|
||||
let flags = r.u8()?;
|
||||
let parameters = if flags & Self::FLAG_PARAMETERS != 0 {
|
||||
let read_real = |r: &mut Reader<'_>| -> Result<Option<RealMask>> {
|
||||
Ok(if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(r)? }) } else { None })
|
||||
};
|
||||
let has_parameters = flags & Self::FLAG_PARAMETERS != 0;
|
||||
let real_first = has_parameters && Self::real_before_parameters(d);
|
||||
let mut real = if real_first { read_real(&mut r)? } else { None };
|
||||
let parameters = if has_parameters {
|
||||
let pf = r.u8()?;
|
||||
Some(MaskParameters {
|
||||
flags: pf,
|
||||
@@ -214,14 +244,28 @@ impl LayerMask {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let real = if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) } else { None };
|
||||
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec() })
|
||||
if !real_first {
|
||||
real = read_real(&mut r)?;
|
||||
}
|
||||
// The order only matters when both are present; otherwise default to Photoshop's.
|
||||
let real_first = real_first || real.is_none() || parameters.is_none();
|
||||
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first })
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut Vec<u8>) {
|
||||
self.rect.write(out);
|
||||
out.put_u8(self.default_color);
|
||||
out.put_u8(self.flags);
|
||||
let write_real = |out: &mut Vec<u8>| {
|
||||
if let Some(real) = &self.real {
|
||||
out.put_u8(real.flags);
|
||||
out.put_u8(real.background);
|
||||
real.rect.write(out);
|
||||
}
|
||||
};
|
||||
if self.real_first {
|
||||
write_real(out);
|
||||
}
|
||||
if let Some(p) = &self.parameters {
|
||||
out.put_u8(p.flags);
|
||||
if let Some(v) = p.user_density {
|
||||
@@ -237,10 +281,8 @@ impl LayerMask {
|
||||
out.put_f64(v);
|
||||
}
|
||||
}
|
||||
if let Some(real) = &self.real {
|
||||
out.put_u8(real.flags);
|
||||
out.put_u8(real.background);
|
||||
real.rect.write(out);
|
||||
if !self.real_first {
|
||||
write_real(out);
|
||||
}
|
||||
out.put(&self.trailing);
|
||||
}
|
||||
@@ -755,6 +797,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop stores the real-mask fields before the mask parameters (psd-tools
|
||||
/// mask-density-layervectormask.psd): user + vector mask with both densities set.
|
||||
#[test]
|
||||
fn real_mask_before_parameters_photoshop_layout() {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(15, -1, 18, 10).write(&mut d);
|
||||
d.extend([0, LayerMask::FLAG_PARAMETERS | 8]);
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 32, 8).write(&mut d);
|
||||
d.extend([0b0101, 64, 64, 0]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 32, 8)));
|
||||
assert_eq!(m.real.map(|r| r.background), Some(255));
|
||||
let p = m.parameters.unwrap();
|
||||
assert_eq!((p.user_density, p.vector_density), (Some(64), Some(64)));
|
||||
assert_eq!(m.trailing, vec![0]);
|
||||
assert!(m.real_first);
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
}
|
||||
|
||||
/// The spec's order (parameters, then real fields) still parses and stays byte-stable.
|
||||
#[test]
|
||||
fn real_mask_after_parameters_spec_layout() {
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS]);
|
||||
d.extend([0b0011, 200]);
|
||||
d.extend(1.5f64.to_be_bytes());
|
||||
d.extend([0, 255]);
|
||||
Rect::from_xywh(0, 0, 3, 3).write(&mut d);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert!(!m.real_first);
|
||||
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 3, 3)));
|
||||
assert_eq!(m.parameters.unwrap().user_feather, Some(1.5));
|
||||
let mut out = Vec::new();
|
||||
m.write(&mut out);
|
||||
assert_eq!(out, d);
|
||||
// Parameters alone (no real mask) keep reading in the spec's order.
|
||||
let mut d = Vec::new();
|
||||
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
|
||||
d.extend([255, LayerMask::FLAG_PARAMETERS, 0b1010]);
|
||||
d.extend(1.0f64.to_be_bytes());
|
||||
d.extend(2.0f64.to_be_bytes());
|
||||
d.extend([0, 0]);
|
||||
let m = LayerMask::parse(&d).unwrap();
|
||||
assert_eq!(m.real, None);
|
||||
assert_eq!(m.parameters.map(|p| (p.user_feather, p.vector_feather)), Some((Some(1.0), Some(2.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_sizes() {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -281,6 +281,7 @@ pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compr
|
||||
parameters: Some(MaskParameters { flags: 0b0011, user_density: Some(200), user_feather: Some(1.5), vector_density: None, vector_feather: None }),
|
||||
real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }),
|
||||
trailing: Vec::new(),
|
||||
real_first: true,
|
||||
};
|
||||
next(&mut layers, Rect::from_xywh(3, 3, 6, 4), "Grüße", BlendMode::ColorDodge, Some(real_mask));
|
||||
layers.push(group_record(&header, "Inner", SectionType::ClosedFolder, BlendMode::Normal, id0 + 2));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` at the
|
||||
//! workspace root when present (fetched by `xtask corpus`; never committed).
|
||||
//! Skips silently when the directory does not exist.
|
||||
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` and
|
||||
//! `corpus/psd-tools/**/*.{psd,psb}` (the full psd-tools set, fetched by
|
||||
//! `cargo xtask corpus --psd-tools`) at the workspace root when present
|
||||
//! (never committed). Skips silently when neither directory exists.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -24,16 +25,21 @@ const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools
|
||||
|
||||
#[test]
|
||||
fn corpus_parse_and_byte_stable() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
|
||||
for dir in ["corpus/psd", "corpus/psd-tools"] {
|
||||
parse_and_byte_stable(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(dir));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_and_byte_stable(root: &Path) {
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
collect(root, &mut files);
|
||||
files.sort();
|
||||
let mut failures = Vec::new();
|
||||
for p in &files {
|
||||
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
|
||||
let name = p.strip_prefix(root).unwrap_or(p).display().to_string();
|
||||
let bytes = match std::fs::read(p) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -61,6 +67,6 @@ fn corpus_parse_and_byte_stable() {
|
||||
},
|
||||
}
|
||||
}
|
||||
eprintln!("corpus: {} files, {} failures", files.len(), failures.len());
|
||||
eprintln!("corpus {}: {} files, {} failures", root.display(), files.len(), failures.len());
|
||||
assert!(failures.is_empty(), "corpus failures:\n{}", failures.join("\n"));
|
||||
}
|
||||
@@ -11,3 +11,8 @@ workspace = true
|
||||
photocraft-geom = { workspace = true }
|
||||
photocraft-color = { workspace = true }
|
||||
photocraft-raster = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0).
|
||||
memory-stats = "1.2"
|
||||
@@ -1 +1,5 @@
|
||||
//! photocraft-testkit
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod perf;
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Benchmark helpers shared by the `--json` benches that `cargo xtask perf` runs (#221).
|
||||
//!
|
||||
//! - [`RssSampler`]: peak resident memory measured in-process. A background thread polls the
|
||||
//! process's resident set size every few milliseconds; [`RssSampler::reset`] starts a new
|
||||
//! window, so each scenario gets its own peak. Polling can miss a spike shorter than the
|
||||
//! interval; on Linux [`process_peak_rss_bytes`] also reads the kernel's exact high-water mark.
|
||||
//! - [`Summary`]: p50 / p95 / max of a scenario's samples (nearest-rank percentiles).
|
||||
//! - [`row`] / [`report`]: the JSON shape `xtask perf` reads.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Current resident set size of this process, in bytes.
|
||||
pub fn current_rss_bytes() -> Option<u64> {
|
||||
memory_stats::memory_stats().map(|m| m.physical_mem as u64)
|
||||
}
|
||||
|
||||
/// The process's peak resident set size so far: the kernel's high-water mark on Linux
|
||||
/// (`VmHWM`), `None` elsewhere (use an [`RssSampler`]).
|
||||
pub fn process_peak_rss_bytes() -> Option<u64> {
|
||||
let status = std::fs::read_to_string("/proc/self/status").ok()?;
|
||||
parse_vm_hwm(&status)
|
||||
}
|
||||
|
||||
fn parse_vm_hwm(status: &str) -> Option<u64> {
|
||||
let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
|
||||
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
|
||||
kb.checked_mul(1024)
|
||||
}
|
||||
|
||||
/// Polls the resident set size on a background thread and keeps the maximum.
|
||||
pub struct RssSampler {
|
||||
peak: Arc<AtomicU64>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RssSampler {
|
||||
/// Starts sampling every `interval` (2–5 ms is cheap: one syscall per sample).
|
||||
pub fn start(interval: Duration) -> Self {
|
||||
let peak = Arc::new(AtomicU64::new(current_rss_bytes().unwrap_or(0)));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (p, s) = (Arc::clone(&peak), Arc::clone(&stop));
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("rss-sampler".into())
|
||||
.spawn(move || {
|
||||
while !s.load(Ordering::Relaxed) {
|
||||
if let Some(v) = current_rss_bytes() {
|
||||
p.fetch_max(v, Ordering::Relaxed);
|
||||
}
|
||||
std::thread::sleep(interval);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
Self { peak, stop, thread }
|
||||
}
|
||||
|
||||
/// Starts a new window: the peak becomes the current resident size.
|
||||
pub fn reset(&self) {
|
||||
self.peak.store(current_rss_bytes().unwrap_or(0), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The peak resident size since the last [`reset`](Self::reset) (or start), including now.
|
||||
pub fn peak(&self) -> Option<u64> {
|
||||
if let Some(v) = current_rss_bytes() {
|
||||
self.peak.fetch_max(v, Ordering::Relaxed);
|
||||
}
|
||||
Some(self.peak.load(Ordering::Relaxed)).filter(|v| *v > 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RssSampler {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
// A sampler thread that panicked has nothing to report; don't cascade.
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// p50 / p95 / max of a set of timings (ms).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Summary {
|
||||
pub n: usize,
|
||||
pub p50: f64,
|
||||
pub p95: f64,
|
||||
pub max: f64,
|
||||
}
|
||||
|
||||
/// Nearest-rank percentile `q` (0..=1) of sorted, non-empty `v`.
|
||||
fn rank(v: &[f64], q: f64) -> Option<f64> {
|
||||
let n = v.len();
|
||||
let k = ((q * n as f64).ceil() as usize).clamp(1, n.max(1));
|
||||
v.get(k - 1).copied()
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
/// `None` when there are no finite samples.
|
||||
pub fn of(samples: &[f64]) -> Option<Self> {
|
||||
let mut v: Vec<f64> = samples.iter().copied().filter(|x| x.is_finite()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
Some(Self { n: v.len(), p50: rank(&v, 0.5)?, p95: rank(&v, 0.95)?, max: *v.last()? })
|
||||
}
|
||||
}
|
||||
|
||||
/// One scenario row: `name`, sample count, p50/p95/max, the raw samples, peak RSS and GPU bytes.
|
||||
pub fn row(name: &str, samples: &[f64], peak_rss: Option<u64>, gpu_bytes: Option<u64>) -> Value {
|
||||
let s = Summary::of(samples);
|
||||
json!({
|
||||
"name": name,
|
||||
"n": s.map_or(0, |s| s.n),
|
||||
"p50_ms": s.map(|s| s.p50),
|
||||
"p95_ms": s.map(|s| s.p95),
|
||||
"max_ms": s.map(|s| s.max),
|
||||
"samples_ms": samples,
|
||||
"peak_rss_bytes": peak_rss,
|
||||
"gpu_bytes": gpu_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// A bench's whole report: its name, free-form context (document size, adapter…), the rows and
|
||||
/// the process's peak RSS (the kernel's high-water mark where there is one, else the highest
|
||||
/// sampled peak).
|
||||
pub fn report(bench: &str, context: Value, rows: Vec<Value>, sampler: Option<&RssSampler>) -> Value {
|
||||
let sampled = rows.iter().filter_map(|r| r["peak_rss_bytes"].as_u64()).chain(sampler.and_then(RssSampler::peak)).max();
|
||||
let process_peak = process_peak_rss_bytes().or(sampled);
|
||||
json!({
|
||||
"bench": bench,
|
||||
"schema": 1,
|
||||
"context": context,
|
||||
"rows": rows,
|
||||
"process_peak_rss_bytes": process_peak,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `report` to `path` (pretty JSON). Errors are returned, not panicked.
|
||||
pub fn write_report(path: &str, report: &Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string_pretty(report).map_err(|e| format!("encode {path}: {e}"))?;
|
||||
std::fs::write(path, text).map_err(|e| format!("write {path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn summary_nearest_rank() {
|
||||
let v: Vec<f64> = (1..=20).map(f64::from).collect();
|
||||
let s = Summary::of(&v).unwrap();
|
||||
assert_eq!((s.n, s.p50, s.p95, s.max), (20, 10.0, 19.0, 20.0));
|
||||
let s = Summary::of(&[3.0]).unwrap();
|
||||
assert_eq!((s.p50, s.p95, s.max), (3.0, 3.0, 3.0));
|
||||
assert!(Summary::of(&[]).is_none());
|
||||
assert!(Summary::of(&[f64::NAN]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vm_hwm_parses() {
|
||||
assert_eq!(parse_vm_hwm("Name:\tx\nVmHWM:\t 2048 kB\nVmRSS: 1 kB\n"), Some(2048 * 1024));
|
||||
assert_eq!(parse_vm_hwm("VmHWM: lots\n"), None);
|
||||
assert_eq!(parse_vm_hwm(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_reports_a_peak() {
|
||||
let s = RssSampler::start(Duration::from_millis(1));
|
||||
s.reset();
|
||||
let big = vec![1u8; 32 << 20];
|
||||
std::hint::black_box(&big);
|
||||
let p = s.peak();
|
||||
drop(big);
|
||||
// Platforms without a memory API report nothing rather than a made-up number.
|
||||
if current_rss_bytes().is_some() {
|
||||
assert!(p.unwrap() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_shape() {
|
||||
let r = row("x", &[2.0, 1.0, 3.0], Some(10), None);
|
||||
assert_eq!(r["p50_ms"], 2.0);
|
||||
assert_eq!(r["max_ms"], 3.0);
|
||||
assert_eq!(r["n"], 3);
|
||||
assert!(r["gpu_bytes"].is_null());
|
||||
let empty = row("y", &[], None, None);
|
||||
assert!(empty["p95_ms"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//! CJK font fallback: which installed system fonts cover Japanese, Simplified Chinese,
|
||||
//! Traditional Chinese and Korean, and in which order to try them for the user's locale.
|
||||
//!
|
||||
//! Han characters are shared between the four, but their preferred glyph forms differ, so the
|
||||
//! font for the locale's script goes first: Japanese forms only for a Japanese locale, Chinese
|
||||
//! forms for zh-Hans / zh-Hant, Korean (Hanja-capable) fonts for ko. Kana always prefers a
|
||||
//! Japanese font, Hangul a Korean one and Bopomofo a Traditional Chinese one.
|
||||
//!
|
||||
//! Used by the egui shell (UI fallback fonts, loaded lazily) and by [`crate::fonts::FontDb`]
|
||||
//! (Type tool fallback families).
|
||||
|
||||
/// A CJK writing system with its own preferred fonts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum CjkScript {
|
||||
Japanese,
|
||||
SimplifiedChinese,
|
||||
TraditionalChinese,
|
||||
Korean,
|
||||
}
|
||||
|
||||
/// The kind of CJK character that needs a fallback font.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum CjkChar {
|
||||
/// Ideographs, CJK punctuation and full-width forms: font choice follows the locale.
|
||||
Han,
|
||||
Kana,
|
||||
Hangul,
|
||||
Bopomofo,
|
||||
}
|
||||
|
||||
impl CjkChar {
|
||||
/// The script whose fonts should be tried first for this character.
|
||||
pub fn preferred(self, order: &[CjkScript; 4]) -> CjkScript {
|
||||
match self {
|
||||
CjkChar::Kana => CjkScript::Japanese,
|
||||
CjkChar::Hangul => CjkScript::Korean,
|
||||
CjkChar::Bopomofo => CjkScript::TraditionalChinese,
|
||||
CjkChar::Han => order[0],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies a character that needs a CJK font, or `None` for everything else.
|
||||
pub fn classify(c: char) -> Option<CjkChar> {
|
||||
let u = c as u32;
|
||||
Some(match u {
|
||||
0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7AF | 0xD7B0..=0xD7FF | 0xFFA0..=0xFFDC => CjkChar::Hangul,
|
||||
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF65..=0xFF9F | 0x1B000..=0x1B16F => CjkChar::Kana,
|
||||
0x3100..=0x312F | 0x31A0..=0x31BF => CjkChar::Bopomofo,
|
||||
0x2E80..=0x2FDF | 0x3000..=0x303F | 0x3190..=0x319F | 0x31C0..=0x31EF | 0x3200..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF => CjkChar::Han,
|
||||
0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFF64 | 0xFFE0..=0xFFEF | 0x20000..=0x3FFFF => CjkChar::Han,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Script order for a locale tag such as `ja_JP.UTF-8`, `zh-Hant-TW`, `zh_CN` or `ko-KR`.
|
||||
/// Non-CJK (or unknown) locales get Simplified Chinese first, so Japanese forms only win
|
||||
/// for shared Han when the locale is Japanese.
|
||||
pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] {
|
||||
use CjkScript::*;
|
||||
let tag = locale.unwrap_or("").split(['.', '@']).next().unwrap_or("").to_ascii_lowercase().replace('_', "-");
|
||||
let mut parts = tag.split('-');
|
||||
let lang = parts.next().unwrap_or("");
|
||||
let rest: Vec<&str> = parts.collect();
|
||||
match lang {
|
||||
"ja" => [Japanese, SimplifiedChinese, TraditionalChinese, Korean],
|
||||
"ko" => [Korean, TraditionalChinese, SimplifiedChinese, Japanese],
|
||||
"zh" | "yue" => {
|
||||
let hant = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo"));
|
||||
let hans = rest.iter().any(|p| matches!(*p, "hans" | "cn" | "sg"));
|
||||
if hant && !hans || lang == "yue" && !hans {
|
||||
[TraditionalChinese, SimplifiedChinese, Japanese, Korean]
|
||||
} else {
|
||||
[SimplifiedChinese, TraditionalChinese, Japanese, Korean]
|
||||
}
|
||||
}
|
||||
_ => [SimplifiedChinese, TraditionalChinese, Japanese, Korean],
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's UI locale (cached). `PHOTOCRAFT_LOCALE` overrides; then macOS's preferred
|
||||
/// language list, the Windows user locale, and finally `LC_ALL` / `LC_MESSAGES` / `LANG` /
|
||||
/// `LANGUAGE`. `None` on the web or when nothing is set.
|
||||
pub fn ui_locale() -> Option<&'static str> {
|
||||
static LOCALE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
LOCALE.get_or_init(detect_locale).as_deref()
|
||||
}
|
||||
|
||||
/// [`script_order`] for [`ui_locale`].
|
||||
pub fn ui_script_order() -> [CjkScript; 4] {
|
||||
script_order(ui_locale())
|
||||
}
|
||||
|
||||
fn detect_locale() -> Option<String> {
|
||||
let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && v != "C" && v != "POSIX" && !v.starts_with("C."));
|
||||
if let Some(v) = env("PHOTOCRAFT_LOCALE") {
|
||||
return Some(v);
|
||||
}
|
||||
if let Some(v) = os_locale() {
|
||||
return Some(v);
|
||||
}
|
||||
["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"].iter().find_map(|k| env(k)).map(|v| v.split(':').next().unwrap_or("").to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_locale() -> Option<String> {
|
||||
let home = std::env::var_os("HOME")?;
|
||||
let path = std::path::Path::new(&home).join("Library/Preferences/.GlobalPreferences.plist");
|
||||
let meta = std::fs::metadata(&path).ok()?;
|
||||
if meta.len() > 4 << 20 {
|
||||
return None;
|
||||
}
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
bplist_first_language(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_locale() -> Option<String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
let out = std::process::Command::new("reg")
|
||||
.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// " LocaleName REG_SZ ja-JP"
|
||||
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn os_locale() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// First entry of `AppleLanguages` in a binary property list (macOS `.GlobalPreferences.plist`).
|
||||
/// A minimal, bounds-checked reader of the documented `bplist00` format; `None` on anything else.
|
||||
pub fn bplist_first_language(b: &[u8]) -> Option<String> {
|
||||
if b.len() < 40 || !b.starts_with(b"bplist00") {
|
||||
return None;
|
||||
}
|
||||
let t = b.get(b.len() - 32..)?;
|
||||
let offset_size = *t.get(6)? as usize;
|
||||
let ref_size = *t.get(7)? as usize;
|
||||
let be = |s: &[u8]| s.iter().fold(0u64, |a, &x| (a << 8) | x as u64);
|
||||
let num_objects = usize::try_from(be(t.get(8..16)?)).ok()?;
|
||||
let top = usize::try_from(be(t.get(16..24)?)).ok()?;
|
||||
let table = usize::try_from(be(t.get(24..32)?)).ok()?;
|
||||
if !(1..=8).contains(&offset_size) || !(1..=8).contains(&ref_size) || num_objects > b.len() {
|
||||
return None;
|
||||
}
|
||||
let offset = |obj: usize| -> Option<usize> {
|
||||
if obj >= num_objects {
|
||||
return None;
|
||||
}
|
||||
let at = table.checked_add(obj.checked_mul(offset_size)?)?;
|
||||
usize::try_from(be(b.get(at..at.checked_add(offset_size)?)?)).ok()
|
||||
};
|
||||
// (marker high nibble, count, start of payload)
|
||||
let header = |obj: usize| -> Option<(u8, usize, usize)> {
|
||||
let at = offset(obj)?;
|
||||
let m = *b.get(at)?;
|
||||
let (kind, low) = (m >> 4, (m & 0x0F) as usize);
|
||||
if low != 0x0F || !matches!(kind, 0x5 | 0x6 | 0xA | 0xD) {
|
||||
return Some((kind, low, at + 1));
|
||||
}
|
||||
let im = *b.get(at + 1)?;
|
||||
if im >> 4 != 0x1 {
|
||||
return None;
|
||||
}
|
||||
let n = 1usize.checked_shl((im & 0x0F) as u32)?;
|
||||
let count = usize::try_from(be(b.get(at + 2..at.checked_add(2 + n)?)?)).ok()?;
|
||||
Some((kind, count, at + 2 + n))
|
||||
};
|
||||
let string = |obj: usize| -> Option<String> {
|
||||
let (kind, n, start) = header(obj)?;
|
||||
match kind {
|
||||
0x5 => b.get(start..start.checked_add(n)?).map(|s| String::from_utf8_lossy(s).into_owned()),
|
||||
0x6 => {
|
||||
let s = b.get(start..start.checked_add(n.checked_mul(2)?)?)?;
|
||||
Some(String::from_utf16_lossy(&s.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect::<Vec<_>>()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let obj_ref = |at: usize| -> Option<usize> { usize::try_from(be(b.get(at..at.checked_add(ref_size)?)?)).ok() };
|
||||
let (kind, n, start) = header(top)?;
|
||||
if kind != 0xD {
|
||||
return None;
|
||||
}
|
||||
for i in 0..n {
|
||||
let key = obj_ref(start.checked_add(i.checked_mul(ref_size)?)?)?;
|
||||
if string(key).as_deref() != Some("AppleLanguages") {
|
||||
continue;
|
||||
}
|
||||
let val = obj_ref(start.checked_add(n.checked_add(i)?.checked_mul(ref_size)?)?)?;
|
||||
let (akind, an, astart) = header(val)?;
|
||||
if akind != 0xA || an == 0 {
|
||||
return None;
|
||||
}
|
||||
return string(obj_ref(astart)?).filter(|s| !s.is_empty());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Family names (as the Type tool's font database knows them) per script, most preferred first.
|
||||
pub fn families(script: CjkScript) -> &'static [&'static str] {
|
||||
match script {
|
||||
CjkScript::Japanese => &["Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP", "IPAexGothic"],
|
||||
CjkScript::SimplifiedChinese => {
|
||||
&["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Heiti SC"]
|
||||
}
|
||||
CjkScript::TraditionalChinese => &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Noto Sans TC", "Hiragino Sans CNS", "Heiti TC"],
|
||||
CjkScript::Korean => &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "NanumGothic", "UnDotum"],
|
||||
}
|
||||
}
|
||||
|
||||
/// An installed font file to try: its path and the family of the wanted face in a collection
|
||||
/// (empty: face 0). Matching by family name keeps working when a `.ttc` reorders its faces.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FontFile {
|
||||
pub path: std::path::PathBuf,
|
||||
pub family: &'static str,
|
||||
}
|
||||
|
||||
/// System font files for `script`, most preferred first. Paths that don't exist are included;
|
||||
/// callers skip them.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn font_files(script: CjkScript) -> Vec<FontFile> {
|
||||
use CjkScript::*;
|
||||
let f = |p: &str, family: &'static str| FontFile { path: p.into(), family };
|
||||
if cfg!(target_os = "macos") {
|
||||
let pingfang = |family| mac_pingfang().map(|path| FontFile { path, family });
|
||||
match script {
|
||||
Japanese => vec![f("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", ""), f("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", "")],
|
||||
SimplifiedChinese => pingfang("PingFang SC")
|
||||
.into_iter()
|
||||
.chain([f("/System/Library/Fonts/Hiragino Sans GB.ttc", ""), f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti SC")])
|
||||
.collect(),
|
||||
TraditionalChinese => pingfang("PingFang TC").into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(),
|
||||
Korean => vec![f("/System/Library/Fonts/AppleSDGothicNeo.ttc", "Apple SD Gothic Neo")],
|
||||
}
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family };
|
||||
match script {
|
||||
Japanese => vec![w("YuGothR.ttc", "Yu Gothic"), w("YuGothM.ttc", "Yu Gothic"), w("meiryo.ttc", "Meiryo"), w("msgothic.ttc", "")],
|
||||
SimplifiedChinese => vec![w("msyh.ttc", "Microsoft YaHei"), w("msyh.ttf", ""), w("simsun.ttc", "")],
|
||||
TraditionalChinese => vec![w("msjh.ttc", "Microsoft JhengHei"), w("msjh.ttf", ""), w("mingliu.ttc", "")],
|
||||
Korean => vec![w("malgun.ttf", ""), w("gulim.ttc", "")],
|
||||
}
|
||||
} else {
|
||||
// One Noto Sans CJK collection covers all four scripts; the face picks the glyph forms.
|
||||
let noto = match script {
|
||||
Japanese => "Noto Sans CJK JP",
|
||||
SimplifiedChinese => "Noto Sans CJK SC",
|
||||
TraditionalChinese => "Noto Sans CJK TC",
|
||||
Korean => "Noto Sans CJK KR",
|
||||
};
|
||||
let mut v: Vec<FontFile> = [
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
// FreeBSD ports (x11-fonts/noto-sans-cjk and friends) install under /usr/local.
|
||||
"/usr/local/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/local/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/local/share/fonts/noto/NotoSansCJK-VF.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-VF.ttc",
|
||||
]
|
||||
.iter()
|
||||
.map(|p| f(p, noto))
|
||||
.collect();
|
||||
v.extend(match script {
|
||||
Japanese => vec![
|
||||
f("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", ""),
|
||||
f("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", ""),
|
||||
f("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", ""),
|
||||
],
|
||||
SimplifiedChinese | TraditionalChinese => vec![
|
||||
f("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", ""),
|
||||
f("/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", ""),
|
||||
f("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", ""),
|
||||
f("/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", ""),
|
||||
],
|
||||
Korean => vec![
|
||||
f("/usr/share/fonts/truetype/nanum/NanumGothic.ttf", ""),
|
||||
f("/usr/share/fonts/nanum/NanumGothic.ttf", ""),
|
||||
f("/usr/share/fonts/truetype/unfonts-core/UnDotum.ttf", ""),
|
||||
f("/usr/share/fonts/un-core/UnDotum.ttf", ""),
|
||||
],
|
||||
});
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// Broad-coverage fonts tried after every script's own fonts (they cover Han, kana and Hangul
|
||||
/// but look worse than the script fonts).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn last_resort_files() -> Vec<FontFile> {
|
||||
let f = |p: &str| FontFile { path: p.into(), family: "" };
|
||||
if cfg!(target_os = "macos") {
|
||||
vec![f("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), f("/Library/Fonts/Arial Unicode.ttf")]
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
|
||||
vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: "" }]
|
||||
} else {
|
||||
vec![f("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), f("/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf")]
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS ships PingFang as a pre-installed "mobile asset" outside `/System/Library/Fonts`
|
||||
/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<hash>.asset/AssetData/PingFang.ttc`).
|
||||
/// Looks there (a few small directory listings) and in the pre-Catalina location.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn mac_pingfang() -> Option<std::path::PathBuf> {
|
||||
use std::path::{Path, PathBuf};
|
||||
let old = PathBuf::from("/System/Library/Fonts/PingFang.ttc");
|
||||
if old.is_file() {
|
||||
return Some(old);
|
||||
}
|
||||
let roots = [Path::new("/System/Library/AssetsV2"), Path::new("/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs")];
|
||||
for root in roots {
|
||||
let Ok(rd) = std::fs::read_dir(root) else { continue };
|
||||
for e in rd.flatten().take(512) {
|
||||
if !e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
|
||||
continue;
|
||||
}
|
||||
let Ok(assets) = std::fs::read_dir(e.path()) else { continue };
|
||||
for a in assets.flatten().take(4096) {
|
||||
let p = a.path().join("AssetData/PingFang.ttc");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is
|
||||
/// empty or no face matches. Never panics on malformed data.
|
||||
pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 {
|
||||
use skrifa::{MetadataProvider, raw::FileRef, string::StringId};
|
||||
if family.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 };
|
||||
for (i, f) in c.iter().enumerate() {
|
||||
let Ok(f) = f else { continue };
|
||||
let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars())));
|
||||
if named {
|
||||
return i as u32;
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use CjkScript::*;
|
||||
|
||||
#[test]
|
||||
fn locale_orders() {
|
||||
assert_eq!(script_order(Some("ja_JP.UTF-8"))[0], Japanese);
|
||||
assert_eq!(script_order(Some("ja"))[0], Japanese);
|
||||
assert_eq!(script_order(Some("zh_CN.UTF-8"))[0], SimplifiedChinese);
|
||||
assert_eq!(script_order(Some("zh-Hans-CN"))[0], SimplifiedChinese);
|
||||
assert_eq!(script_order(Some("zh-Hant-TW"))[0], TraditionalChinese);
|
||||
assert_eq!(script_order(Some("zh_TW"))[0], TraditionalChinese);
|
||||
assert_eq!(script_order(Some("zh-HK"))[0], TraditionalChinese);
|
||||
assert_eq!(script_order(Some("zh-Hans-HK"))[0], SimplifiedChinese);
|
||||
assert_eq!(script_order(Some("ko-KR"))[0], Korean);
|
||||
// Non-CJK and unknown: Japanese forms are never first.
|
||||
for l in [None, Some(""), Some("en_US.UTF-8"), Some("C"), Some("de"), Some("!!@@")] {
|
||||
let o = script_order(l);
|
||||
assert_eq!(o[0], SimplifiedChinese);
|
||||
assert_ne!(o[0], Japanese);
|
||||
}
|
||||
// Every order is a permutation of all four scripts.
|
||||
for l in ["ja", "ko", "zh-TW", "zh", "en"] {
|
||||
let o = script_order(Some(l));
|
||||
for s in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] {
|
||||
assert!(o.contains(&s), "{l}: {o:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_tool_fallback_follows_locale() {
|
||||
let pos = |v: &[&str], f: &str| v.iter().position(|x| *x == f).unwrap();
|
||||
let ja = crate::fonts::fallback_candidates(&script_order(Some("ja_JP")));
|
||||
assert!(pos(&ja, "Hiragino Sans") < pos(&ja, "PingFang SC"));
|
||||
assert!(pos(&ja, "Noto Sans CJK JP") < pos(&ja, "Noto Sans CJK SC"));
|
||||
let sc = crate::fonts::fallback_candidates(&script_order(Some("zh_CN")));
|
||||
assert!(pos(&sc, "PingFang SC") < pos(&sc, "Hiragino Sans"));
|
||||
assert!(pos(&sc, "Microsoft YaHei") < pos(&sc, "Microsoft JhengHei"));
|
||||
let tc = crate::fonts::fallback_candidates(&script_order(Some("zh-Hant")));
|
||||
assert!(pos(&tc, "PingFang TC") < pos(&tc, "PingFang SC"));
|
||||
let ko = crate::fonts::fallback_candidates(&script_order(Some("ko")));
|
||||
assert!(pos(&ko, "Apple SD Gothic Neo") < pos(&ko, "PingFang SC"));
|
||||
assert!(pos(&ko, "Malgun Gothic") < pos(&ko, "Yu Gothic"));
|
||||
// Arial Unicode covers Han too: it must not pre-empt the script fonts.
|
||||
for v in [&ja, &sc, &tc, &ko] {
|
||||
assert!(pos(v, "Arial Unicode MS") > pos(v, "Noto Sans CJK KR"));
|
||||
assert!(pos(v, "Noto Sans") < pos(v, "Hiragino Sans"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_chars() {
|
||||
let order = script_order(Some("en"));
|
||||
assert_eq!(classify('a'), None);
|
||||
assert_eq!(classify('é'), None);
|
||||
assert_eq!(classify('图'), Some(CjkChar::Han));
|
||||
assert_eq!(classify('圖'), Some(CjkChar::Han));
|
||||
assert_eq!(classify('レ'), Some(CjkChar::Kana));
|
||||
assert_eq!(classify('ー'), Some(CjkChar::Kana));
|
||||
assert_eq!(classify('카'), Some(CjkChar::Hangul));
|
||||
assert_eq!(classify('ㄅ'), Some(CjkChar::Bopomofo));
|
||||
assert_eq!(CjkChar::Kana.preferred(&order), Japanese);
|
||||
assert_eq!(CjkChar::Hangul.preferred(&order), Korean);
|
||||
assert_eq!(CjkChar::Han.preferred(&order), SimplifiedChinese);
|
||||
assert_eq!(CjkChar::Han.preferred(&script_order(Some("ja"))), Japanese);
|
||||
}
|
||||
|
||||
/// `plutil -convert binary1` of a dict with AKLastLocale, AppleLanguages
|
||||
/// [zh-Hant-TW, en-US] and AppleLocale.
|
||||
const SAMPLE: &[u8] = &[
|
||||
0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd3, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x5b, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x6f, 0x63, 0x61,
|
||||
0x6c, 0x65, 0x5c, 0x41, 0x4b, 0x4c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x5e, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x61, 0x6e, 0x67,
|
||||
0x75, 0x61, 0x67, 0x65, 0x73, 0x55, 0x7a, 0x68, 0x5f, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x5f, 0x55, 0x53, 0xa2, 0x07, 0x08, 0x5a, 0x7a, 0x68, 0x2d, 0x48,
|
||||
0x61, 0x6e, 0x74, 0x2d, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x2d, 0x55, 0x53, 0x08, 0x0f, 0x1b, 0x28, 0x37, 0x3d, 0x43, 0x46, 0x51, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x57,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn reads_apple_languages_and_rejects_garbage() {
|
||||
assert_eq!(bplist_first_language(SAMPLE).as_deref(), Some("zh-Hant-TW"));
|
||||
assert_eq!(script_order(bplist_first_language(SAMPLE).as_deref())[0], TraditionalChinese);
|
||||
assert_eq!(bplist_first_language(b""), None);
|
||||
assert_eq!(bplist_first_language(b"bplist00"), None);
|
||||
assert_eq!(bplist_first_language(&[0xFF; 64]), None);
|
||||
// Every truncation and every single-byte corruption must fail gracefully, never panic.
|
||||
for n in 0..SAMPLE.len() {
|
||||
let _ = bplist_first_language(&SAMPLE[..n]);
|
||||
}
|
||||
for i in 0..SAMPLE.len() {
|
||||
for v in [0x00, 0x0F, 0x5F, 0xAF, 0xDF, 0xFF] {
|
||||
let mut b = SAMPLE.to_vec();
|
||||
b[i] = v;
|
||||
let _ = bplist_first_language(&b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_index_handles_bad_data() {
|
||||
assert_eq!(face_index_for_family(b"", "PingFang SC"), 0);
|
||||
assert_eq!(face_index_for_family(b"ttcf\0\0\0\0garbage", "PingFang SC"), 0);
|
||||
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
assert_eq!(face_index_for_family(inter, "Inter"), 0);
|
||||
assert_eq!(face_index_for_family(inter, ""), 0);
|
||||
}
|
||||
|
||||
/// On a Mac with PingFang, the SC and TC faces are found by name (not a hard-coded index).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn pingfang_faces_by_name() {
|
||||
let Some(p) = mac_pingfang() else { return };
|
||||
let Ok(bytes) = std::fs::read(p) else { return };
|
||||
let sc = face_index_for_family(&bytes, "PingFang SC");
|
||||
let tc = face_index_for_family(&bytes, "PingFang TC");
|
||||
assert_ne!(sc, tc);
|
||||
}
|
||||
}
|
||||
+34
-30
@@ -15,38 +15,37 @@ pub const DEFAULT_FAMILY: &str = "Inter";
|
||||
/// Bundled monospace family.
|
||||
pub const MONO_FAMILY: &str = "JetBrains Mono";
|
||||
|
||||
// The font files are embedded once, here. The UI (`ui-egui` theme) uses these same statics:
|
||||
// a second `include_bytes!` of the same file elsewhere put a second 1.5 MB copy into the wasm.
|
||||
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
|
||||
pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
|
||||
pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
|
||||
|
||||
/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`).
|
||||
pub const BUNDLED: &[(&str, &[u8])] = &[
|
||||
("Inter-Regular.ttf", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")),
|
||||
("Inter-Medium.ttf", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")),
|
||||
("Inter-SemiBold.ttf", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")),
|
||||
("JetBrainsMono-Regular.ttf", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")),
|
||||
pub static BUNDLED: &[(&str, &[u8])] = &[
|
||||
("Inter-Regular.ttf", INTER_REGULAR),
|
||||
("Inter-Medium.ttf", INTER_MEDIUM),
|
||||
("Inter-SemiBold.ttf", INTER_SEMIBOLD),
|
||||
("JetBrainsMono-Regular.ttf", JETBRAINS_MONO_REGULAR),
|
||||
];
|
||||
|
||||
/// Families tried (if installed) after the requested one, for missing glyphs.
|
||||
const FALLBACK_CANDIDATES: &[&str] = &[
|
||||
"Noto Sans",
|
||||
"Arial Unicode MS",
|
||||
"Segoe UI",
|
||||
"DejaVu Sans",
|
||||
"Geeza Pro",
|
||||
"Arial Hebrew",
|
||||
"Noto Sans Arabic",
|
||||
"Noto Sans Hebrew",
|
||||
// Japanese first, so shared Han characters get Japanese forms before the SC fonts are tried.
|
||||
"Hiragino Sans",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"Yu Gothic",
|
||||
"Meiryo",
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans JP",
|
||||
"IPAexGothic",
|
||||
"Noto Sans CJK SC",
|
||||
"PingFang SC",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Noto Color Emoji",
|
||||
];
|
||||
/// Families tried (if installed) after the requested one, for missing glyphs. The CJK families
|
||||
/// ([`crate::cjk::families`]) go between these two lists, in the UI locale's script order.
|
||||
const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"];
|
||||
/// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get
|
||||
/// the locale's forms instead of Arial Unicode's.
|
||||
const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"];
|
||||
|
||||
/// Every fallback family candidate, CJK ordered for `order` (see [`crate::cjk::script_order`]).
|
||||
pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static str> {
|
||||
let mut v = FALLBACK_CANDIDATES.to_vec();
|
||||
for s in order {
|
||||
v.extend_from_slice(crate::cjk::families(*s));
|
||||
}
|
||||
v.extend_from_slice(FALLBACK_LAST);
|
||||
v
|
||||
}
|
||||
|
||||
/// One face in the database (for font menus).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -121,6 +120,10 @@ impl FontDb {
|
||||
for d in system_font_dirs() {
|
||||
collect_font_files(&d, 0, &mut files);
|
||||
}
|
||||
// PingFang (the macOS Chinese UI font) lives outside the font folders.
|
||||
if cfg!(target_os = "macos") {
|
||||
files.extend(crate::cjk::mac_pingfang());
|
||||
}
|
||||
for f in files {
|
||||
self.fcx.collection.load_fonts_from_paths([f]);
|
||||
}
|
||||
@@ -161,7 +164,8 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.fallbacks = FALLBACK_CANDIDATES.iter().filter(|f| c.family_id(f).is_some()).map(|s| s.to_string()).collect();
|
||||
let order = crate::cjk::ui_script_order();
|
||||
self.fallbacks = fallback_candidates(&order).into_iter().filter(|f| c.family_id(f).is_some()).map(str::to_string).collect();
|
||||
}
|
||||
|
||||
/// Families available after the requested one (bundled default + installed coverage fonts).
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod cjk;
|
||||
pub mod engine_data;
|
||||
pub mod fonts;
|
||||
pub mod glyphs;
|
||||
|
||||
@@ -18,6 +18,7 @@ photocraft-engine = { workspace = true }
|
||||
photocraft-plugins = { workspace = true }
|
||||
photocraft-algo = { workspace = true }
|
||||
photocraft-text = { workspace = true }
|
||||
photocraft-vector = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
@@ -36,3 +37,5 @@ photocraft-io = { workspace = true }
|
||||
# Only for examples/interactive_bench.rs (JPEG encode, .pcraft saves).
|
||||
photocraft-codecs = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
# Benchmark helpers for the `--json` benches `cargo xtask perf` runs (in-process RSS, percentiles).
|
||||
photocraft-testkit = { workspace = true }
|
||||
@@ -130,6 +130,7 @@ struct Gpu {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
comp: photocraft_gpu::Compositor,
|
||||
adapter: String,
|
||||
}
|
||||
|
||||
fn gpu() -> Option<Gpu> {
|
||||
@@ -142,7 +143,7 @@ fn gpu() -> Option<Gpu> {
|
||||
let limits = photocraft_ui_egui::gpu_canvas::device_limits(&adapter);
|
||||
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?;
|
||||
let comp = photocraft_gpu::Compositor::new(&device);
|
||||
Some(Gpu { device, queue, comp })
|
||||
Some(Gpu { device, queue, comp, adapter: adapter.get_info().name })
|
||||
}
|
||||
|
||||
fn gpu_time(g: &mut Gpu, doc: &Document, region: Rect) -> Result<f64, String> {
|
||||
@@ -227,6 +228,7 @@ fn main() {
|
||||
let full = doc.bounds();
|
||||
println!("document {}x{} ({} layers)", doc.size.width, doc.size.height, doc.walk().len());
|
||||
let mut g = gpu();
|
||||
let adapter = g.as_ref().map(|g| g.adapter.clone());
|
||||
let mut gt = |doc: &Document, r: Rect| match g.as_mut() {
|
||||
Some(g) => gpu_time(g, doc, r),
|
||||
None => Err("no adapter".into()),
|
||||
@@ -246,8 +248,13 @@ fn main() {
|
||||
};
|
||||
println!("{what:<42} {mp:6.2} MP cpu {c:>22} gpu {g} (min / median)");
|
||||
};
|
||||
// `--json out.json` (`cargo xtask perf`): one row per case and compositor, with samples and
|
||||
// the peak RSS while the case ran.
|
||||
let rss = photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2));
|
||||
let mut json_rows: Vec<serde_json::Value> = Vec::new();
|
||||
// Each case: `edit` mutates the document, then both compositors refresh `region`.
|
||||
let mut case = |doc: &mut Document, what: &str, n: usize, edit: &mut dyn FnMut(&mut Document, usize) -> Rect, cpu_too: bool| {
|
||||
rss.reset();
|
||||
let (mut cs, mut gs, mut gerr) = (Vec::new(), Vec::new(), None);
|
||||
let mut region = full;
|
||||
for i in 0..n {
|
||||
@@ -260,6 +267,13 @@ fn main() {
|
||||
Err(e) => gerr = Some(e),
|
||||
}
|
||||
}
|
||||
let peak = rss.peak();
|
||||
if !gs.is_empty() {
|
||||
json_rows.push(photocraft_testkit::perf::row(&format!("{what} (GPU)"), &gs, peak, None));
|
||||
}
|
||||
if !cs.is_empty() {
|
||||
json_rows.push(photocraft_testkit::perf::row(&format!("{what} (CPU)"), &cs, peak, None));
|
||||
}
|
||||
show(
|
||||
what,
|
||||
region,
|
||||
@@ -350,4 +364,12 @@ fn main() {
|
||||
},
|
||||
true,
|
||||
);
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let context = json!({"width": w, "height": h, "gpu_adapter": adapter});
|
||||
let report = photocraft_testkit::perf::report("fx_bench", context, json_rows, Some(&rss));
|
||||
if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Huge-document benchmark (issue #49): canvas refreshes of a 20000×20000 (or `--size`) 8-bit
|
||||
//! RGB document with eleven layers (full-canvas pixel layers, soft blobs, an adjustment, type
|
||||
//! layers with effects, a full-width band whose effect region is wider than the GPU texture
|
||||
//! limit, a group with a clipped layer), on a device created the way the app creates it.
|
||||
//!
|
||||
//! One operation per run, so the process's peak memory is that operation's (plus building the
|
||||
//! document):
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo build --release -p photocraft-ui-egui --example huge_doc_bench
|
||||
//! for op in full view; do
|
||||
//! /usr/bin/time -l target/release/examples/huge_doc_bench --size 20000x20000 --op $op 2>&1 | grep -E 'ms|MB|maximum resident|peak memory'
|
||||
//! done
|
||||
//! # The CPU fallback, for comparison:
|
||||
//! PHOTOCRAFT_CPU_COMPOSE=1 /usr/bin/time -l target/release/examples/huge_doc_bench --op full
|
||||
//! ```
|
||||
//!
|
||||
//! `full`: the first refresh (every page uploaded, every effect map built) and three repeats.
|
||||
//! `view`: refreshes of a 2560×1440 viewport (zoomed in) at three places, after the first
|
||||
//! refresh, then the same after a brush dab in each.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui_wgpu::RenderState;
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{Adjustment, Document, Effect, FxCommon, FxPaint, Layer, LayerContent, Shadow, StrokeFx, StrokePosition, TextLayer};
|
||||
use photocraft_geom::{Rect, Size};
|
||||
use photocraft_ui_egui::gpu_canvas::GpuCanvas;
|
||||
use rayon::prelude::*;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// A layer over `r` whose RGBA8 pixels are `f(x, y)`, written in parallel-generated bands.
|
||||
fn layer_from(name: &str, r: Rect, f: impl Fn(i32, i32) -> [u8; 4] + Sync) -> Layer {
|
||||
let mut l = Layer::raster(name, PixelFormat::RGBA8);
|
||||
let s = l.surface_mut().expect("raster");
|
||||
let mut y = r.y0;
|
||||
while y < r.y1 {
|
||||
let band = Rect::new(r.x0, y, r.x1, (y + 256).min(r.y1));
|
||||
let w = band.width() as usize;
|
||||
let mut bytes = vec![0u8; w * band.height() as usize * 4];
|
||||
bytes.par_chunks_mut(w * 4).enumerate().for_each(|(row, px)| {
|
||||
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
p.copy_from_slice(&f(band.x0 + i as i32, band.y0 + row as i32));
|
||||
}
|
||||
});
|
||||
s.write_interleaved(band, &bytes);
|
||||
y = band.y1;
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn hash(x: i32, y: i32) -> u8 {
|
||||
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 251) as u8
|
||||
}
|
||||
|
||||
/// A soft-edged disc of colour `c` centred in `r`.
|
||||
fn blob(name: &str, r: Rect, c: [u8; 3]) -> Layer {
|
||||
let (cx, cy, rad) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0, r.width().min(r.height()) as f32 / 2.0 - 8.0);
|
||||
layer_from(name, r, |x, y| {
|
||||
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
|
||||
let a = ((rad - d) / 6.0).clamp(0.0, 1.0);
|
||||
[c[0], c[1].wrapping_add(hash(x, y) / 16), c[2], (a * 255.0) as u8]
|
||||
})
|
||||
}
|
||||
|
||||
/// A type layer: rows of glyph-like blocks rasterised into its cache.
|
||||
fn text(name: &str, r: Rect) -> Layer {
|
||||
let glyphs = layer_from(name, r, |x, y| {
|
||||
let (gx, gy) = ((x - r.x0) % 90, (y - r.y0) % 160);
|
||||
let on = gx < 70 && gy < 120 && !(gx > 20 && gx < 50 && gy > 30 && gy < 90);
|
||||
[20, 30, 200, if on { 255 } else { 0 }]
|
||||
});
|
||||
let mut l = Layer::new(name, LayerContent::Text(TextLayer { cache: glyphs.surface().cloned(), ..Default::default() }));
|
||||
l.effects.items = vec![
|
||||
Effect::DropShadow(Shadow {
|
||||
common: FxCommon::new(BlendMode::Multiply, 0.75),
|
||||
color: Color::rgb(0.0, 0.0, 0.0),
|
||||
angle: 120.0,
|
||||
use_global_light: false,
|
||||
distance: 12.0,
|
||||
spread: 0.0,
|
||||
size: 16.0,
|
||||
contour: photocraft_doc::Contour::Linear,
|
||||
anti_alias: false,
|
||||
noise: 0.0,
|
||||
knocks_out: true,
|
||||
}),
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 4.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)),
|
||||
}),
|
||||
];
|
||||
l
|
||||
}
|
||||
|
||||
fn build(w: u32, h: u32) -> Document {
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let mut d = Document::new("huge", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.push(layer_from("background", Rect::new(0, 0, wi, hi), |x, y| {
|
||||
let (fx, fy) = (x as f32 / wi as f32, y as f32 / hi as f32);
|
||||
let n = hash(x, y) / 8;
|
||||
[(80.0 + 120.0 * fx) as u8 + n, (60.0 + 140.0 * fy) as u8 + n, (150.0 - 60.0 * fx * fy) as u8 + n, 255]
|
||||
}));
|
||||
let mut over = layer_from("texture", Rect::new(0, 0, wi * 3 / 5, hi), |x, y| [200, 180u8.wrapping_add(hash(x, y) / 4), 120, 90 + hash(y, x) / 2]);
|
||||
over.blend = BlendMode::Multiply;
|
||||
over.opacity = 0.7;
|
||||
d.layers.push(over);
|
||||
let mut adj = Layer::new("exposure", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.3, offset: 0.0, gamma: 1.05 }));
|
||||
adj.opacity = 0.8;
|
||||
d.layers.push(adj);
|
||||
for (i, (x, y)) in [(0.15, 0.2), (0.55, 0.35), (0.3, 0.7)].into_iter().enumerate() {
|
||||
let (x, y) = ((wi as f32 * x) as i32, (hi as f32 * y) as i32);
|
||||
let mut b = blob(&format!("blob {i}"), Rect::new(x, y, x + wi / 6, y + wi / 6), [220, 60 + 60 * i as u8, 40]);
|
||||
b.blend = if i == 1 { BlendMode::Screen } else { BlendMode::Normal };
|
||||
d.layers.push(b);
|
||||
}
|
||||
// A full-width band with effects: its effect region is wider than a 16384 px texture.
|
||||
let mut band = layer_from("band", Rect::new(40, hi / 2, wi - 40, hi / 2 + 1200), |x, y| [240, 240, 230, if (x / 64 + y / 64) % 5 == 0 { 0 } else { 230 }]);
|
||||
band.effects.items = vec![
|
||||
Effect::DropShadow(Shadow {
|
||||
common: FxCommon::new(BlendMode::Multiply, 0.6),
|
||||
color: Color::rgb(0.0, 0.0, 0.1),
|
||||
angle: 90.0,
|
||||
use_global_light: false,
|
||||
distance: 20.0,
|
||||
spread: 0.0,
|
||||
size: 24.0,
|
||||
contour: photocraft_doc::Contour::Linear,
|
||||
anti_alias: false,
|
||||
noise: 0.0,
|
||||
knocks_out: true,
|
||||
}),
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 3.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(0.1, 0.1, 0.1)),
|
||||
}),
|
||||
];
|
||||
d.layers.push(band);
|
||||
d.layers.push(text("title", Rect::new(wi / 10, hi / 12, wi / 10 + 6000, hi / 12 + 900)));
|
||||
d.layers.push(text("caption", Rect::new(wi / 2, hi * 5 / 6, wi / 2 + 4000, hi * 5 / 6 + 500)));
|
||||
let a = blob("group base", Rect::new(wi * 2 / 3, hi / 10, wi * 2 / 3 + wi / 4, hi / 10 + wi / 4), [40, 160, 90]);
|
||||
let mut c = layer_from("clipped", Rect::new(wi * 2 / 3, hi / 10, wi, hi / 10 + wi / 8), |x, y| [250, 230, 40, 120 + hash(x, y) / 3]);
|
||||
c.clipped = true;
|
||||
c.blend = BlendMode::Overlay;
|
||||
let mut g = Layer::group("group", vec![a, c]);
|
||||
g.opacity = 0.9;
|
||||
d.layers.push(g);
|
||||
d
|
||||
}
|
||||
|
||||
/// A headless GPU canvas on a device created like the app's.
|
||||
fn canvas() -> Option<(GpuCanvas, RenderState)> {
|
||||
let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup();
|
||||
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?;
|
||||
let l = rs.device.limits();
|
||||
eprintln!("adapter: {} (max texture {})", rs.adapter.get_info().name, l.max_texture_dimension_2d);
|
||||
Some((GpuCanvas::new(&rs), rs))
|
||||
}
|
||||
|
||||
/// One canvas refresh (everything, or `damage`), waiting for the GPU. Returns the path taken.
|
||||
fn refresh(g: &GpuCanvas, rs: &RenderState, doc: &Document, damage: Option<Rect>) -> (String, Option<String>) {
|
||||
let r = g.refresh(doc.id.0, doc, damage, None);
|
||||
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
let kind = if r.kind.starts_with("gpu") { format!("{}, {} tiles up", r.kind, r.uploads) } else { r.kind.to_string() };
|
||||
(kind, r.fallback)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (w, h) =
|
||||
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(20000), b.parse().unwrap_or(20000)))).unwrap_or((20000, 20000));
|
||||
let op = arg(&args, "--op").unwrap_or_else(|| "full".into());
|
||||
let Some((g, rs)) = canvas() else {
|
||||
eprintln!("no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let t = Instant::now();
|
||||
let mut doc = build(w, h);
|
||||
println!("document {w}×{h} ({:.0} MP), {} layers, built in {:.0} ms", (w as f64 * h as f64) / 1e6, doc.walk().len(), ms(t));
|
||||
|
||||
// The compositor budget the app would use: Memory Usage (default 8 GB, `--memory-mb`) less
|
||||
// the document's pixels, within a quarter of RAM (`PHOTOCRAFT_RAM_MB` simulates a smaller
|
||||
// machine). The view is the first viewport (`view`) or the whole document (`full`).
|
||||
let allowance = arg(&args, "--memory-mb").and_then(|v| v.parse::<u64>().ok()).unwrap_or(8192) << 20;
|
||||
let pixels: u64 = doc
|
||||
.walk()
|
||||
.iter()
|
||||
.flat_map(|(_, _, l)| l.surface().into_iter().chain(l.mask.as_ref().map(|m| &m.surface)))
|
||||
.map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::<u64>())
|
||||
.sum();
|
||||
let ram = photocraft_ui_egui::gpu_canvas::physical_memory();
|
||||
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram);
|
||||
g.set_memory_budget(budget);
|
||||
println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20);
|
||||
let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440);
|
||||
g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() }));
|
||||
|
||||
let t = Instant::now();
|
||||
let (kind, fallback) = refresh(&g, &rs, &doc, None);
|
||||
println!("first refresh ({kind:<8}) {:>9.0} ms{}", ms(t), fallback.map_or(String::new(), |f| format!(" [fallback: {f}]")));
|
||||
let report = |g: &GpuCanvas| {
|
||||
if let Some((format, bytes)) = g.texture_info(doc.id.0) {
|
||||
println!("canvas texture {format:?}: {} MB with mips", bytes >> 20);
|
||||
}
|
||||
if let Some((pages, fx)) = g.compositor_bytes() {
|
||||
println!("compositor: {} MB resident pages, {} MB effect maps", pages >> 20, fx >> 20);
|
||||
}
|
||||
};
|
||||
report(&g);
|
||||
match op.as_str() {
|
||||
"full" => {
|
||||
for _ in 0..3 {
|
||||
let t = Instant::now();
|
||||
let (kind, _) = refresh(&g, &rs, &doc, None);
|
||||
println!("full refresh ({kind:<8}) {:>9.0} ms", ms(t));
|
||||
}
|
||||
}
|
||||
"view" => {
|
||||
let views: Vec<Rect> = [(0.1, 0.1), (0.45, 0.48), (0.7, 0.8)]
|
||||
.into_iter()
|
||||
.map(|(x, y)| Rect::from_xywh((w as f32 * x) as i32, (h as f32 * y) as i32, 2560, 1440).intersect(&doc.bounds()))
|
||||
.collect();
|
||||
for v in &views {
|
||||
let t = Instant::now();
|
||||
let (kind, _) = refresh(&g, &rs, &doc, Some(*v));
|
||||
println!("viewport refresh ({kind:<8}) {:>9.0} ms at {v:?}", ms(t));
|
||||
}
|
||||
// A dab on the band (an effect layer) inside each viewport, then its refresh.
|
||||
let band = doc.layers.iter().position(|l| l.name == "band").unwrap_or(0);
|
||||
for v in &views {
|
||||
let c = Rect::from_xywh(v.x0 + 600, h as i32 / 2 + 300, 120, 120);
|
||||
if let Some(s) = doc.layers.get_mut(band).and_then(Layer::surface_mut) {
|
||||
s.fill_rect(c, &[0.1, 0.3, 0.9, 1.0]);
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (kind, _) = refresh(&g, &rs, &doc, Some(*v));
|
||||
println!("dab + viewport refresh ({kind:<8}) {:>6.0} ms", ms(t));
|
||||
}
|
||||
}
|
||||
other => eprintln!("unknown --op {other}"),
|
||||
}
|
||||
report(&g);
|
||||
}
|
||||
@@ -83,6 +83,10 @@ struct Bench {
|
||||
/// state later rows need).
|
||||
only: Option<String>,
|
||||
rows: Vec<(String, f64, f64, f64, f64)>,
|
||||
/// Per row (same order as `rows`): the timed samples and the peak RSS while they ran, for
|
||||
/// `--json` (`cargo xtask perf`).
|
||||
extra: Vec<(Vec<f64>, Option<u64>)>,
|
||||
rss: photocraft_testkit::perf::RssSampler,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
@@ -116,8 +120,10 @@ impl Bench {
|
||||
f(self);
|
||||
return;
|
||||
}
|
||||
self.rss.reset();
|
||||
let cold = f(self);
|
||||
let mut v: Vec<f64> = (0..self.reps).map(|_| f(self)).collect();
|
||||
self.extra.push((v.clone(), self.rss.peak()));
|
||||
v.sort_by(f64::total_cmp);
|
||||
let med = v[v.len() / 2];
|
||||
println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold);
|
||||
@@ -161,7 +167,14 @@ fn main() {
|
||||
let (w, h) =
|
||||
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912));
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1);
|
||||
let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, only: arg(&args, "--only"), rows: Vec::new() };
|
||||
let mut b = Bench {
|
||||
gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() },
|
||||
reps,
|
||||
only: arg(&args, "--only"),
|
||||
rows: Vec::new(),
|
||||
extra: Vec::new(),
|
||||
rss: photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2)),
|
||||
};
|
||||
println!("document {w}×{h} ({:.1} MP), 8-bit RGB, {} reps, {}", (w * h) as f64 / 1e6, reps, if b.gpu.is_some() { "GPU canvas" } else { "CPU canvas" });
|
||||
|
||||
// ---- open / save / export ----------------------------------------------------------------
|
||||
@@ -399,8 +412,18 @@ fn main() {
|
||||
});
|
||||
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let rows: Vec<Value> =
|
||||
b.rows.iter().map(|(n, med, min, max, cold)| json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold})).collect();
|
||||
let rows: Vec<Value> = b
|
||||
.rows
|
||||
.iter()
|
||||
.zip(&b.extra)
|
||||
.map(|((n, med, min, max, cold), (samples, rss))| {
|
||||
let mut r = json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold});
|
||||
// For `cargo xtask perf`: raw samples (it computes p50 / p95) and peak RSS.
|
||||
r["samples_ms"] = json!(samples);
|
||||
r["peak_rss_bytes"] = json!(rss);
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(&out, serde_json::to_string_pretty(&json!({"width": w, "height": h, "gpu": b.gpu.is_some(), "rows": rows})).unwrap())
|
||||
.expect("write json");
|
||||
}
|
||||
|
||||
@@ -99,7 +99,10 @@ fn main() {
|
||||
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).expect("jpeg");
|
||||
std::fs::write(&src, jpeg).expect("write cache");
|
||||
eprintln!("generated {} in {:.0} ms (run again to measure)", src.display(), ms(t));
|
||||
return;
|
||||
// `--json` (`cargo xtask perf`) measures in the same run.
|
||||
if arg(&args, "--json").is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
|
||||
let gpu = gpu.as_ref();
|
||||
@@ -119,23 +122,37 @@ fn main() {
|
||||
let t_open = ms(t);
|
||||
let t = Instant::now();
|
||||
let path = refresh(gpu, &s, true);
|
||||
let t_first = ms(t);
|
||||
println!("open (decode + document) {t_open:>9.0} ms");
|
||||
println!("first refresh ({path:<8}) {:>9.0} ms", ms(t));
|
||||
println!("first refresh ({path:<8}) {t_first:>9.0} ms");
|
||||
// `--json out.json` (`cargo xtask perf`): open and first refresh (every op includes them),
|
||||
// the canvas texture size and the process's peak RSS.
|
||||
let json_out = arg(&args, "--json");
|
||||
let mut json_rows = vec![
|
||||
photocraft_testkit::perf::row("open (decode + document)", &[t_open], None, None),
|
||||
photocraft_testkit::perf::row("first refresh", &[t_first], None, None),
|
||||
];
|
||||
if let Some((g, _)) = gpu
|
||||
&& let Some((format, bytes)) = g.texture_info(s.active().expect("doc").doc.id.0)
|
||||
{
|
||||
println!("canvas texture {format:?}, {} MB with mips", bytes >> 20);
|
||||
if let Some(r) = json_rows.get_mut(1) {
|
||||
r["gpu_bytes"] = json!(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
let doc = || s.active().expect("doc").doc.clone();
|
||||
match op.as_str() {
|
||||
"open" => {}
|
||||
"refresh" => {
|
||||
let mut v = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let t = Instant::now();
|
||||
let path = refresh(gpu, &s, true);
|
||||
v.push(ms(t));
|
||||
println!("full refresh ({path:<8}) {:>9.0} ms", ms(t));
|
||||
}
|
||||
json_rows.push(photocraft_testkit::perf::row("full refresh", &v, None, None));
|
||||
}
|
||||
"thumbs" => {
|
||||
let d = doc();
|
||||
@@ -181,4 +198,13 @@ fn main() {
|
||||
}
|
||||
other => eprintln!("unknown --op {other}"),
|
||||
}
|
||||
if let Some(out) = json_out {
|
||||
let peak = photocraft_testkit::perf::process_peak_rss_bytes().or_else(photocraft_testkit::perf::current_rss_bytes);
|
||||
let context = json!({"width": w, "height": h, "op": op, "gpu_adapter": gpu.map(|(_, rs)| rs.adapter.get_info().name)});
|
||||
let mut report = photocraft_testkit::perf::report("large_image_bench", context, json_rows, None);
|
||||
report["process_peak_rss_bytes"] = json!(peak);
|
||||
if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Frame times of the whole app on a designer's layout document (#125, #128): ~175 layers in
|
||||
//! nested groups (pass-through and isolated), 60 text layers in several fonts and sizes, stroked
|
||||
//! shapes, smart objects, drop shadows / strokes / gradient overlays, adjustment layers and masks,
|
||||
//! 4000×3000 8-bit RGB (`tests/support/layout_doc.rs`), saved as PSD and reopened through the real
|
||||
//! import path.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-ui-egui --example layout_bench -- [--reps 9] [--json out.json] [--direct] [--navigator] [--size 4000x3000]
|
||||
//! ```
|
||||
//!
|
||||
//! The real `PhotocraftApp` runs in an offscreen egui_kittest harness on wgpu (the GPU canvas
|
||||
//! path, with the device the app requests). Interactions go through real input where the harness
|
||||
//! can reach it (key shortcuts, pointer drags and clicks on the canvas) and otherwise through the
|
||||
//! command a panel runs (`layer.select`, `layer.setProps`), then the frames that follow. Each row
|
||||
//! is the median / p90 / max of a frame's time over the repetitions; `PHOTOCRAFT_GPU_SYNC` makes
|
||||
//! the canvas wait for the GPU, so a frame's time includes its composite. Move drags report the
|
||||
//! press, the pointer-move frames and the release (the commit) separately. `--direct` skips the
|
||||
//! PSD round trip; `--navigator` also shows the Navigator panel; `--spin idle|select|tool|move-
|
||||
//! pixel|move-text|move-shape|move-group|move-smart|move-headline` repeats one interaction for 20 s
|
||||
//! for a sampling profiler.
|
||||
|
||||
#[path = "../tests/support/layout_doc.rs"]
|
||||
mod layout_doc;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use photocraft_ui_egui::canvas::ViewXform;
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type H = egui_kittest::Harness<'static, PhotocraftApp>;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
/// One frame, timed.
|
||||
fn frame(h: &mut H) -> f64 {
|
||||
let t = Instant::now();
|
||||
h.step();
|
||||
ms(t)
|
||||
}
|
||||
|
||||
struct Rows {
|
||||
reps: usize,
|
||||
rows: Vec<(String, f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
/// A row from frame times already taken.
|
||||
fn add(&mut self, name: &str, v: Vec<f64>) {
|
||||
let mut v: Vec<f64> = v.into_iter().filter(|t| t.is_finite()).collect();
|
||||
if v.is_empty() {
|
||||
return;
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
let (med, p90, max) = (v[v.len() / 2], v[(v.len() * 9 / 10).min(v.len() - 1)], v[v.len() - 1]);
|
||||
let flag = if med > 16.0 { " <-- over 16 ms" } else { "" };
|
||||
println!("{name:<46} median {med:>8.2} ms p90 {p90:>8.2} max {max:>8.2}{flag}");
|
||||
self.rows.push((name.to_string(), med, p90, max));
|
||||
}
|
||||
|
||||
/// `f` performs one interaction and returns the worst frame it took (ms).
|
||||
fn time(&mut self, name: &str, mut f: impl FnMut() -> f64) {
|
||||
let v = (0..self.reps).map(|_| f()).collect();
|
||||
self.add(name, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` frames (returns the worst).
|
||||
fn settle(h: &mut H, n: usize) -> f64 {
|
||||
(0..n).map(|_| frame(h)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Screen position of document point (x, y) in the main canvas.
|
||||
fn screen(h: &H, x: f64, y: f64) -> Pos2 {
|
||||
let app = h.state();
|
||||
let idx = app.session.active_index().unwrap_or(0);
|
||||
let v = &app.ui.views[idx];
|
||||
let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect);
|
||||
ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }.to_screen(x as f32, y as f32)
|
||||
}
|
||||
|
||||
fn select(h: &mut H, id: LayerId) {
|
||||
let _ = h.state_mut().run("layer.select", json!({"layer": id.0}));
|
||||
}
|
||||
|
||||
/// A drag on the canvas from document point `from` by `d`, `steps` pointer moves one frame apart.
|
||||
/// Returns the frame times: hover, press, the moves, release, one more.
|
||||
fn drag(h: &mut H, from: [f64; 2], d: [f64; 2], steps: usize) -> Vec<f64> {
|
||||
let p0 = screen(h, from[0], from[1]);
|
||||
let mut times = Vec::new();
|
||||
h.event(Event::PointerMoved(p0));
|
||||
times.push(frame(h));
|
||||
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
times.push(frame(h));
|
||||
for i in 1..=steps {
|
||||
let f = i as f64 / steps as f64;
|
||||
let p = screen(h, from[0] + d[0] * f, from[1] + d[1] * f);
|
||||
h.event(Event::PointerMoved(p));
|
||||
times.push(frame(h));
|
||||
}
|
||||
let p1 = screen(h, from[0] + d[0], from[1] + d[1]);
|
||||
h.event(Event::PointerButton { pos: p1, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
times.push(frame(h));
|
||||
times.push(frame(h));
|
||||
times
|
||||
}
|
||||
|
||||
/// Rows for Move drags (frame times from [`drag`]): the press, the pointer moves, the release.
|
||||
fn move_rows(rows: &mut Rows, label: &str, drags: &[Vec<f64>]) {
|
||||
let pick = |f: &dyn Fn(&[f64]) -> Vec<f64>| drags.iter().flat_map(|d| f(d)).collect::<Vec<f64>>();
|
||||
rows.add(&format!("Move {label}: press"), pick(&|d| d.get(1).copied().into_iter().collect()));
|
||||
rows.add(&format!("Move {label}: drag frames"), pick(&|d| d.get(2..d.len().saturating_sub(2)).unwrap_or_default().to_vec()));
|
||||
rows.add(&format!("Move {label}: release (commit)"), pick(&|d| d.len().checked_sub(2).and_then(|i| d.get(i)).copied().into_iter().collect()));
|
||||
}
|
||||
|
||||
fn contains(l: &photocraft_doc::Layer, t: LayerId) -> bool {
|
||||
l.id == t || l.children().is_some_and(|c| c.iter().any(|k| contains(k, t)))
|
||||
}
|
||||
|
||||
/// A point where `id` (or one of its layers, for a group) is the topmost layer with pixels.
|
||||
fn point_on(h: &H, id: LayerId) -> Option<[f64; 2]> {
|
||||
let st = h.state().session.active()?;
|
||||
let l = st.doc.layer(id)?;
|
||||
let b = photocraft_engine::layer_multi_cmds::layer_bounds(l)?;
|
||||
let (cx, cy) = ((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2);
|
||||
for r in 0..60 {
|
||||
for (dx, dy) in [(0, 0), (r, 0), (-r, 0), (0, r), (0, -r)] {
|
||||
let (x, y) = (cx + dx * 3, cy + dy * 3);
|
||||
if photocraft_engine::pick_cmds::layers_at(&st.doc, x, y).first().is_some_and(|&t| contains(l, t)) {
|
||||
return Some([x as f64 + 0.5, y as f64 + 0.5]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
reexec_with_gpu_sync();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(9).max(3);
|
||||
let mut spec = layout_doc::Spec::full();
|
||||
if let Some((w, hh)) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))) {
|
||||
(spec.width, spec.height) = (w, hh);
|
||||
}
|
||||
let t = Instant::now();
|
||||
let (doc, _) = layout_doc::build(spec);
|
||||
let (n, texts) = layout_doc::count(&doc);
|
||||
println!("built {}×{} layout: {n} layers, {texts} text layers in {:.0} ms", spec.width, spec.height, ms(t));
|
||||
let doc = if args.iter().any(|a| a == "--direct") {
|
||||
doc
|
||||
} else {
|
||||
let t = Instant::now();
|
||||
let psd = photocraft_io::export(&doc, "layout.psd", &Default::default()).expect("psd export").bytes;
|
||||
let t_save = ms(t);
|
||||
let t = Instant::now();
|
||||
let r = photocraft_io::import("layout.psd", &psd).expect("psd import");
|
||||
println!("PSD: {:.1} MB, save {t_save:.0} ms, open {:.0} ms ({} warnings)", psd.len() as f64 / 1e6, ms(t), r.warnings.len());
|
||||
// PSD export writes engine-made smart objects as pixels (no SoLd yet): convert them back,
|
||||
// as a Photoshop-made file would have them.
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
s.open_document(r.document, None);
|
||||
let smart = |l: &photocraft_doc::Layer| l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group());
|
||||
let ids: Vec<u64> = s.active().map(|st| st.doc.walk().iter().filter(|(_, _, l)| smart(l)).map(|(_, _, l)| l.id.0).collect()).unwrap_or_default();
|
||||
for id in ids {
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id})).expect("convert");
|
||||
}
|
||||
(*s.active().expect("doc").doc).clone()
|
||||
};
|
||||
// Layer ids change through the PSD round trip: find the layers by name and kind.
|
||||
let find = |name: &str, pred: &dyn Fn(&LayerContent) -> bool| -> LayerId {
|
||||
doc.walk().iter().find(|(_, _, l)| l.name == name && pred(&l.content)).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}"))
|
||||
};
|
||||
let pixel = find("Tile 1", &|c| matches!(c, LayerContent::Raster(_)));
|
||||
let text = find("Product 1 — Oak", &|c| matches!(c, LayerContent::Text(_)));
|
||||
let shape = find("Badge", &|c| matches!(c, LayerContent::Shape(_)));
|
||||
let group = find("Card 1", &|c| matches!(c, LayerContent::Group(_)));
|
||||
let smart = find("Product 1", &|c| matches!(c, LayerContent::Smart(_)));
|
||||
let hero = find("Hero", &|c| matches!(c, LayerContent::Group(_)));
|
||||
let headline = find("Make things that last", &|c| matches!(c, LayerContent::Text(_)));
|
||||
let products = find("Products", &|c| matches!(c, LayerContent::Group(_)));
|
||||
|
||||
let navigator = args.iter().any(|a| a == "--navigator");
|
||||
let ppp: f32 = arg(&args, "--ppp").and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let t = Instant::now();
|
||||
let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp);
|
||||
let mut h: H = builder.wgpu_setup(photocraft_ui_egui::gpu_canvas::wgpu_setup()).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
|
||||
if let Some(rs) = cc.wgpu_render_state.as_ref() {
|
||||
rs.device.set_device_lost_callback(|r, m| eprintln!("GPU device lost ({r:?}): {m}"));
|
||||
app.set_wgpu(rs.clone());
|
||||
}
|
||||
app.session.open_document(doc, Some("layout.psd".into()));
|
||||
app.sync_views();
|
||||
app.ui.panels.navigator = navigator;
|
||||
app
|
||||
});
|
||||
settle(&mut h, 4);
|
||||
{
|
||||
let p = &h.state().perf;
|
||||
println!(
|
||||
"open + first frames: {:.0} ms (canvas: gpu {}, fallback {:?}, first composite {} {:.0} ms)",
|
||||
ms(t),
|
||||
p.gpu,
|
||||
p.gpu_fallback,
|
||||
p.last_refresh,
|
||||
p.composite_ms
|
||||
);
|
||||
}
|
||||
settle(&mut h, 4);
|
||||
|
||||
if let Some(dir) = arg(&args, "--shots") {
|
||||
shots(&mut h, &dir, ppp, [group, headline]);
|
||||
return;
|
||||
}
|
||||
if let Some(what) = arg(&args, "--spin") {
|
||||
spin(&mut h, &what, [pixel, text, shape, group, smart, headline]);
|
||||
return;
|
||||
}
|
||||
let mut rows = Rows { reps, rows: Vec::new() };
|
||||
rows.time("idle frame", || frame(&mut h));
|
||||
rows.time("hover over the canvas", || {
|
||||
let p = screen(&h, 1000.0, 1000.0);
|
||||
h.event(Event::PointerMoved(p));
|
||||
let a = frame(&mut h);
|
||||
h.event(Event::PointerMoved(p + egui::vec2(7.0, 3.0)));
|
||||
a.max(frame(&mut h))
|
||||
});
|
||||
|
||||
// Selecting layers: the command a Layers panel row click runs, then the frames after (the
|
||||
// Layers and Properties panels follow the new layer).
|
||||
let picks = [pixel, text, shape, group, smart, hero, headline];
|
||||
let mut k = 0;
|
||||
rows.time("select a layer (Layers panel)", || {
|
||||
k += 1;
|
||||
let t = Instant::now();
|
||||
select(&mut h, picks[k % picks.len()]);
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
// A real click on a Layers panel row (rows carry their layer's name for accessibility).
|
||||
let names = ["Gallery", "Products", "Hero", "Header", "Footer"];
|
||||
rows.time("click a Layers panel row", || {
|
||||
k += 1;
|
||||
// The row is the widest node with the name (its disclosure triangle has it too).
|
||||
let row = h.query_all_by_label(names[k % names.len()]).map(|n| n.rect()).max_by(|a, b| a.width().total_cmp(&b.width()));
|
||||
let Some(p) = row.map(|r| r.center()) else { return f64::NAN };
|
||||
h.event(Event::PointerMoved(p));
|
||||
let a = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
let b = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
a.max(b).max(frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
rows.time("open / close a group (Layers panel)", || {
|
||||
let t = Instant::now();
|
||||
let _ = h.state_mut().run("layer.setExpanded", json!({"layer": products.0}));
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
let tools = [Key::V, Key::B, Key::M, Key::T, Key::U, Key::E];
|
||||
rows.time("switch tool (shortcut)", || {
|
||||
k += 1;
|
||||
h.key_press(tools[k % tools.len()]);
|
||||
frame(&mut h).max(frame(&mut h))
|
||||
});
|
||||
let mut vis = false;
|
||||
for (label, id) in [("text layer", text), ("group", group)] {
|
||||
rows.time(&format!("toggle visibility ({label})"), || {
|
||||
vis = !vis;
|
||||
let t = Instant::now();
|
||||
let _ = h.state_mut().run("layer.setProps", json!({"layer": id.0, "visible": vis}));
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
let _ = h.state_mut().run("layer.setProps", json!({"layer": id.0, "visible": true}));
|
||||
settle(&mut h, 3);
|
||||
}
|
||||
let mut op = 1.0;
|
||||
select(&mut h, shape);
|
||||
settle(&mut h, 2);
|
||||
rows.time("opacity change (Layers / Properties)", || {
|
||||
op = if op > 0.6 { 0.5 } else { 0.9 };
|
||||
let t = Instant::now();
|
||||
let _ = h.state_mut().run("layer.setProps", json!({"layer": shape.0, "opacity": op}));
|
||||
(ms(t) + frame(&mut h)).max(frame(&mut h))
|
||||
});
|
||||
|
||||
// Move tool drags. Auto-Select off (Photoshop's default): the selected layer moves.
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = false;
|
||||
settle(&mut h, 3);
|
||||
for (label, id) in [("pixel", pixel), ("text", text), ("shape", shape), ("group", group), ("smart object", smart)] {
|
||||
select(&mut h, id);
|
||||
settle(&mut h, 3);
|
||||
let Some(from) = point_on(&h, id) else {
|
||||
println!("move {label}: no visible pixel found");
|
||||
continue;
|
||||
};
|
||||
// Back and forth, so every other drag returns the layer where it started.
|
||||
let mut commit = Vec::new();
|
||||
let drags: Vec<Vec<f64>> = (0..reps)
|
||||
.map(|i| {
|
||||
let d = drag(&mut h, from, if i.is_multiple_of(2) { [37.0, 23.0] } else { [-37.0, -23.0] }, 8);
|
||||
commit.push(h.state().perf.command_ms);
|
||||
d
|
||||
})
|
||||
.collect();
|
||||
move_rows(&mut rows, label, &drags);
|
||||
rows.add(&format!(" of which layer.translate ({label})"), commit);
|
||||
}
|
||||
// Auto-Select on: a click on the canvas selects the layer under the pointer; a press on a
|
||||
// type layer that isn't selected drags it.
|
||||
h.state_mut().ui.tool_options.move_auto_select = true;
|
||||
if let (Some(a), Some(b)) = (point_on(&h, text), point_on(&h, pixel)) {
|
||||
let mut i = 0u32;
|
||||
rows.time("click the canvas (auto-select a layer)", || {
|
||||
i += 1;
|
||||
let p = screen(&h, if i.is_multiple_of(2) { a[0] } else { b[0] }, if i.is_multiple_of(2) { a[1] } else { b[1] });
|
||||
h.event(Event::PointerMoved(p));
|
||||
let f0 = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
let f1 = frame(&mut h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
let f2 = frame(&mut h);
|
||||
let f3 = frame(&mut h);
|
||||
f0.max(f1).max(f2).max(f3)
|
||||
});
|
||||
}
|
||||
if let Some(from) = point_on(&h, headline) {
|
||||
let drags: Vec<Vec<f64>> = (0..reps)
|
||||
.map(|i| {
|
||||
select(&mut h, pixel);
|
||||
settle(&mut h, 2);
|
||||
drag(&mut h, from, if i.is_multiple_of(2) { [25.0, 0.0] } else { [-25.0, 0.0] }, 8)
|
||||
})
|
||||
.collect();
|
||||
move_rows(&mut rows, "auto-select type", &drags);
|
||||
}
|
||||
|
||||
println!("perf spans: {:?}", h.state().perf.spans);
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let r: Vec<Value> = rows.rows.iter().map(|(n, med, p90, max)| json!({"name": n, "median_ms": med, "p90_ms": p90, "max_ms": max})).collect();
|
||||
let report = json!({"width": spec.width, "height": spec.height, "layers": n, "text_layers": texts, "rows": r});
|
||||
std::fs::write(&out, serde_json::to_string_pretty(&report).expect("json")).expect("write json");
|
||||
}
|
||||
}
|
||||
|
||||
/// `--shots DIR`: screenshots of the whole app while a Move drag is under way and after the
|
||||
/// release, fitted on screen and at 100 %, for a card group and the hero headline (`{DIR}/
|
||||
/// move-{what}-{view}-{phase}@{ppp}x.png`).
|
||||
fn shots(h: &mut H, dir: &str, ppp: f32, [group, headline]: [LayerId; 2]) {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let save = |h: &mut H, name: &str| match h.render() {
|
||||
Ok(img) => {
|
||||
let p = format!("{dir}/{name}@{ppp}x.png");
|
||||
match img.save(&p) {
|
||||
Ok(()) => println!("wrote {p}"),
|
||||
Err(e) => eprintln!("{p}: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("{name}: render failed: {e}"),
|
||||
};
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = false;
|
||||
for (what, id) in [("card", group), ("headline", headline)] {
|
||||
for view in ["fit", "100"] {
|
||||
select(h, id);
|
||||
if let Some(b) = h.state().session.active().and_then(|s| s.doc.layer(id)).and_then(photocraft_engine::layer_multi_cmds::layer_bounds) {
|
||||
let app = h.state_mut();
|
||||
let v = &mut app.ui.views[0];
|
||||
if view == "100" {
|
||||
v.zoom = 1.0;
|
||||
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
|
||||
v.fit_pending = false;
|
||||
} else {
|
||||
v.fit_pending = true;
|
||||
}
|
||||
}
|
||||
settle(h, 6);
|
||||
let Some(from) = point_on(h, id) else { continue };
|
||||
// Press and drag most of the way, render mid-drag, then release and render again.
|
||||
let d = [140.0, 60.0];
|
||||
let p0 = screen(h, from[0], from[1]);
|
||||
h.event(Event::PointerMoved(p0));
|
||||
frame(h);
|
||||
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
frame(h);
|
||||
for i in 1..=10 {
|
||||
let f = f64::from(i) / 10.0;
|
||||
h.event(Event::PointerMoved(screen(h, from[0] + d[0] * f, from[1] + d[1] * f)));
|
||||
frame(h);
|
||||
}
|
||||
save(h, &format!("move-{what}-{view}-dragging"));
|
||||
let p1 = screen(h, from[0] + d[0], from[1] + d[1]);
|
||||
h.event(Event::PointerButton { pos: p1, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
settle(h, 4);
|
||||
save(h, &format!("move-{what}-{view}-released"));
|
||||
let _ = h.state_mut().run("edit.undo", json!({}));
|
||||
settle(h, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--spin`: one interaction over and over for 20 s, for a sampling profiler.
|
||||
fn spin(h: &mut H, what: &str, [pixel, text, shape, group, smart, headline]: [LayerId; 6]) {
|
||||
let t = Instant::now();
|
||||
let mut k = 0usize;
|
||||
if let Some(target) = what.strip_prefix("move-") {
|
||||
let id = match target {
|
||||
"group" => group,
|
||||
"text" => text,
|
||||
"shape" => shape,
|
||||
"smart" => smart,
|
||||
"headline" => headline,
|
||||
_ => pixel,
|
||||
};
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
select(h, id);
|
||||
settle(h, 3);
|
||||
let from = point_on(h, id).unwrap_or([1000.0, 1000.0]);
|
||||
while t.elapsed().as_secs() < 20 {
|
||||
k += 1;
|
||||
let s = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
|
||||
drag(h, from, [30.0 * s, 20.0 * s], 30);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if what == "click" {
|
||||
h.state_mut().ui.tool = Tool::Move;
|
||||
h.state_mut().ui.tool_options.move_auto_select = true;
|
||||
let pts: Vec<[f64; 2]> = [text, pixel].iter().filter_map(|&id| point_on(h, id)).collect();
|
||||
while t.elapsed().as_secs() < 20 && !pts.is_empty() {
|
||||
k += 1;
|
||||
let p = screen(h, pts[k % pts.len()][0], pts[k % pts.len()][1]);
|
||||
h.event(Event::PointerMoved(p));
|
||||
frame(h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
frame(h);
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
settle(h, 2);
|
||||
}
|
||||
return;
|
||||
}
|
||||
while t.elapsed().as_secs() < 20 {
|
||||
k += 1;
|
||||
match what {
|
||||
"select" => select(h, [pixel, text, shape, group][k % 4]),
|
||||
"tool" => h.key_press([Key::V, Key::B, Key::M, Key::T][k % 4]),
|
||||
_ => {}
|
||||
}
|
||||
frame(h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Run again with `PHOTOCRAFT_GPU_SYNC=1` unless it is set (the workspace forbids the `unsafe`
|
||||
/// that setting it in-process needs).
|
||||
fn reexec_with_gpu_sync() {
|
||||
if std::env::var_os("PHOTOCRAFT_GPU_SYNC").is_some() {
|
||||
return;
|
||||
}
|
||||
let exe = std::env::current_exe().expect("exe");
|
||||
let status = std::process::Command::new(exe).args(std::env::args().skip(1)).env("PHOTOCRAFT_GPU_SYNC", "1").status().expect("re-exec");
|
||||
std::process::exit(status.code().unwrap_or(1));
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
//! Scorecard performance scenarios (#221): the measurable rows of the targets in #209, #210 and
|
||||
//! #211, each timed through the command path the UI, CLI and MCP share (`Session::execute`) plus
|
||||
//! the canvas refresh the app does after it (the app's own `GpuCanvas`, on a device created the
|
||||
//! way the app creates it; the CPU compositor without an adapter).
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run --release -p photocraft-ui-egui --example perf_scenarios -- [--quick] [--json out.json] [--only text] [--reps N] [--cpu]
|
||||
//! ```
|
||||
//!
|
||||
//! `cargo xtask perf` runs this and maps the rows to scenario ids in `perf/budgets.toml`, so
|
||||
//! keep row names stable. `--quick` uses small synthetic documents (seconds, for smoke runs);
|
||||
//! its numbers are only comparable with other `--quick` runs. Every row records p50 / p95 / max
|
||||
//! over its samples, the peak resident memory measured in-process while it ran, and the GPU
|
||||
//! bytes the canvas holds for the document (texture + compositor) when there is a GPU.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use eframe::egui_wgpu::RenderState;
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_testkit::perf::{RssSampler, report, row, write_report};
|
||||
use photocraft_ui_egui::gpu_canvas::GpuCanvas;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
fn arg(args: &[String], name: &str) -> Option<String> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
|
||||
}
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
fn exec(s: &mut Session, id: &str, p: Value) -> Res<Value> {
|
||||
s.execute(id, p).map_err(|e| format!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn active_layer(s: &Session) -> Res<u64> {
|
||||
s.active().and_then(|d| d.active_layer).map(|l| l.0).ok_or_else(|| "no active layer".to_string())
|
||||
}
|
||||
|
||||
/// A photo-like RGB image (gradients, soft blobs, fine noise) with a high-contrast disc as the
|
||||
/// subject, encoded as JPEG so it opens like a real photo.
|
||||
fn photo_jpeg(w: u32, h: u32) -> Res<Vec<u8>> {
|
||||
use rayon::prelude::*;
|
||||
let mut px = vec![0u8; w as usize * h as usize * 3];
|
||||
let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0);
|
||||
px.par_chunks_mut(w as usize * 3).enumerate().for_each(|(y, row)| {
|
||||
for x in 0..w as usize {
|
||||
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
|
||||
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
|
||||
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
|
||||
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r;
|
||||
let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) };
|
||||
let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08];
|
||||
for (c, v) in rgb.into_iter().enumerate() {
|
||||
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
}
|
||||
}
|
||||
});
|
||||
let img =
|
||||
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?;
|
||||
photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn open_photo(w: u32, h: u32) -> Res<Session> {
|
||||
let jpeg = photo_jpeg(w, h)?;
|
||||
let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document;
|
||||
let mut s = Session::new();
|
||||
s.open_document(doc, Some("photo.jpg".into()));
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// How far an edit's composite change reaches beyond its damage (layer effects); mirrors
|
||||
/// `canvas::effect_reach`.
|
||||
fn reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
layers
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let own = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 };
|
||||
let kids = match &l.content {
|
||||
photocraft_doc::LayerContent::Group(g) => reach(&g.children),
|
||||
_ => 0,
|
||||
};
|
||||
own + kids
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
struct Bench {
|
||||
gpu: Option<(GpuCanvas, RenderState)>,
|
||||
sampler: RssSampler,
|
||||
reps: usize,
|
||||
only: Option<String>,
|
||||
rows: Vec<Value>,
|
||||
/// Rows that couldn't run: (name, error).
|
||||
errors: Vec<(String, String)>,
|
||||
/// The row being timed, so a panic inside it is reported against that row.
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
impl Bench {
|
||||
fn wanted(&self, name: &str) -> bool {
|
||||
self.only.as_ref().is_none_or(|o| name.contains(o.as_str()))
|
||||
}
|
||||
|
||||
/// The canvas refresh after an edit (`canvas::ensure_gpu`): the damage rect grown by the
|
||||
/// effect reach when the session reports one, else everything; waits for the GPU.
|
||||
fn refresh(&self, s: &Session, full: bool) -> Res<f64> {
|
||||
let st = s.active().ok_or("no document")?;
|
||||
let doc = &st.doc;
|
||||
let damage = if full { None } else { st.last_damage.map(|r| r.inflate(reach(&doc.layers)).intersect(&doc.bounds())) };
|
||||
let t = Instant::now();
|
||||
match &self.gpu {
|
||||
Some((g, rs)) => {
|
||||
g.refresh(doc.id.0, doc, damage, None);
|
||||
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
|
||||
}
|
||||
None => {
|
||||
let r = damage.unwrap_or(doc.bounds()).intersect(&doc.bounds());
|
||||
if !r.is_empty() {
|
||||
std::hint::black_box(photocraft_compose::render(doc, r));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ms(t))
|
||||
}
|
||||
|
||||
fn gpu_bytes(&self, s: &Session) -> Option<u64> {
|
||||
let (g, _) = self.gpu.as_ref()?;
|
||||
let tex = s.active().and_then(|d| g.texture_info(d.doc.id.0)).map_or(0, |(_, b)| b);
|
||||
let (resident, fx) = g.compositor_bytes().unwrap_or((0, 0));
|
||||
Some(tex + resident + fx as u64)
|
||||
}
|
||||
|
||||
/// Times `f` `reps` times after one untimed warm-up run; `f` returns its own time in ms.
|
||||
fn time(&mut self, name: &str, s: &mut Session, reps: usize, warm: bool, mut f: impl FnMut(&Self, &mut Session, usize) -> Res<f64>) {
|
||||
if !self.wanted(name) {
|
||||
return;
|
||||
}
|
||||
self.current = Some(name.to_string());
|
||||
self.sampler.reset();
|
||||
let mut samples = Vec::new();
|
||||
let mut run = || -> Res<()> {
|
||||
if warm {
|
||||
f(self, s, 0)?;
|
||||
}
|
||||
for i in 0..reps.max(1) {
|
||||
samples.push(f(self, s, i + 1)?);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let result = run();
|
||||
self.record(name, s, &samples, result);
|
||||
}
|
||||
|
||||
fn record(&mut self, name: &str, s: &Session, samples: &[f64], result: Res<()>) {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s));
|
||||
println!(
|
||||
"{name:<52} p50 {:>9.2} ms p95 {:>9.2} ms max {:>9.2} ms (n {}, peak RSS {} MB)",
|
||||
r["p50_ms"].as_f64().unwrap_or(f64::NAN),
|
||||
r["p95_ms"].as_f64().unwrap_or(f64::NAN),
|
||||
r["max_ms"].as_f64().unwrap_or(f64::NAN),
|
||||
samples.len(),
|
||||
r["peak_rss_bytes"].as_u64().unwrap_or(0) >> 20,
|
||||
);
|
||||
self.rows.push(r);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("{name:<52} ERROR {e}");
|
||||
self.errors.push((name.to_string(), e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A headless GPU canvas on a device created like the app's.
|
||||
fn canvas() -> Option<(GpuCanvas, RenderState)> {
|
||||
let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup();
|
||||
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?;
|
||||
eprintln!("adapter: {}", rs.adapter.get_info().name);
|
||||
Some((GpuCanvas::new(&rs), rs))
|
||||
}
|
||||
|
||||
/// Sizes per mode.
|
||||
struct Sizes {
|
||||
/// The layered document of #209 (20 MP).
|
||||
layered: (u32, u32),
|
||||
/// A layer of 12 MP to duplicate and paste.
|
||||
big_layer: (u32, u32),
|
||||
/// Layers in the "150-layer document".
|
||||
many_layers: usize,
|
||||
/// The 16-bit document (15000 × 10000).
|
||||
huge16: (u32, u32),
|
||||
/// The 32-bit document with 20 layers.
|
||||
float32: (u32, u32),
|
||||
/// 10 MP for Content-Aware Fill and Spot Healing.
|
||||
ten_mp: (u32, u32),
|
||||
/// 24 MP for the selection algorithms and Content-Aware Scale.
|
||||
big_photo: (u32, u32),
|
||||
/// A4 at 300 ppi.
|
||||
a4: (u32, u32),
|
||||
}
|
||||
|
||||
const FULL: Sizes = Sizes {
|
||||
layered: (5472, 3648),
|
||||
big_layer: (4243, 2828),
|
||||
many_layers: 150,
|
||||
huge16: (15000, 10000),
|
||||
float32: (4000, 3000),
|
||||
ten_mp: (3872, 2592),
|
||||
big_photo: (6000, 4000),
|
||||
a4: (2480, 3508),
|
||||
};
|
||||
|
||||
const QUICK: Sizes = Sizes {
|
||||
layered: (1600, 1066),
|
||||
big_layer: (1200, 800),
|
||||
many_layers: 40,
|
||||
huge16: (2400, 1600),
|
||||
float32: (1200, 900),
|
||||
ten_mp: (1200, 800),
|
||||
big_photo: (1600, 1066),
|
||||
a4: (1240, 1754),
|
||||
};
|
||||
|
||||
/// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke,
|
||||
/// four type layers with a drop shadow, and a Levels adjustment on top. Returns the session and
|
||||
/// the id of the styled layer the edits target.
|
||||
fn layered_doc(w: u32, h: u32) -> Res<(Session, u64)> {
|
||||
let mut s = Session::new();
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
|
||||
let mut target = 0;
|
||||
for i in 0..6u32 {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("panel {i}")}))?;
|
||||
let (x, y) = ((i % 3) * w / 3 + w / 24, (i / 3) * h / 2 + h / 16);
|
||||
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 4, "height": h / 3, "ellipse": i % 2 == 1}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}6040", 40 + i * 30)}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 12, "size": 16}))?;
|
||||
exec(&mut s, "layer.layerStyle.stroke", json!({"size": 3, "color": "#202020", "add": true}))?;
|
||||
if i == 1 {
|
||||
target = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
for i in 0..4u32 {
|
||||
exec(&mut s, "type.create", json!({"x": w / 10 + i * w / 5, "y": h * 9 / 10, "text": format!("Headline {i}"), "size": (h / 30).max(12)}))?;
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
|
||||
}
|
||||
exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 8, "inWhite": 245, "gamma": 1.05}))?;
|
||||
exec(&mut s, "layer.select", json!({"layer": target}))?;
|
||||
Ok((s, target))
|
||||
}
|
||||
|
||||
/// A document with `n` layers: small painted rects, every fifth a type layer, every seventh
|
||||
/// styled.
|
||||
fn many_layers_doc(w: u32, h: u32, n: usize) -> Res<(Session, u64)> {
|
||||
let mut s = Session::new();
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
|
||||
let mut mid = 0;
|
||||
for i in 0..n as u32 {
|
||||
let (x, y) = ((i * 397) % (w - w / 8), (i * 211) % (h - h / 8));
|
||||
if i % 5 == 4 {
|
||||
exec(&mut s, "type.create", json!({"x": x, "y": y + 40, "text": format!("Label {i}"), "size": 28}))?;
|
||||
} else {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("item {i}")}))?;
|
||||
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 10, "height": h / 10}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256)}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
}
|
||||
if i % 7 == 3 {
|
||||
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
|
||||
}
|
||||
if i as usize == n / 2 {
|
||||
mid = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
exec(&mut s, "layer.select", json!({"layer": mid}))?;
|
||||
Ok((s, mid))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let quick = args.iter().any(|a| a == "--quick");
|
||||
let sz = if quick { &QUICK } else { &FULL };
|
||||
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).max(1);
|
||||
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
|
||||
let adapter = gpu.as_ref().map(|(_, rs)| rs.adapter.get_info().name);
|
||||
let mut b = Bench {
|
||||
gpu,
|
||||
sampler: RssSampler::start(std::time::Duration::from_millis(2)),
|
||||
reps,
|
||||
only: arg(&args, "--only"),
|
||||
rows: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
current: None,
|
||||
};
|
||||
println!("perf_scenarios: {} mode, {} reps, {}", if quick { "quick" } else { "full" }, reps, adapter.as_deref().unwrap_or("CPU canvas"));
|
||||
let t_all = Instant::now();
|
||||
|
||||
// A panic in one group (a crash the scenario found) is reported against the row that was
|
||||
// running, and the other groups still run.
|
||||
type Group = fn(&mut Bench, &Sizes);
|
||||
let groups: [(&str, Group); 5] = [
|
||||
("layered", layered_scenarios),
|
||||
("many layers", many_layer_scenarios),
|
||||
("bit depths", depth_scenarios),
|
||||
("long operations", job_scenarios),
|
||||
("algorithms", algorithm_scenarios),
|
||||
];
|
||||
let use_gpu = b.gpu.is_some();
|
||||
for (i, (group, run)) in groups.into_iter().enumerate() {
|
||||
b.current = None;
|
||||
// Each group starts on a fresh canvas device (one document open, as in the app), so
|
||||
// GPU memory or an error left by one group never carries into the next.
|
||||
if use_gpu && i > 0 {
|
||||
b.gpu = None;
|
||||
b.gpu = canvas();
|
||||
}
|
||||
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&mut b, sz))) {
|
||||
let msg = e.downcast_ref::<String>().cloned().or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "panic".into());
|
||||
let msg = format!("panicked: {}", msg.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>().join(" / "));
|
||||
let row = b.current.take().unwrap_or_else(|| group.to_string());
|
||||
println!("{row:<52} ERROR {msg}");
|
||||
b.errors.push((row, msg));
|
||||
}
|
||||
}
|
||||
|
||||
println!("total {:.1} s", t_all.elapsed().as_secs_f64());
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
let context = json!({
|
||||
"mode": if quick { "quick" } else { "full" },
|
||||
"reps": reps,
|
||||
"gpu_adapter": adapter,
|
||||
"errors": b.errors.iter().map(|(n, e)| json!({"name": n, "error": e})).collect::<Vec<_>>(),
|
||||
});
|
||||
let rep = report("perf_scenarios", context, std::mem::take(&mut b.rows), Some(&b.sampler));
|
||||
if let Err(e) = write_report(&out, &rep) {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #209 rows on the layered 20 MP document.
|
||||
fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let (w, h) = sz.layered;
|
||||
let (mut s, target) = match layered_doc(w, h) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push(("layered document".into(), e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time("move styled layer 10 px + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let d = if i % 2 == 0 { 10 } else { -10 };
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.translate", json!({"layer": target, "dx": d, "dy": d / 2}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("set layer opacity + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("set layer fill + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "fill": if i % 2 == 0 { 0.5 } else { 1.0 }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("set blend mode + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "blend": if i % 2 == 0 { "Multiply" } else { "Normal" }}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("toggle visibility + refresh", &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.setProps", json!({"layer": target, "visible": i % 2 == 0}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
let _ = exec(&mut s, "layer.setProps", json!({"layer": target, "visible": true}));
|
||||
b.time("undo move + refresh", &mut s, reps, true, |b, s, _| {
|
||||
exec(s, "layer.translate", json!({"layer": target, "dx": 7, "dy": 3}))?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("redo opacity change + refresh", &mut s, reps, true, |b, s, i| {
|
||||
exec(s, "layer.setProps", json!({"layer": target, "opacity": 0.5 + 0.02 * i as f64}))?;
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.redo", json!({}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
|
||||
// A 12 MP layer, duplicated and pasted: the first time (a fresh layer, cold caches) and
|
||||
// again (the same source).
|
||||
let (lw, lh) = sz.big_layer;
|
||||
let fresh_layer = |s: &mut Session| -> Res<u64> {
|
||||
exec(s, "layer.new.layer", json!({"name": "12 MP"}))?;
|
||||
exec(s, "select.rect", json!({"x": (w - lw) / 2, "y": (h - lh) / 2, "width": lw, "height": lh}))?;
|
||||
exec(s, "edit.fill", json!({"color": "#3070a0"}))?;
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
active_layer(s)
|
||||
};
|
||||
let reps_big = reps.min(5);
|
||||
let mut src = 0;
|
||||
b.time("duplicate 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
src = fresh_layer(s)?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.duplicate", json!({"layer": src}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("duplicate 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.duplicate", json!({"layer": src}))?;
|
||||
let v = ms(t) + b.refresh(s, false)?;
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
Ok(v)
|
||||
});
|
||||
b.time("paste 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
let id = fresh_layer(s)?;
|
||||
exec(s, "layer.select", json!({"layer": id}))?;
|
||||
exec(s, "select.all", json!({}))?;
|
||||
exec(s, "edit.copy", json!({}))?;
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.paste", json!({}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time("paste 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.paste", json!({}))?;
|
||||
let v = ms(t) + b.refresh(s, false)?;
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
b.refresh(s, false)?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
|
||||
/// #209: arrow-key nudge in the 150-layer document.
|
||||
fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
// Row names stay stable across modes: the layer count is in the mode, not the key.
|
||||
let name = "nudge 1 px in the many-layer document + refresh".to_string();
|
||||
if !b.wanted(&name) {
|
||||
return;
|
||||
}
|
||||
// The document's first refresh is part of this row (a crash there is reported against it).
|
||||
b.current = Some(name.clone());
|
||||
let (w, h) = sz.layered;
|
||||
let (mut s, mid) = match many_layers_doc(w, h, sz.many_layers) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push((name, e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time(&name, &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.translate", json!({"layer": mid, "dx": if i % 2 == 0 { 1 } else { -1 }, "dy": 0}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
|
||||
/// #209: edits on the 15000 × 10000 16-bit document and the 32-bit document with 20 layers.
|
||||
fn depth_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let reps = b.reps;
|
||||
for (label, (w, h), depth, layers) in [("16-bit 15000x10000", sz.huge16, 16, 1u32), ("32-bit 20 layers", sz.float32, 32, 20)] {
|
||||
let dab = format!("brush dab 60 px on {label} + refresh");
|
||||
let mv = format!("move layer on {label} + refresh");
|
||||
if !b.wanted(&dab) && !b.wanted(&mv) {
|
||||
continue;
|
||||
}
|
||||
let built = (|| -> Res<(Session, u64)> {
|
||||
let mut s = Session::new();
|
||||
exec(&mut s, "file.new", json!({"width": w, "height": h, "depth": depth, "background": "white"}))?;
|
||||
let mut first = 0;
|
||||
for i in 0..layers {
|
||||
exec(&mut s, "layer.new.layer", json!({"name": format!("layer {i}")}))?;
|
||||
let (x, y) = ((i * 173) % (w * 3 / 4), (i * 97) % (h * 3 / 4));
|
||||
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 4, "height": h / 4}))?;
|
||||
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}8060", (i * 41) % 256)}))?;
|
||||
exec(&mut s, "select.deselect", json!({}))?;
|
||||
if i == 0 {
|
||||
first = active_layer(&s)?;
|
||||
}
|
||||
}
|
||||
Ok((s, first))
|
||||
})();
|
||||
let (mut s, layer) = match built {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
b.errors.push((dab, e));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = b.refresh(&s, true);
|
||||
let _ = exec(&mut s, "layer.select", json!({"layer": layer}));
|
||||
b.time(&dab, &mut s, reps, true, |b, s, i| {
|
||||
let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400));
|
||||
let t = Instant::now();
|
||||
exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 60, "hardness": 0.8, "color": "#c03020"}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
b.time(&mv, &mut s, reps, true, |b, s, i| {
|
||||
let t = Instant::now();
|
||||
exec(s, "layer.translate", json!({"layer": layer, "dx": if i % 2 == 0 { 10 } else { -10 }, "dy": 0}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// #210 rows that are measurable before the job system exists.
|
||||
fn job_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let reps = b.reps.min(5);
|
||||
let open = "open 20 MP layered PSD (decode + first composite)";
|
||||
if b.wanted(open) {
|
||||
let (w, h) = sz.layered;
|
||||
match layered_doc(w, h).and_then(|(s, _)| {
|
||||
let d = s.active().ok_or("no document")?.doc.clone();
|
||||
photocraft_io::export(&d, "layered.psd", &Default::default()).map(|o| o.bytes).map_err(|e| e.to_string())
|
||||
}) {
|
||||
Ok(psd) => {
|
||||
let mut holder = Session::new();
|
||||
b.time(open, &mut holder, reps, true, |b, s, _| {
|
||||
let t = Instant::now();
|
||||
let d = photocraft_io::import("layered.psd", &psd).map_err(|e| e.to_string())?.document;
|
||||
*s = Session::new();
|
||||
s.open_document(d, Some("layered.psd".into()));
|
||||
Ok(ms(t) + b.refresh(s, true)?)
|
||||
});
|
||||
}
|
||||
Err(e) => b.errors.push((open.into(), e)),
|
||||
}
|
||||
}
|
||||
let caf = "Content-Aware Fill 10 MP (200 px hole)";
|
||||
let heal = "Spot Healing stroke 10 MP";
|
||||
if b.wanted(caf) || b.wanted(heal) {
|
||||
let (w, h) = sz.ten_mp;
|
||||
match open_photo(w, h) {
|
||||
Ok(mut s) => {
|
||||
b.time(caf, &mut s, reps, false, |_, s, i| {
|
||||
exec(s, "select.rect", json!({"x": w / 3 + i as u32 * 40, "y": h / 3, "width": 200, "height": 200, "ellipse": true}))?;
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.contentAwareFill", json!({"seed": i}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
b.time(heal, &mut s, reps, false, |b, s, i| {
|
||||
let (x, y) = (w / 4 + i as u32 * 60, h / 4);
|
||||
let pts: Vec<Value> = (0..10).map(|k| json!([x + k * 12, y + k * 4])).collect();
|
||||
let t = Instant::now();
|
||||
exec(s, "paint.spotHealing", json!({"points": pts, "size": 60}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
Err(e) => b.errors.push((caf.into(), e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #211 rows: selection algorithms, Content-Aware Scale and Select Subject on 24 MP, and a brush
|
||||
/// dab on an A4 300 ppi CMYK document.
|
||||
fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
|
||||
let reps = b.reps.min(5);
|
||||
let names = [
|
||||
"Select > Modify > Smooth r 50 on 24 MP",
|
||||
"Select > Modify > Feather r 50 on 24 MP",
|
||||
"Select Subject 24 MP (repeat)",
|
||||
"Content-Aware Scale 24 MP to 90 % width",
|
||||
];
|
||||
if names.iter().any(|n| b.wanted(n)) {
|
||||
let (w, h) = sz.big_photo;
|
||||
match open_photo(w, h) {
|
||||
Ok(mut s) => {
|
||||
for (name, op) in [(names[0], "select.modify.smooth"), (names[1], "select.modify.feather")] {
|
||||
b.time(name, &mut s, reps, false, |_, s, _| {
|
||||
exec(s, "select.rect", json!({"x": w / 6, "y": h / 6, "width": w * 2 / 3, "height": h * 2 / 3, "ellipse": true}))?;
|
||||
let t = Instant::now();
|
||||
exec(s, op, json!({"radius": 50}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
b.time(names[2], &mut s, reps, true, |_, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "select.subject", json!({}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "select.deselect", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
// One run: today this takes far longer than its budget.
|
||||
b.time(names[3], &mut s, 1, false, |_, s, _| {
|
||||
let t = Instant::now();
|
||||
exec(s, "edit.contentAwareScale", json!({"scaleX": 90, "scaleY": 100}))?;
|
||||
let v = ms(t);
|
||||
exec(s, "edit.undo", json!({}))?;
|
||||
Ok(v)
|
||||
});
|
||||
}
|
||||
Err(e) => b.errors.push((names[0].into(), e)),
|
||||
}
|
||||
}
|
||||
let cmyk = "brush dab on A4 300 ppi CMYK + refresh";
|
||||
if b.wanted(cmyk) {
|
||||
let (w, h) = sz.a4;
|
||||
let mut s = Session::new();
|
||||
let setup = exec(&mut s, "file.new", json!({"width": w, "height": h, "mode": "cmyk", "resolution": 300, "background": "white"}))
|
||||
.and_then(|_| exec(&mut s, "layer.new.layer", json!({"name": "paint"})));
|
||||
if let Err(e) = setup {
|
||||
b.errors.push((cmyk.into(), e));
|
||||
return;
|
||||
}
|
||||
let _ = b.refresh(&s, true);
|
||||
let reps = b.reps;
|
||||
b.time(cmyk, &mut s, reps, true, |b, s, i| {
|
||||
let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400));
|
||||
let t = Instant::now();
|
||||
exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 40, "hardness": 0.8, "color": "#c03020"}))?;
|
||||
Ok(ms(t) + b.refresh(s, false)?)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,20 @@ fn main() {
|
||||
while size(&app) != start && undos < steps + 1 && app.session.undo() {
|
||||
undos += 1;
|
||||
}
|
||||
// `--json out.json` (`cargo xtask perf`): the per-step samples.
|
||||
if let Some(out) = arg(&args, "--json") {
|
||||
use photocraft_testkit::perf::{process_peak_rss_bytes, report, row, write_report};
|
||||
let rss = photocraft_testkit::perf::current_rss_bytes().max(process_peak_rss_bytes());
|
||||
let rows = vec![
|
||||
row("font size step: command", &cmd, rss, None),
|
||||
row("font size step: canvas refresh", &canvas, rss, None),
|
||||
row("font size step: total", &total, rss, None),
|
||||
];
|
||||
let rep = report("type_bench", json!({"width": w, "height": h, "layers": n, "steps": steps, "effects": effects}), rows, None);
|
||||
if let Err(e) = write_report(&out, &rep) {
|
||||
eprintln!("{e}");
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"size step (median of {steps}): command {:.2} ms, canvas refresh {:.2} ms, total {:.2} ms; undo steps for the drag: {undos}",
|
||||
median(cmd),
|
||||
|
||||
@@ -366,6 +366,13 @@ struct CurveUi {
|
||||
/// Dragging a point this far outside the graph removes it (it comes back if dragged back in).
|
||||
pub const DRAG_OFF: f32 = 12.0;
|
||||
const HIT_RADIUS: f32 = 9.0;
|
||||
const CURVE_HANDLE_SIZE: f32 = 7.0;
|
||||
const CURVE_GRAPH_PADDING: f32 = 5.0;
|
||||
|
||||
fn curve_graph(full: Rect, side: f32) -> Rect {
|
||||
let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
|
||||
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
|
||||
}
|
||||
|
||||
fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> {
|
||||
let pts: Vec<[f32; 2]> = v
|
||||
@@ -403,7 +410,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
||||
let side = ui.available_width().clamp(120.0, 300.0);
|
||||
ui.add_space(4.0);
|
||||
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
|
||||
let graph = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
|
||||
let graph = curve_graph(full, side);
|
||||
ui.data_mut(|d| d.insert_temp(cx.mem.with("curves-graph"), graph));
|
||||
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
|
||||
let to_val =
|
||||
@@ -535,7 +542,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
|
||||
}
|
||||
draw_curve(&pts, if ch == 0 { t.text } else { bar_color(&chan, &t) }, 1.5);
|
||||
for (i, q) in pts.iter().enumerate() {
|
||||
let r = Rect::from_center_size(to_scr(*q), vec2(7.0, 7.0));
|
||||
let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));
|
||||
if Some(i) == st.sel {
|
||||
p.rect_filled(r, 0.0, t.text);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
|
||||
@@ -1404,6 +1411,20 @@ mod tests {
|
||||
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_endpoint_handles_fit_inside_the_allocated_area() {
|
||||
for side in [120.0, 200.0, 300.0] {
|
||||
let full = Rect::from_min_size(pos2(100.0, 50.0), vec2(side, side + 14.0));
|
||||
let graph = curve_graph(full, side);
|
||||
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
|
||||
for q in [[0.0, 0.0], [255.0, 255.0]] {
|
||||
let r = Rect::from_center_size(to_scr(q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE)).expand(1.0);
|
||||
assert!(r.left() >= full.left() && r.right() <= full.right());
|
||||
assert!(r.top() >= full.top() && r.bottom() <= full.bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_helpers() {
|
||||
for g in [0.3f32, 1.0, 1.8, 4.0] {
|
||||
|
||||
@@ -41,7 +41,11 @@ fn app_harness(kind: &str, mode: &str, depth: u32) -> Harness<'static, Photocraf
|
||||
s.execute("edit.fill", json!({"color": "#6a3020"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap();
|
||||
PhotocraftApp::new(s, crate::Services::default())
|
||||
let mut app = PhotocraftApp::new(s, crate::Services::default());
|
||||
// The default Properties group gives way to Layers (#147) and scrolls taller editors;
|
||||
// these tests drive every control without scrolling, so they size it like a user would.
|
||||
app.ui.dock.heights.insert(crate::dock::Group::Properties, 560.0);
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
|
||||
+127
-219
@@ -8,11 +8,15 @@
|
||||
//!
|
||||
//! The panel never writes the session brush itself: each frame's edits become one
|
||||
//! `tools.setBrush` call carrying only the changed fields (Rule 1), so the journal, the control
|
||||
//! channel and the MCP server see exactly what the panel did and can do the same.
|
||||
//! channel and the MCP server see exactly what the panel did and can do the same. A drag's calls
|
||||
//! share a `coalesce` key, so a whole gesture is one journal entry ([`commit_gesture`]).
|
||||
//!
|
||||
//! The lock beside a section name keeps that section's settings when another preset is picked
|
||||
//! (`locks` in the brush, applied by the engine). The Brushes tab lives in [`crate::brushes_tab`].
|
||||
|
||||
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
|
||||
use photocraft_engine::BrushSettings;
|
||||
use photocraft_engine::paint::{self, TipShape};
|
||||
use photocraft_engine::paint;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -22,6 +26,7 @@ use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
pub use crate::brush_preview::{preview_pixels, preview_sig};
|
||||
pub use crate::brush_sections::section_body;
|
||||
pub use photocraft_engine::brush_cmds::brush_patch;
|
||||
|
||||
/// Sections in Photoshop's order. The bool says whether the section has an enable box.
|
||||
pub const SECTIONS: [(&str, bool); 13] = [
|
||||
@@ -41,9 +46,31 @@ pub const SECTIONS: [(&str, bool); 13] = [
|
||||
];
|
||||
|
||||
/// Panel width and the stroke strip's size.
|
||||
const WIDTH: f32 = 540.0;
|
||||
pub(crate) const WIDTH: f32 = 540.0;
|
||||
const STRIP: (u32, u32) = (488, 76);
|
||||
|
||||
/// How the Brushes tab lists presets.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BrushesView {
|
||||
/// Rows: tip, size, stroke preview, name.
|
||||
#[default]
|
||||
List,
|
||||
/// Tip thumbnails with their sizes.
|
||||
Grid,
|
||||
}
|
||||
|
||||
/// A rename in progress in the Brushes tab (a preset, or a group when `group` is set).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Renaming {
|
||||
pub group: bool,
|
||||
/// The preset (or group) being renamed.
|
||||
pub name: String,
|
||||
/// The text typed so far.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Brushes panel view state (serde, so the control channel can read and drive it).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -52,6 +79,8 @@ pub struct BrushesPanelState {
|
||||
pub collapsed: Vec<String>,
|
||||
/// Case-insensitive name filter.
|
||||
pub filter: String,
|
||||
pub view: BrushesView,
|
||||
pub renaming: Option<Renaming>,
|
||||
}
|
||||
|
||||
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
|
||||
@@ -72,63 +101,74 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
|
||||
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
|
||||
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
|
||||
let mut c = b.clone();
|
||||
if skip.0 {
|
||||
c.tip = TipShape::Round;
|
||||
}
|
||||
if skip.1 {
|
||||
c.dual_brush.tip = TipShape::Round;
|
||||
}
|
||||
if skip.2 {
|
||||
c.texture.pattern = paint::Pattern::default();
|
||||
}
|
||||
serde_json::to_value(&c).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// Fields of `new` that differ from `old`, nested objects diffed key by key: a minimal
|
||||
/// `tools.setBrush` patch (`{}` when nothing changed).
|
||||
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
|
||||
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
|
||||
fn diff(a: &Value, b: &Value) -> Option<Value> {
|
||||
match (a, b) {
|
||||
(Value::Object(ao), Value::Object(bo)) => {
|
||||
let mut out = Map::new();
|
||||
for (k, bv) in bo {
|
||||
match ao.get(k) {
|
||||
Some(av) => {
|
||||
if let Some(d) = diff(av, bv) {
|
||||
out.insert(k.clone(), d);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.insert(k.clone(), bv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
// Enums with payloads (tips, patterns) are replaced whole.
|
||||
_ => (a != b).then(|| b.clone()),
|
||||
}
|
||||
}
|
||||
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
|
||||
/// The lock behind section `i` (every section but Brush Tip Shape has one, as in Photoshop).
|
||||
pub fn section_lock(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
|
||||
let l = &mut b.locks;
|
||||
Some(match i {
|
||||
1 => &mut l.shape_dynamics,
|
||||
2 => &mut l.scattering,
|
||||
3 => &mut l.texture,
|
||||
4 => &mut l.dual_brush,
|
||||
5 => &mut l.color_dynamics,
|
||||
6 => &mut l.transfer,
|
||||
7 => &mut l.pose,
|
||||
8 => &mut l.noise,
|
||||
9 => &mut l.wet_edges,
|
||||
10 => &mut l.build_up,
|
||||
11 => &mut l.smoothing,
|
||||
12 => &mut l.protect_texture,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send the panel's edits (`before` → `after`) through `tools.setBrush`.
|
||||
pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) {
|
||||
send(app, before, after, None);
|
||||
}
|
||||
|
||||
fn send(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings, key: Option<String>) -> bool {
|
||||
if before == after {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let patch = brush_patch(before, after);
|
||||
if patch.as_object().is_some_and(Map::is_empty) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) {
|
||||
let mut p = json!({ "brush": patch });
|
||||
if let Some(k) = key {
|
||||
p["coalesce"] = json!(k);
|
||||
}
|
||||
if let Err(e) = app.run("tools.setBrush", p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
const GESTURE: &str = "brush-set-gesture";
|
||||
|
||||
/// The current brush-edit gesture number. A pointer press starts a new gesture (once per frame);
|
||||
/// [`commit_gesture`] ends one after an edit made with the pointer up (a click, a typed value).
|
||||
fn gesture(ctx: &egui::Context) -> u64 {
|
||||
let frame = ctx.cumulative_pass_nr();
|
||||
let pressed = ctx.input(|i| i.pointer.any_pressed());
|
||||
ctx.data_mut(|d| {
|
||||
let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE));
|
||||
if pressed && g.1 != frame {
|
||||
g.0 += 1;
|
||||
g.1 = frame;
|
||||
}
|
||||
g.0
|
||||
})
|
||||
}
|
||||
|
||||
/// [`commit`] as part of a gesture: every call of one drag carries the same `coalesce` key, so
|
||||
/// the engine journals the drag as a single `tools.setBrush` (Rule 1: one command per gesture).
|
||||
/// Call it every frame (it tracks presses even when nothing changed).
|
||||
pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) {
|
||||
let g = gesture(ctx);
|
||||
if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) {
|
||||
ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Group label for presets saved without a group.
|
||||
@@ -147,189 +187,41 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
|
||||
out
|
||||
}
|
||||
|
||||
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
|
||||
/// the full comparison clones the preset and its tip.
|
||||
/// Does the current brush match `preset` (everything but colour, size and the tool state that
|
||||
/// picking a preset keeps: smoothing and the section locks)? Cheap fields first: the full
|
||||
/// comparison clones the preset and its tip.
|
||||
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> bool {
|
||||
preset.hardness == brush.hardness
|
||||
&& preset.spacing == brush.spacing
|
||||
&& preset.tip == brush.tip
|
||||
&& BrushSettings { color: brush.color, size: brush.size, background: brush.background, ..preset.clone() } == *brush
|
||||
&& BrushSettings {
|
||||
color: brush.color,
|
||||
size: brush.size,
|
||||
background: brush.background,
|
||||
smoothing: brush.smoothing.clone(),
|
||||
locks: brush.locks.clone(),
|
||||
..preset.clone()
|
||||
} == *brush
|
||||
}
|
||||
|
||||
/// A fresh "Brush N" name.
|
||||
fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
|
||||
pub(crate) fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
|
||||
(1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into())
|
||||
}
|
||||
|
||||
fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
|
||||
pub(crate) fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
|
||||
if let Err(e) = app.run(id, p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
|
||||
fn full_uv() -> egui::Rect {
|
||||
pub(crate) fn full_uv() -> egui::Rect {
|
||||
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0))
|
||||
}
|
||||
|
||||
fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Size of the current brush (Photoshop's Brushes panel slider).
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Size").color(t.text_dim));
|
||||
let mut lv = b.size.max(1.0).ln();
|
||||
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
|
||||
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
|
||||
if r.changed() {
|
||||
b.size = lv.exp().round().clamp(1.0, 5000.0);
|
||||
}
|
||||
r
|
||||
});
|
||||
let mut s = b.size;
|
||||
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
|
||||
b.size = s.round().clamp(1.0, 5000.0);
|
||||
}
|
||||
});
|
||||
commit(app, &before, &b);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
|
||||
ui.add_space(20.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 60.0));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
|
||||
let groups = grouped_presets(&app.session.tools.presets);
|
||||
let brush = &app.session.tools.brush;
|
||||
let mut clicked = None;
|
||||
let mut toggle = None;
|
||||
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for (group, items) in groups {
|
||||
let items: Vec<usize> = items
|
||||
.into_iter()
|
||||
.filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter)))
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let x = r.left() + 4.0;
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
12.0,
|
||||
t.text_dim,
|
||||
);
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
|
||||
if open { "folder-open" } else { "folder" },
|
||||
14.0,
|
||||
t.icon,
|
||||
);
|
||||
ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text);
|
||||
ui.painter().text(
|
||||
egui::pos2(r.right() - 8.0, r.center().y),
|
||||
egui::Align2::RIGHT_CENTER,
|
||||
items.len().to_string(),
|
||||
theme::medium(11.0),
|
||||
t.text_faint,
|
||||
);
|
||||
if resp.clicked() {
|
||||
toggle = Some(group.clone());
|
||||
}
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
for i in items {
|
||||
let Some(p) = app.session.tools.presets.get(i) else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
|
||||
if !ui.is_rect_visible(r) {
|
||||
continue;
|
||||
}
|
||||
if is_current(&p.brush, brush) {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let pb = &p.brush;
|
||||
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
|
||||
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
|
||||
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
|
||||
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
egui::pos2(cell.center().x, r.bottom() - 1.0),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
format!("{}", pb.size.round() as i64),
|
||||
egui::FontId::proportional(9.0),
|
||||
t.text_faint,
|
||||
);
|
||||
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
|
||||
let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
|
||||
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
egui::pos2(sr.right() + 12.0, r.center().y),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&p.name,
|
||||
egui::FontId::proportional(12.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(p.name.clone());
|
||||
}
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
if let Some(g) = toggle {
|
||||
let c = &mut app.ui.brushes_panel.collapsed;
|
||||
match c.iter().position(|x| *x == g) {
|
||||
Some(i) => {
|
||||
c.remove(i);
|
||||
}
|
||||
None => c.push(g),
|
||||
}
|
||||
}
|
||||
if let Some(name) = clicked {
|
||||
run_or_status(app, "tools.setBrush", json!({ "preset": name }));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
|
||||
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
|
||||
&& let Some(name) = current
|
||||
{
|
||||
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
|
||||
let name = new_preset_name(&app.session.tools.presets);
|
||||
run_or_status(app, "brush.presets.save", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
|
||||
app.open_dialog_file();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Forget previews of presets that no longer exist.
|
||||
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
|
||||
brush_preview::with_cache(ui.ctx(), |c| {
|
||||
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
|
||||
});
|
||||
}
|
||||
|
||||
/// The section list: enable boxes, names, the selection. Clicking a name shows the section and
|
||||
/// turns it on (Photoshop); clicking a box only toggles it.
|
||||
/// The section list: enable boxes, names, locks, the selection. Clicking a name shows the section
|
||||
/// and turns it on (Photoshop); clicking a box only toggles it; the lock on the right keeps the
|
||||
/// section when another preset is picked.
|
||||
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
@@ -355,9 +247,10 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
} else {
|
||||
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
|
||||
}
|
||||
let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0);
|
||||
if box_resp.clicked() {
|
||||
*flag = !*flag;
|
||||
} else if resp.clicked() {
|
||||
} else if resp.clicked() && !on_lock {
|
||||
*flag = true;
|
||||
}
|
||||
x += 20.0;
|
||||
@@ -367,7 +260,22 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
|
||||
let color = if sel { t.text } else { t.text_dim };
|
||||
let font = if i == 0 { theme::semibold(12.0) } else { theme::medium(12.0) };
|
||||
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, font, color);
|
||||
if resp.clicked() {
|
||||
let mut lock_clicked = false;
|
||||
if let Some(lock) = section_lock(b, i) {
|
||||
let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0));
|
||||
let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click());
|
||||
// Like Photoshop: the lock shows only when set or hovered.
|
||||
if *lock || resp.hovered() || lresp.hovered() {
|
||||
let tint = if *lock { t.text } else { t.text_faint };
|
||||
icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint);
|
||||
}
|
||||
let tip = if *lock { "Unlock: picking a preset replaces these settings" } else { "Lock: keep these settings when picking another preset" };
|
||||
if lresp.on_hover_text(tip).clicked() {
|
||||
*lock = !*lock;
|
||||
lock_clicked = true;
|
||||
}
|
||||
}
|
||||
if resp.clicked() && !lock_clicked {
|
||||
app.ui.brush_section = i;
|
||||
}
|
||||
if i == 0 {
|
||||
@@ -411,11 +319,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
|
||||
}
|
||||
if icons::button(ui, "undo-2", 24.0, false, "Reset the brush to the defaults").clicked() {
|
||||
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), ..Default::default() };
|
||||
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() };
|
||||
}
|
||||
});
|
||||
});
|
||||
commit(app, &before, &b);
|
||||
commit_gesture(app, ui.ctx(), &before, &b);
|
||||
}
|
||||
|
||||
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
@@ -457,7 +365,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(6.0);
|
||||
if app.ui.brush_tab == 1 {
|
||||
brushes_tab(app, ui);
|
||||
crate::brushes_tab::show(app, ui);
|
||||
} else {
|
||||
settings_tab(app, ui);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.roundness = 0.4;
|
||||
b.hardness = 0.25;
|
||||
b.spacing = 0.6;
|
||||
b.spacing_enabled = false;
|
||||
}
|
||||
1 => {
|
||||
let sd = &mut b.shape_dynamics;
|
||||
@@ -43,6 +44,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
|
||||
sd.flip_x_jitter = true;
|
||||
sd.flip_y_jitter = true;
|
||||
sd.tilt_scale = 1.25;
|
||||
sd.brush_projection = true;
|
||||
}
|
||||
2 => {
|
||||
let sc = &mut b.scattering;
|
||||
@@ -92,6 +95,9 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
|
||||
b.transfer.enabled = true;
|
||||
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
|
||||
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
|
||||
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
|
||||
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
|
||||
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
|
||||
}
|
||||
7 => {
|
||||
let p = &mut b.pose;
|
||||
@@ -136,7 +142,7 @@ fn every_section_round_trips_through_set_brush() {
|
||||
let (id, p) = last_journal(&app).expect("journaled");
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
let patch = &p["brush"];
|
||||
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}");
|
||||
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 8), "section {i}: {patch}");
|
||||
// And the engine's view (`brush.get`) reads back the same settings.
|
||||
let got = app.session.execute("brush.get", json!({})).unwrap();
|
||||
assert_eq!(serde_json::from_value::<BrushSettings>(got).unwrap(), after, "section {i}");
|
||||
@@ -189,6 +195,144 @@ fn bad_patches_fail_without_touching_the_brush() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_locks_round_trip_and_survive_preset_picks() {
|
||||
let mut b = BrushSettings::default();
|
||||
for (i, (name, _)) in SECTIONS.iter().enumerate() {
|
||||
assert_eq!(section_lock(&mut b, i).is_some(), i > 0, "{name} has a lock");
|
||||
}
|
||||
let mut app = app();
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut after = before.clone();
|
||||
edit_section(2, &mut after);
|
||||
for i in [2, 6, 8] {
|
||||
*section_lock(&mut after, i).unwrap() = true;
|
||||
}
|
||||
commit(&mut app, &before, &after);
|
||||
let (_, p) = last_journal(&app).unwrap();
|
||||
assert_eq!(p["brush"]["locks"], json!({"scattering": true, "transfer": true, "noise": true}));
|
||||
// Picking a preset keeps the locked sections (and the locks), and the preset still reads as current.
|
||||
let chalk = paint::presets::find(&app.session.tools.presets, "Chalk").unwrap().brush.clone();
|
||||
app.run("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
|
||||
let b = &app.session.tools.brush;
|
||||
assert_eq!(b.scattering, after.scattering);
|
||||
assert!(b.locks.scattering && b.locks.noise);
|
||||
assert_eq!(b.tip, chalk.tip);
|
||||
}
|
||||
|
||||
/// A harness that shows the options bar with `tool` in the Studio (non-pro) theme.
|
||||
fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = app();
|
||||
app.ui.tool = tool;
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::options_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_bar_edits_are_one_set_brush_per_gesture() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = options_bar_harness(crate::state::Tool::Brush);
|
||||
let n = h.state().session.journal.len();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing");
|
||||
// Drag the Opacity field left: many frames, one journal entry.
|
||||
let label = h.get_by_label("Opacity").rect();
|
||||
let start = egui::pos2(label.right() + 40.0, label.center().y);
|
||||
h.hover_at(start);
|
||||
h.run();
|
||||
h.drag_at(start);
|
||||
h.run();
|
||||
for k in 1..=8 {
|
||||
h.hover_at(start - vec2(5.0 * k as f32, 0.0));
|
||||
h.run();
|
||||
}
|
||||
h.drop_at(start - vec2(40.0, 0.0));
|
||||
h.run_steps(2);
|
||||
let opacity = h.state().session.tools.brush.opacity;
|
||||
assert!(opacity < 0.95, "the drag lowered the opacity: {opacity}");
|
||||
let j = &h.state().session.journal;
|
||||
assert_eq!(j.len(), n + 1, "{:?}", &j[n..]);
|
||||
let (id, p) = j.last().unwrap();
|
||||
assert_eq!(id, "tools.setBrush");
|
||||
assert_eq!(p["brush"]["opacity"].as_f64().map(|v| v as f32), Some(opacity));
|
||||
// A click is its own gesture: the Pressure for Size switch sits left of its label.
|
||||
let label = h.get_by_label("Pressure for Size").rect();
|
||||
let was = h.state().session.tools.brush.pressure_size;
|
||||
let at = egui::pos2(label.left() - 18.0, label.center().y);
|
||||
h.hover_at(at);
|
||||
h.run();
|
||||
h.drag_at(at);
|
||||
h.run();
|
||||
h.drop_at(at);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.tools.brush.pressure_size, !was);
|
||||
assert_eq!(h.state().session.journal.len(), n + 2);
|
||||
assert_eq!(h.state().session.journal.last().unwrap().1["brush"], json!({"pressureSize": !was}));
|
||||
// Replaying the journal gives the same brush.
|
||||
let mut s = photocraft_engine::Session::new();
|
||||
for (id, p) in &h.state().session.journal {
|
||||
s.execute(id, p.clone()).unwrap();
|
||||
}
|
||||
assert_eq!(s.tools.brush, h.state().session.tools.brush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_and_actions_reorder_presets() {
|
||||
use crate::brushes_tab::{Action, apply, drop_target, group_key};
|
||||
let mut app = app();
|
||||
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta")] {
|
||||
app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
|
||||
}
|
||||
let p = &app.session.tools.presets;
|
||||
// Dropping A1 on A3's lower half lands after A3 (index 2 once A1 is out); on its upper half, before it.
|
||||
assert_eq!(drop_target(p, "A1", "A3", true), Some(("Alpha".into(), 2)));
|
||||
assert_eq!(drop_target(p, "A1", "A3", false), Some(("Alpha".into(), 1)));
|
||||
assert_eq!(drop_target(p, "A3", "A1", false), Some(("Alpha".into(), 0)));
|
||||
assert_eq!(drop_target(p, "A2", "B1", true), Some(("Beta".into(), 1)));
|
||||
assert_eq!(drop_target(p, "A2", "Nope", true), None);
|
||||
assert_eq!(group_key(p, UNGROUPED), "");
|
||||
let (group, index) = drop_target(p, "A1", "A3", true).unwrap();
|
||||
apply(&mut app, vec![Action::Move { name: "A1".into(), group, index: Some(index) }]);
|
||||
let names = |app: &PhotocraftApp, g: &str| app.session.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(names(&app, "Alpha"), ["A2", "A3", "A1"]);
|
||||
apply(&mut app, vec![Action::Move { name: "A2".into(), group: "Beta".into(), index: None }]);
|
||||
assert_eq!(names(&app, "Beta"), ["B1", "A2"]);
|
||||
apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), before: Some("Alpha".into()) }]);
|
||||
let order = photocraft_engine::brush_preset_cmds::group_order(&app.session.tools.presets);
|
||||
assert!(order.iter().position(|g| g == "Beta") < order.iter().position(|g| g == "Alpha"));
|
||||
// Rename and delete from the context menu.
|
||||
apply(&mut app, vec![Action::Rename(Renaming { group: false, name: "A3".into(), text: "Third".into() })]);
|
||||
assert!(paint::presets::find(&app.session.tools.presets, "Third").is_some());
|
||||
assert!(app.ui.brushes_panel.renaming.is_none());
|
||||
apply(&mut app, vec![Action::Rename(Renaming { group: true, name: "Beta".into(), text: "Bees".into() })]);
|
||||
assert_eq!(names(&app, "Bees"), ["B1", "A2"]);
|
||||
apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup("Bees".into())]);
|
||||
assert_eq!(names(&app, "Alpha"), ["A1"]);
|
||||
assert!(names(&app, "Bees").is_empty());
|
||||
// Every action went through a journaled command.
|
||||
let ids: Vec<&str> = app.session.journal.iter().map(|(id, _)| id.as_str()).collect();
|
||||
for id in [
|
||||
"brush.presets.move",
|
||||
"brush.presets.moveGroup",
|
||||
"brush.presets.rename",
|
||||
"brush.presets.renameGroup",
|
||||
"brush.presets.delete",
|
||||
"brush.presets.deleteGroup",
|
||||
] {
|
||||
assert!(ids.contains(&id), "{id}: {ids:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
|
||||
let mut app = app();
|
||||
app.ui.panels.brush_settings = true;
|
||||
@@ -229,9 +373,13 @@ fn preview_strip_re_renders_only_when_the_brush_changes() {
|
||||
|
||||
#[test]
|
||||
fn every_section_draws_without_issuing_commands() {
|
||||
for tab in 0..2 {
|
||||
for tab in 0..3 {
|
||||
for section in 0..SECTIONS.len() {
|
||||
let mut h = harness(tab, section);
|
||||
// Tab 2: the Brushes tab in grid view.
|
||||
let mut h = harness(tab.min(1), section);
|
||||
if tab == 2 {
|
||||
h.state_mut().ui.brushes_panel.view = BrushesView::Grid;
|
||||
}
|
||||
// A brush with every section on and a sampled tip.
|
||||
let mut b = BrushSettings::default();
|
||||
for i in 0..SECTIONS.len() {
|
||||
|
||||
@@ -328,12 +328,22 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
|
||||
ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| {
|
||||
pct(ui, "Hardness", &mut b.hardness, 1.0);
|
||||
});
|
||||
signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0);
|
||||
// Unchecked, the pointer's speed sets the spacing (Photoshop).
|
||||
widgets::checkbox(ui, &mut b.spacing_enabled, "Spacing").on_hover_text("Off: the speed of the pointer sets the spacing");
|
||||
ui.add_space(2.0);
|
||||
let on = b.spacing_enabled;
|
||||
ui.add_enabled_ui(on, |ui| signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0));
|
||||
}
|
||||
|
||||
fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let sd = &mut b.shape_dynamics;
|
||||
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"), false);
|
||||
pct(ui, "Size Jitter", &mut sd.size.jitter, 1.0);
|
||||
control_row(ui, "brush-size-ctl", &mut sd.size, false);
|
||||
pct(ui, "Minimum Diameter", &mut sd.size.minimum, 1.0);
|
||||
// Tilt Scale applies with the size control on Pen Tilt (greyed out otherwise, like Photoshop).
|
||||
let tilt = sd.size.control == Control::PenTilt;
|
||||
ui.add_enabled_ui(tilt, |ui| pct(ui, "Tilt Scale", &mut sd.tilt_scale, 2.0));
|
||||
ui.add_space(6.0);
|
||||
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None, true);
|
||||
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"), false);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -341,6 +351,8 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
ui.add_space(8.0);
|
||||
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
widgets::checkbox(ui, &mut sd.brush_projection, "Brush Projection").on_hover_text("Apply the pen's tilt and rotation to the tip shape");
|
||||
}
|
||||
|
||||
fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
@@ -519,6 +531,25 @@ fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let tr = &mut b.transfer;
|
||||
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"), false);
|
||||
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"), false);
|
||||
dynamic(ui, "brush-wet-ctl", "Wetness Jitter", &mut tr.wetness, 1.0, Some("Minimum"), false);
|
||||
dynamic(ui, "brush-mix-ctl", "Mix Jitter", &mut tr.mix, 1.0, Some("Minimum"), false);
|
||||
}
|
||||
|
||||
/// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options
|
||||
/// bar; `paint.mixerBrush` uses them unless its params override them).
|
||||
fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.label(RichText::new("Mixer Brush").font(theme::semibold(11.5)).color(t.text_dim));
|
||||
ui.add_space(2.0);
|
||||
let m = &mut b.mixer;
|
||||
pct(ui, "Wet", &mut m.wet, 1.0);
|
||||
pct(ui, "Load", &mut m.load, 1.0);
|
||||
pct(ui, "Mix", &mut m.mix, 1.0);
|
||||
pct(ui, "Flow", &mut m.flow, 1.0);
|
||||
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
|
||||
}
|
||||
|
||||
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
|
||||
@@ -564,7 +595,10 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets:
|
||||
3 => texture(ui, b),
|
||||
4 => dual_brush(ui, b, presets),
|
||||
5 => color_dynamics(ui, b),
|
||||
6 => transfer(ui, b),
|
||||
6 => {
|
||||
transfer(ui, b);
|
||||
mixer_options(ui, b);
|
||||
}
|
||||
7 => pose(ui, b),
|
||||
8 => note(ui, "Adds extra randomness to the soft edges of the brush tip. No options: turn it on in the list."),
|
||||
9 => note(ui, "Builds up paint along the edges of the stroke for a watercolour look. No options: turn it on in the list."),
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! Window › Brushes: the brush presets in collapsible groups, as a list (tip, size, stroke
|
||||
//! preview, name) or a grid of tips, with a size slider and a search field.
|
||||
//!
|
||||
//! Presets and groups are organised like Photoshop's panel: drag a preset within its group or
|
||||
//! into another group (onto a preset, or onto a group's header to append), drag a group's header
|
||||
//! to reorder groups, and right-click for Rename and Delete. Every change is a `brush.presets.*`
|
||||
//! command, so it is journaled, drivable, and persisted by the preset store.
|
||||
|
||||
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
|
||||
use photocraft_engine::paint::BrushPreset;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::brush_panel::{BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, is_current, new_preset_name, run_or_status};
|
||||
use crate::brush_preview;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PhotocraftApp, icons, widgets};
|
||||
|
||||
/// What a drag in the Brushes tab carries.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BrushDrag {
|
||||
Preset(String),
|
||||
Group(String),
|
||||
}
|
||||
|
||||
/// What the tab does after drawing (the presets are borrowed while it draws).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Action {
|
||||
Select(String),
|
||||
ToggleGroup(String),
|
||||
Move { name: String, group: String, index: Option<usize> },
|
||||
MoveGroup { group: String, before: Option<String> },
|
||||
Rename(Renaming),
|
||||
Delete(String),
|
||||
DeleteGroup(String),
|
||||
}
|
||||
|
||||
/// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as
|
||||
/// [`UNGROUPED`].
|
||||
pub fn group_key(presets: &[BrushPreset], label: &str) -> String {
|
||||
if label == UNGROUPED && !presets.iter().any(|p| p.group == UNGROUPED) { String::new() } else { label.to_string() }
|
||||
}
|
||||
|
||||
/// Where a preset dropped onto `target` lands: (group, index in that group after the dragged
|
||||
/// preset is taken out). `after` = dropped on the target's lower (list) or right (grid) half.
|
||||
pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: bool) -> Option<(String, usize)> {
|
||||
let t = presets.iter().find(|p| p.name == target)?;
|
||||
let members: Vec<&str> = presets.iter().filter(|p| p.group == t.group).map(|p| p.name.as_str()).collect();
|
||||
let tp = members.iter().position(|n| *n == target)?;
|
||||
let mut idx = tp + usize::from(after);
|
||||
if let Some(dp) = members.iter().position(|n| *n == dragged)
|
||||
&& dp < idx
|
||||
{
|
||||
idx -= 1;
|
||||
}
|
||||
Some((t.group.clone(), idx))
|
||||
}
|
||||
|
||||
/// Turn the tab's actions into commands.
|
||||
pub fn apply(app: &mut PhotocraftApp, acts: Vec<Action>) {
|
||||
for a in acts {
|
||||
match a {
|
||||
Action::Select(name) => run_or_status(app, "tools.setBrush", json!({ "preset": name })),
|
||||
Action::ToggleGroup(g) => {
|
||||
let c = &mut app.ui.brushes_panel.collapsed;
|
||||
match c.iter().position(|x| *x == g) {
|
||||
Some(i) => drop(c.remove(i)),
|
||||
None => c.push(g),
|
||||
}
|
||||
}
|
||||
Action::Move { name, group, index } => {
|
||||
let mut p = json!({ "name": name, "group": group });
|
||||
if let Some(i) = index {
|
||||
p["index"] = json!(i);
|
||||
}
|
||||
run_or_status(app, "brush.presets.move", p);
|
||||
}
|
||||
Action::MoveGroup { group, before } => run_or_status(app, "brush.presets.moveGroup", json!({ "group": group, "before": before })),
|
||||
Action::Rename(r) => {
|
||||
let text = r.text.trim().to_string();
|
||||
if !text.is_empty() && text != r.name {
|
||||
if r.group {
|
||||
run_or_status(app, "brush.presets.renameGroup", json!({ "group": r.name, "newName": text }));
|
||||
} else {
|
||||
run_or_status(app, "brush.presets.rename", json!({ "name": r.name, "newName": text }));
|
||||
}
|
||||
}
|
||||
app.ui.brushes_panel.renaming = None;
|
||||
}
|
||||
Action::Delete(name) => run_or_status(app, "brush.presets.delete", json!({ "name": name })),
|
||||
Action::DeleteGroup(g) => run_or_status(app, "brush.presets.deleteGroup", json!({ "group": g })),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An accent insertion line on one edge of `r` while a drag hovers it.
|
||||
fn drop_line(ui: &egui::Ui, r: egui::Rect, after: bool, vertical: bool, t: &Tokens) {
|
||||
let s = Stroke::new(2.0, t.accent);
|
||||
if vertical {
|
||||
let x = if after { r.right() } else { r.left() };
|
||||
ui.painter().line_segment([pos2(x, r.top() + 2.0), pos2(x, r.bottom() - 2.0)], s);
|
||||
} else {
|
||||
let y = if after { r.bottom() } else { r.top() };
|
||||
ui.painter().line_segment([pos2(r.left() + 4.0, y), pos2(r.right() - 4.0, y)], s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the pointer on the second half of `r` (lower in a list, right in a grid)?
|
||||
fn second_half(ui: &egui::Ui, r: egui::Rect, grid: bool) -> bool {
|
||||
ui.ctx().pointer_interact_pos().is_some_and(|p| if grid { p.x > r.center().x } else { p.y > r.center().y })
|
||||
}
|
||||
|
||||
/// Drag, drop and context menu of one preset cell or row.
|
||||
fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: &BrushPreset, presets: &[BrushPreset], grid: bool, acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
resp.dnd_set_drag_payload(BrushDrag::Preset(p.name.clone()));
|
||||
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>()
|
||||
&& matches!(&*d, BrushDrag::Preset(n) if *n != p.name)
|
||||
{
|
||||
drop_line(ui, r, second_half(ui, r, grid), grid, &t);
|
||||
}
|
||||
if let Some(d) = resp.dnd_release_payload::<BrushDrag>()
|
||||
&& let BrushDrag::Preset(n) = &*d
|
||||
&& *n != p.name
|
||||
&& let Some((group, index)) = drop_target(presets, n, &p.name, second_half(ui, r, grid))
|
||||
{
|
||||
acts.push(Action::Move { name: n.clone(), group, index: Some(index) });
|
||||
}
|
||||
if resp.clicked() {
|
||||
acts.push(Action::Select(p.name.clone()));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Rename Brush…").clicked() {
|
||||
acts.push(Action::Rename(Renaming { group: false, name: p.name.clone(), text: String::new() }));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete Brush").clicked() {
|
||||
acts.push(Action::Delete(p.name.clone()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag());
|
||||
if !ui.is_rect_visible(r) {
|
||||
return;
|
||||
}
|
||||
if current {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let pb = &p.brush;
|
||||
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
|
||||
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
|
||||
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
|
||||
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
pos2(cell.center().x, r.bottom() - 1.0),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
format!("{}", pb.size.round() as i64),
|
||||
egui::FontId::proportional(9.0),
|
||||
t.text_faint,
|
||||
);
|
||||
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
|
||||
let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
|
||||
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(pos2(sr.right() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, &p.name, egui::FontId::proportional(12.0), t.text_dim);
|
||||
preset_interactions(ui, &resp, r, p, presets, false, acts);
|
||||
}
|
||||
|
||||
fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 58.0), Sense::click_and_drag());
|
||||
if !ui.is_rect_visible(r) {
|
||||
return;
|
||||
}
|
||||
if current {
|
||||
ui.painter().rect_filled(r, 3.0, t.accent_soft);
|
||||
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let pb = &p.brush;
|
||||
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
|
||||
let ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 23.0), vec2(38.0, 38.0) * crate::brush_sections::thumb_scale(pb.size));
|
||||
ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE);
|
||||
ui.painter().text(
|
||||
pos2(r.center().x, r.bottom() - 7.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!("{}", pb.size.round() as i64),
|
||||
egui::FontId::proportional(9.5),
|
||||
t.text_dim,
|
||||
);
|
||||
let resp = resp.on_hover_text(&p.name);
|
||||
preset_interactions(ui, &resp, r, p, presets, true, acts);
|
||||
}
|
||||
|
||||
/// A group's header: chevron, folder, name, count. Click toggles; drag reorders groups; a
|
||||
/// preset dropped here moves to the end of the group.
|
||||
fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: usize, acts: &mut Vec<Action>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, t.radius_sm, t.hover);
|
||||
}
|
||||
let x = r.left() + 4.0;
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
12.0,
|
||||
t.text_dim,
|
||||
);
|
||||
icons::paint(
|
||||
ui,
|
||||
egui::Rect::from_min_size(pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
|
||||
if open { "folder-open" } else { "folder" },
|
||||
14.0,
|
||||
t.icon,
|
||||
);
|
||||
ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, label, theme::semibold(12.0), t.text);
|
||||
ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint);
|
||||
resp.dnd_set_drag_payload(BrushDrag::Group(key.to_string()));
|
||||
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>() {
|
||||
match &*d {
|
||||
BrushDrag::Group(g) if g != key => drop_line(ui, r, false, false, &t),
|
||||
BrushDrag::Preset(_) => {
|
||||
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(d) = resp.dnd_release_payload::<BrushDrag>() {
|
||||
match &*d {
|
||||
BrushDrag::Group(g) if g != key => acts.push(Action::MoveGroup { group: g.clone(), before: Some(key.to_string()) }),
|
||||
BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), index: None }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
acts.push(Action::ToggleGroup(label.to_string()));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Rename Group…").clicked() {
|
||||
acts.push(Action::Rename(Renaming { group: true, name: key.to_string(), text: String::new() }));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete Group").clicked() {
|
||||
acts.push(Action::DeleteGroup(key.to_string()));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The rename bar shown while a preset or group is being renamed. Enter or OK renames, Escape or
|
||||
/// Cancel stops.
|
||||
fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec<Action>) {
|
||||
let Some(r) = app.ui.brushes_panel.renaming.as_mut() else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if r.text.is_empty() {
|
||||
r.text = r.name.clone();
|
||||
}
|
||||
let mut cancel = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(if r.group { "Group name" } else { "Brush name" }).color(t.text_dim));
|
||||
let resp = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(WIDTH - 230.0).id_salt("brush-rename"));
|
||||
if !resp.has_focus() && !resp.lost_focus() {
|
||||
resp.request_focus();
|
||||
}
|
||||
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if widgets::primary_button(ui, "OK", 52.0).clicked() || enter {
|
||||
acts.push(Action::Rename(r.clone()));
|
||||
}
|
||||
if widgets::secondary_button(ui, "Cancel", 60.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
if cancel {
|
||||
app.ui.brushes_panel.renaming = None;
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
/// The Brushes tab.
|
||||
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Size of the current brush (Photoshop's Brushes panel slider).
|
||||
let before = app.session.tools.brush.clone();
|
||||
let mut b = before.clone();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Size").color(t.text_dim));
|
||||
let mut lv = b.size.max(1.0).ln();
|
||||
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
|
||||
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
|
||||
if r.changed() {
|
||||
b.size = lv.exp().round().clamp(1.0, 5000.0);
|
||||
}
|
||||
r
|
||||
});
|
||||
let mut s = b.size;
|
||||
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
|
||||
b.size = s.round().clamp(1.0, 5000.0);
|
||||
}
|
||||
});
|
||||
commit_gesture(app, ui.ctx(), &before, &b);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
|
||||
ui.add_space(20.0);
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 120.0));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let view = &mut app.ui.brushes_panel.view;
|
||||
if icons::button(ui, "grid-2x2", 24.0, *view == BrushesView::Grid, "Grid view").clicked() {
|
||||
*view = BrushesView::Grid;
|
||||
}
|
||||
if icons::button(ui, "align-justify", 24.0, *view == BrushesView::List, "List view").clicked() {
|
||||
*view = BrushesView::List;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let mut acts = Vec::new();
|
||||
rename_bar(app, ui, &mut acts);
|
||||
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
|
||||
let grid = app.ui.brushes_panel.view == BrushesView::Grid;
|
||||
let presets = &app.session.tools.presets;
|
||||
let groups = grouped_presets(presets);
|
||||
let brush = &app.session.tools.brush;
|
||||
let collapsed = &app.ui.brushes_panel.collapsed;
|
||||
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 1.0;
|
||||
for (label, items) in groups {
|
||||
let items: Vec<usize> =
|
||||
items.into_iter().filter(|i| filter.is_empty() || presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter))).collect();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let key = group_key(presets, &label);
|
||||
let open = !filter.is_empty() || !collapsed.contains(&label);
|
||||
group_header(ui, &label, &key, open, items.len(), &mut acts);
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
if grid {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
|
||||
ui.add_space(20.0);
|
||||
for i in items {
|
||||
if let Some(p) = presets.get(i) {
|
||||
grid_cell(ui, p, is_current(&p.brush, brush), presets, &mut acts);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
for i in items {
|
||||
if let Some(p) = presets.get(i) {
|
||||
list_row(ui, p, is_current(&p.brush, brush), presets, &mut acts);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
});
|
||||
// The dragged preset or group follows the pointer.
|
||||
if let Some(d) = egui::DragAndDrop::payload::<BrushDrag>(ui.ctx())
|
||||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
let name = match &*d {
|
||||
BrushDrag::Preset(n) => n.clone(),
|
||||
BrushDrag::Group(g) => {
|
||||
if g.is_empty() {
|
||||
UNGROUPED.to_string()
|
||||
} else {
|
||||
g.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
egui::Area::new(egui::Id::new("brush-drag-label")).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name));
|
||||
},
|
||||
);
|
||||
}
|
||||
apply(app, acts);
|
||||
ui.add_space(4.0);
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
|
||||
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
|
||||
&& let Some(name) = current
|
||||
{
|
||||
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
|
||||
let name = new_preset_name(&app.session.tools.presets);
|
||||
run_or_status(app, "brush.presets.save", json!({ "name": name }));
|
||||
}
|
||||
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
|
||||
app.open_dialog_file();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Forget previews of presets that no longer exist.
|
||||
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
|
||||
brush_preview::with_cache(ui.ctx(), |c| {
|
||||
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
|
||||
});
|
||||
}
|
||||
+258
-69
@@ -40,6 +40,8 @@ pub struct Drag {
|
||||
/// A Brush/Eraser stroke that erases: the Eraser, or a right-button Brush drag with
|
||||
/// Preferences › Tools › Right-click with painting tools set to Erase.
|
||||
pub erase: bool,
|
||||
/// ⇧ constraint of a painting stroke (stroke_constraint.rs).
|
||||
pub constrain: Option<crate::stroke_constraint::Axis>,
|
||||
}
|
||||
|
||||
/// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of
|
||||
@@ -223,6 +225,10 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
|
||||
if let Some(shown) = crate::adjust_preview::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
// Move tool drag: the moving layers at the pointer.
|
||||
if let Some(shown) = crate::move_ui::display_doc(app, idx) {
|
||||
return shown;
|
||||
}
|
||||
let st = &app.session.documents()[idx];
|
||||
if let Some(l) = live_stroke(app, idx) {
|
||||
return (l.stroke.doc.clone(), l.display_key());
|
||||
@@ -282,21 +288,30 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
if app.gpu.is_none() {
|
||||
return ensure_texture(app, ctx, idx).map(|(t, _)| t);
|
||||
}
|
||||
let (revision, id) = app.session.documents().get(idx).map(|st| (st.revision, st.doc.id))?;
|
||||
// Keyed by the document snapshot, not its revision: selecting a layer changes no pixels.
|
||||
let (snapshot, id) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id))?;
|
||||
let (doc, preview_key) = display_doc(app, idx);
|
||||
let (display, display_key) = canvas_display(app, &doc);
|
||||
let preview_key = preview_key ^ display_key;
|
||||
let key = egui::Id::new(("navigator", id.0));
|
||||
let cached: Option<(u64, u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key));
|
||||
type Cached = (std::sync::Weak<Document>, u64, egui::TextureHandle);
|
||||
let cached: Option<Cached> = ctx.data(|d| d.get_temp(key));
|
||||
if let Some((r, p, t)) = &cached
|
||||
&& (*r, *p) == (revision, preview_key)
|
||||
&& r.ptr_eq(&snapshot)
|
||||
&& *p == preview_key
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
// While a Move drag is under way the navigator keeps its image and catches up on release.
|
||||
if let Some((_, _, t)) = &cached
|
||||
&& crate::move_ui::showing(app)
|
||||
{
|
||||
return Some(t.id());
|
||||
}
|
||||
// While an adjustment dialog's settings are changing, the navigator keeps its image and
|
||||
// catches up once they settle (a thumbnail composite per change would cost more than the canvas).
|
||||
if let Some((r, _, t)) = &cached
|
||||
&& *r == revision
|
||||
&& r.ptr_eq(&snapshot)
|
||||
&& crate::adjust_preview::settling(app, NAVIGATOR_SETTLE_MS)
|
||||
{
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(NAVIGATOR_SETTLE_MS as u64));
|
||||
@@ -312,7 +327,7 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
|
||||
None => ctx.load_texture(format!("navigator-{}", id.0), image, TextureOptions::LINEAR),
|
||||
};
|
||||
app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0);
|
||||
ctx.data_mut(|d| d.insert_temp(key, (revision, preview_key, tex.clone())));
|
||||
ctx.data_mut(|d| d.insert_temp(key, (snapshot, preview_key, tex.clone())));
|
||||
Some(tex.id())
|
||||
}
|
||||
|
||||
@@ -393,11 +408,34 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
|
||||
{
|
||||
return Some(r);
|
||||
}
|
||||
// Between a Move drag's offsets: where the moving layers were and are (Auto-Select's pick
|
||||
// at the press changes no pixels).
|
||||
if (seen.0 == now.0 || (seen.0 + 1 == now.0 && last_damage.is_some_and(|r| r.is_empty())))
|
||||
&& let Some(st) = app.session.documents().get(idx)
|
||||
&& let Some(r) = crate::move_ui::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
|
||||
{
|
||||
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
|
||||
}
|
||||
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
|
||||
let r = l.since(seen.1 ^ display_key)?;
|
||||
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
|
||||
}
|
||||
|
||||
/// Document `doc`'s canvas caches showed a preview that the edit just committed reproduces
|
||||
/// (`was_preview` tells its keys): count it as the document itself, so the commit's damage rect
|
||||
/// refreshes only that area instead of everything.
|
||||
pub(crate) fn shown_as_document(app: &mut PhotocraftApp, doc: photocraft_doc::DocId, was_preview: impl Fn(u64) -> bool) {
|
||||
let display_key = app.session.active().map_or(0, |st| canvas_display(app, &st.doc).1);
|
||||
if let Some(c) = app.canvases.get_mut(&doc) {
|
||||
if was_preview(c.preview_key ^ display_key) {
|
||||
c.preview_key = display_key;
|
||||
}
|
||||
if was_preview(c.tex_preview_key ^ display_key) {
|
||||
c.tex_preview_key = display_key;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows,
|
||||
/// strokes, …) on the edited layer and on the groups around it reach that far.
|
||||
pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
|
||||
@@ -460,6 +498,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
|
||||
return true;
|
||||
}
|
||||
let partial = present && damage.is_some();
|
||||
gpu_budget(app, &gpu, idx, partial, visible);
|
||||
let r = gpu.refresh(id.0, &doc, if partial { damage } else { None }, display.as_deref());
|
||||
if let Some(e) = &r.fallback
|
||||
&& app.perf.gpu_fallback.as_deref() != Some(e.as_str())
|
||||
@@ -480,6 +519,23 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory
|
||||
/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels
|
||||
/// counted once per structural change, not per brush dab) and whenever the preference changes.
|
||||
fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) {
|
||||
gpu.set_focus(Some(visible));
|
||||
let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20);
|
||||
if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(st) = app.session.documents().get(idx) else { return };
|
||||
let pixels = st.history.pixel_bytes(&st.doc) as u64;
|
||||
let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory());
|
||||
gpu.set_memory_budget(budget);
|
||||
app.perf.gpu_budget_mb = budget >> 20;
|
||||
app.perf.gpu_budget_allowance = allowance;
|
||||
}
|
||||
|
||||
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
|
||||
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
|
||||
if crate::adjust_preview::on_layer(app, idx) {
|
||||
@@ -1132,7 +1188,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
// Tools follow the left button; the right one opens the Brush Preset picker or erases
|
||||
// (Preferences › Tools, `paint_mouse`).
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
|
||||
// Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below.
|
||||
if tool == Tool::Zoom && crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) {
|
||||
(buttons.started, buttons.dragged, buttons.stopped) = (false, false, false);
|
||||
}
|
||||
// A drag is only recognised once the pointer has moved past egui's click distance: the
|
||||
// gesture starts where the button went down, not where it is now (#123).
|
||||
if buttons.started
|
||||
@@ -1187,6 +1247,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
crate::rulers::draw_guides(app, &painter, &xf, &doc);
|
||||
}
|
||||
draw_drag_preview(app, &painter, &xf);
|
||||
crate::zoom_tool::draw(&ctx, &painter);
|
||||
draw_transform_controls(app, &painter, &xf);
|
||||
crate::paint_mouse::show_picker(app, &ctx);
|
||||
crate::snap_ui::draw(app, &painter, &xf);
|
||||
@@ -1243,7 +1304,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
|
||||
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
|
||||
// The Background Eraser always shows its sampling hotspot (Photoshop).
|
||||
if cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser {
|
||||
// Quick Selection shows its +/− badge there instead.
|
||||
if (cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser) && tool != Tool::QuickSelection {
|
||||
crosshair(3.0);
|
||||
}
|
||||
egui::CursorIcon::None
|
||||
@@ -1273,6 +1335,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
|
||||
_ => egui::CursorIcon::Crosshair,
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
// Selection tools: + / − / × badge for the effective mode (#170). A gesture keeps the
|
||||
// mode it started with (⇧ then constrains the marquee instead of adding).
|
||||
let held = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers));
|
||||
let mods = app.drag.as_ref().map_or(held, |d| d.modifiers);
|
||||
if let Some(b) = crate::tool_feedback::badge(app, tool, mods) {
|
||||
crate::tool_feedback::draw_badge(&painter, p, b, tool == Tool::QuickSelection);
|
||||
}
|
||||
}
|
||||
}
|
||||
if primary {
|
||||
@@ -1393,10 +1462,10 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
|
||||
if let Some(h) = hover {
|
||||
pts.push(h);
|
||||
}
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
|
||||
for p in &pts[..app.ui.polygon.len()] {
|
||||
crate::tool_feedback::draw_ants(painter, &pts, false);
|
||||
for p in pts.iter().take(app.ui.polygon.len()) {
|
||||
painter.rect_filled(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
|
||||
painter.rect_stroke(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
if let Some(c) = app.ui.crop_rect {
|
||||
@@ -1456,9 +1525,12 @@ fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf:
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||
fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
|
||||
draw_tool_state(app, painter, xf, painter.ctx().input(|i| i.pointer.hover_pos()));
|
||||
let Some(d) = &app.drag else { return };
|
||||
let Some(d) = &app.drag else {
|
||||
app.trail = None;
|
||||
return;
|
||||
};
|
||||
let mut last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start);
|
||||
if matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) {
|
||||
let o = &app.ui.tool_options;
|
||||
@@ -1468,31 +1540,31 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
// The canvas shows the live stroke itself (`LiveStroke`).
|
||||
Tool::Brush | Tool::Eraser => {}
|
||||
t if t.is_brushlike() || t == Tool::QuickSelection => {
|
||||
// Retouching strokes preview as a translucent trail of the brush footprint.
|
||||
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
|
||||
let w = (app.session.tools.brush.size * xf.zoom).max(1.0);
|
||||
// Retouching strokes preview as a translucent trail of the brush footprint: a mask,
|
||||
// not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189).
|
||||
let col = Color32::from_white_alpha(if t == Tool::QuickSelection { 40 } else { 60 });
|
||||
if pts.len() == 1 {
|
||||
painter.circle_filled(pts[0], w / 2.0, col);
|
||||
} else {
|
||||
painter.add(egui::Shape::line(pts, Stroke::new(w, col)));
|
||||
}
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let size = [st.doc.size.width, st.doc.size.height];
|
||||
let doc_rect = xf.doc_rect(st.doc.bounds());
|
||||
let trail = app.trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size));
|
||||
trail.feed(&d.points, app.session.tools.brush.size);
|
||||
trail.draw(painter, doc_rect, xf.flip, col);
|
||||
}
|
||||
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.modifiers),
|
||||
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => {
|
||||
// Marching ants, visible on any pixels (#172).
|
||||
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
|
||||
if d.tool == Tool::EllipseMarquee {
|
||||
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE)));
|
||||
let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5));
|
||||
let pts = if d.tool == Tool::EllipseMarquee {
|
||||
crate::tool_feedback::ellipse_points(r)
|
||||
} else {
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
|
||||
}
|
||||
vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]
|
||||
};
|
||||
crate::tool_feedback::draw_ants(painter, &pts, true);
|
||||
}
|
||||
Tool::Lasso => {
|
||||
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
|
||||
if pts.len() > 1 {
|
||||
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
|
||||
}
|
||||
crate::tool_feedback::draw_ants(painter, &pts, false);
|
||||
}
|
||||
Tool::Gradient => {
|
||||
let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32);
|
||||
@@ -1508,7 +1580,8 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE)));
|
||||
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 3.0, 3.0));
|
||||
}
|
||||
Tool::Move => {
|
||||
// The layers themselves follow the pointer (`move_ui`); the arrow only when they can't.
|
||||
Tool::Move if !crate::move_ui::showing(app) => {
|
||||
let off = vec2(((last[0] - d.start[0]) as f32) * xf.zoom, ((last[1] - d.start[1]) as f32) * xf.zoom);
|
||||
painter.arrow(xf.to_screen(d.start[0] as f32, d.start[1] as f32), off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent));
|
||||
}
|
||||
@@ -1516,9 +1589,20 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
|
||||
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
|
||||
let color: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
|
||||
if color.len() == 4 && color[3] > 0.0 {
|
||||
let key = if mods.alt { "background" } else { "foreground" };
|
||||
let _ = app.run("tools.setColors", json!({ key: [color[0], color[1], color[2], 1.0] }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool state machine. Shared by mouse input and automation.
|
||||
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
|
||||
// View › Snap / Snap To and smart guides (snap_ui.rs).
|
||||
let raw = ev;
|
||||
let ev = crate::snap_ui::filter_event(app, ev, mods);
|
||||
if crate::transform_tool::pointer(app, ev, mods) {
|
||||
return;
|
||||
@@ -1528,6 +1612,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
|
||||
let mods = crate::workspace_ui::sticky_mods(app, mods);
|
||||
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
|
||||
let ev = crate::move_mods::filter_event(app, ev, mods);
|
||||
// Ruler, Count and Note tools.
|
||||
if crate::analysis_ui::pointer(app, ev, mods) {
|
||||
return;
|
||||
@@ -1541,6 +1627,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
return;
|
||||
}
|
||||
let tool = app.ui.tool;
|
||||
if tool == Tool::Eyedropper {
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => {
|
||||
sample_eyedropper(app, x, y, mods);
|
||||
return;
|
||||
}
|
||||
ToolEvent::Up { .. } => return,
|
||||
}
|
||||
}
|
||||
// Move tool over a guide drags the guide (off the canvas deletes it).
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, .. } if tool == Tool::Move => {
|
||||
@@ -1573,16 +1668,6 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
match ev {
|
||||
ToolEvent::Down { x, y, pressure } => {
|
||||
if tool == Tool::Eyedropper {
|
||||
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
|
||||
let c: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
|
||||
if c.len() == 4 && c[3] > 0.0 {
|
||||
let key = if mods.alt { "background" } else { "foreground" };
|
||||
let _ = app.run("tools.setColors", json!({ key: [c[0], c[1], c[2], 1.0] }));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match tool {
|
||||
Tool::Pen => {
|
||||
crate::vector_ui::pen_down(app, x, y);
|
||||
@@ -1625,8 +1710,19 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
}
|
||||
crate::paint_mouse::sync_tool_smoothing(app);
|
||||
let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase);
|
||||
app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods, erase });
|
||||
// ⇧-click after a stroke: a straight line from where it ended (stroke_constraint.rs).
|
||||
let active = app.session.active().map(|st| st.doc.id);
|
||||
let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p);
|
||||
let mut points = vec![[x, y, pressure as f64]];
|
||||
if let Some(p) = from {
|
||||
points.insert(0, [p[0], p[1], pressure as f64]);
|
||||
}
|
||||
app.drag = Some(Drag { tool, start: from.unwrap_or([x, y]), points, modifiers: mods, erase, constrain: None });
|
||||
app.trail = None;
|
||||
app.stylus.begin_stroke();
|
||||
if from.is_some() {
|
||||
app.stylus.record_point();
|
||||
}
|
||||
app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None };
|
||||
}
|
||||
ToolEvent::Move { x, y, pressure } => {
|
||||
@@ -1636,11 +1732,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_move(app, x, y);
|
||||
}
|
||||
if let Some(d) = &mut app.drag
|
||||
&& d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25)
|
||||
{
|
||||
d.points.push([x, y, pressure as f64]);
|
||||
app.stylus.record_point();
|
||||
let zoom = app.current_zoom();
|
||||
if let Some(d) = &mut app.drag {
|
||||
// ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs).
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
|
||||
if d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25) {
|
||||
d.points.push([x, y, pressure as f64]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
}
|
||||
feed_live_stroke(app);
|
||||
}
|
||||
@@ -1653,18 +1753,35 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
|
||||
if tool == Tool::Pen {
|
||||
crate::vector_ui::pen_up(app);
|
||||
}
|
||||
let zoom = app.current_zoom();
|
||||
let Some(mut d) = app.drag.take() else { return };
|
||||
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
|
||||
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
|
||||
// A Move-tool click (released where it was pressed) selects, it never moves: snapping
|
||||
// the release point would otherwise nudge the layer onto a nearby edge.
|
||||
if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
|
||||
app.move_preview = None;
|
||||
crate::move_mods::finish(app);
|
||||
return;
|
||||
}
|
||||
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
|
||||
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
|
||||
app.stylus.record_point();
|
||||
}
|
||||
finish_gesture(app, d);
|
||||
crate::move_mods::finish(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]);
|
||||
// Where the next ⇧-click line starts.
|
||||
if crate::stroke_constraint::connects(d.tool)
|
||||
&& let Some(st) = app.session.active()
|
||||
{
|
||||
app.last_stroke_end = Some((st.doc.id, [end[0], end[1]]));
|
||||
}
|
||||
if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) {
|
||||
return;
|
||||
}
|
||||
@@ -1708,18 +1825,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bar = ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize];
|
||||
let mode = if !d.modifiers.shift && !d.modifiers.alt {
|
||||
bar
|
||||
} else if d.modifiers.shift && d.modifiers.alt {
|
||||
"intersect"
|
||||
} else if d.modifiers.shift {
|
||||
"add"
|
||||
} else if d.modifiers.alt {
|
||||
"subtract"
|
||||
} else {
|
||||
"replace"
|
||||
};
|
||||
let mode = selection_mode(app, d.modifiers);
|
||||
let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather);
|
||||
let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather}));
|
||||
}
|
||||
@@ -1745,9 +1851,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
}
|
||||
Tool::Move => {
|
||||
let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round());
|
||||
if dx != 0.0 || dy != 0.0 {
|
||||
let _ = app.run("layer.translate", json!({"dx": dx, "dy": dy}));
|
||||
}
|
||||
crate::move_ui::finish(app, dx, dy);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1783,16 +1887,9 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
|
||||
/// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect).
|
||||
/// The cursor badge announces the same mode (`tool_feedback`).
|
||||
fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str {
|
||||
if m.shift && m.alt {
|
||||
"intersect"
|
||||
} else if m.shift {
|
||||
"add"
|
||||
} else if m.alt {
|
||||
"subtract"
|
||||
} else {
|
||||
["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize]
|
||||
}
|
||||
crate::tool_feedback::selection_mode(Tool::Lasso, app.ui.selection_mode, m)
|
||||
}
|
||||
|
||||
/// Close the polygonal lasso and make the selection.
|
||||
@@ -1856,6 +1953,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eyedropper_drag_updates_the_sampled_colour() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [0, 0, 20, 20], "fill": "#ff0000"})).unwrap();
|
||||
app.run("shape.create", json!({"kind": "rect", "rect": [20, 0, 20, 20], "fill": "#00ff00"})).unwrap();
|
||||
app.ui.tool = Tool::Eyedropper;
|
||||
|
||||
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
assert!(app.session.tools.foreground[0] > 0.99 && app.session.tools.foreground[1] < 0.01);
|
||||
|
||||
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_drag_shows_the_real_stroke_and_commits_it() {
|
||||
// The drag used to draw a hard, flat stand-in and only showed the soft brush on release.
|
||||
@@ -2005,6 +2117,83 @@ mod tests {
|
||||
assert!((0..120).all(|y| (0..200).all(|x| a.rgba(x, y) == b.rgba(x, y))), "no stale tails in the preview");
|
||||
}
|
||||
|
||||
/// Shapes the drag preview paints for `app` this frame.
|
||||
fn drag_preview_shapes(app: &mut PhotocraftApp, ctx: &egui::Context, zoom: f32) -> Vec<egui::epaint::ClippedShape> {
|
||||
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let ctx = ui.ctx();
|
||||
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||||
let painter = ctx.layer_painter(egui::LayerId::background()).with_clip_rect(rect);
|
||||
let xf = ViewXform { rect, zoom, center: [60.0, 30.0], flip: false };
|
||||
draw_drag_preview(app, &painter, &xf);
|
||||
});
|
||||
let egui::FullOutput { mut textures_delta, shapes, .. } = out;
|
||||
textures_delta.clear();
|
||||
shapes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_drags_paint_no_stand_in_shape_over_the_canvas() {
|
||||
// #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide
|
||||
// as the brush; zoomed in, egui tessellated it into hard black wedges fanning out from the
|
||||
// start. The canvas shows the real stroke (`LiveStroke`) and nothing is drawn over it.
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 60})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 30, "hardness": 0.0}})).unwrap();
|
||||
for tool in [Tool::Brush, Tool::Eraser] {
|
||||
app.ui.tool = tool;
|
||||
let idle = drag_preview_shapes(&mut app, &ctx, 12.0).len();
|
||||
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
for i in 1..40 {
|
||||
let t = f64::from(i);
|
||||
tool_event(&mut app, ToolEvent::Move { x: 20.0 + t, y: 30.0 + (t / 3.0).sin() * 4.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
}
|
||||
assert!(app.live_stroke.is_some(), "{tool:?}: the canvas shows the live stroke");
|
||||
assert_eq!(drag_preview_shapes(&mut app, &ctx, 12.0).len(), idle, "{tool:?}: no overlay while dragging");
|
||||
tool_event(&mut app, ToolEvent::Up { x: 59.0, y: 30.0 }, egui::Modifiers::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_drags_show_the_footprint_without_wedges() {
|
||||
// The retouching tools' trail was the same brush-wide polyline (#189): now a mask of the
|
||||
// footprint, with nothing outside the brush radius of the path, however small the steps.
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 120, "height": 60})).unwrap();
|
||||
app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap();
|
||||
app.ui.tool = Tool::Dodge;
|
||||
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
let mut pts = vec![[20.0, 30.0]];
|
||||
for i in 1..80 {
|
||||
let t = f64::from(i) * 0.5;
|
||||
let p = [20.0 + t, 30.0 + (t / 2.0).sin() * 3.0];
|
||||
tool_event(&mut app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::NONE);
|
||||
pts.push(p);
|
||||
if i % 20 == 0 {
|
||||
// One mesh for the trail, however long the drag.
|
||||
let shapes = drag_preview_shapes(&mut app, &ctx, 12.0);
|
||||
assert!(!shapes.iter().any(|s| matches!(s.shape, egui::Shape::Path(_))), "no polyline");
|
||||
}
|
||||
}
|
||||
drag_preview_shapes(&mut app, &ctx, 12.0);
|
||||
let trail = app.trail.as_ref().unwrap();
|
||||
assert_eq!(trail.scale(), 1.0);
|
||||
for y in 0..60 {
|
||||
for x in 0..120 {
|
||||
let d = pts.iter().map(|p| (x as f64 + 0.5 - p[0]).hypot(y as f64 + 0.5 - p[1])).fold(f64::MAX, f64::min);
|
||||
if d < 14.0 {
|
||||
assert_eq!(trail.coverage(x, y), 255, "({x}, {y}) inside the footprint");
|
||||
} else if d > 16.0 {
|
||||
assert_eq!(trail.coverage(x, y), 0, "({x}, {y}) outside the footprint");
|
||||
}
|
||||
}
|
||||
}
|
||||
tool_event(&mut app, ToolEvent::Up { x: 59.5, y: 30.0 }, egui::Modifiers::NONE);
|
||||
drag_preview_shapes(&mut app, &ctx, 12.0);
|
||||
assert!(app.trail.is_none(), "the trail ends with the drag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_style_dialog_previews_live_and_cancel_restores() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -17,6 +17,8 @@ enum Row {
|
||||
Color(usize),
|
||||
Alpha(usize),
|
||||
QuickMask,
|
||||
/// The selected layer's mask: a temporary channel, listed while the layer is selected.
|
||||
LayerMask,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
@@ -27,6 +29,7 @@ impl Row {
|
||||
Row::Color(k) => json!({ "color": k }),
|
||||
Row::Alpha(i) => json!(i),
|
||||
Row::QuickMask => json!("quickMask"),
|
||||
Row::LayerMask => json!("mask"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,25 +53,38 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
};
|
||||
let doc = st.doc.clone();
|
||||
let view = st.channel_view.clone();
|
||||
let active_layer = st.active_layer;
|
||||
let mode = doc.pixel_format().mode;
|
||||
let colors = mode.color_channels();
|
||||
let quick = doc.quick_mask.is_some();
|
||||
let ctx = ui.ctx().clone();
|
||||
let thumbs = app.channel_thumbs(&ctx);
|
||||
// The active layer's mask (Photoshop lists it, in italics, below the colour channels).
|
||||
let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()).cloned();
|
||||
let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?)));
|
||||
let mask_targeted = masked.is_some() && app.ui.mask_target && view.target == ChannelTarget::Composite && !quick;
|
||||
// Multichannel images are their ink channels only (no composite or colour rows).
|
||||
let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel;
|
||||
let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] };
|
||||
if colors > 1 && !multichannel {
|
||||
rows.extend((0..colors).map(Row::Color));
|
||||
}
|
||||
if masked.is_some() {
|
||||
rows.push(Row::LayerMask);
|
||||
}
|
||||
rows.extend((0..doc.channels.len()).map(Row::Alpha));
|
||||
if quick {
|
||||
rows.push(Row::QuickMask);
|
||||
}
|
||||
let shown_colors = if colors > 1 { colors } else { 0 };
|
||||
let mut actions: Vec<(String, Value)> = Vec::new();
|
||||
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
|
||||
let footer = 34.0;
|
||||
egui::ScrollArea::vertical().max_height((ui.available_height() - footer).max(60.0)).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let fill = ui.available_height() > footer + 60.0;
|
||||
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
|
||||
let mut mask_click = None;
|
||||
let mut drawn = Vec::new();
|
||||
egui::ScrollArea::vertical().max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(ui, |ui| {
|
||||
for row in rows {
|
||||
let (name, thumb_idx, slot, visible, selected) = match row {
|
||||
Row::Composite => (
|
||||
@@ -76,14 +92,14 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
0,
|
||||
Some(2),
|
||||
view.visible_colors(colors) == colors,
|
||||
view.target == ChannelTarget::Composite && !quick,
|
||||
view.target == ChannelTarget::Composite && !quick && !mask_targeted,
|
||||
),
|
||||
Row::Color(k) => (
|
||||
channel_cmds::color_names(mode)[k].to_string(),
|
||||
1 + k,
|
||||
Some(3 + k),
|
||||
view.color_visible(k),
|
||||
(view.target == ChannelTarget::Composite && !quick) || view.target == ChannelTarget::Color(k),
|
||||
(view.target == ChannelTarget::Composite && !quick && !mask_targeted) || view.target == ChannelTarget::Color(k),
|
||||
),
|
||||
Row::Alpha(i) => (
|
||||
doc.channels[i].name.clone(),
|
||||
@@ -95,6 +111,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
Row::QuickMask => {
|
||||
("Quick Mask".to_string(), 1 + shown_colors + doc.channels.len(), None, !view.quick_mask_hidden, view.target == ChannelTarget::Composite)
|
||||
}
|
||||
Row::LayerMask => (masked.as_ref().map(|l| format!("{} Mask", l.name)).unwrap_or_default(), usize::MAX, None, false, mask_targeted),
|
||||
};
|
||||
let row_h = if t.pro { 36.0 } else { 40.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
|
||||
@@ -110,15 +127,24 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 11.0), vec2(22.0, 22.0));
|
||||
let eye_resp = ui.interact(eye, ui.id().with(("chan-eye", format!("{:?}", row.reference()))), Sense::click());
|
||||
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
|
||||
if eye_resp.clicked() {
|
||||
// The layer mask's eye slot stays empty: its overlay view isn't supported yet.
|
||||
if row != Row::LayerMask {
|
||||
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
|
||||
}
|
||||
if eye_resp.clicked() && row != Row::LayerMask {
|
||||
actions.push(("channel.setVisible".into(), json!({ "channel": row.reference(), "visible": !visible })));
|
||||
}
|
||||
let ts = if t.pro { 28.0 } else { 30.0 };
|
||||
let thumb = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||||
if let Some(tex) = thumbs.get(thumb_idx) {
|
||||
painter.image(*tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let cell = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - ts / 2.0), vec2(ts, ts));
|
||||
// Thumbnails keep the document's aspect ratio (the textures are letterboxed squares).
|
||||
let (thumb, uv) = fit_thumb(cell, doc.size.width, doc.size.height);
|
||||
let tex = if row == Row::LayerMask { mask_tex } else { thumbs.get(thumb_idx).copied() };
|
||||
if let Some(tex) = tex {
|
||||
painter.image(tex, thumb, uv, Color32::WHITE);
|
||||
}
|
||||
drawn.push((name.clone(), thumb));
|
||||
// Rows are painted: name them for screen readers and UI tests.
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name));
|
||||
// Spot channels: a swatch of the ink on the thumbnail corner.
|
||||
if let Row::Alpha(i) = row
|
||||
&& let Some((ink, _)) = doc.channels[i].spot
|
||||
@@ -127,12 +153,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
painter.rect_filled(Rect::from_min_size(thumb.right_bottom() - vec2(9.0, 9.0), vec2(9.0, 9.0)), 0.0, Color32::from_rgb(c[0], c[1], c[2]));
|
||||
}
|
||||
painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }), StrokeKind::Outside);
|
||||
let italic = row == Row::QuickMask || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
|
||||
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
|
||||
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
|
||||
let galley = painter.layout_job(job);
|
||||
let text_pos = pos2(thumb.right() + 10.0, rect.center().y - galley.size().y / 2.0);
|
||||
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
|
||||
painter.galley(text_pos, galley, t.text);
|
||||
if let Some(slot) = slot {
|
||||
painter.text(
|
||||
@@ -146,9 +172,19 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let rename_id = egui::Id::new(("chan-rename", doc.id.0, format!("{:?}", row.reference())));
|
||||
if resp.clicked() && !eye_resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if m.command {
|
||||
if m.command && row == Row::LayerMask {
|
||||
let layer = masked.as_ref().map(|l| l.id.0);
|
||||
actions.push(("select.loadSelection".into(), json!({ "channel": "mask", "layer": layer, "operation": load_operation(m) })));
|
||||
} else if m.command {
|
||||
actions.push(("select.loadSelection".into(), json!({ "channel": row.reference(), "operation": load_operation(m) })));
|
||||
} else if row == Row::LayerMask {
|
||||
// Targets the mask, as clicking its thumbnail in the Layers panel does.
|
||||
mask_click = Some(true);
|
||||
actions.push(("channel.target".into(), json!({ "channel": "composite" })));
|
||||
} else {
|
||||
if matches!(row, Row::Composite | Row::Color(_)) {
|
||||
mask_click = Some(false);
|
||||
}
|
||||
let target = if row == Row::QuickMask { json!("composite") } else { row.reference() };
|
||||
actions.push(("channel.target".into(), json!({ "channel": target })));
|
||||
}
|
||||
@@ -200,6 +236,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
Row::Composite | Row::Color(_) => {
|
||||
item(ui, a, "Duplicate Channel", "channel.duplicate", json!({ "channel": row.reference() }));
|
||||
}
|
||||
Row::LayerMask => {
|
||||
let enabled = masked.as_ref().and_then(|l| l.mask.as_ref()).is_none_or(|m| m.enabled);
|
||||
item(ui, a, if enabled { "Disable Layer Mask" } else { "Enable Layer Mask" }, "layer.layerMask.enabled", json!({}));
|
||||
item(ui, a, "Delete Layer Mask", "layer.layerMask.delete", json!({}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
item(ui, a, "New Channel…", "channel.new", json!({}));
|
||||
@@ -235,6 +276,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn));
|
||||
if let Some(on) = mask_click {
|
||||
app.ui.mask_target = on;
|
||||
}
|
||||
for (id, p) in actions {
|
||||
if id == "ui.renameChannel" {
|
||||
if let Some(i) = p.as_u64().and_then(|i| doc.channels.get(i as usize)) {
|
||||
@@ -247,6 +292,25 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn thumbs_id() -> egui::Id {
|
||||
egui::Id::new("channel-thumb-rects")
|
||||
}
|
||||
|
||||
/// The Channels rows drawn last frame: (name, thumbnail rect).
|
||||
pub fn recorded(ctx: &egui::Context) -> Vec<(String, Rect)> {
|
||||
ctx.data(|d| d.get_temp(thumbs_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The part of a square `cell` a `w`×`h` document fills, and the matching UVs of a square,
|
||||
/// letterboxed thumbnail texture: thumbnails keep the document's aspect ratio.
|
||||
pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) {
|
||||
let (w, h) = (w.max(1) as f32, h.max(1) as f32);
|
||||
let (fw, fh) = (w / w.max(h), h / w.max(h));
|
||||
let size = vec2(cell.width() * fw, cell.height() * fh).max(vec2(1.0, 1.0));
|
||||
let uv = Rect::from_center_size(pos2(0.5, 0.5), vec2(fw, fh));
|
||||
(Rect::from_center_size(cell.center(), size), uv)
|
||||
}
|
||||
|
||||
fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) {
|
||||
if ui.button(label).clicked() {
|
||||
actions.push((cmd.into(), p));
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Lazy CJK fallback fonts for the UI.
|
||||
//!
|
||||
//! The bundled Inter / JetBrains Mono have no Japanese, Chinese or Korean glyphs, and the OS
|
||||
//! fonts that do are large (Hiragino ~10 MB, Apple SD Gothic Neo 28 MB, PingFang 78 MB, Noto
|
||||
//! Sans CJK ~20 MB). Instead of reading them at startup, an egui plugin scans each frame's text
|
||||
//! for CJK characters no registered font covers, and registers the next system font for that
|
||||
//! character's script (`ctx.add_font`, active from the next frame, which it requests). Fonts are
|
||||
//! appended at the lowest priority to every family, so Latin text keeps Inter.
|
||||
//!
|
||||
//! Script order follows the UI locale ([`photocraft_text::cjk::script_order`]): Kana prefers a
|
||||
//! Japanese font, Hangul a Korean one, Bopomofo a Traditional Chinese one, and Han the
|
||||
//! locale's script (Japanese forms only for a Japanese locale). At most one font is read per
|
||||
//! frame, each file at most once, and once every script has been tried the scan stops.
|
||||
|
||||
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
|
||||
use egui::{FontData, FontFamily, FontId, Shape};
|
||||
use photocraft_text::cjk::{self, CjkChar, CjkScript, FontFile};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
|
||||
pub const MAX_FONT_BYTES: u64 = 128 << 20;
|
||||
/// Name prefix of the registered fallback fonts.
|
||||
pub const FONT_PREFIX: &str = "system-cjk";
|
||||
|
||||
/// Where fonts come from; swapped out in tests.
|
||||
#[derive(Clone)]
|
||||
pub struct Sources {
|
||||
pub locale: fn() -> Option<String>,
|
||||
pub files: fn(CjkScript) -> Vec<FontFile>,
|
||||
pub last_resort: fn() -> Vec<FontFile>,
|
||||
}
|
||||
|
||||
impl Sources {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn system() -> Self {
|
||||
Self { locale: || cjk::ui_locale().map(str::to_string), files: cjk::font_files, last_resort: cjk::last_resort_files }
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn system() -> Self {
|
||||
Self { locale: || None, files: |_| Vec::new(), last_resort: Vec::new }
|
||||
}
|
||||
}
|
||||
|
||||
/// The lazy loader's state: which scripts were tried and which files were read.
|
||||
pub struct CjkFallback {
|
||||
sources: Sources,
|
||||
order: Option<[CjkScript; 4]>,
|
||||
tried: Vec<CjkScript>,
|
||||
last_resort_tried: bool,
|
||||
loaded: Vec<PathBuf>,
|
||||
/// Fonts registered so far: (name, path, face index).
|
||||
pub registered: Vec<(String, PathBuf, u32)>,
|
||||
}
|
||||
|
||||
impl CjkFallback {
|
||||
pub fn new(sources: Sources) -> Self {
|
||||
Self { sources, order: None, tried: Vec::new(), last_resort_tried: false, loaded: Vec::new(), registered: Vec::new() }
|
||||
}
|
||||
|
||||
/// Script order for the UI locale (resolved on first use, not at startup).
|
||||
pub fn order(&mut self) -> [CjkScript; 4] {
|
||||
*self.order.get_or_insert_with(|| cjk::script_order((self.sources.locale)().as_deref()))
|
||||
}
|
||||
|
||||
/// Every script and the last-resort fonts have been tried: nothing more to load.
|
||||
pub fn exhausted(&self) -> bool {
|
||||
self.tried.len() >= 4 && self.last_resort_tried
|
||||
}
|
||||
|
||||
/// The next font to register for a character of kind `kind` that no current font covers:
|
||||
/// the first readable file of the first untried script (the character's preferred script,
|
||||
/// then the locale order), then the last-resort fonts. `None` when nothing is left.
|
||||
pub fn next_font(&mut self, kind: CjkChar) -> Option<(String, FontData)> {
|
||||
let order = self.order();
|
||||
let scripts: Vec<CjkScript> = std::iter::once(kind.preferred(&order)).chain(order).collect();
|
||||
for s in scripts {
|
||||
if self.tried.contains(&s) {
|
||||
continue;
|
||||
}
|
||||
self.tried.push(s);
|
||||
let files = (self.sources.files)(s);
|
||||
if let Some(f) = self.load_first(&files) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
if !self.last_resort_tried {
|
||||
self.last_resort_tried = true;
|
||||
let files = (self.sources.last_resort)();
|
||||
return self.load_first(&files);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn load_first(&mut self, files: &[FontFile]) -> Option<(String, FontData)> {
|
||||
for f in files {
|
||||
if self.loaded.contains(&f.path) {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&f.path) else { continue };
|
||||
if !meta.is_file() || meta.len() == 0 || meta.len() > MAX_FONT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&f.path) else { continue };
|
||||
let index = cjk::face_index_for_family(&bytes, f.family);
|
||||
self.loaded.push(f.path.clone());
|
||||
let name = format!("{FONT_PREFIX}-{}", self.registered.len());
|
||||
log::info!("UI font fallback: registered {} (face {index}) as {name}", f.path.display());
|
||||
self.registered.push((name.clone(), f.path.clone(), index));
|
||||
let mut data = FontData::from_owned(bytes);
|
||||
data.index = index;
|
||||
return Some((name, data));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// First CJK character in the frame's text that the UI fonts can't draw.
|
||||
fn missing_char(ctx: &egui::Context, shapes: &[egui::epaint::ClippedShape]) -> Option<char> {
|
||||
fn collect(shape: &Shape, out: &mut Vec<char>) {
|
||||
match shape {
|
||||
Shape::Text(t) => {
|
||||
let text = &t.galley.job.text;
|
||||
if !text.is_ascii() {
|
||||
for c in text.chars().filter(|c| cjk::classify(*c).is_some()) {
|
||||
if out.len() >= 256 {
|
||||
return;
|
||||
}
|
||||
if !out.contains(&c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut chars = Vec::new();
|
||||
for s in shapes {
|
||||
collect(&s.shape, &mut chars);
|
||||
}
|
||||
if chars.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let font = FontId::proportional(12.0);
|
||||
ctx.fonts_mut(|f| chars.into_iter().find(|c| !f.has_glyph(&font, *c)))
|
||||
}
|
||||
|
||||
/// Registers `name` at the lowest priority in every font family.
|
||||
fn add_to_all_families(ctx: &egui::Context, name: String, data: FontData) {
|
||||
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
|
||||
for f in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
if !families.contains(&f) {
|
||||
families.push(f);
|
||||
}
|
||||
}
|
||||
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
|
||||
ctx.add_font(FontInsert { name, data, families });
|
||||
}
|
||||
|
||||
/// The egui plugin that watches drawn text and loads fallback fonts on demand.
|
||||
pub struct CjkFontPlugin(pub CjkFallback);
|
||||
|
||||
impl egui::Plugin for CjkFontPlugin {
|
||||
fn debug_name(&self) -> &'static str {
|
||||
"photocraft-cjk-fonts"
|
||||
}
|
||||
|
||||
fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {
|
||||
if self.0.exhausted() {
|
||||
return;
|
||||
}
|
||||
let Some(c) = missing_char(ctx, &output.shapes) else { return };
|
||||
let Some(kind) = cjk::classify(c) else { return };
|
||||
if let Some((name, data)) = self.0.next_font(kind) {
|
||||
add_to_all_families(ctx, name, data);
|
||||
ctx.request_repaint();
|
||||
} else if !self.0.exhausted() {
|
||||
// Nothing readable for this script; try the next one on the next frame.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the lazy CJK fallback (system fonts; nothing on the web).
|
||||
pub fn install(ctx: &egui::Context) {
|
||||
install_with(ctx, Sources::system());
|
||||
}
|
||||
|
||||
pub fn install_with(ctx: &egui::Context, sources: Sources) {
|
||||
ctx.add_plugin(CjkFontPlugin(CjkFallback::new(sources)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
|
||||
fn fake_dir() -> PathBuf {
|
||||
let mut g = DIR.lock().unwrap();
|
||||
g.get_or_insert_with(|| {
|
||||
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("font.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
|
||||
std::fs::write(dir.join("empty.ttf"), b"").unwrap();
|
||||
dir
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn fake(name: &str) -> FontFile {
|
||||
FontFile { path: fake_dir().join(name), family: "" }
|
||||
}
|
||||
|
||||
fn fake_sources(locale: fn() -> Option<String>) -> Sources {
|
||||
Sources {
|
||||
locale,
|
||||
// Korean has a readable "font", Japanese only broken files, Chinese none.
|
||||
files: |s| match s {
|
||||
CjkScript::Korean => vec![fake("missing.ttc"), fake("empty.ttf"), fake("font.ttf")],
|
||||
CjkScript::Japanese => vec![fake("empty.ttf")],
|
||||
_ => vec![],
|
||||
},
|
||||
last_resort: || vec![fake("font.ttf")],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_preferred_script_skips_unreadable_and_exhausts() {
|
||||
let mut fb = CjkFallback::new(fake_sources(|| Some("en_US".into())));
|
||||
assert!(!fb.exhausted());
|
||||
// Hangul: Korean first; missing and empty files are skipped.
|
||||
let (name, data) = fb.next_font(CjkChar::Hangul).expect("korean font");
|
||||
assert!(name.starts_with(FONT_PREFIX));
|
||||
assert_eq!(data.index, 0);
|
||||
assert_eq!(fb.tried, vec![CjkScript::Korean]);
|
||||
// Han on an English locale: SC, TC, JA have nothing readable, and the last-resort file was
|
||||
// already loaded, so nothing new; everything is now tried.
|
||||
assert!(fb.next_font(CjkChar::Han).is_none());
|
||||
assert!(fb.exhausted());
|
||||
assert_eq!(fb.tried, vec![CjkScript::Korean, CjkScript::SimplifiedChinese, CjkScript::TraditionalChinese, CjkScript::Japanese]);
|
||||
assert_eq!(fb.registered.len(), 1, "a file is read at most once");
|
||||
assert!(fb.next_font(CjkChar::Kana).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_follows_locale() {
|
||||
for (loc, first) in [
|
||||
("ja_JP.UTF-8", CjkScript::Japanese),
|
||||
("zh-Hans-CN", CjkScript::SimplifiedChinese),
|
||||
("zh-Hant-TW", CjkScript::TraditionalChinese),
|
||||
("ko_KR", CjkScript::Korean),
|
||||
] {
|
||||
let mut fb = CjkFallback::new(Sources { locale: || None, files: |_| vec![], last_resort: Vec::new });
|
||||
fb.order = Some(cjk::script_order(Some(loc)));
|
||||
assert!(fb.next_font(CjkChar::Han).is_none());
|
||||
assert_eq!(fb.tried[0], first, "{loc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs frames showing `text` until fonts settle; returns whether every glyph is covered.
|
||||
fn render(ctx: &egui::Context, text: &str) -> bool {
|
||||
for _ in 0..12 {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
ui.label(text);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(12.0), &text.replace(' ', "")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_registration_triggers_only_for_cjk() {
|
||||
let ctx = egui::Context::default();
|
||||
install_with(&ctx, fake_sources(|| Some("ko".into())));
|
||||
let _ = render(&ctx, "Layer 1 – café");
|
||||
let n = ctx.fonts(|f| f.definitions().font_data.keys().filter(|k| k.starts_with(FONT_PREFIX)).count());
|
||||
assert_eq!(n, 0, "Latin text loads nothing");
|
||||
// Hangul isn't in the stand-in font, but the Korean candidate must have been registered.
|
||||
let _ = render(&ctx, "카드 배경");
|
||||
let fonts = ctx.fonts(|f| f.definitions().clone());
|
||||
let name = format!("{FONT_PREFIX}-0");
|
||||
assert!(fonts.font_data.contains_key(&name));
|
||||
for (fam, stack) in &fonts.families {
|
||||
assert_eq!(stack.last(), Some(&name), "{fam:?}: appended last so Latin keeps Inter");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the real system fonts, every CJK sample renders (skipped per script when the
|
||||
/// machine has no font for it, e.g. CI Linux without Noto CJK).
|
||||
#[test]
|
||||
fn system_fonts_cover_cjk_samples() {
|
||||
let available = |s: CjkScript| cjk::font_files(s).iter().any(|f| f.path.is_file());
|
||||
let samples = [
|
||||
("图层", CjkScript::SimplifiedChinese),
|
||||
("圖層", CjkScript::TraditionalChinese),
|
||||
("카드 배경", CjkScript::Korean),
|
||||
("レイヤー", CjkScript::Japanese),
|
||||
];
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
for (text, script) in samples {
|
||||
if !available(script) {
|
||||
eprintln!("skipping {text}: no {script:?} system font");
|
||||
continue;
|
||||
}
|
||||
assert!(render(&ctx, text), "{text} still has missing glyphs");
|
||||
}
|
||||
// All at once in a fresh context too (several scripts in one frame).
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let all: String = samples.iter().filter(|(_, s)| available(*s)).map(|(t, _)| *t).collect::<Vec<_>>().join(" ");
|
||||
assert!(render(&ctx, &all), "{all}");
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,8 @@ pub struct Preview {
|
||||
/// Hash of the params the mask texture shows.
|
||||
mask_key: u64,
|
||||
mask: Option<egui::TextureHandle>,
|
||||
/// Why the preview couldn't be drawn (shown in the dialog and logged, never silent: #145).
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn hash(text: &str) -> u64 {
|
||||
@@ -170,13 +172,13 @@ fn hash(text: &str) -> u64 {
|
||||
|
||||
/// The selection mask `params` would make, on the proxy: the engine command run on a scratch
|
||||
/// session (Out Of Gamut uses the document's own proof setup).
|
||||
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
|
||||
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result<Vec<f32>, String> {
|
||||
let area = proxy.bounds();
|
||||
if params.get("select").and_then(Value::as_str) == Some("outOfGamut") {
|
||||
let pv = app.session.color.proof(proxy.id);
|
||||
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
|
||||
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?;
|
||||
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
|
||||
return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
|
||||
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
|
||||
}
|
||||
let mut p = params.clone();
|
||||
// Eyedropper points are in document pixels; the proxy is 1/k the size.
|
||||
@@ -203,9 +205,20 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
|
||||
if let Some(id) = app.session.active().and_then(|d| d.active_layer) {
|
||||
let _ = s.select_layer(id);
|
||||
}
|
||||
s.execute(COMMAND, p).ok()?;
|
||||
let d = s.active()?;
|
||||
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
|
||||
s.execute(COMMAND, p).map_err(|e| e.to_string())?;
|
||||
let d = s.active().ok_or("no preview document")?;
|
||||
Ok(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
|
||||
}
|
||||
|
||||
/// Record why the preview failed (logged once per new reason), or clear it.
|
||||
fn report(app: &mut PhotocraftApp, error: Option<String>) {
|
||||
let Some(p) = app.color_range.as_mut() else { return };
|
||||
if p.error != error {
|
||||
if let Some(e) = &error {
|
||||
log::warn!("Color Range preview: {e}");
|
||||
}
|
||||
p.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
|
||||
@@ -219,7 +232,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
let side = doc.size.width.max(doc.size.height).max(1);
|
||||
let k = side.div_ceil(PREVIEW).max(1);
|
||||
let proxy = Arc::new(crate::proxy::proxy_document(&doc, k));
|
||||
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
|
||||
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None, error: None });
|
||||
}
|
||||
let image_view = s(f, "__view", "selection") == "image";
|
||||
let params = params(f);
|
||||
@@ -234,6 +247,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height));
|
||||
let size = [thumb.width as usize, thumb.height as usize];
|
||||
if size != [w, h] || thumb.pixels.len() != w * h * 4 {
|
||||
report(app, Some(format!("the image thumbnail is {}×{}, expected {w}×{h}", thumb.width, thumb.height)));
|
||||
return None;
|
||||
}
|
||||
let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels);
|
||||
@@ -245,8 +259,18 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
|
||||
return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k));
|
||||
}
|
||||
if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) {
|
||||
let mask = proxy_mask(app, &proxy, k, ¶ms).unwrap_or_else(|| vec![0.0; w * h]);
|
||||
let mask = match proxy_mask(app, &proxy, k, ¶ms) {
|
||||
Ok(m) => {
|
||||
report(app, None);
|
||||
m
|
||||
}
|
||||
Err(e) => {
|
||||
report(app, Some(e));
|
||||
vec![0.0; w * h]
|
||||
}
|
||||
};
|
||||
if mask.len() != w * h {
|
||||
report(app, Some(format!("the selection preview has {} pixels, expected {}", mask.len(), w * h)));
|
||||
return None;
|
||||
}
|
||||
let px: Vec<Color32> = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect();
|
||||
@@ -368,8 +392,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
pick(app, f, at, ui.input(|i| i.modifiers));
|
||||
}
|
||||
}
|
||||
// A preview that can't be drawn says why (#145) instead of staying blank.
|
||||
if let Some(e) = app.color_range.as_ref().and_then(|p| p.error.clone()) {
|
||||
ui.painter().rect_filled(frame, 0.0, t.canvas);
|
||||
let msg = format!(
|
||||
"Preview unavailable:
|
||||
{e}"
|
||||
);
|
||||
let g = ui.painter().layout(msg, egui::FontId::proportional(11.0), t.text_faint, frame.width() - 16.0);
|
||||
ui.painter().galley(frame.center() - g.size() / 2.0, g, t.text_faint);
|
||||
}
|
||||
ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
|
||||
let label = match app.color_range.as_ref().and_then(|p| p.error.as_deref()) {
|
||||
Some(e) => format!("Color Range preview unavailable: {e}"),
|
||||
None => "Color Range preview".to_string(),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
|
||||
ui.add_space(10.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.add_enabled_ui(c.sampling, |ui| {
|
||||
@@ -704,4 +742,51 @@ mod tests {
|
||||
app.run(COMMAND, params(&f)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #145: the whole app without a GPU (no wgpu render state: the CPU canvas, as on a Linux
|
||||
/// machine whose adapter can't run the GPU canvas), on a small window. Select › Color Range…
|
||||
/// from the menu opens the dialog on screen and draws both previews.
|
||||
#[test]
|
||||
fn opens_and_previews_in_the_full_app_without_a_gpu() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1024.0, 600.0)).build_eframe(|cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
assert!(cc.wgpu_render_state.is_none(), "this test covers the no-GPU path");
|
||||
app_with_doc()
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.state().gpu.is_none(), "the CPU canvas");
|
||||
h.get_all_by_label("Select").next().expect("the Select menu").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Color Range…").click();
|
||||
h.run_steps(6);
|
||||
let id = dialog_id(h.state());
|
||||
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
|
||||
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
|
||||
assert!(h.query_by_label("Color Range preview").is_some());
|
||||
let p = h.state().color_range.as_ref().expect("the preview is cached");
|
||||
assert!(p.mask.is_some() && p.error.is_none(), "the selection preview is drawn: {:?}", p.error);
|
||||
set(&mut h, "__view", json!("image"));
|
||||
let p = h.state().color_range.as_ref().unwrap();
|
||||
assert!(p.image.is_some() && p.error.is_none(), "the image preview is drawn: {:?}", p.error);
|
||||
set(&mut h, "select", json!("reds"));
|
||||
ok(&mut h);
|
||||
h.run_steps(3);
|
||||
assert_eq!(coverage(h.state(), 5, 5), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preview_failure_is_shown_not_silent() {
|
||||
let mut h = harness(app_with_doc());
|
||||
open(h.state_mut());
|
||||
h.run_steps(3);
|
||||
// The engine refuses these params: the preview says so instead of staying blank.
|
||||
let id = dialog_id(h.state());
|
||||
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("select".into(), json!("noSuchMode"));
|
||||
h.run_steps(3);
|
||||
let err = h.state().color_range.as_ref().and_then(|p| p.error.clone());
|
||||
assert!(err.is_some(), "the failure is recorded");
|
||||
assert!(h.query_by_label_contains("Color Range preview unavailable").is_some(), "and shown");
|
||||
set(&mut h, "select", json!("reds"));
|
||||
assert!(h.state().color_range.as_ref().unwrap().error.is_none(), "and cleared once the preview works again");
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
if let Some(m) = p.get("maskTarget").and_then(Value::as_bool) {
|
||||
app.ui.mask_target = m;
|
||||
}
|
||||
// Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect.
|
||||
if let Some(m) = p.get("selectionMode").and_then(Value::as_u64) {
|
||||
app.ui.selection_mode = m.min(3) as u8;
|
||||
}
|
||||
if let Some(tabs) = p.get("dockTabs") {
|
||||
let mut cur = serde_json::to_value(app.ui.dock_tabs).unwrap_or_default();
|
||||
if let (Some(c), Some(n)) = (cur.as_object_mut(), tabs.as_object()) {
|
||||
@@ -173,6 +177,12 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
if let Some(i) = u("brushTab") {
|
||||
app.ui.brush_tab = (i as usize).min(1);
|
||||
}
|
||||
if let Some(v) = p.get("brushesView").cloned() {
|
||||
match serde_json::from_value(v) {
|
||||
Ok(v) => app.ui.brushes_panel.view = v,
|
||||
Err(e) => return err(format!("brushesView: {e} (list, grid)")),
|
||||
}
|
||||
}
|
||||
if let Some(size) = p.get("brushSize").and_then(Value::as_f64) {
|
||||
app.session.tools.brush.size = size as f32;
|
||||
}
|
||||
|
||||
+106
-10
@@ -29,6 +29,8 @@ pub enum Group {
|
||||
Color,
|
||||
/// Properties | Adjustments.
|
||||
Properties,
|
||||
/// Character | Paragraph (not in the default workspace; Window › Character opens it, #150).
|
||||
Character,
|
||||
/// Navigator | Histogram | Info.
|
||||
Navigator,
|
||||
/// History | Actions | Layer Comps.
|
||||
@@ -39,29 +41,54 @@ pub enum Group {
|
||||
|
||||
impl Group {
|
||||
/// Photoshop Essentials order, top to bottom.
|
||||
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
|
||||
pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers];
|
||||
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Group::Color => "color",
|
||||
Group::Properties => "properties",
|
||||
Group::Character => "character",
|
||||
Group::Navigator => "navigator",
|
||||
Group::History => "history",
|
||||
Group::Layers => "layers",
|
||||
}
|
||||
}
|
||||
|
||||
/// Height (points, tab strip included) a group gets until the user resizes it.
|
||||
/// Height (points, tab strip included) a group gets until the user resizes it, when the
|
||||
/// column has room (see [`DockLayout::heights_for`]).
|
||||
pub fn default_height(self) -> f32 {
|
||||
match self {
|
||||
Group::Color => 200.0,
|
||||
Group::Properties => 340.0,
|
||||
Group::Navigator => 230.0,
|
||||
Group::History => 220.0,
|
||||
Group::Color => 190.0,
|
||||
Group::Properties => 250.0,
|
||||
Group::Character => 270.0,
|
||||
Group::Navigator => 210.0,
|
||||
Group::History => 200.0,
|
||||
Group::Layers => 320.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a group left at its default height gives way down to so the filler (Layers) keeps
|
||||
/// [`Group::preferred_fill`] in a short column (#147). Content taller than this scrolls.
|
||||
pub fn compact_height(self) -> f32 {
|
||||
match self {
|
||||
Group::Color => 130.0,
|
||||
Group::Properties => 160.0,
|
||||
Group::Character => 160.0,
|
||||
Group::Navigator => 140.0,
|
||||
Group::History => 130.0,
|
||||
Group::Layers => 200.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The height the filling group asks for before default-sized groups above it get their
|
||||
/// full defaults: Layers wants room for about ten rows at 900 pt (#147).
|
||||
pub fn preferred_fill(self) -> f32 {
|
||||
match self {
|
||||
Group::Layers => 500.0,
|
||||
g => g.min_height(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest height an expanded group can be dragged or squeezed to.
|
||||
pub fn min_height(self) -> f32 {
|
||||
match self {
|
||||
@@ -76,6 +103,7 @@ impl Group {
|
||||
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
|
||||
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
|
||||
Group::Properties => &["Properties", "Adjustments"],
|
||||
Group::Character => &["Character", "Paragraph"],
|
||||
Group::Navigator => &["Navigator", "Histogram", "Info"],
|
||||
Group::History => &["History", "Actions", "Layer Comps"],
|
||||
Group::Layers => &["Layers", "Channels", "Paths"],
|
||||
@@ -91,6 +119,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => &mut tabs.color,
|
||||
Group::Properties => &mut tabs.properties,
|
||||
Group::Character => &mut tabs.character,
|
||||
Group::Navigator => &mut tabs.navigator,
|
||||
Group::History => &mut tabs.history,
|
||||
Group::Layers => &mut tabs.layers,
|
||||
@@ -106,6 +135,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => panels.color,
|
||||
Group::Properties => panels.properties,
|
||||
Group::Character => panels.character,
|
||||
Group::Navigator => panels.navigator,
|
||||
Group::History => panels.history,
|
||||
Group::Layers => panels.layers,
|
||||
@@ -116,6 +146,7 @@ impl Group {
|
||||
match self {
|
||||
Group::Color => &mut panels.color,
|
||||
Group::Properties => &mut panels.properties,
|
||||
Group::Character => &mut panels.character,
|
||||
Group::Navigator => &mut panels.navigator,
|
||||
Group::History => &mut panels.history,
|
||||
Group::Layers => &mut panels.layers,
|
||||
@@ -127,7 +158,8 @@ impl Group {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DockLayout {
|
||||
/// Top-to-bottom order; groups missing here keep their default place after the listed ones.
|
||||
/// Top-to-bottom order; a group missing here (say one added after the layout was saved)
|
||||
/// goes just below the nearest group that precedes it by default (or last).
|
||||
pub order: Vec<Group>,
|
||||
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
|
||||
pub heights: BTreeMap<Group, f32>,
|
||||
@@ -144,11 +176,23 @@ impl DockLayout {
|
||||
/// Every group once, in display order.
|
||||
pub fn order(&self) -> Vec<Group> {
|
||||
let mut out: Vec<Group> = Vec::with_capacity(Group::ALL.len());
|
||||
for g in self.order.iter().chain(Group::ALL.iter()) {
|
||||
for g in &self.order {
|
||||
if !out.contains(g) {
|
||||
out.push(*g);
|
||||
}
|
||||
}
|
||||
// Missing groups slot in below their default predecessor, so a group new to an old
|
||||
// saved layout (Character) never lands below Layers and takes over as the filler.
|
||||
for (i, g) in Group::ALL.iter().enumerate() {
|
||||
if out.contains(g) {
|
||||
continue;
|
||||
}
|
||||
let prev = Group::ALL.iter().take(i).rev().find_map(|p| out.iter().position(|x| x == p));
|
||||
match prev {
|
||||
Some(at) => out.insert(at + 1, *g),
|
||||
None => out.push(*g),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -185,7 +229,9 @@ impl DockLayout {
|
||||
|
||||
/// Lay out the `shown` groups (in display order) in a column `avail` points tall with
|
||||
/// `strip`-high tab strips. Returns each group's height. The last expanded group fills the
|
||||
/// rest; when the column is too short the others give way down to their minimum heights.
|
||||
/// rest. Groups the user never resized give way first, down to their compact heights, so
|
||||
/// the filler gets its preferred height (Layers: ~10 rows, #147); when the column is still
|
||||
/// too short every group gives way down to its minimum height.
|
||||
pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> {
|
||||
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
|
||||
let filler = shown.iter().rposition(|g| !self.is_collapsed(*g));
|
||||
@@ -205,6 +251,23 @@ impl DockLayout {
|
||||
if let Some(f) = filler {
|
||||
let gaps = GAP * shown.len().saturating_sub(1) as f32;
|
||||
let min_fill = shown.get(f).map_or(0.0, |g| g.min_height());
|
||||
let pref_fill = shown.get(f).map_or(0.0, |g| g.preferred_fill());
|
||||
let used: f32 = hs.iter().sum::<f32>() + gaps;
|
||||
let mut deficit = (used + pref_fill - avail).max(0.0);
|
||||
for i in (0..f).rev() {
|
||||
if deficit <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let Some(g) = shown.get(i) else { continue };
|
||||
if self.is_collapsed(*g) || self.heights.contains_key(g) {
|
||||
continue;
|
||||
}
|
||||
if let Some(h) = hs.get_mut(i) {
|
||||
let give = (*h - g.compact_height()).max(0.0).min(deficit);
|
||||
*h -= give;
|
||||
deficit -= give;
|
||||
}
|
||||
}
|
||||
let used: f32 = hs.iter().sum::<f32>() + gaps;
|
||||
let mut deficit = (used + min_fill - avail).max(0.0);
|
||||
// Squeeze the expanded groups nearest the filler first.
|
||||
@@ -269,6 +332,27 @@ fn rects_id() -> egui::Id {
|
||||
egui::Id::new("dock-group-rects")
|
||||
}
|
||||
|
||||
/// A group's tab strip as drawn last frame (screen points), for tests and automation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StripRects {
|
||||
pub group: Group,
|
||||
/// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu).
|
||||
pub tabs: Vec<(usize, Rect)>,
|
||||
/// The panel menu button.
|
||||
pub menu: Rect,
|
||||
/// The » overflow button, when some tabs didn't fit.
|
||||
pub chevron: Option<Rect>,
|
||||
}
|
||||
|
||||
/// The tab strips drawn last frame.
|
||||
pub fn last_strips(ctx: &egui::Context) -> Vec<StripRects> {
|
||||
ctx.data(|d| d.get_temp::<Vec<StripRects>>(strips_id())).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn strips_id() -> egui::Id {
|
||||
egui::Id::new("dock-strip-rects")
|
||||
}
|
||||
|
||||
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
|
||||
/// group's tab content.
|
||||
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) {
|
||||
@@ -281,6 +365,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
let rects = rects_after_layout(&heights, area);
|
||||
let mut actions: Vec<Action> = Vec::new();
|
||||
let mut dragging: Option<Group> = None;
|
||||
let mut strips: Vec<StripRects> = Vec::with_capacity(rects.len());
|
||||
for (i, (g, rect)) in rects.iter().copied().enumerate() {
|
||||
let collapsed = app.ui.dock.is_collapsed(g);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
|
||||
@@ -302,6 +387,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
.show(ui, |ui| body(app, ui, g, tab));
|
||||
}
|
||||
});
|
||||
strips.push(StripRects { group: g, tabs: resp.tabs.clone(), menu: resp.menu.rect, chevron: resp.chevron });
|
||||
// The body may switch tabs itself (Adjustments jumps back to Properties).
|
||||
if sel != before {
|
||||
*g.tab_mut(&mut app.ui.dock_tabs) = sel.min(tabs.len().saturating_sub(1));
|
||||
@@ -368,7 +454,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
|
||||
};
|
||||
ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent));
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(rects_id(), rects));
|
||||
ui.ctx().data_mut(|d| {
|
||||
d.insert_temp(rects_id(), rects);
|
||||
d.insert_temp(strips_id(), strips);
|
||||
});
|
||||
ui.advance_cursor_after_rect(area);
|
||||
for a in actions {
|
||||
match a {
|
||||
@@ -422,6 +511,13 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32)
|
||||
let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g));
|
||||
let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return };
|
||||
let Some(&(n, nh)) = heights.get(j) else { return };
|
||||
// The first drag pins the other groups at the heights they show, so groups still at their
|
||||
// defaults (which give way to the filler) don't shift while this one is resized.
|
||||
for (k, (o, oh)) in heights.iter().enumerate() {
|
||||
if Some(k) != filler && !layout.is_collapsed(*o) {
|
||||
layout.heights.entry(*o).or_insert(*oh);
|
||||
}
|
||||
}
|
||||
let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height()));
|
||||
layout.heights.insert(g, new_h);
|
||||
if Some(j) != filler {
|
||||
|
||||
@@ -12,15 +12,15 @@ const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers];
|
||||
#[test]
|
||||
fn last_expanded_group_fills_the_column() {
|
||||
let l = DockLayout::default();
|
||||
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
|
||||
assert_eq!(hs[0], (Group::Color, Group::Color.default_height()));
|
||||
assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height()));
|
||||
let total: f32 = hs.iter().map(|(_, h)| h).sum::<f32>() + 2.0 * GAP;
|
||||
assert!((total - 800.0).abs() < 1e-3, "{hs:?}");
|
||||
assert!((total - 1200.0).abs() < 1e-3, "{hs:?}");
|
||||
// Collapsed groups shrink to the tab strip; the one above Layers keeps its height.
|
||||
let mut l = DockLayout::default();
|
||||
l.set_collapsed(Group::Color, true);
|
||||
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 1000.0, 28.0);
|
||||
assert_eq!(hs[0].1, 28.0);
|
||||
assert_eq!(hs[1].1, Group::Properties.default_height());
|
||||
// Layers collapsed: Properties becomes the filler.
|
||||
@@ -33,10 +33,10 @@ fn last_expanded_group_fills_the_column() {
|
||||
#[test]
|
||||
fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
|
||||
let l = DockLayout::default();
|
||||
let hs = l.heights_for(&ESSENTIALS, 500.0, 28.0);
|
||||
let hs = l.heights_for(&ESSENTIALS, 400.0, 28.0);
|
||||
assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}");
|
||||
assert_eq!(hs[0].1, Group::Color.default_height(), "the group farthest from Layers gives way last");
|
||||
assert!(hs[1].1 < Group::Properties.default_height());
|
||||
assert_eq!(hs[0].1, Group::Color.compact_height(), "the group farthest from Layers gives way last");
|
||||
assert!(hs[1].1 < Group::Properties.compact_height());
|
||||
for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] {
|
||||
for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) {
|
||||
assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}");
|
||||
@@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
|
||||
#[test]
|
||||
fn bad_stored_values_are_sanitised() {
|
||||
let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() };
|
||||
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
|
||||
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
|
||||
l.heights.insert(Group::Color, bad);
|
||||
let h = l.height(Group::Color);
|
||||
@@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() {
|
||||
}
|
||||
// Unknown fields, wrong types and old UI state all load.
|
||||
let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap();
|
||||
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
|
||||
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap();
|
||||
ui.as_object_mut().unwrap().remove("dock");
|
||||
let ui: crate::state::UiState = serde_json::from_value(ui).unwrap();
|
||||
@@ -150,11 +150,13 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
|
||||
let props = rect_of(&h, Group::Properties);
|
||||
let layers = rect_of(&h, Group::Layers);
|
||||
let split = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
|
||||
drag(&mut h, split, split - vec2(0.0, 90.0));
|
||||
let color = rect_of(&h, Group::Color);
|
||||
drag(&mut h, split, split - vec2(0.0, 60.0));
|
||||
let props2 = rect_of(&h, Group::Properties);
|
||||
let layers2 = rect_of(&h, Group::Layers);
|
||||
assert!((props2.height() - (props.height() - 90.0)).abs() < 2.0, "{props:?} -> {props2:?}");
|
||||
assert!((layers2.top() - (layers.top() - 90.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
|
||||
assert!((props2.height() - (props.height() - 60.0)).abs() < 2.0, "{props:?} -> {props2:?}");
|
||||
assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
|
||||
assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place");
|
||||
assert_eq!(layers2.bottom(), layers.bottom());
|
||||
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
|
||||
// Dragging past the minimum stops at it.
|
||||
@@ -348,3 +350,219 @@ fn clicking_around_the_ui_keeps_the_panels_put() {
|
||||
h.state_mut().ui.dialogs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pro(theme: ThemeKind) -> bool {
|
||||
matches!(theme, ThemeKind::Pro | ThemeKind::ProMedium)
|
||||
}
|
||||
|
||||
/// Full app at `size` (1× scale) on a document with `n` layers named "Row 00", "Row 01", …
|
||||
fn app_harness_rows(size: egui::Vec2, theme: ThemeKind, n: usize) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(size).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.ui.theme = theme;
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
for i in 0..n {
|
||||
app.run("layer.new.layer", json!({"name": format!("Row {i:02}")})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// Layer rows fully inside the Layers rows viewport.
|
||||
fn rows_in_view(h: &Harness<'static, PhotocraftApp>, n: usize) -> usize {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let layers = rect_of(h, Group::Layers);
|
||||
(0..n)
|
||||
.filter(|i| {
|
||||
h.query_all_by_label(&format!("Row {i:02}"))
|
||||
.map(|q| q.rect())
|
||||
.any(|r| r.left() >= layers.left() - 1.0 && r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// #147: the default workspace gives Layers the column's spare height: at least ten rows on a
|
||||
/// 900 pt window, and a usable list on a 720 pt one.
|
||||
#[test]
|
||||
fn default_layout_shows_ten_layer_rows_at_900pt() {
|
||||
for (size, want) in [(vec2(1440.0, 900.0), 10), (vec2(1280.0, 720.0), 4)] {
|
||||
let h = app_harness_rows(size, ThemeKind::ProMedium, 40);
|
||||
let rows = rows_in_view(&h, 40);
|
||||
let groups = last_rects(&h.ctx);
|
||||
assert!(rows >= want, "{size:?}: {rows} rows visible, want ≥ {want}: {groups:?}");
|
||||
// Color and Properties stay open, just not at the expense of Layers.
|
||||
for g in [Group::Color, Group::Properties] {
|
||||
assert!(rect_of(&h, g).height() >= g.compact_height() - 0.5, "{g:?} at {size:?}: {groups:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_give_way_to_layers_but_user_sizes_stay() {
|
||||
let l = DockLayout::default();
|
||||
// A tall column: everyone at their defaults, Layers takes the rest.
|
||||
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
|
||||
assert_eq!(hs[0].1, Group::Color.default_height());
|
||||
assert_eq!(hs[1].1, Group::Properties.default_height());
|
||||
// A short one: default-sized groups shrink toward their compact heights so Layers keeps
|
||||
// its preferred height, the one nearest Layers first.
|
||||
let hs = l.heights_for(&ESSENTIALS, 820.0, 28.0);
|
||||
assert!(hs[2].1 >= Group::Layers.preferred_fill() - 0.5, "{hs:?}");
|
||||
assert!(hs[1].1 < Group::Properties.default_height() && hs[1].1 >= Group::Properties.compact_height(), "{hs:?}");
|
||||
// Heights the user dragged to (saved in prefs) are kept as they are.
|
||||
let mut mine = DockLayout::default();
|
||||
mine.heights.insert(Group::Properties, 340.0);
|
||||
mine.heights.insert(Group::Color, 200.0);
|
||||
let hs = mine.heights_for(&ESSENTIALS, 800.0, 28.0);
|
||||
assert_eq!((hs[0].1, hs[1].1), (200.0, 340.0), "{hs:?}");
|
||||
}
|
||||
|
||||
/// #150: Window › Character opens a Character | Paragraph group next to Properties (which
|
||||
/// stays), and toggles closed again; old saved layouts place it above Layers.
|
||||
#[test]
|
||||
fn window_character_opens_its_own_group_and_keeps_properties() {
|
||||
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
|
||||
let (app, _, _) = app_with_layers();
|
||||
let mut h = harness(app, vec2(1440.0, 900.0), theme);
|
||||
let ctx = h.ctx.clone();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.character", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(app.ui.panels.character && app.ui.panels.properties && app.ui.dock_tabs.character == 0, "{theme:?}");
|
||||
let ch = rect_of(&h, Group::Character);
|
||||
assert!(ch.height() > 60.0, "{theme:?}: Character expanded: {ch:?}");
|
||||
if is_pro(theme) {
|
||||
let props = rect_of(&h, Group::Properties);
|
||||
assert!(props.height() > 60.0 && props.bottom() <= ch.top(), "{theme:?}: Properties visible above Character");
|
||||
}
|
||||
assert!(rect_of(&h, Group::Layers).top() > ch.bottom(), "{theme:?}: Layers stays the filler at the bottom");
|
||||
assert_eq!(crate::view_cmds::checked(h.state(), "window.panel.character"), Some(true));
|
||||
// Paragraph is the group's second tab; choosing it again on its tab closes the group.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
|
||||
assert!(h.state().ui.panels.character && h.state().ui.dock_tabs.character == 1);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties, "{theme:?}");
|
||||
assert!(!last_rects(&h.ctx).iter().any(|(g, _)| *g == Group::Character));
|
||||
// Type › Panels › Character Panel always shows it; Reset Workspace closes it.
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
|
||||
assert!(h.state().ui.panels.character);
|
||||
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
|
||||
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties);
|
||||
}
|
||||
// A layout saved before the group existed keeps Layers last.
|
||||
let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() };
|
||||
assert_eq!(old.order(), Group::ALL.to_vec());
|
||||
let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() };
|
||||
assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
|
||||
}
|
||||
|
||||
type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option<Rect>, Rect)>;
|
||||
|
||||
/// #151: at narrow widths and 2× scale, in every theme, tabs elide or overflow into a chevron
|
||||
/// and never run under the panel menu button.
|
||||
#[test]
|
||||
fn tab_strips_never_overlap_the_menu_button() {
|
||||
let overlap = |a: Rect, b: Rect| {
|
||||
let i = a.intersect(b);
|
||||
i.width() > 0.5 && i.height() > 0.5
|
||||
};
|
||||
for theme in ThemeKind::ALL {
|
||||
for scale in [1.0, 2.0] {
|
||||
for width in [180.0, 250.0, 290.0, 420.0] {
|
||||
for g in Group::ALL {
|
||||
let tabs = g.tabs(is_pro(theme));
|
||||
for sel in 0..tabs.len() {
|
||||
let mut h = Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).build_ui_state(
|
||||
move |ui, out: &mut StripProbe| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
let mut s = sel;
|
||||
let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, |ui, _| {
|
||||
ui.label("body");
|
||||
});
|
||||
*out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect()));
|
||||
},
|
||||
None,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.run_steps(3);
|
||||
let (shown, menu, chevron, card) = h.state().clone().unwrap();
|
||||
let what = format!("{theme:?} {scale}x {width}pt {g:?} selected {sel}");
|
||||
assert!(shown.iter().any(|(i, _)| *i == sel), "{what}: the selected tab is on the strip: {shown:?}");
|
||||
for (i, r) in &shown {
|
||||
assert!(!overlap(*r, menu), "{what}: tab {i} {r:?} runs under the menu {menu:?}");
|
||||
assert!(r.left() >= card.left() - 0.5 && r.right() <= card.right() + 0.5, "{what}: tab {i} outside the card");
|
||||
}
|
||||
for w in shown.windows(2) {
|
||||
assert!(!overlap(w[0].1, w[1].1), "{what}: tabs overlap");
|
||||
}
|
||||
if let Some(c) = chevron {
|
||||
assert!(!overlap(c, menu) && shown.iter().all(|(_, r)| !overlap(*r, c)), "{what}: chevron overlaps");
|
||||
} else {
|
||||
assert_eq!(shown.len(), tabs.len(), "{what}: a tab went missing without a chevron");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #151 in the real dock: every strip at 2× scale fits beside its menu button, and a tab in the
|
||||
/// chevron menu can still be chosen.
|
||||
#[test]
|
||||
fn dock_strips_fit_and_the_chevron_menu_switches_tabs() {
|
||||
for theme in ThemeKind::ALL {
|
||||
let (mut app, _, _) = app_with_layers();
|
||||
app.ui.panels.history = true;
|
||||
app.ui.panels.navigator = true;
|
||||
app.ui.panels.character = true;
|
||||
let mut h = Harness::builder().with_size(vec2(900.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::right_dock(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.state_mut().ui.theme = theme;
|
||||
h.run_steps(4);
|
||||
let strips = last_strips(&h.ctx);
|
||||
assert!(strips.len() >= 5, "{theme:?}: {strips:?}");
|
||||
for s in &strips {
|
||||
for (i, r) in &s.tabs {
|
||||
assert!(r.intersect(s.menu).width() <= 0.5, "{theme:?} {:?}: tab {i} under the menu", s.group);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A strip too narrow for its tabs: pick a hidden tab from the chevron menu.
|
||||
let mut h = Harness::builder().with_size(vec2(150.0, 200.0)).build_ui_state(
|
||||
|ui, sel: &mut usize| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, |ui, _| {
|
||||
ui.label("body");
|
||||
});
|
||||
},
|
||||
0usize,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium);
|
||||
h.run_steps(3);
|
||||
use egui_kittest::kittest::Queryable;
|
||||
h.get_by_label("More panels").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Patterns").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu");
|
||||
}
|
||||
@@ -243,6 +243,10 @@ fn choice_label(v: &str) -> String {
|
||||
label(v)
|
||||
}
|
||||
|
||||
fn uses_logarithmic_slider(min: f32, max: f32) -> bool {
|
||||
min > 0.0 && max / min > 500.0
|
||||
}
|
||||
|
||||
/// Dialog body for filter commands.
|
||||
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -268,16 +272,20 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let logarithmic = min > 0.0 && max / min > 500.0;
|
||||
if logarithmic {
|
||||
// Scrub the value field directly; add a log-scaled slider for huge ranges.
|
||||
let mut lv = v.max(min.max(0.1)).ln();
|
||||
let r = crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
|
||||
let _ = r;
|
||||
let r2 = crate::widgets::slider(ui, &mut lv, min.max(0.1).ln()..=max.ln(), None);
|
||||
if r2.changed() {
|
||||
if uses_logarithmic_slider(min, max) {
|
||||
// Keep one numeric field for direct entry and one log-scaled slider for
|
||||
// wide ranges. slider_row would add a second, linear slider (#146).
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
|
||||
});
|
||||
});
|
||||
let mut lv = v.clamp(min, max).ln();
|
||||
if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() {
|
||||
v = lv.exp();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
} else {
|
||||
crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
|
||||
}
|
||||
@@ -421,6 +429,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_positive_ranges_use_the_logarithmic_slider_path() {
|
||||
assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius");
|
||||
assert!(uses_logarithmic_slider(1.0, 9999.0));
|
||||
assert!(!uses_logarithmic_slider(1.0, 500.0));
|
||||
assert!(!uses_logarithmic_slider(0.0, 1000.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_filters_have_dialogs_or_run_directly() {
|
||||
for id in [
|
||||
|
||||
@@ -124,6 +124,11 @@ impl GpuCanvas {
|
||||
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
|
||||
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
|
||||
/// `Rgba8Unorm`.
|
||||
/// The device's largest 2D texture side (egui textures can use it too).
|
||||
pub fn max_texture_side(&self) -> usize {
|
||||
self.rs.device.limits().max_texture_dimension_2d as usize
|
||||
}
|
||||
|
||||
pub fn format_for(&self, depth: photocraft_doc::SampleType, size: [u32; 2]) -> wgpu::TextureFormat {
|
||||
let px = size[0] as u64 * size[1] as u64;
|
||||
let high = match self.high {
|
||||
@@ -147,6 +152,37 @@ impl GpuCanvas {
|
||||
Some((d.format, bytes))
|
||||
}
|
||||
|
||||
/// Set the GPU memory the wgpu compositor may hold for layer pages and effect maps (see
|
||||
/// [`memory_budget`]); pages beyond it are evicted least recently used first.
|
||||
pub fn set_memory_budget(&self, bytes: u64) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.compositor_budget = Some(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The compositor's memory budget (`None` until [`GpuCanvas::set_memory_budget`]).
|
||||
pub fn memory_budget(&self) -> Option<u64> {
|
||||
let r = self.rs.renderer.read();
|
||||
r.callback_resources.get::<Resources>()?.compositor_budget
|
||||
}
|
||||
|
||||
/// The document area the view shows: full refreshes composite it last, so its layer pages
|
||||
/// are the ones still resident for the edits that follow.
|
||||
pub fn set_focus(&self, focus: Option<photocraft_geom::Rect>) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
|
||||
res.compositor_focus = focus;
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU bytes the wgpu compositor holds: resident layer pages and cached effect maps.
|
||||
pub fn compositor_bytes(&self) -> Option<(u64, usize)> {
|
||||
let r = self.rs.renderer.read();
|
||||
let c = r.callback_resources.get::<Resources>()?.compositor.as_ref()?;
|
||||
Some((c.resident_bytes(), c.fx_cache_bytes()))
|
||||
}
|
||||
|
||||
/// Set the checkerboard and gamut warning colours.
|
||||
pub fn set_style(&self, style: CanvasStyle) {
|
||||
let mut r = self.rs.renderer.write();
|
||||
@@ -174,10 +210,8 @@ impl GpuCanvas {
|
||||
}
|
||||
match &res.compositor {
|
||||
Some(c) => c.supports(doc).is_ok(),
|
||||
None => {
|
||||
let max = self.rs.device.limits().max_texture_dimension_2d;
|
||||
doc.size.width <= max && doc.size.height <= max && photocraft_gpu::plan(doc).is_ok()
|
||||
}
|
||||
// Layers larger than the texture limit are stored in pages, so any size qualifies.
|
||||
None => photocraft_gpu::plan(doc).is_ok(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,14 +320,26 @@ impl GpuCanvas {
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(b) = res.compositor_budget {
|
||||
comp.set_memory_budget(b);
|
||||
}
|
||||
comp.set_focus(res.compositor_focus);
|
||||
let key = doc.id.0;
|
||||
let format = self.format_for(doc.depth, size);
|
||||
let fresh = res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format);
|
||||
let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) };
|
||||
if let Err(e) = comp.supports(doc) {
|
||||
// Checked before allocating a new canvas texture only: `render` plans (and refuses) itself.
|
||||
if fresh && let Err(e) = comp.supports(doc) {
|
||||
res.compositor = Some(comp);
|
||||
return Err(e);
|
||||
}
|
||||
// A full refresh whose layer pages don't fit the memory budget would re-upload the
|
||||
// evicted ones every time (no faster than the CPU, and more memory): the banded CPU
|
||||
// compositor does it, and edits in the view (damage rects) stay on the GPU.
|
||||
if region == doc.bounds() && !comp.fits_budget(doc, region) {
|
||||
res.compositor = Some(comp);
|
||||
return Err(photocraft_gpu::Unsupported("layers exceed the GPU memory budget; full refresh on the CPU".into()));
|
||||
}
|
||||
if fresh {
|
||||
let tex = DocTextures::new(device, res, size, self.tile, format);
|
||||
res.docs.insert(key, tex);
|
||||
@@ -376,6 +422,11 @@ impl GpuCanvas {
|
||||
let damage = damage.filter(|_| self.has(key, size) && self.format_of(key) == Some(format)).map(|r| r.intersect(&bounds));
|
||||
let t0 = now_ms();
|
||||
let mut out = Refresh::default();
|
||||
if damage.is_some_and(|r| r.is_empty()) {
|
||||
// Nothing changed on the canvas (selecting a layer, say): don't even plan.
|
||||
out.kind = "rect";
|
||||
return out;
|
||||
}
|
||||
let region = damage.unwrap_or(bounds);
|
||||
let encode_srgb = display.is_some_and(|d| d.encode_srgb);
|
||||
let gpu = if key == doc.id.0 { self.composite(doc, region, encode_srgb) } else { Err(photocraft_gpu::Unsupported("preview texture".into())) };
|
||||
@@ -642,6 +693,12 @@ pub struct Perf {
|
||||
pub gpu_uploads: u64,
|
||||
/// Why the last refresh fell back to the CPU compositor (None = GPU composited).
|
||||
pub gpu_fallback: Option<String>,
|
||||
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
|
||||
/// [`memory_budget`]).
|
||||
pub gpu_budget_mb: u64,
|
||||
/// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from.
|
||||
#[serde(skip)]
|
||||
pub gpu_budget_allowance: u64,
|
||||
/// Last command run through the app and its duration (ms).
|
||||
pub last_command: String,
|
||||
pub command_ms: f64,
|
||||
@@ -804,6 +861,56 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// GPU resources
|
||||
|
||||
/// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers.
|
||||
pub const MIN_GPU_BUDGET: u64 = 512 << 20;
|
||||
|
||||
/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage
|
||||
/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History
|
||||
/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at
|
||||
/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the
|
||||
/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the
|
||||
/// rest is uploaded per refresh as the view needs it.
|
||||
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>) -> u64 {
|
||||
let ram = ram.unwrap_or(16 << 30);
|
||||
allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET)
|
||||
}
|
||||
|
||||
/// Physical memory of this machine in bytes, if known. `PHOTOCRAFT_RAM_MB` overrides it (to
|
||||
/// simulate a smaller machine).
|
||||
pub fn physical_memory() -> Option<u64> {
|
||||
static RAM: std::sync::OnceLock<Option<u64>> = std::sync::OnceLock::new();
|
||||
*RAM.get_or_init(|| {
|
||||
if let Some(mb) = std::env::var("PHOTOCRAFT_RAM_MB").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
|
||||
return mb.checked_mul(1 << 20);
|
||||
}
|
||||
detect_physical_memory()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?;
|
||||
String::from_utf8(out.stdout).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb: u64 = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
|
||||
kb.checked_mul(1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "freebsd")]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
let out = std::process::Command::new("/sbin/sysctl").args(["-n", "hw.physmem"]).output().ok()?;
|
||||
String::from_utf8(out.stdout).ok()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "freebsd")))]
|
||||
fn detect_physical_memory() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
struct Resources {
|
||||
view_bgl: wgpu::BindGroupLayout,
|
||||
tile_bgl: wgpu::BindGroupLayout,
|
||||
@@ -823,6 +930,10 @@ struct Resources {
|
||||
compositor: Option<photocraft_gpu::Compositor>,
|
||||
/// Why the wgpu compositor couldn't be created (then the CPU compositor is used).
|
||||
compositor_failed: Option<photocraft_gpu::Unsupported>,
|
||||
/// GPU memory the compositor may hold (`None`: its default), and the document area the
|
||||
/// view shows; applied before every composite.
|
||||
compositor_budget: Option<u64>,
|
||||
compositor_focus: Option<photocraft_geom::Rect>,
|
||||
encode_bgl: wgpu::BindGroupLayout,
|
||||
encode_pipeline: wgpu::RenderPipeline,
|
||||
lut_bgl: wgpu::BindGroupLayout,
|
||||
@@ -1082,6 +1193,8 @@ impl Resources {
|
||||
out_linear: target.is_srgb(),
|
||||
compositor: None,
|
||||
compositor_failed: None,
|
||||
compositor_budget: None,
|
||||
compositor_focus: None,
|
||||
encode_bgl,
|
||||
encode_pipeline,
|
||||
}
|
||||
@@ -1574,6 +1687,19 @@ fn fs(in: V) -> @location(0) vec4<f32> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gpu_budget_follows_memory_usage_and_ram() {
|
||||
const G: u64 = 1 << 30;
|
||||
// What Memory Usage leaves after the document, within a quarter of RAM.
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G);
|
||||
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G);
|
||||
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G);
|
||||
// Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown.
|
||||
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET);
|
||||
assert_eq!(memory_budget(64 * G, 0, None), 4 * G);
|
||||
assert!(physical_memory().is_none_or(|b| b >= 256 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_modes() {
|
||||
assert_eq!(filter_mode(0.25), (0.0, 2.0));
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
//! Properties panel for pixel (and other bounded) layers, Photoshop 2026 style: a Transform section
|
||||
//! (W/H/X/Y of the layer content, editable), Align and Distribute, and Quick Actions. Edits dispatch
|
||||
//! (W/H/X/Y of the layer content, editable), Align and Distribute, the kind's sections and Quick Actions. Edits dispatch
|
||||
//! `edit.transform`, `layer.translate`, `layer.align.*` and selection commands.
|
||||
|
||||
use egui::{Rect, Sense, Stroke, pos2, vec2};
|
||||
use photocraft_doc::Layer;
|
||||
use photocraft_doc::{Layer, LayerContent};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section};
|
||||
use crate::theme::{ROW_GAP, Tokens};
|
||||
use crate::{PhotocraftApp, widgets};
|
||||
|
||||
/// `edit.transform` params that scale the content box `b` = [x0, y0, x1, y1] to `w` x `h`, keeping
|
||||
@@ -32,34 +33,14 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>,
|
||||
Some(json!({"layer": layer, "rect": b, "quad": quad}))
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let key = egui::Id::new(("layer-props-section", id));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
|
||||
crate::icons::paint(
|
||||
ui,
|
||||
Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)),
|
||||
if open { "chevron-down" } else { "chevron-right" },
|
||||
11.0,
|
||||
t.text_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// A number field that reports a value once committed (drag released, Enter, focus lost).
|
||||
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32) -> Option<f32> {
|
||||
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32) -> Option<f32> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 22.0), Sense::hover());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(12.0), t.text_dim);
|
||||
let key = egui::Id::new(("layer-props-field", id));
|
||||
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or(current);
|
||||
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", 80.0);
|
||||
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", width);
|
||||
if resp.dragged() || resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(key, v));
|
||||
return None;
|
||||
@@ -107,7 +88,12 @@ fn align_glyph(ui: &egui::Ui, r: Rect, kind: &str, color: egui::Color32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pro Properties body for a non-adjustment layer.
|
||||
/// Width of the link toggle between the W and H fields (the X/Y row leaves the same gap).
|
||||
const LINK_W: f32 = 20.0;
|
||||
|
||||
/// Pro Properties body for a non-adjustment layer: Transform, Align and Distribute, the kind's own
|
||||
/// sections (Character/Paragraph/Type Options for type, Appearance/Shape for shapes), then Quick
|
||||
/// Actions, all with the same collapsible headers (#155).
|
||||
pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut run: Vec<(String, Value)> = Vec::new();
|
||||
@@ -117,32 +103,33 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
let link_key = egui::Id::new("layer-props-link");
|
||||
let linked = ui.data(|d| d.get_temp::<bool>(link_key)).unwrap_or(true);
|
||||
let bb = [b.x0, b.y0, b.x1, b.y1];
|
||||
// Two label+field columns with the link toggle between them, filling the panel.
|
||||
let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if let Some(v) = field(ui, "w", "W", b.width() as f32) {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
if let Some(v) = field(ui, "w", "W", b.width() as f32, w) {
|
||||
run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p)));
|
||||
}
|
||||
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, 20.0, linked, "Link width and height").clicked() {
|
||||
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, "Link width and height").clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(link_key, !linked));
|
||||
}
|
||||
if let Some(v) = field(ui, "h", "H", b.height() as f32) {
|
||||
if let Some(v) = field(ui, "h", "H", b.height() as f32, w) {
|
||||
run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p)));
|
||||
}
|
||||
});
|
||||
ui.add_space(ROW_GAP);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if let Some(v) = field(ui, "x", "X", b.x0 as f32) {
|
||||
ui.spacing_mut().item_spacing.x = LABEL_GAP;
|
||||
if let Some(v) = field(ui, "x", "X", b.x0 as f32, w) {
|
||||
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0})));
|
||||
}
|
||||
ui.add_space(24.0);
|
||||
if let Some(v) = field(ui, "y", "Y", b.y0 as f32) {
|
||||
ui.add_space(LINK_W + LABEL_GAP);
|
||||
if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w) {
|
||||
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32})));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
if section(ui, "align", "Align and Distribute") {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -164,25 +151,28 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.add_space(ROW_GAP);
|
||||
}
|
||||
widgets::hairline(ui);
|
||||
ui.add_space(2.0);
|
||||
if section(ui, "quick", "Quick Actions") {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, id) in [("Remove Background", "layer.removeBackground"), ("Select Subject", "select.subject")] {
|
||||
if photocraft_engine::commands::find(id).is_some() && widgets::secondary_button(ui, label, 0.0).clicked() {
|
||||
run.push((id.to_string(), json!({})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (id, p) in run {
|
||||
for (id, p) in run.drain(..) {
|
||||
if let Err(e) = app.run(&id, p) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Text(_) => crate::type_tool::type_properties(app, ui),
|
||||
LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id),
|
||||
_ => {}
|
||||
}
|
||||
// Re-read: a section above may have changed the layer (e.g. point to paragraph type).
|
||||
let content = app.session.active().and_then(|s| s.doc.layer(layer.id)).map_or_else(|| layer.content.clone(), |l| l.content.clone());
|
||||
if let Some(id) = quick_actions_ui(app, ui, &content) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Reveal the active layer in the Layers panel (#152), like Photoshop: whenever the active
|
||||
//! layer changes, by whatever means (a click on the canvas with Auto-Select, Select › layer
|
||||
//! commands, ⌥[ / ⌥], undo, automation), its closed parent groups open (`layer.setExpanded`)
|
||||
//! and the panel scrolls just enough to show its row, unless the row is already in view.
|
||||
//!
|
||||
//! Only a change of the active layer reveals: closing the group that holds it, or scrolling
|
||||
//! away, is left alone until the next change.
|
||||
|
||||
use egui::{Rect, pos2};
|
||||
use photocraft_doc::{LayerContent, LayerId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
/// Last active layer the panel saw: (document index, layer).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
struct Seen(usize, Option<LayerId>);
|
||||
|
||||
fn seen_id() -> egui::Id {
|
||||
egui::Id::new("layers-reveal-seen")
|
||||
}
|
||||
|
||||
/// Called once per Layers panel frame, before its rows are listed. When the active layer
|
||||
/// changed since the last frame, opens its closed parent groups and returns it: the row to
|
||||
/// scroll into view (see [`scroll_to_row`]). The first frame of a document only records it.
|
||||
pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option<LayerId> {
|
||||
let index = app.session.active_index()?;
|
||||
let active = app.session.active()?.active_layer;
|
||||
let now = Seen(index, active);
|
||||
let before = ctx.data(|d| d.get_temp::<Seen>(seen_id()));
|
||||
ctx.data_mut(|d| d.insert_temp(seen_id(), now));
|
||||
match before {
|
||||
Some(Seen(i, prev)) if i == index && prev != active => {}
|
||||
_ => return None,
|
||||
}
|
||||
let id = active?;
|
||||
for group in closed_ancestors(app, id) {
|
||||
// A view change (no history step); a failure just leaves the group closed.
|
||||
let _ = app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true}));
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The groups containing `id` that are closed, outermost first.
|
||||
fn closed_ancestors(app: &PhotocraftApp, id: LayerId) -> Vec<LayerId> {
|
||||
let Some(doc) = app.session.active().map(|s| &s.doc) else { return Vec::new() };
|
||||
let Some(path) = doc.path_of(id) else { return Vec::new() };
|
||||
(1..path.len()).filter_map(|n| doc.layer_at(path.get(..n)?)).filter(|l| matches!(&l.content, LayerContent::Group(g) if !g.expanded)).map(|l| l.id).collect()
|
||||
}
|
||||
|
||||
/// After drawing the row that started at `top` in the Layers scroll area, scroll it into view
|
||||
/// if any of it is hidden (no animation: Photoshop jumps).
|
||||
pub fn scroll_to_row(ui: &egui::Ui, top: f32) {
|
||||
let bottom = ui.min_rect().bottom().max(top);
|
||||
let row = Rect::from_min_max(pos2(ui.min_rect().left(), top), pos2(ui.min_rect().right(), bottom));
|
||||
let view = ui.clip_rect();
|
||||
if row.top() < view.top() - 0.5 || row.bottom() > view.bottom() + 0.5 {
|
||||
ui.scroll_to_rect_animation(row, None, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "layer_reveal_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,132 @@
|
||||
//! #152: a new active layer opens its parent groups and is scrolled into view.
|
||||
|
||||
use egui::{Rect, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::dock::{Group, last_rects};
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
/// 30 layers above a closed group holding `inner` (near the bottom of the panel).
|
||||
fn tall_doc() -> (PhotocraftApp, LayerId, LayerId, LayerId, LayerId) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
|
||||
app.run("layer.new.layer", json!({"name": "Deep inside"})).unwrap();
|
||||
let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.unwrap();
|
||||
let inner = active(&app);
|
||||
app.run("layer.groupLayers", json!({})).unwrap();
|
||||
let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap();
|
||||
app.run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
|
||||
app.run("layer.select", json!({"layer": group.0})).unwrap();
|
||||
let mut stack = Vec::new();
|
||||
for i in 0..30 {
|
||||
app.run("layer.new.layer", json!({"name": format!("Stack {i:02}")})).unwrap();
|
||||
stack.push(active(&app));
|
||||
}
|
||||
app.sync_views();
|
||||
(app, inner, group, stack[29], stack[28])
|
||||
}
|
||||
|
||||
fn harness(app: PhotocraftApp, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
|
||||
return;
|
||||
}
|
||||
crate::panels::right_dock(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
},
|
||||
app,
|
||||
);
|
||||
PhotocraftApp::setup_context(&h.ctx, theme);
|
||||
h.state_mut().ui.theme = theme;
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// The Layers panel row of `name`, if drawn.
|
||||
fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
|
||||
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers)?.1;
|
||||
h.query_all_by_label(name).map(|n| n.rect()).find(|r| layers.contains(r.center()) || (r.left() >= layers.left() && r.right() <= layers.right()))
|
||||
}
|
||||
|
||||
/// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer).
|
||||
fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool {
|
||||
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1;
|
||||
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0
|
||||
}
|
||||
|
||||
fn expanded(app: &PhotocraftApp, group: LayerId) -> bool {
|
||||
matches!(&app.session.active().unwrap().doc.layer(group).unwrap().content, photocraft_doc::LayerContent::Group(g) if g.expanded)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
|
||||
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
|
||||
let (app, inner, group, top, below_top) = tall_doc();
|
||||
let mut h = harness(app, theme);
|
||||
let top_row = row(&h, "Stack 29").expect("top row drawn");
|
||||
assert!(in_view(&h, top_row), "{theme:?}: the panel starts at the top");
|
||||
assert!(row(&h, "Deep inside").is_none(), "{theme:?}: hidden in its closed group");
|
||||
|
||||
// Select › layer, Auto-Select, automation: all end in a new active layer.
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(expanded(h.state(), group), "{theme:?}: the parent group opened");
|
||||
let r = row(&h, "Deep inside").expect("row listed once its group is open");
|
||||
assert!(in_view(&h, r), "{theme:?}: scrolled into view: {r:?}");
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: the top scrolled away");
|
||||
|
||||
// Back to the top layer: scrolls back up.
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: scrolled back to the top");
|
||||
|
||||
// A layer already in view doesn't move the list.
|
||||
let before = row(&h, "Stack 29").unwrap();
|
||||
h.state_mut().run("layer.select", json!({"layer": below_top.0})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(row(&h, "Stack 29").unwrap(), before, "{theme:?}: no scroll for a visible row");
|
||||
|
||||
// Closing the group by hand is respected until the active layer changes again.
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!expanded(h.state(), group), "{theme:?}: stays closed");
|
||||
// Undo and other commands that change the active layer reveal it too: undoing a new
|
||||
// layer deep in the list makes the top layer active again.
|
||||
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(expanded(h.state(), group));
|
||||
h.state_mut().run("layer.new.layer", json!({"name": "Scratch"})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()));
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "{theme:?}: undo made the top layer active");
|
||||
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: undo revealed it");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_survives_odd_documents() {
|
||||
// No document, a document without layers in groups, a layer deleted between frames.
|
||||
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
let mut h = harness(app, ThemeKind::ProMedium);
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.new.layer", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("layer.delete", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("file.close", json!({})).ok();
|
||||
h.state_mut().sync_views();
|
||||
h.run_steps(3);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const ICON_W: f32 = 14.0;
|
||||
/// Width of the effects triangle beside the fx badge.
|
||||
const TRIANGLE_W: f32 = 10.0;
|
||||
/// The blend-mode label is shown only while the name keeps at least this much room.
|
||||
const MIN_NAME_W: f32 = 64.0;
|
||||
pub const MIN_NAME_W: f32 = 64.0;
|
||||
|
||||
/// A right-hand row indicator.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user