mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-15 19:56:50 -04:00
Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4717de1398 | ||
|
|
e5e2a8f2eb | ||
|
|
b5b5ce8394 | ||
|
|
8713e26f5a | ||
|
|
4c94e4d63a | ||
|
|
c34facea4d | ||
|
|
a96a621dee | ||
|
|
5ada60c35b | ||
|
|
3423864502 | ||
|
|
7db46219ce | ||
|
|
17d4cd0d78 | ||
|
|
c08322b7bc | ||
|
|
352c849139 | ||
|
|
1520d1c527 | ||
|
|
65c934f374 | ||
|
|
fb297945cd | ||
|
|
6dc11904bd | ||
|
|
315be69dd5 | ||
|
|
9a76e74c24 | ||
|
|
ea0beac9fd | ||
|
|
6266b7a96b | ||
|
|
07074634f6 | ||
|
|
3c1bab7cc1 | ||
|
|
433b6a7184 | ||
|
|
2172d085c6 | ||
|
|
47f4abfed4 | ||
|
|
02c07eb021 | ||
|
|
968baf639e | ||
|
|
c454af8d96 | ||
|
|
7c6a78b05a | ||
|
|
415362df6f | ||
|
|
dbb8911f2a | ||
|
|
8bac8926a3 | ||
|
|
ae80df2eb4 | ||
|
|
5a5faccd4a | ||
|
|
e2444ceb68 | ||
|
|
866c33f7c1 | ||
|
|
34de1a5dfc | ||
|
|
82270711de | ||
|
|
eb11e1af46 | ||
|
|
15eb3a8ba5 | ||
|
|
f114f62179 | ||
|
|
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,35 @@ 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
|
||||
|
||||
corpus:
|
||||
# Real-file corpus tests (cargo feature `corpus`), on every PR and on main. The corpora are
|
||||
# pinned in xtask/src/corpus_pins.rs and sha256-verified (xtask/*.sha256) after both a cache
|
||||
# restore and a download; a missing corpus fails the tests. The cache key changes only when a
|
||||
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
|
||||
name: corpus tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: corpus
|
||||
- name: Restore test corpora
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: corpus/
|
||||
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
|
||||
- name: Fetch and verify corpora
|
||||
run: cargo xtask corpus --all
|
||||
- name: Corpus tests
|
||||
run: cargo xtask test-corpus
|
||||
|
||||
wasm:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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.
|
||||
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
|
||||
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
|
||||
# uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- .github/workflows/release.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package 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 bash 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: |
|
||||
set -e
|
||||
. "$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
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
|
||||
@@ -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
|
||||
@@ -239,6 +239,50 @@ jobs:
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
@@ -274,7 +318,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, flatpak, web]
|
||||
needs: [version, macos, windows, linux, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -11,9 +11,11 @@ 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 |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
|
||||
## 2. Workspace map
|
||||
|
||||
@@ -21,6 +23,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
|
||||
crates/
|
||||
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
|
||||
psd codecs L0 standalone format crates (no workspace deps; publishable)
|
||||
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
|
||||
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
|
||||
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
|
||||
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
|
||||
@@ -32,7 +35,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 | test-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`.
|
||||
@@ -44,7 +47,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | p
|
||||
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
|
||||
|
||||
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`.
|
||||
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
|
||||
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
|
||||
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
|
||||
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
|
||||
@@ -67,9 +70,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,8 +92,15 @@ 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
|
||||
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
|
||||
```
|
||||
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
|
||||
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).
|
||||
|
||||
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
|
||||
@@ -101,6 +118,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.
|
||||
|
||||
+14
-4
@@ -12,7 +12,7 @@ next to them.
|
||||
|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
@@ -48,6 +48,16 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
|
||||
|
||||
## Test data (not committed, not shipped)
|
||||
|
||||
`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.
|
||||
`corpus/` is gitignored and no test fixtures are committed to the repository.
|
||||
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
|
||||
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
|
||||
next to the files:
|
||||
|
||||
| Path (fetched) | Title | Author | Source | License |
|
||||
|---|---|---|---|---|
|
||||
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
|
||||
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
|
||||
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
|
||||
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
|
||||
|
||||
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
|
||||
Generated
+89
@@ -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"
|
||||
@@ -3366,6 +3404,7 @@ dependencies = [
|
||||
"photocraft-geom",
|
||||
"photocraft-io",
|
||||
"photocraft-raster",
|
||||
"photocraft-tablet",
|
||||
"photocraft-ui-egui",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
@@ -3555,6 +3594,8 @@ dependencies = [
|
||||
name = "photocraft-io"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"photocraft-algo",
|
||||
"photocraft-cms",
|
||||
"photocraft-codecs",
|
||||
"photocraft-color",
|
||||
@@ -3569,6 +3610,7 @@ dependencies = [
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -3635,13 +3677,27 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-tablet"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation 0.3.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "photocraft-testkit"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"memory-stats",
|
||||
"photocraft-color",
|
||||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3680,7 +3736,9 @@ dependencies = [
|
||||
"photocraft-io",
|
||||
"photocraft-plugins",
|
||||
"photocraft-raster",
|
||||
"photocraft-testkit",
|
||||
"photocraft-text",
|
||||
"photocraft-vector",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5084,6 +5142,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 +6190,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 +6287,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
|
||||
name = "xtask"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+31
@@ -33,6 +33,7 @@ photocraft-plugins = { path = "crates/plugins" }
|
||||
photocraft-engine = { path = "crates/engine" }
|
||||
photocraft-ui-egui = { path = "crates/ui-egui" }
|
||||
photocraft-testkit = { path = "crates/testkit" }
|
||||
photocraft-tablet = { path = "crates/tablet" }
|
||||
|
||||
# external
|
||||
thiserror = "2"
|
||||
@@ -68,3 +69,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
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<p align="center">
|
||||
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="macOS · Windows · Linux · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
</p>
|
||||
@@ -70,7 +70,7 @@
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🗂️ Real PSD files</h3>
|
||||
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
|
||||
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🤖 Agent-ready</h3>
|
||||
@@ -213,7 +213,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
|
||||
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
|
||||
|
||||
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
|
||||
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
|
||||
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
|
||||
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
|
||||
|
||||
@@ -257,17 +257,25 @@ cargo test --workspace # the test suite
|
||||
|
||||
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
|
||||
|
||||
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
|
||||
|
||||
```sh
|
||||
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
|
||||
flatpak run ai.storyteller.photocraft
|
||||
```
|
||||
|
||||
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
|
||||
|
||||
```sh
|
||||
pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
tar -xzf photocraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
|
||||
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
|
||||
|
||||
@@ -277,6 +285,13 @@ Developer, architecture, automation, format, and security documentation is maint
|
||||
|
||||
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
|
||||
|
||||
## Test corpora
|
||||
|
||||
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
|
||||
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
|
||||
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
|
||||
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -69,7 +69,7 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// Pen pressure, tilt, twist and the eraser button from Pointer Events (eframe forwards none of them for pens) into
|
||||
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
|
||||
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
|
||||
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
|
||||
@@ -83,7 +83,15 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
|
||||
return;
|
||||
}
|
||||
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
|
||||
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
|
||||
// W3C Pointer Events: `buttons` bit 5 (32) is the pen's eraser.
|
||||
let eraser = e.buttons() & 32 != 0;
|
||||
feed.set(pen.then(|| PenSample {
|
||||
pressure: e.pressure(),
|
||||
tilt_x: e.tilt_x() as f32,
|
||||
tilt_y: e.tilt_y() as f32,
|
||||
rotation: e.twist() as f32,
|
||||
eraser,
|
||||
}));
|
||||
});
|
||||
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
|
||||
cb.forget();
|
||||
|
||||
@@ -22,6 +22,8 @@ photocraft-io = { workspace = true }
|
||||
photocraft-format = { workspace = true }
|
||||
photocraft-automation = { workspace = true }
|
||||
photocraft-ui-egui = { workspace = true }
|
||||
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
|
||||
photocraft-tablet = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -29,7 +31,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,234 @@
|
||||
//! Where the desktop app keeps its per-user data: preferences, brush presets, crash-recovery
|
||||
//! autosaves and the GPU startup marker all live under the one directory [`config_dir`] returns.
|
||||
//!
|
||||
//! In order:
|
||||
//! 1. `PHOTOCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
|
||||
//! 2. **Portable mode** (#228): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts
|
||||
//! everything in `<exe dir>/PhotoCraftData`, so a portable copy on a USB stick leaves nothing in
|
||||
//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that
|
||||
//! folder can't be written (read-only medium, Program Files), the app warns and falls back.
|
||||
//! 3. The platform convention: macOS `~/Library/Application Support/Photocraft`, Windows
|
||||
//! `%APPDATA%\Photocraft`, Linux `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
|
||||
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "PhotoCraft.portable"];
|
||||
/// The data folder created beside the executable in portable mode.
|
||||
pub const PORTABLE_DATA_DIR: &str = "PhotoCraftData";
|
||||
|
||||
/// How [`DataDir::dir`] was chosen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// `PHOTOCRAFT_CONFIG_DIR`.
|
||||
Override,
|
||||
/// `<exe dir>/PhotoCraftData`.
|
||||
Portable,
|
||||
/// The platform's per-user config directory.
|
||||
Platform,
|
||||
}
|
||||
|
||||
/// The resolved data directory.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DataDir {
|
||||
/// `None` only when no platform directory is known either (no `HOME`/`APPDATA`).
|
||||
pub dir: Option<PathBuf>,
|
||||
pub mode: Mode,
|
||||
/// Set when a portable marker was found but its data folder isn't writable.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// The data directory for this process, resolved once (the portable check probes the disk).
|
||||
pub fn current() -> &'static DataDir {
|
||||
static DIR: OnceLock<DataDir> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let exe = std::env::current_exe().ok();
|
||||
let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent));
|
||||
if let Some(w) = &d.warning {
|
||||
log::warn!("{w}");
|
||||
eprintln!("photocraft: {w}");
|
||||
}
|
||||
log::info!("settings directory ({:?}): {:?}", d.mode, d.dir);
|
||||
d
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-user settings directory (see the module docs).
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
current().dir.clone()
|
||||
}
|
||||
|
||||
/// Resolve the data directory from the environment (`env`) and the executable's folder.
|
||||
pub fn resolve(env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> DataDir {
|
||||
if let Some(d) = env("PHOTOCRAFT_CONFIG_DIR").filter(|d| !d.is_empty()) {
|
||||
return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None };
|
||||
}
|
||||
let platform = platform_dir(&env);
|
||||
let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else {
|
||||
return DataDir { dir: platform, mode: Mode::Platform, warning: None };
|
||||
};
|
||||
let data = exe_dir.join(PORTABLE_DATA_DIR);
|
||||
match ensure_writable(&data) {
|
||||
Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None },
|
||||
Err(e) => {
|
||||
let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string());
|
||||
let warning =
|
||||
format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display());
|
||||
DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`).
|
||||
pub fn platform_dir(env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return env("APPDATA").filter(|a| !a.is_empty()).map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
env("XDG_CONFIG_HOME").filter(|x| !x.is_empty()).map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
}
|
||||
|
||||
/// The first portable marker file in `exe_dir`, if any.
|
||||
pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
|
||||
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
|
||||
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let probe = dir.join(format!(".write-test-{}", std::process::id()));
|
||||
std::fs::write(&probe, b"")?;
|
||||
// A leftover probe is harmless; don't fail portable mode over it.
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn temp(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("photocraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let m: HashMap<String, OsString> = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect();
|
||||
move |k| m.get(k).cloned()
|
||||
}
|
||||
|
||||
/// A fake user folder that every platform's convention resolves under.
|
||||
fn fake_home(root: &Path) -> impl Fn(&str) -> Option<OsString> + use<> {
|
||||
let home = root.join("home");
|
||||
let appdata = root.join("appdata");
|
||||
env_of(&[("HOME", &home), ("APPDATA", &appdata)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_platform_dir() {
|
||||
let root = temp("platform");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
let env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(Some(dir.clone()), platform_dir(&env));
|
||||
assert!(!dir.starts_with(&exe));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker");
|
||||
// The convention per platform.
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(dir, root.join("home/Library/Application Support/Photocraft"));
|
||||
} else if cfg!(windows) {
|
||||
assert_eq!(dir, root.join("appdata").join("Photocraft"));
|
||||
} else {
|
||||
assert_eq!(dir, root.join("home/.config/photocraft"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_marker_keeps_everything_beside_the_exe() {
|
||||
for marker in PORTABLE_MARKERS {
|
||||
let root = temp("portable");
|
||||
let exe = root.join("PhotoCraft");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join(marker), b"").unwrap();
|
||||
let d = resolve(fake_home(&root), Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Portable, "{marker}");
|
||||
assert!(d.warning.is_none());
|
||||
let dir = d.dir.unwrap();
|
||||
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR));
|
||||
assert!(dir.is_dir(), "the data folder is created");
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
|
||||
// Every derived path sits under the exe dir.
|
||||
for sub in ["preferences.json", "Presets", "Recovery", crate::gpu_startup::MARKER_FILE] {
|
||||
assert!(dir.join(sub).starts_with(&exe));
|
||||
}
|
||||
assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_marker_folder_is_not_a_marker() {
|
||||
let root = temp("dir-marker");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
|
||||
assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_portable_folder_falls_back_with_a_warning() {
|
||||
let root = temp("unwritable");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
// A file where the data folder should go: it can't be created, on every platform.
|
||||
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
|
||||
let env = fake_home(&root);
|
||||
let d = resolve(&env, Some(&exe));
|
||||
assert_eq!(d.mode, Mode::Platform);
|
||||
assert_eq!(d.dir, platform_dir(&env));
|
||||
let w = d.warning.unwrap();
|
||||
assert!(w.contains("portable") && w.contains("isn't writable"), "{w}");
|
||||
// No user folder at all: still no panic, just no directory.
|
||||
let d = resolve(|_| None, Some(&exe));
|
||||
assert!(d.dir.is_none() && d.warning.is_some());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_wins_over_portable() {
|
||||
let root = temp("override");
|
||||
let exe = root.join("bin");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::write(exe.join("portable.txt"), b"").unwrap();
|
||||
let custom = root.join("custom");
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &custom)]), Some(&exe));
|
||||
assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom)));
|
||||
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_exe_dir_or_env_never_panics() {
|
||||
let d = resolve(|_| None, None);
|
||||
assert_eq!((d.mode, d.dir), (Mode::Platform, None));
|
||||
let empty = PathBuf::new();
|
||||
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
|
||||
assert_eq!(d.dir, None, "empty variables are ignored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The app icon, everywhere Windows, macOS and Linux show one (#248).
|
||||
//!
|
||||
//! - **Executable:** `build.rs` embeds `assets/app-icon/photocraft.ico` (16–256 px) as icon
|
||||
//! resource 1, which Explorer, Alt+Tab and an unpinned taskbar button fall back to.
|
||||
//! - **Window:** [`window_icon`] sets the title-bar and taskbar icon at runtime (`WM_SETICON` on
|
||||
//! Windows, `_NET_WM_ICON` on X11, the Dock when running unbundled on macOS).
|
||||
//! - **Start Menu shortcut (MSI):** the taskbar matches a running window to the shortcut that
|
||||
//! launches its executable and shows that shortcut's icon. Windows Installer caches an advertised
|
||||
//! shortcut's icon under its `<Icon Id>`, so that id must carry the target's `.exe` extension
|
||||
//! (ICE50); an extensionless id showed a blank page on the taskbar. The tests below check it.
|
||||
//!
|
||||
//! The process keeps Windows' implicit AppUserModelID (derived from the executable path), which
|
||||
//! is what makes that matching work for both the MSI shortcut and the portable exe. An explicit
|
||||
//! ID would need a Win32 call (`unsafe`, which the workspace forbids) and would then also have to
|
||||
//! be stamped on the shortcut, or the taskbar would stop matching the two.
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
pub fn window_icon() -> egui::IconData {
|
||||
match eframe::icon_data::from_png_bytes(PNG) {
|
||||
Ok(icon) => icon,
|
||||
Err(e) => {
|
||||
log::warn!("app icon: {e}");
|
||||
egui::IconData::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ICO: &[u8] = include_bytes!("../../../assets/app-icon/photocraft.ico");
|
||||
const WXS: &str = include_str!("../../../packaging/windows/photocraft.wxs");
|
||||
|
||||
fn u16_at(b: &[u8], i: usize) -> usize {
|
||||
u16::from_le_bytes([b[i], b[i + 1]]) as usize
|
||||
}
|
||||
fn u32_at(b: &[u8], i: usize) -> usize {
|
||||
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_icon_decodes() {
|
||||
let icon = window_icon();
|
||||
assert!(icon.width >= 256 && icon.width == icon.height, "{}x{}", icon.width, icon.height);
|
||||
assert_eq!(icon.rgba.len(), icon.width as usize * icon.height as usize * 4);
|
||||
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
|
||||
}
|
||||
|
||||
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
|
||||
#[test]
|
||||
fn exe_icon_has_every_shell_size() {
|
||||
assert_eq!((u16_at(ICO, 0), u16_at(ICO, 2)), (0, 1), "ICONDIR header, type 1 (icon)");
|
||||
let n = u16_at(ICO, 4);
|
||||
let mut sizes = Vec::new();
|
||||
for i in 0..n {
|
||||
let e = 6 + i * 16;
|
||||
let w = if ICO[e] == 0 { 256 } else { ICO[e] as usize };
|
||||
let h = if ICO[e + 1] == 0 { 256 } else { ICO[e + 1] as usize };
|
||||
assert_eq!(w, h);
|
||||
let (len, off) = (u32_at(ICO, e + 8), u32_at(ICO, e + 12));
|
||||
let data = ICO.get(off..off + len).expect("image data inside the file");
|
||||
let png = data.starts_with(b"\x89PNG\r\n\x1a\n");
|
||||
let bmp = data.len() >= 40 && u32_at(data, 0) == 40;
|
||||
assert!(png || bmp, "{w}px entry is neither PNG nor BMP");
|
||||
if png {
|
||||
// IHDR width/height match the directory entry.
|
||||
let pw = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
|
||||
assert_eq!(pw, w, "{w}px entry holds a {pw}px PNG");
|
||||
}
|
||||
sizes.push(w);
|
||||
}
|
||||
for want in [16, 24, 32, 48, 64, 256] {
|
||||
assert!(sizes.contains(&want), "photocraft.ico lacks {want}px (has {sizes:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of `attr="…"` inside `element`.
|
||||
fn attr<'a>(element: &'a str, attr: &str) -> Option<&'a str> {
|
||||
let key = format!(" {attr}=\"");
|
||||
let start = element.find(&key)? + key.len();
|
||||
element[start..].split('"').next()
|
||||
}
|
||||
|
||||
/// Each `<tag …>` element's text up to its closing `>`.
|
||||
fn elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
|
||||
let open = format!("<{tag} ");
|
||||
xml.match_indices(&open).map(|(i, _)| &xml[i..i + xml[i..].find('>').unwrap()]).collect()
|
||||
}
|
||||
|
||||
/// ICE50 (#248): advertised shortcut icons are cached under their `<Icon Id>`; without the
|
||||
/// target's extension the shell shows a blank page on the Start Menu and the taskbar.
|
||||
#[test]
|
||||
fn msi_shortcut_icons_keep_the_exe_extension() {
|
||||
let icons: Vec<&str> = elements(WXS, "Icon").iter().filter_map(|e| attr(e, "Id")).collect();
|
||||
assert!(!icons.is_empty());
|
||||
for id in &icons {
|
||||
assert!(id.ends_with(".exe") || id.ends_with(".ico"), "<Icon Id=\"{id}\"> needs an .exe or .ico extension");
|
||||
}
|
||||
let shortcuts = elements(WXS, "Shortcut");
|
||||
assert!(!shortcuts.is_empty());
|
||||
for s in shortcuts {
|
||||
let icon = attr(s, "Icon").expect("shortcuts name their icon");
|
||||
assert!(icons.contains(&icon), "shortcut icon {icon} isn't an <Icon>");
|
||||
if attr(s, "Advertise") == Some("yes") {
|
||||
// Every shortcut in the MSI targets photocraft.exe.
|
||||
assert!(icon.ends_with(".exe"), "advertised shortcut icon {icon} must end in .exe like its target (ICE50)");
|
||||
}
|
||||
}
|
||||
let arp = WXS.split("Id=\"ARPPRODUCTICON\" Value=\"").nth(1).and_then(|r| r.split('"').next()).expect("ARPPRODUCTICON");
|
||||
assert!(icons.contains(&arp), "ARPPRODUCTICON {arp} isn't an <Icon>");
|
||||
// The Start Menu shortcut must not set its own AppUserModelID: the process keeps the
|
||||
// implicit one, and a mismatch would split the taskbar button from the shortcut.
|
||||
assert!(!WXS.contains("System.AppUserModel.ID"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
//! Crash-safe GPU startup (#4).
|
||||
//!
|
||||
//! A graphics driver that segfaults while the window's wgpu device is created (the Intel Vulkan
|
||||
//! driver `igvk64.dll` on Windows) can't be caught in-process. So before the device exists the app
|
||||
//! writes a *marker* (`gpu-starting.json` in the config directory: backend and, once chosen, the
|
||||
//! adapter) and holds an OS file lock on it; it clears the marker once the first frames have
|
||||
//! rendered. If the next launch finds a marker that no running process holds, the previous start
|
||||
//! died inside the driver, and this one retries with the next safer backend:
|
||||
//!
|
||||
//! - Windows: Vulkan → DX12 → CPU
|
||||
//! - Linux and other Unix: Vulkan → GL → CPU
|
||||
//! - macOS: Metal → CPU
|
||||
//!
|
||||
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
|
||||
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
|
||||
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
|
||||
//!
|
||||
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
|
||||
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use eframe::{egui_wgpu, wgpu};
|
||||
use photocraft_engine::prefs::GpuBackend;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The marker's file name in the config directory.
|
||||
pub const MARKER_FILE: &str = "gpu-starting.json";
|
||||
/// PCI vendor id of Intel.
|
||||
pub const INTEL: u32 = 0x8086;
|
||||
/// Largest marker read (a marker is a few hundred bytes).
|
||||
const MARKER_MAX: u64 = 64 << 10;
|
||||
|
||||
/// Platform families with different backend chains.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Os {
|
||||
Windows,
|
||||
Mac,
|
||||
/// Linux, the BSDs and other Unix.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Os {
|
||||
pub fn current() -> Self {
|
||||
if cfg!(windows) {
|
||||
Os::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
Os::Mac
|
||||
} else {
|
||||
Os::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a start records before touching the driver.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Marker {
|
||||
/// The plan's backend (`auto`, `dx12`, …), or `env:<value>` under `WGPU_BACKEND`.
|
||||
pub backend: String,
|
||||
/// The chosen adapter's name, backend and driver (empty until the adapter is chosen).
|
||||
pub adapter: String,
|
||||
pub adapter_backend: String,
|
||||
pub driver: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl Marker {
|
||||
pub fn to_json(&self) -> String {
|
||||
json!({
|
||||
"backend": self.backend,
|
||||
"adapter": self.adapter,
|
||||
"adapterBackend": self.adapter_backend,
|
||||
"driver": self.driver,
|
||||
"version": self.version,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a marker leniently: anything unreadable is a marker of an unknown backend.
|
||||
pub fn parse(text: &str) -> Self {
|
||||
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
|
||||
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").chars().take(256).collect::<String>();
|
||||
Marker { backend: s("backend"), adapter: s("adapter"), adapter_backend: s("adapterBackend"), driver: s("driver"), version: s("version") }
|
||||
}
|
||||
|
||||
/// The backend the crashed start was using: the adapter's when it got that far with `auto`,
|
||||
/// else the plan's. `None` for a start under `WGPU_BACKEND` (the user's own override).
|
||||
pub fn tried(&self) -> Option<GpuBackend> {
|
||||
let planned = if self.backend.is_empty() { Some(GpuBackend::Auto) } else { GpuBackend::parse(&self.backend) }?;
|
||||
match (planned, GpuBackend::parse(&self.adapter_backend)) {
|
||||
(GpuBackend::Auto, Some(actual)) => Some(actual),
|
||||
_ => Some(planned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend this launch uses and why.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub backend: GpuBackend,
|
||||
/// `WGPU_BACKEND`, which picks the backends itself.
|
||||
pub env: Option<String>,
|
||||
/// Why this isn't simply the preference.
|
||||
pub reason: Option<String>,
|
||||
/// Store `backend` in the preferences once the start succeeded (a crash fallback).
|
||||
pub remember: bool,
|
||||
}
|
||||
|
||||
/// A backend the platform has (others fall back to `auto`).
|
||||
pub fn sanitize(pref: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, pref) {
|
||||
(Os::Windows, Metal) | (Os::Mac, Vulkan | Dx12 | Gl) | (Os::Other, Dx12 | Metal) => Auto,
|
||||
(_, b) => b,
|
||||
}
|
||||
}
|
||||
|
||||
/// The next safer backend after a start on `tried` crashed.
|
||||
pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
|
||||
use GpuBackend::*;
|
||||
match (os, tried) {
|
||||
(Os::Windows, Auto | Vulkan | Gl | Metal) => Dx12,
|
||||
(Os::Windows, Dx12 | Cpu) => Cpu,
|
||||
(Os::Mac, _) => Cpu,
|
||||
(Os::Other, Auto | Vulkan | Dx12 | Metal) => Gl,
|
||||
(Os::Other, Gl | Cpu) => Cpu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
|
||||
/// `WGPU_BACKEND` and `--safe-gpu`.
|
||||
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
|
||||
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
let backend = if safe_gpu { GpuBackend::Cpu } else { GpuBackend::Auto };
|
||||
return Plan { backend, env: Some(v.to_string()), reason: Some(format!("WGPU_BACKEND={v} overrides the backend")), remember: false };
|
||||
}
|
||||
if safe_gpu {
|
||||
return Plan { backend: GpuBackend::Cpu, env: None, reason: Some("--safe-gpu: CPU renderer for this launch".into()), remember: false };
|
||||
}
|
||||
let pref = sanitize(pref, os);
|
||||
if let Some((m, tried)) = crashed.and_then(|m| Some((m, m.tried()?))) {
|
||||
let next = next_safer(tried, os);
|
||||
let on = if m.adapter.is_empty() { String::new() } else { format!(" ({})", m.adapter) };
|
||||
return Plan {
|
||||
backend: next,
|
||||
env: None,
|
||||
reason: Some(format!("the previous start didn't finish on {}{on}; using {}", tried.name(), next.name())),
|
||||
remember: next != pref,
|
||||
};
|
||||
}
|
||||
Plan { backend: pref, env: None, reason: None, remember: false }
|
||||
}
|
||||
|
||||
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
|
||||
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
|
||||
if plan.env.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(match plan.backend {
|
||||
GpuBackend::Auto => return None,
|
||||
GpuBackend::Vulkan => wgpu::Backends::VULKAN,
|
||||
GpuBackend::Dx12 => wgpu::Backends::DX12,
|
||||
GpuBackend::Metal => wgpu::Backends::METAL,
|
||||
GpuBackend::Gl => wgpu::Backends::GL,
|
||||
GpuBackend::Cpu => match os {
|
||||
Os::Windows => wgpu::Backends::DX12,
|
||||
Os::Mac => wgpu::Backends::METAL,
|
||||
Os::Other => wgpu::Backends::GL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// What adapter selection needs to know about an adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
pub vendor: u32,
|
||||
pub device_type: wgpu::DeviceType,
|
||||
pub backend: wgpu::Backend,
|
||||
/// Can present to the window.
|
||||
pub surface_ok: bool,
|
||||
}
|
||||
|
||||
/// Rank of an adapter (lower is better): high-performance device types first (egui's power
|
||||
/// preference) or software adapters first for `cpu`; then backends, with DX12 before Vulkan for
|
||||
/// Intel on Windows.
|
||||
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
|
||||
let ty = match a.device_type {
|
||||
wgpu::DeviceType::DiscreteGpu => 0,
|
||||
wgpu::DeviceType::IntegratedGpu => 1,
|
||||
wgpu::DeviceType::VirtualGpu => 2,
|
||||
wgpu::DeviceType::Other => 3,
|
||||
wgpu::DeviceType::Cpu => 4,
|
||||
};
|
||||
let ty = match (backend, a.device_type) {
|
||||
(GpuBackend::Cpu, wgpu::DeviceType::Cpu) => 0,
|
||||
(GpuBackend::Cpu, _) => ty + 1,
|
||||
_ => ty,
|
||||
};
|
||||
let be = match a.backend {
|
||||
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
|
||||
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
|
||||
wgpu::Backend::Dx12 => 2,
|
||||
wgpu::Backend::Gl => 3,
|
||||
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
|
||||
};
|
||||
(ty, be)
|
||||
}
|
||||
|
||||
/// The adapter to use among `adapters` (`None` when there is none).
|
||||
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
|
||||
let any_ok = adapters.iter().any(|a| a.surface_ok);
|
||||
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok || !any_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
|
||||
/// same vendor was available).
|
||||
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
|
||||
let Some(c) = adapters.get(chosen) else { return false };
|
||||
os == Os::Windows
|
||||
&& c.vendor == INTEL
|
||||
&& c.backend == wgpu::Backend::Dx12
|
||||
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
|
||||
}
|
||||
|
||||
/// Configure eframe's wgpu setup for `plan`: instance backends, adapter selection (Intel on
|
||||
/// Windows, software adapters for `cpu`) and the marker update once the adapter is chosen.
|
||||
/// `note` receives a remark for System Info (e.g. the Intel default).
|
||||
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
|
||||
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
|
||||
if let Some(b) = backends(plan, os) {
|
||||
create.instance_descriptor.backends = b;
|
||||
}
|
||||
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
|
||||
if select {
|
||||
let backend = plan.backend;
|
||||
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
|
||||
let cands: Vec<Candidate> = adapters
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let i = a.get_info();
|
||||
Candidate {
|
||||
vendor: i.vendor,
|
||||
device_type: i.device_type,
|
||||
backend: i.backend,
|
||||
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
|
||||
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
|
||||
*note.lock().unwrap_or_else(PoisonError::into_inner) =
|
||||
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
|
||||
}
|
||||
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
|
||||
}));
|
||||
}
|
||||
}
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits_with(setup, move |adapter| {
|
||||
let info = adapter.get_info();
|
||||
let mut guard = sentinel.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(s) = guard.as_mut() {
|
||||
let mut m = s.marker.clone();
|
||||
m.adapter = info.name.clone();
|
||||
m.adapter_backend = photocraft_ui_egui::gpu_canvas::backend_name(info.backend).to_string();
|
||||
m.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
|
||||
if let Err(e) = s.write(m) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a previous start left behind.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Previous {
|
||||
/// Nothing: it finished starting (or never ran).
|
||||
Clean,
|
||||
/// Its marker, unlocked: it died before rendering its first frames.
|
||||
Crashed(Marker),
|
||||
/// Another instance is starting right now (it holds the lock).
|
||||
Busy,
|
||||
}
|
||||
|
||||
impl Previous {
|
||||
pub fn crashed(&self) -> Option<&Marker> {
|
||||
match self {
|
||||
Previous::Crashed(m) => Some(m),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The locked marker file of this start.
|
||||
#[derive(Debug)]
|
||||
pub struct Sentinel {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
marker: Marker,
|
||||
}
|
||||
|
||||
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
|
||||
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
|
||||
|
||||
impl Sentinel {
|
||||
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
|
||||
/// unless another instance holds the lock or the directory isn't writable, this start's
|
||||
/// sentinel (not yet written: see [`Sentinel::write`]).
|
||||
pub fn begin(dir: &Path) -> (Previous, Option<Sentinel>) {
|
||||
let path = dir.join(MARKER_FILE);
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
let mut file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("GPU startup marker {}: {e}", path.display());
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
};
|
||||
match file.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => return (Previous::Busy, None),
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
log::warn!("GPU startup marker lock: {e}");
|
||||
return (Previous::Clean, None);
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
let _ = (&mut file).take(MARKER_MAX).read_to_string(&mut text);
|
||||
let previous = if text.trim().is_empty() { Previous::Clean } else { Previous::Crashed(Marker::parse(&text)) };
|
||||
(previous, Some(Sentinel { path, file: Some(file), marker: Marker::default() }))
|
||||
}
|
||||
|
||||
/// Replace the marker's contents with `m`.
|
||||
pub fn write(&mut self, m: Marker) -> std::io::Result<()> {
|
||||
let f = self.file.as_mut().ok_or_else(|| std::io::Error::other("marker closed"))?;
|
||||
f.set_len(0)?;
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
f.write_all(m.to_json().as_bytes())?;
|
||||
f.flush()?;
|
||||
self.marker = m;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The start succeeded: clear the marker and release the lock.
|
||||
pub fn finish(mut self) {
|
||||
if let Some(f) = self.file.take() {
|
||||
// Empty means "no crash" even if removing fails (another instance has it open).
|
||||
let _ = f.set_len(0);
|
||||
drop(f);
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// `performance.gpuBackend` and `performance.useGpu` from the preferences file, read before the
|
||||
/// app (and its full preference load) exists. Unreadable values are the defaults.
|
||||
pub fn read_prefs(path: Option<&Path>) -> (GpuBackend, bool) {
|
||||
let v: Value = path.and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||
let perf = v.get("performance");
|
||||
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
|
||||
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
|
||||
(backend, use_gpu)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use GpuBackend::*;
|
||||
|
||||
fn crashed(backend: &str, adapter_backend: &str) -> Marker {
|
||||
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_marker_uses_the_preference() {
|
||||
for os in [Os::Windows, Os::Mac, Os::Other] {
|
||||
let p = plan(Auto, None, None, false, os);
|
||||
assert_eq!(p, Plan { backend: Auto, env: None, reason: None, remember: false });
|
||||
}
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Windows).backend, Dx12);
|
||||
assert_eq!(plan(Gl, None, None, false, Os::Other).backend, Gl);
|
||||
// A backend the platform lacks is `auto`.
|
||||
assert_eq!(plan(Metal, None, None, false, Os::Windows).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Mac).backend, Auto);
|
||||
assert_eq!(plan(Dx12, None, None, false, Os::Other).backend, Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crash_moves_down_each_chain_and_is_remembered() {
|
||||
// Windows: Vulkan → DX12 → CPU.
|
||||
let p = plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Dx12, true));
|
||||
assert!(p.reason.as_deref().unwrap_or("").contains("vulkan (Intel(R) UHD Graphics)"), "{p:?}");
|
||||
assert_eq!(plan(Dx12, Some(&crashed("dx12", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Crashed before the adapter was chosen (enumeration): from `auto`.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "")), None, false, Os::Windows).backend, Dx12);
|
||||
// `auto` that had already picked DX12 (the Intel default) goes to the CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "dx12")), None, false, Os::Windows).backend, Cpu);
|
||||
// Linux: Vulkan → GL → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Other).backend, Gl);
|
||||
assert_eq!(plan(Gl, Some(&crashed("gl", "gl")), None, false, Os::Other).backend, Cpu);
|
||||
// macOS: Metal → CPU.
|
||||
assert_eq!(plan(Auto, Some(&crashed("auto", "metal")), None, false, Os::Mac).backend, Cpu);
|
||||
// The CPU path stays there, and isn't "remembered" again.
|
||||
let p = plan(Cpu, Some(&crashed("cpu", "dx12")), None, false, Os::Windows);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
// An unreadable marker still counts as a crash.
|
||||
assert_eq!(plan(Auto, Some(&Marker::parse("{garbage")), None, false, Os::Windows).backend, Dx12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_beats_everything() {
|
||||
let p = plan(Cpu, Some(&crashed("auto", "vulkan")), Some("vulkan"), false, Os::Windows);
|
||||
assert_eq!(p.env.as_deref(), Some("vulkan"));
|
||||
assert_eq!(p.backend, Auto);
|
||||
assert!(!p.remember);
|
||||
assert_eq!(backends(&p, Os::Windows), None, "egui reads WGPU_BACKEND itself");
|
||||
// A crash under the override doesn't count against `auto` later.
|
||||
let p = plan(Auto, Some(&Marker { backend: "env:vulkan".into(), ..Default::default() }), None, false, Os::Windows);
|
||||
assert_eq!(p.backend, Auto);
|
||||
// Empty means unset.
|
||||
assert_eq!(plan(Auto, None, Some(" "), false, Os::Windows).env, None);
|
||||
// --safe-gpu with the override: CPU canvas, the override's backends.
|
||||
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_gpu_forces_the_cpu_for_one_launch() {
|
||||
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
|
||||
assert_eq!((p.backend, p.remember), (Cpu, false));
|
||||
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
|
||||
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL));
|
||||
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
|
||||
}
|
||||
|
||||
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
|
||||
Candidate { vendor, device_type, backend, surface_ok: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_on_windows_defaults_to_dx12() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
|
||||
let i = pick(&intel, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(intel[i].backend, B::Dx12);
|
||||
assert!(intel_dx12_applied(&intel, i, Os::Windows));
|
||||
// Elsewhere (and for other vendors) Vulkan stays first.
|
||||
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
|
||||
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
|
||||
let i = pick(&nv, Auto, Os::Windows).unwrap();
|
||||
assert_eq!(nv[i].backend, B::Vulkan);
|
||||
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
|
||||
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
|
||||
let hybrid = [intel[0], intel[1], nv[1]];
|
||||
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
|
||||
// Adapters that can't present lose to ones that can.
|
||||
let mut only = [cand(0x10de, T::DiscreteGpu, B::Vulkan), intel[1]];
|
||||
only[0].surface_ok = false;
|
||||
assert_eq!(pick(&only, Auto, Os::Windows), Some(1));
|
||||
assert_eq!(pick(&[], Auto, Os::Windows), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_prefers_software_adapters() {
|
||||
use wgpu::{Backend as B, DeviceType as T};
|
||||
let a = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(0x1414, T::Cpu, B::Dx12)];
|
||||
assert_eq!(pick(&a, Cpu, Os::Windows), Some(1));
|
||||
// No software adapter: still something.
|
||||
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_round_trip_and_lenient_parse() {
|
||||
let m = Marker {
|
||||
backend: "auto".into(),
|
||||
adapter: "A \"quoted\" GPU".into(),
|
||||
adapter_backend: "vulkan".into(),
|
||||
driver: "1.2".into(),
|
||||
version: "0.1".into(),
|
||||
};
|
||||
assert_eq!(Marker::parse(&m.to_json()), m);
|
||||
assert_eq!(Marker::parse(""), Marker::default());
|
||||
assert_eq!(Marker::parse("[1,2]").tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": 7}"#).tried(), Some(Auto));
|
||||
assert_eq!(Marker::parse(r#"{"backend": "env:dx12"}"#).tried(), None);
|
||||
let long = format!(r#"{{"adapter": "{}"}}"#, "x".repeat(10_000));
|
||||
assert_eq!(Marker::parse(&long).adapter.len(), 256);
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_detects_a_crash_and_clears_on_success() {
|
||||
let dir = temp_dir("cycle");
|
||||
// First launch: nothing left behind.
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "auto".into(), ..Default::default() }).unwrap();
|
||||
s.write(Marker { backend: "auto".into(), adapter: "GPU".into(), adapter_backend: "vulkan".into(), ..Default::default() }).unwrap();
|
||||
// While it is starting, a second instance sees it busy, not crashed.
|
||||
let (busy, none) = Sentinel::begin(&dir);
|
||||
assert_eq!(busy, Previous::Busy);
|
||||
assert!(none.is_none());
|
||||
// The process dies in the driver: the lock goes, the marker stays.
|
||||
drop(s);
|
||||
let (prev, s) = Sentinel::begin(&dir);
|
||||
let m = prev.crashed().cloned().expect("crash detected");
|
||||
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
|
||||
// This start succeeds: the next one finds nothing.
|
||||
let mut s = s.expect("sentinel");
|
||||
s.write(Marker { backend: "dx12".into(), ..Default::default() }).unwrap();
|
||||
s.finish();
|
||||
assert!(!dir.join(MARKER_FILE).exists());
|
||||
let (prev, _s) = Sentinel::begin(&dir);
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwritable_config_dir_is_not_fatal() {
|
||||
let dir = temp_dir("file");
|
||||
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
|
||||
std::fs::write(&dir, b"not a directory").unwrap();
|
||||
let (prev, s) = Sentinel::begin(&dir.join("sub"));
|
||||
assert_eq!(prev, Previous::Clean);
|
||||
assert!(s.is_none());
|
||||
let _ = std::fs::remove_file(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_are_read_leniently() {
|
||||
let dir = temp_dir("prefs");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("preferences.json");
|
||||
assert_eq!(read_prefs(None), (Auto, true));
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Dx12, false));
|
||||
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
std::fs::write(&p, "{not json").unwrap();
|
||||
assert_eq!(read_prefs(Some(&p)), (Auto, true));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
-21
@@ -2,7 +2,11 @@
|
||||
//!
|
||||
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
|
||||
//! --control-token-file <path>] [--automation-read-root <dir>]
|
||||
//! [--automation-write-root <dir>] [files…]`
|
||||
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
|
||||
//!
|
||||
//! `--safe-gpu` starts with the CPU renderer (no GPU canvas; a software adapter for the window
|
||||
//! where the platform has one) for this launch, e.g. after a graphics driver crash. A start that
|
||||
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
|
||||
//!
|
||||
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
|
||||
//! The first line must authenticate; subsequent request lines get reply lines.
|
||||
@@ -13,12 +17,21 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod app_dirs;
|
||||
mod app_icon;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
mod crash_guard;
|
||||
mod gpu_startup;
|
||||
// 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;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
mod tablet;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
@@ -26,16 +39,6 @@ use photocraft_ui_egui::PhotocraftApp;
|
||||
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
|
||||
const APP_ID: &str = "ai.storyteller.photocraft";
|
||||
|
||||
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
|
||||
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
|
||||
fn app_icon() -> egui::IconData {
|
||||
#[cfg(target_os = "macos")]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
|
||||
eframe::icon_data::from_png_bytes(PNG).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
crash_guard::install_hook();
|
||||
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
@@ -44,6 +47,7 @@ fn main() -> eframe::Result {
|
||||
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
|
||||
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
|
||||
let mut files = Vec::new();
|
||||
let mut safe_gpu = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
@@ -52,6 +56,7 @@ fn main() -> eframe::Result {
|
||||
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
|
||||
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
|
||||
"--safe-gpu" => safe_gpu = true,
|
||||
"--version" => {
|
||||
println!("photocraft {}", photocraft_engine::build_info::long_version());
|
||||
return Ok(());
|
||||
@@ -62,6 +67,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()) {
|
||||
@@ -96,13 +109,20 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
|
||||
// Pen tablet samples on macOS (AppKit event monitor, before winit sees each event) and X11
|
||||
// (started once eframe says which display server it is on). The monitor lives until the event
|
||||
// loop returns.
|
||||
let stylus_feed = photocraft_ui_egui::stylus::StylusFeed::default();
|
||||
#[cfg(target_os = "macos")]
|
||||
let _tablet = tablet::install_macos(&stylus_feed);
|
||||
|
||||
// Read the main display's ICC profile while the window opens (colour-managed canvas).
|
||||
let monitor = monitor_profile::detect_async();
|
||||
// Brush presets load in the background; the app attaches them when they arrive.
|
||||
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
|
||||
let mut options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_icon(app_icon())
|
||||
.with_icon(app_icon::window_icon())
|
||||
.with_app_id(APP_ID)
|
||||
.with_title("PhotoCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
@@ -113,9 +133,38 @@ fn main() -> eframe::Result {
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
|
||||
// driver moves to a safer one), and lock this start's marker until the first frames render.
|
||||
let t_sentinel = std::time::Instant::now();
|
||||
let os = gpu_startup::Os::current();
|
||||
let (pref, _) = gpu_startup::read_prefs(services::prefs_file().as_deref());
|
||||
let (previous, sentinel) = match services::config_dir() {
|
||||
Some(dir) => gpu_startup::Sentinel::begin(&dir),
|
||||
None => (gpu_startup::Previous::Clean, None),
|
||||
};
|
||||
let env_backend = std::env::var("WGPU_BACKEND").ok();
|
||||
let plan = gpu_startup::plan(pref, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
|
||||
if let Some(m) = previous.crashed() {
|
||||
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
|
||||
}
|
||||
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
|
||||
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
|
||||
let backend = match &plan.env {
|
||||
Some(v) => format!("env:{v}"),
|
||||
None => plan.backend.name().to_string(),
|
||||
};
|
||||
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
|
||||
if let Err(e) = s.write(marker) {
|
||||
log::warn!("GPU startup marker: {e}");
|
||||
}
|
||||
}
|
||||
let gpu_note: std::sync::Arc<std::sync::Mutex<Option<String>>> = Default::default();
|
||||
// The adapter's real texture limits (egui asks for 8192 px), so big documents stay on the GPU.
|
||||
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
|
||||
eframe::run_native(
|
||||
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
|
||||
let sentinel_ms = t_sentinel.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("GPU startup: {:?} ({sentinel_ms:.2} ms)", plan);
|
||||
let started_sentinel = sentinel.clone();
|
||||
let result = eframe::run_native(
|
||||
"Photocraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
@@ -127,13 +176,39 @@ fn main() -> eframe::Result {
|
||||
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
|
||||
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
|
||||
}
|
||||
// Preferences › Performance › Use Graphics Processor.
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
|
||||
// composites on the CPU).
|
||||
let info = &mut app.perf.gpu_info;
|
||||
info.preference = pref.name().to_string();
|
||||
info.selected = if plan.env.is_some() { "env".into() } else { plan.backend.name().to_string() };
|
||||
info.fallback = plan.reason.clone().or_else(|| gpu_note.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone());
|
||||
info.canvas = "cpu".into();
|
||||
if let Some(rs) = cc.wgpu_render_state.clone() {
|
||||
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
|
||||
if plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
|
||||
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
|
||||
&& app.session.prefs().performance.use_gpu
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
} else {
|
||||
// The window still draws with wgpu: record its errors instead of panicking.
|
||||
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
|
||||
}
|
||||
}
|
||||
app.perf.span("gpuSentinel", sentinel_ms);
|
||||
// Once the first frames rendered: clear the marker, and keep a crash fallback.
|
||||
let remember = plan.remember.then_some(plan.backend);
|
||||
app.on_started(move |app| {
|
||||
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
|
||||
s.finish();
|
||||
}
|
||||
if let Some(b) = remember
|
||||
&& app.session.prefs().performance.gpu_backend != b
|
||||
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
|
||||
{
|
||||
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
|
||||
}
|
||||
});
|
||||
if let Some((port, token, _)) = control {
|
||||
let rx = control_server::start(port, token, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
@@ -142,9 +217,26 @@ fn main() -> eframe::Result {
|
||||
{
|
||||
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above
|
||||
// and the X11 reader write into this feed.
|
||||
app.stylus.feed = stylus_feed;
|
||||
#[cfg(target_os = "linux")]
|
||||
tablet::spawn_x11(&app.stylus.feed, tablet::DisplayKind::of(cc));
|
||||
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
|
||||
app.open_paths(&files);
|
||||
// Portable marker found but its data folder isn't writable (#228): say where settings went.
|
||||
if let Some(w) = &app_dirs::current().warning {
|
||||
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
);
|
||||
// Closed before the first frames rendered: not a driver crash. (A start that failed to
|
||||
// create its device keeps the marker, so the next one tries a safer backend.)
|
||||
if result.is_ok()
|
||||
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||
{
|
||||
s.finish();
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -36,24 +36,14 @@ fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])>
|
||||
v
|
||||
}
|
||||
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
|
||||
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
|
||||
/// `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`).
|
||||
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else `<exe dir>/PhotoCraftData` in
|
||||
/// portable mode, else the platform convention. Everything the app persists lives under it; see
|
||||
/// [`crate::app_dirs`].
|
||||
pub fn config_dir() -> Option<PathBuf> {
|
||||
if let Some(d) = std::env::var_os("PHOTOCRAFT_CONFIG_DIR") {
|
||||
return Some(PathBuf::from(d));
|
||||
}
|
||||
let home = std::env::var_os("HOME").map(PathBuf::from);
|
||||
if cfg!(target_os = "macos") {
|
||||
return home.map(|h| h.join("Library/Application Support/Photocraft"));
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Photocraft"));
|
||||
}
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
|
||||
crate::app_dirs::config_dir()
|
||||
}
|
||||
|
||||
fn prefs_file() -> Option<PathBuf> {
|
||||
pub fn prefs_file() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("preferences.json"))
|
||||
}
|
||||
|
||||
@@ -67,19 +57,17 @@ fn recovery_dir() -> Option<PathBuf> {
|
||||
config_dir().map(|d| d.join("Recovery"))
|
||||
}
|
||||
|
||||
/// Write `bytes` atomically (temp file + rename) so a crash never leaves half a preferences file.
|
||||
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
|
||||
/// [`photocraft_format::atomic`]). Every document write (Save, Save As, Export, Save for Web) and
|
||||
/// the preferences go through here, so a failed or interrupted save never destroys the old file.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_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())?;
|
||||
@@ -122,7 +110,7 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
|
||||
}
|
||||
Some(d.save_file()?.to_string_lossy().to_string())
|
||||
})),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
|
||||
automation_read,
|
||||
automation_write,
|
||||
automation_command,
|
||||
@@ -132,14 +120,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.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
|
||||
//!
|
||||
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
|
||||
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
|
||||
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
|
||||
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
|
||||
//! `WM_POINTER` pressure as touch force. Wayland has no tablet input yet (see the crate docs).
|
||||
|
||||
use photocraft_tablet::Sample;
|
||||
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
|
||||
|
||||
/// A tablet sample as the UI's pen sample.
|
||||
pub fn pen_sample(s: Sample) -> PenSample {
|
||||
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
|
||||
}
|
||||
|
||||
/// Writes tablet samples into `feed` (`None` = a mouse).
|
||||
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
|
||||
move |s| feed.set(s.map(pen_sample))
|
||||
}
|
||||
|
||||
/// Install the AppKit tablet monitor (main thread, before the event loop). Keep the result until
|
||||
/// the event loop returns. On failure this logs why: pens then paint like a mouse.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
|
||||
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
|
||||
}
|
||||
|
||||
/// Start the XInput2 reader when eframe runs on X11. On Wayland, `$DISPLAY` is Xwayland, which
|
||||
/// sees none of this window's input (and a pen sample from another X app would outlive a mouse
|
||||
/// stroke here), so nothing starts.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
|
||||
if display != Some(DisplayKind::X11) {
|
||||
log::info!("tablet pressure: not available on this display server ({display:?}); Wayland is a follow-up to #79");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The windowing system eframe ended up on.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DisplayKind {
|
||||
X11,
|
||||
Wayland,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl DisplayKind {
|
||||
/// From the creation context's display handle.
|
||||
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
|
||||
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
|
||||
Some(match cc.display_handle().ok()?.as_raw() {
|
||||
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
|
||||
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
|
||||
_ => DisplayKind::Other,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_tablet::{Update, appkit, xi};
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
|
||||
use photocraft_ui_egui::state::Tool;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn samples_map_and_sanitize() {
|
||||
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
|
||||
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
|
||||
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
|
||||
let feed = StylusFeed::default();
|
||||
let f = sink(feed.clone());
|
||||
f(Some(Sample { pressure: 0.5, ..Default::default() }));
|
||||
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
|
||||
f(None);
|
||||
assert_eq!(feed.get(), None);
|
||||
}
|
||||
|
||||
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
|
||||
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
|
||||
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
|
||||
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
|
||||
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let feed = app.stylus.feed.clone();
|
||||
let sink = sink(feed);
|
||||
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
|
||||
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
|
||||
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
|
||||
app.ui.tool = Tool::Brush;
|
||||
let m = egui::Modifiers::NONE;
|
||||
let n = 20;
|
||||
for i in 0..=n {
|
||||
let t = i as f32 / n as f32;
|
||||
sink(next(0.1 + 0.9 * t));
|
||||
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
|
||||
let pressure = app.stylus.pressure();
|
||||
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
|
||||
tool_event(&mut app, ev, m);
|
||||
}
|
||||
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
|
||||
sink(None);
|
||||
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
|
||||
// half-width there.
|
||||
let mut dark = |x: i32, y: i32| -> f64 {
|
||||
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
|
||||
1.0 - px[0].as_f64().unwrap()
|
||||
};
|
||||
let (light, heavy) = (dark(50, 40), dark(200, 40));
|
||||
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
|
||||
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
|
||||
(light, light_w, heavy, heavy_w)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = appkit::State::default();
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
|
||||
let e =
|
||||
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
|
||||
|
||||
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
|
||||
let mut st = appkit::State::default();
|
||||
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
|
||||
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
|
||||
match st.handle(&e) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
}
|
||||
});
|
||||
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
|
||||
let mut st = xi::State::default();
|
||||
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
|
||||
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
|
||||
Update::Set(s) => s,
|
||||
Update::Keep => None,
|
||||
});
|
||||
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
|
||||
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_tablet_pressure_off_paints_like_a_mouse() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
|
||||
// The canvas copies the preference each frame; do what it does.
|
||||
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
|
||||
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
|
||||
assert_eq!(app.stylus.pressure(), 1.0);
|
||||
assert_eq!(app.stylus.sample(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_end_switches_to_the_eraser_and_back() {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
|
||||
let sink = sink(app.stylus.feed.clone());
|
||||
let mut st = appkit::State::default();
|
||||
let mut send = |e: appkit::RawEvent| {
|
||||
if let Update::Set(s) = st.handle(&e) {
|
||||
sink(s);
|
||||
}
|
||||
};
|
||||
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
|
||||
app.ui.tool = Tool::Brush;
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
|
||||
send(prox(appkit::device::PEN, false));
|
||||
send(prox(appkit::device::ERASER, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Eraser);
|
||||
// Mouse moves in between change nothing; the tip coming back restores the Brush.
|
||||
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
|
||||
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
send(prox(appkit::device::PEN, true));
|
||||
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
|
||||
assert_eq!(app.ui.tool, Tool::Brush);
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.tx
|
||||
- `hicolor/<size>/apps/ai.storyteller.photocraft.png` (16–512) and `hicolor/scalable/...svg`:
|
||||
Linux icon theme.
|
||||
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/main.rs`).
|
||||
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/app_icon.rs`,
|
||||
`main.rs`). On Windows the taskbar shows the icon of the Start Menu shortcut that launches the
|
||||
exe, so the MSI's `<Icon Id>` keeps the `.exe` extension (ICE50); `app_icon.rs` documents the
|
||||
details and tests the `.ico` sizes and the WiX icon references.
|
||||
|
||||
## Regenerate
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
<path d="m15 5 4 4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -59,6 +59,7 @@ pub mod scancrop;
|
||||
pub mod seam;
|
||||
pub mod segment;
|
||||
pub mod selection;
|
||||
mod selection_blur;
|
||||
mod sharpen;
|
||||
pub mod stack;
|
||||
mod stylize;
|
||||
|
||||
@@ -111,16 +111,6 @@ fn composite_area(
|
||||
}
|
||||
|
||||
/// Gradient tool over `area` (the selection bounds or canvas).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Per-pixel dither noise in [0, 1) from a position hash (decorrelated, no visible pattern).
|
||||
fn dither_noise(x: i32, y: i32) -> f32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x2C1B_3C6D);
|
||||
h ^= h >> 12;
|
||||
f32::from((h & 0xFFFF) as u16) / 65535.0
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint_gradient(
|
||||
s: &mut Surface,
|
||||
@@ -145,7 +135,7 @@ pub fn paint_gradient(
|
||||
let mut c = sample_stops(stops, if reverse { 1.0 - t } else { t });
|
||||
if dither {
|
||||
// One quantisation step of monochromatic noise breaks 8-bit banding without speckle.
|
||||
let n = (dither_noise(x, y) - 0.5) / 255.0;
|
||||
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
|
||||
for ch in c.iter_mut().take(3) {
|
||||
*ch = (*ch + n).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
+174
-87
@@ -4,10 +4,12 @@
|
||||
//! and combining with an existing selection.
|
||||
|
||||
use photocraft_color::PixelFormat;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
|
||||
use photocraft_raster::Surface;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::photo_util::par_rows;
|
||||
|
||||
/// How a new selection combines with the current one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -36,6 +38,32 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
match s {
|
||||
None => vec![0.0; n],
|
||||
// Selections are GRAY8: copy tile bytes through a table, tile rows in parallel.
|
||||
Some(s) if s.format() == PixelFormat::GRAY8 && !area.is_empty() => {
|
||||
let w = area.width() as usize;
|
||||
let lut: [f32; 256] = std::array::from_fn(|i| i as f32 / 255.0);
|
||||
let dp = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let mut out = vec![0.0f32; n];
|
||||
par_rows(&mut out, w, 1, |yy, row| {
|
||||
let y = area.y0 + yy as i32;
|
||||
let mut x = area.x0;
|
||||
while x < area.x1 {
|
||||
let tc = TileCoord::containing(x, y);
|
||||
let x1 = (tc.rect().x1).min(area.x1);
|
||||
let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default();
|
||||
let src = s.tile(tc).and_then(|t| {
|
||||
let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize;
|
||||
t.bytes().get(base..base + dst.len())
|
||||
});
|
||||
match src {
|
||||
Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]),
|
||||
None => dst.fill(dp),
|
||||
}
|
||||
x = x1;
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
Some(s) => {
|
||||
let mut v = Vec::new();
|
||||
s.read_region_into(area, &mut v);
|
||||
@@ -45,35 +73,81 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 8-bit selection value of a coverage (clamped and rounded, as GRAY8 surfaces store it; NaN → 0).
|
||||
#[inline]
|
||||
fn coverage8(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from 8-bit coverage over `area`, allocating only the tiles
|
||||
/// that hold something (as [`Surface::prune`] would leave them).
|
||||
fn surface_from_coverage8(bytes: &[u8], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
let w = area.width() as usize;
|
||||
let ts = TILE_SIZE as usize;
|
||||
for tc in area.tiles() {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
|
||||
let row = |y: i32| bytes.get((y - area.y0) as usize * w + lx..(y - area.y0) as usize * w + lx + span);
|
||||
if !(tr.y0..tr.y1).any(|y| row(y).is_some_and(|r| r.iter().any(|b| *b != 0))) {
|
||||
continue;
|
||||
}
|
||||
let data = s.tile_mut(tc).bytes_mut();
|
||||
for y in tr.y0..tr.y1 {
|
||||
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
|
||||
if let (Some(src), Some(dst)) = (row(y), data.get_mut(o..o + span)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 8-bit coverage of `m` (missing samples are 0), computed in parallel.
|
||||
fn to_coverage8(m: &[f32], area: Rect, f: impl Fn(usize, f32) -> f32 + Sync + Send) -> Vec<u8> {
|
||||
let w = area.width() as usize;
|
||||
let mut bytes = vec![0u8; w * area.height() as usize];
|
||||
par_rows(&mut bytes, w, 1, |y, row| {
|
||||
for (x, b) in row.iter_mut().enumerate() {
|
||||
let i = y * w + x;
|
||||
*b = coverage8(f(i, m.get(i).copied().unwrap_or(0.0)));
|
||||
}
|
||||
});
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Builds a GRAY8 selection surface from a mask over `area`.
|
||||
pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
if !area.is_empty() {
|
||||
s.write_region(area, m);
|
||||
if area.is_empty() {
|
||||
return Surface::new(PixelFormat::GRAY8);
|
||||
}
|
||||
s.prune();
|
||||
s
|
||||
surface_from_coverage8(&to_coverage8(m, area, |_, v| v), area)
|
||||
}
|
||||
|
||||
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
|
||||
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let prev = mask_from_surface(old, area);
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a
|
||||
// mask with itself is exact.
|
||||
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
|
||||
let new = &q[..];
|
||||
let out: Vec<f32> = match mode {
|
||||
SelectionMode::Replace => new.to_vec(),
|
||||
SelectionMode::Add => prev.iter().zip(new).map(|(a, b)| a.max(*b)).collect(),
|
||||
SelectionMode::Subtract => prev.iter().zip(new).map(|(a, b)| (a - b).max(0.0)).collect(),
|
||||
SelectionMode::Intersect => prev.iter().zip(new).map(|(a, b)| a.min(*b)).collect(),
|
||||
};
|
||||
// Below half an 8-bit step is nothing (selections are stored as 8-bit).
|
||||
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
|
||||
if out.iter().all(|v| *v <= 0.0) {
|
||||
if area.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(mask_to_surface(&out, area))
|
||||
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
|
||||
// exact. Below half an 8-bit step is nothing.
|
||||
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
|
||||
let bytes = match mode {
|
||||
SelectionMode::Replace => to_coverage8(new, area, |_, v| v),
|
||||
_ => {
|
||||
let prev = mask_from_surface(old, area);
|
||||
let at = |i: usize| prev.get(i).copied().unwrap_or(0.0);
|
||||
match mode {
|
||||
SelectionMode::Add => to_coverage8(new, area, |i, v| at(i).max(q(v))),
|
||||
SelectionMode::Subtract => to_coverage8(new, area, |i, v| (at(i) - q(v)).max(0.0)),
|
||||
_ => to_coverage8(new, area, |i, v| at(i).min(q(v))),
|
||||
}
|
||||
}
|
||||
};
|
||||
if bytes.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(surface_from_coverage8(&bytes, area))
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
|
||||
@@ -548,76 +622,17 @@ pub fn border(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect()
|
||||
}
|
||||
|
||||
fn box_blur(m: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
if r == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
let win = (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in x as i64 - r as i64..=x as i64 + r as i64 {
|
||||
s += m[y * w + k.clamp(0, w as i64 - 1) as usize];
|
||||
}
|
||||
tmp[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in y as i64 - r as i64..=y as i64 + r as i64 {
|
||||
s += tmp[k.clamp(0, h as i64 - 1) as usize * w + x];
|
||||
}
|
||||
out[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Smooth: removes specks and rounds corners (box average, then re-threshold).
|
||||
/// Smooth: removes specks and rounds corners (box average of radius `round(r)`, edges
|
||||
/// replicated, then re-thresholded). Parallel and independent of the radius (#211).
|
||||
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
|
||||
crate::selection_blur::smooth(m, w, h, r)
|
||||
}
|
||||
|
||||
/// Feather: Gaussian blur of the mask with sigma = radius / 2.
|
||||
/// Feather: Gaussian blur of the mask with sigma = radius / 2 (kernel truncated at 3σ, zero
|
||||
/// beyond the canvas). Parallel, confined to the selection's bounds, and independent of the
|
||||
/// radius (#211).
|
||||
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let sigma = radius.max(0.0) / 2.0;
|
||||
if sigma < 0.1 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let r = (sigma * 3.0).ceil() as i64;
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let s: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.iter().map(|v| v / s).collect();
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let xx = x as i64 + i as i64 - r;
|
||||
if xx >= 0 && xx < w as i64 {
|
||||
a += m[y * w + xx as usize] * kv;
|
||||
}
|
||||
}
|
||||
tmp[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let yy = y as i64 + i as i64 - r;
|
||||
if yy >= 0 && yy < h as i64 {
|
||||
a += tmp[yy as usize * w + x] * kv;
|
||||
}
|
||||
}
|
||||
out[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
out
|
||||
crate::selection_blur::feather(m, w, h, radius)
|
||||
}
|
||||
|
||||
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
|
||||
@@ -828,6 +843,78 @@ mod tests {
|
||||
assert_eq!(SelectionMode::parse("add"), SelectionMode::Add);
|
||||
}
|
||||
|
||||
/// The previous (per-sample) conversions, kept as the oracle for the tiled fast paths.
|
||||
fn old_mask_to_surface(m: &[f32], area: Rect) -> Surface {
|
||||
let mut s = Surface::new(PixelFormat::GRAY8);
|
||||
if !area.is_empty() {
|
||||
s.write_region(area, m);
|
||||
}
|
||||
s.prune();
|
||||
s
|
||||
}
|
||||
|
||||
fn old_combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
|
||||
let mut prev = Vec::new();
|
||||
match old {
|
||||
Some(s) => s.read_region_into(area, &mut prev),
|
||||
None => prev = vec![0.0; new.len()],
|
||||
}
|
||||
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
|
||||
let out: Vec<f32> = match mode {
|
||||
SelectionMode::Replace => q,
|
||||
SelectionMode::Add => prev.iter().zip(&q).map(|(a, b)| a.max(*b)).collect(),
|
||||
SelectionMode::Subtract => prev.iter().zip(&q).map(|(a, b)| (a - b).max(0.0)).collect(),
|
||||
SelectionMode::Intersect => prev.iter().zip(&q).map(|(a, b)| a.min(*b)).collect(),
|
||||
};
|
||||
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
|
||||
if out.iter().all(|v| *v <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(old_mask_to_surface(&out, area))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_conversions_match_per_sample_ones() {
|
||||
let mut rng = crate::photo_util::Rng(7);
|
||||
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
|
||||
// Off-grid areas spanning several tiles, including negative origins.
|
||||
for area in [Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6)] {
|
||||
let n = area.width() as usize * area.height() as usize;
|
||||
let old_m: Vec<f32> = (0..n).map(|i| if i % 97 < 40 { u() } else { 0.0 }).collect();
|
||||
let old = old_mask_to_surface(&old_m, area);
|
||||
let fast = mask_to_surface(&old_m, area);
|
||||
assert_eq!(fast, old, "{area:?}");
|
||||
let mut slow_read = Vec::new();
|
||||
old.read_region_into(area, &mut slow_read);
|
||||
assert_eq!(mask_from_surface(Some(&old), area), slow_read);
|
||||
for kind in 0..3 {
|
||||
let new: Vec<f32> = (0..n)
|
||||
.map(|i| match kind {
|
||||
0 => u() * 1.2 - 0.1,
|
||||
1 => {
|
||||
if i % 131 < 50 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => 0.001,
|
||||
})
|
||||
.collect();
|
||||
for mode in [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect] {
|
||||
for prev in [None, Some(&old)] {
|
||||
assert_eq!(combine(prev, &new, area, mode), old_combine(prev, &new, area, mode), "{area:?} {kind} {mode:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A short mask buffer reads as zeros instead of panicking.
|
||||
let a = Rect::new(0, 0, 10, 10);
|
||||
assert!(combine(None, &[1.0; 5], a, SelectionMode::Replace).is_some());
|
||||
assert_eq!(mask_to_surface(&[], a).tile_count(), 0);
|
||||
assert!(combine(None, &[], Rect::EMPTY, SelectionMode::Replace).is_none());
|
||||
}
|
||||
|
||||
fn blobs(w: i32, h: i32) -> Vec<[u8; 4]> {
|
||||
// Deterministic blotchy image with gradients and hard edges.
|
||||
(0..w * h)
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
//! Fast mask blurs behind Select › Modify › Smooth and Feather (#211).
|
||||
//!
|
||||
//! Both are separable and give the same numbers as the direct definitions (see the oracle tests):
|
||||
//!
|
||||
//! * **Smooth** is a box average with edge-replicated borders: a running sum per line, `O(1)` per
|
||||
//! pixel whatever the radius.
|
||||
//! * **Feather** is a Gaussian (`σ = radius / 2`, kernel truncated at `⌈3σ⌉` and normalized, zero
|
||||
//! outside the canvas). Small kernels are convolved directly. Wide ones are written as a short
|
||||
//! cosine series over the kernel window, `k(j) ≈ a₀ + Σ aₖ cos(ωₖ j)`, and every term is a
|
||||
//! sliding windowed sum `Σ m[x+j]·e^{iωₖj}` updated in `O(1)` per pixel, so the cost doesn't grow
|
||||
//! with the radius. The series is fitted per call by least squares and its error is checked
|
||||
//! (≤ 0.3/255 per pass); a fit that misses falls back to the direct convolution.
|
||||
//!
|
||||
//! Work is confined to the mask's non-zero bounding box grown by the kernel radius (pixels
|
||||
//! farther away stay exactly 0). Rows run in parallel (`par_rows`/`par_map`, serial on wasm);
|
||||
//! the vertical pass walks strips of 128 columns row by row (vectorizable, no transpose). Windows
|
||||
//! lying entirely in a constant run (inside or outside the selection) are written without
|
||||
//! arithmetic.
|
||||
|
||||
use crate::photo_util::{par_map, par_rows};
|
||||
|
||||
/// Cosine terms in the wide-kernel series (5 gives ≈ 0.1/255 per pass; see the module docs).
|
||||
const TERMS: usize = 5;
|
||||
/// Kernels up to this radius are convolved directly.
|
||||
const DIRECT_MAX_R: usize = 12;
|
||||
/// Largest error (in mask units, per pass) a cosine fit may have before falling back.
|
||||
const FIT_TOL: f64 = 0.3 / 255.0;
|
||||
/// σ cap: far beyond any canvas, keeps the kernel arithmetic finite for absurd radii.
|
||||
const MAX_SIGMA: f32 = 100_000.0;
|
||||
/// Box radius cap for the same reason.
|
||||
const MAX_BOX_R: usize = 1 << 24;
|
||||
/// Columns per task in the vertical pass.
|
||||
const STRIP: usize = 64;
|
||||
|
||||
/// A window of the mask: columns `x0..x1`, rows `y0..y1`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Win {
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
}
|
||||
|
||||
impl Win {
|
||||
fn w(&self) -> usize {
|
||||
self.x1 - self.x0
|
||||
}
|
||||
fn h(&self) -> usize {
|
||||
self.y1 - self.y0
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounding box of the non-zero samples (NaN counts as non-zero), or `None` when all are 0.
|
||||
fn nonzero_bounds(m: &[f32], w: usize, h: usize) -> Option<Win> {
|
||||
let rows: Vec<Option<(usize, usize)>> = par_map(h, |y| {
|
||||
let row = m.get(y * w..(y + 1) * w)?;
|
||||
let first = row.iter().position(|v| *v != 0.0)?;
|
||||
let last = row.iter().rposition(|v| *v != 0.0)?;
|
||||
Some((first, last + 1))
|
||||
});
|
||||
let mut b: Option<Win> = None;
|
||||
for (y, r) in rows.into_iter().enumerate() {
|
||||
if let Some((a, z)) = r {
|
||||
b = Some(match b {
|
||||
None => Win { x0: a, x1: z, y0: y, y1: y + 1 },
|
||||
Some(b) => Win { x0: b.x0.min(a), x1: b.x1.max(z), y0: b.y0, y1: y + 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
/// `b` grown by `g` on every side, clamped to `w × h`.
|
||||
fn grow(b: Win, g: usize, w: usize, h: usize) -> Win {
|
||||
Win { x0: b.x0.saturating_sub(g), x1: b.x1.saturating_add(g).min(w), y0: b.y0.saturating_sub(g), y1: b.y1.saturating_add(g).min(h) }
|
||||
}
|
||||
|
||||
/// The horizontal pass's output as the vertical pass reads it: `bh` rows of `gw` samples placed
|
||||
/// at window rows `off..off + bh` of a window `gh` rows tall (other rows are zero).
|
||||
struct Rows<'a> {
|
||||
tmp: &'a [f32],
|
||||
gw: usize,
|
||||
bh: usize,
|
||||
off: usize,
|
||||
gh: usize,
|
||||
}
|
||||
|
||||
impl Rows<'_> {
|
||||
/// Columns `c0..c0 + sw` of window row `yy`, or `None` for a zero row.
|
||||
fn get(&self, yy: usize, c0: usize, sw: usize) -> Option<&[f32]> {
|
||||
let r = yy.checked_sub(self.off).filter(|r| *r < self.bh)?;
|
||||
self.tmp.get(r * self.gw + c0..r * self.gw + c0 + sw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the vertical pass `f(c0, sw)` on strips of `STRIP` columns in parallel; each returns its
|
||||
/// `gh × sw` result, row-major.
|
||||
fn strips(gw: usize, f: impl Fn(usize, usize) -> Vec<f32> + Sync + Send) -> Vec<Vec<f32>> {
|
||||
par_map(gw.div_ceil(STRIP), |s| {
|
||||
let c0 = s * STRIP;
|
||||
f(c0, STRIP.min(gw - c0))
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes the strips into a fresh `w × h` mask through `f`; outside the window it is `outside`.
|
||||
fn assemble(strips: &[Vec<f32>], win: Win, w: usize, h: usize, outside: f32, f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
|
||||
let mut out = vec![outside; w * h];
|
||||
par_rows(&mut out, w, 1, |y, row| {
|
||||
let Some(yy) = y.checked_sub(win.y0).filter(|_| y < win.y1) else { return };
|
||||
for (s, strip) in strips.iter().enumerate() {
|
||||
let c0 = s * STRIP;
|
||||
let sw = STRIP.min(win.w().saturating_sub(c0));
|
||||
if let (Some(src), Some(dst)) = (strip.get(yy * sw..(yy + 1) * sw), row.get_mut(win.x0 + c0..win.x0 + c0 + sw)) {
|
||||
for (d, v) in dst.iter_mut().zip(src) {
|
||||
*d = f(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Adds `k ×` window row `yy` (columns `c0..c0 + acc.len()`) into `acc`.
|
||||
fn add_row(acc: &mut [f64], rows: &Rows, yy: usize, c0: usize, k: f64) {
|
||||
if k == 0.0 {
|
||||
return;
|
||||
}
|
||||
if let Some(row) = rows.get(yy, c0, acc.len()) {
|
||||
for (a, v) in acc.iter_mut().zip(row) {
|
||||
*a += k * f64::from(*v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Smooth: box average, edges replicated -------------------------------------------------
|
||||
|
||||
/// Running-sum box average of one line (radius `r`, edges replicated) into `dst`.
|
||||
fn box_line(src: &[f32], dst: &mut [f32], r: usize) {
|
||||
let n = src.len().min(dst.len());
|
||||
let (Some(&first), Some(&last)) = (src.first(), src.get(n.wrapping_sub(1))) else { return };
|
||||
if r == 0 {
|
||||
dst[..n].copy_from_slice(&src[..n]);
|
||||
return;
|
||||
}
|
||||
let at = |i: i64| -> f64 { f64::from(src.get(i.clamp(0, n as i64 - 1) as usize).copied().unwrap_or(0.0)) };
|
||||
// Window at x = 0: r copies of the first sample, then src[0..=r], then copies of the last.
|
||||
let inside = r.min(n - 1);
|
||||
let mut acc = r as f64 * f64::from(first) + src[..=inside].iter().map(|v| f64::from(*v)).sum::<f64>() + (r - inside) as f64 * f64::from(last);
|
||||
let norm = 1.0 / (2 * r + 1) as f64;
|
||||
let ri = r as i64;
|
||||
for (x, d) in dst[..n].iter_mut().enumerate() {
|
||||
*d = (acc * norm) as f32;
|
||||
let x = x as i64;
|
||||
acc += at(x + ri + 1) - at(x - ri);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then the
|
||||
/// contrast curve `(v − ½)·4 + ½` that re-hardens the edge and drops specks.
|
||||
pub(crate) fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
let curve = |v: f32| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0);
|
||||
let n = w.saturating_mul(h);
|
||||
if m.len() != n || n == 0 {
|
||||
return m.iter().map(|v| curve(*v)).collect();
|
||||
}
|
||||
let r = if r.is_finite() {
|
||||
(r.max(0.0).round() as usize).min(MAX_BOX_R)
|
||||
} else if r > 0.0 {
|
||||
MAX_BOX_R
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let Some(b) = nonzero_bounds(m, w, h) else {
|
||||
return vec![curve(0.0); n];
|
||||
};
|
||||
// Grown one past the radius, so a window edge inside the canvas sits on zeros and edge
|
||||
// replication there equals the zeros beyond it.
|
||||
let win = grow(b, r.saturating_add(1), w, h);
|
||||
let (gw, gh) = (win.w(), win.h());
|
||||
// Rows: only the bounding box's rows are non-zero.
|
||||
let mut tmp = vec![0.0f32; b.h() * gw];
|
||||
par_rows(&mut tmp, gw, 1, |y, row| {
|
||||
let o = (b.y0 + y) * w + win.x0;
|
||||
if let Some(s) = m.get(o..o + gw) {
|
||||
box_line(s, row, r);
|
||||
}
|
||||
});
|
||||
// Columns: a running sum of rows per strip of columns.
|
||||
let rows = Rows { tmp: &tmp, gw, bh: b.h(), off: b.y0 - win.y0, gh };
|
||||
let res = strips(gw, |c0, sw| box_strip(&rows, c0, sw, r));
|
||||
drop(tmp);
|
||||
assemble(&res, win, w, h, curve(0.0), curve)
|
||||
}
|
||||
|
||||
/// The vertical box pass over columns `c0..c0 + sw` (edges replicated).
|
||||
fn box_strip(rows: &Rows, c0: usize, sw: usize, r: usize) -> Vec<f32> {
|
||||
let gh = rows.gh;
|
||||
let mut out = vec![0.0f32; gh * sw];
|
||||
if gh == 0 || sw == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut acc = vec![0.0f64; sw];
|
||||
// Window at row 0: r copies of row 0, rows 0..=r, then copies of the last row.
|
||||
let inside = r.min(gh - 1);
|
||||
add_row(&mut acc, rows, 0, c0, r as f64);
|
||||
for j in 0..=inside {
|
||||
add_row(&mut acc, rows, j, c0, 1.0);
|
||||
}
|
||||
add_row(&mut acc, rows, gh - 1, c0, (r - inside) as f64);
|
||||
let norm = 1.0 / (2 * r + 1) as f64;
|
||||
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
|
||||
for (d, a) in dst.iter_mut().zip(&acc) {
|
||||
*d = (a * norm) as f32;
|
||||
}
|
||||
add_row(&mut acc, rows, (yy + r + 1).min(gh - 1), c0, 1.0);
|
||||
add_row(&mut acc, rows, yy.saturating_sub(r), c0, -1.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- Feather: truncated Gaussian, zero outside -----------------------------------------------
|
||||
|
||||
/// Sliding cosine-series form of a symmetric kernel over `-r..=r`.
|
||||
struct CosKernel {
|
||||
r: usize,
|
||||
a0: f64,
|
||||
a: [f64; TERMS],
|
||||
/// `e^{-iω}`: shifts the window by one sample.
|
||||
rot: [(f64, f64); TERMS],
|
||||
/// `e^{-iωr}` (leaving sample) and `e^{iωr}` (entering sample).
|
||||
out_w: [(f64, f64); TERMS],
|
||||
in_w: [(f64, f64); TERMS],
|
||||
/// `Σ_{|j|≤r} cos(ωj)`: a window full of ones.
|
||||
full: [f64; TERMS],
|
||||
omega: [f64; TERMS],
|
||||
}
|
||||
|
||||
enum Kernel {
|
||||
/// Taps for `-r..=r`.
|
||||
Direct(Vec<f32>),
|
||||
Cos(Box<CosKernel>),
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_range_loop)] // textbook elimination reads clearer with indices
|
||||
/// Solves the small dense system `a·x = b` (partial pivoting); `None` when singular.
|
||||
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let n = b.len();
|
||||
for c in 0..n {
|
||||
let p = (c..n).max_by(|i, j| a[*i][c].abs().total_cmp(&a[*j][c].abs()))?;
|
||||
if a[p][c].abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
a.swap(c, p);
|
||||
b.swap(c, p);
|
||||
for i in c + 1..n {
|
||||
let f = a[i][c] / a[c][c];
|
||||
for j in c..n {
|
||||
a[i][j] -= f * a[c][j];
|
||||
}
|
||||
b[i] -= f * b[c];
|
||||
}
|
||||
}
|
||||
let mut x = vec![0.0; n];
|
||||
for i in (0..n).rev() {
|
||||
let s: f64 = (i + 1..n).map(|j| a[i][j] * x[j]).sum();
|
||||
x[i] = (b[i] - s) / a[i][i];
|
||||
}
|
||||
x.iter().all(|v| v.is_finite()).then_some(x)
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
/// The feather kernel (σ, truncation radius `r`) as applied along lines of length `n`.
|
||||
fn new(sigma: f64, r: usize, n: usize) -> Kernel {
|
||||
// Normalized over the full truncated window, as the direct definition does.
|
||||
let g = |j: f64| (-(j * j) / (2.0 * sigma * sigma)).exp();
|
||||
let total: f64 = 1.0 + 2.0 * (1..=r).map(|j| g(j as f64)).sum::<f64>();
|
||||
// Only offsets inside the line matter.
|
||||
let jr = r.min(n.saturating_sub(1));
|
||||
if jr == 0 {
|
||||
// One-sample line: the centre tap alone.
|
||||
return Kernel::Direct(vec![(1.0 / total) as f32]);
|
||||
}
|
||||
let taps: Vec<f64> = (0..=jr).map(|j| g(j as f64) / total).collect();
|
||||
let direct = || Kernel::Direct((0..=2 * jr).map(|i| taps[i.abs_diff(jr)] as f32).collect());
|
||||
if jr <= DIRECT_MAX_R {
|
||||
return direct();
|
||||
}
|
||||
// Least squares over j = -jr..=jr (symmetric: weight 2 for j > 0).
|
||||
let l = (jr + 1) as f64;
|
||||
let omega: [f64; TERMS] = std::array::from_fn(|k| std::f64::consts::PI * (k + 1) as f64 / l);
|
||||
let basis = |q: usize, j: usize| if q == 0 { 1.0 } else { (omega[q - 1] * j as f64).cos() };
|
||||
let wt = |j: usize| if j == 0 { 1.0 } else { 2.0 };
|
||||
let mut a = vec![vec![0.0; TERMS + 1]; TERMS + 1];
|
||||
let mut b = vec![0.0; TERMS + 1];
|
||||
for (j, t) in taps.iter().enumerate() {
|
||||
let phi: Vec<f64> = (0..=TERMS).map(|q| basis(q, j)).collect();
|
||||
for p in 0..=TERMS {
|
||||
b[p] += wt(j) * phi[p] * t;
|
||||
for q in 0..=TERMS {
|
||||
a[p][q] += wt(j) * phi[p] * phi[q];
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(c) = solve(a, b) else { return direct() };
|
||||
// Error bound per pass: half the L1 distance between the series and the kernel.
|
||||
let err: f64 = taps.iter().enumerate().map(|(j, t)| wt(j) * ((0..=TERMS).map(|q| c[q] * basis(q, j)).sum::<f64>() - t).abs()).sum::<f64>() / 2.0;
|
||||
if !err.is_finite() || err > FIT_TOL {
|
||||
return direct();
|
||||
}
|
||||
let jf = jr as f64;
|
||||
let full: [f64; TERMS] = std::array::from_fn(|k| 1.0 + 2.0 * (1..=jr).map(|j| (omega[k] * j as f64).cos()).sum::<f64>());
|
||||
Kernel::Cos(Box::new(CosKernel {
|
||||
r: jr,
|
||||
a0: c[0],
|
||||
a: std::array::from_fn(|k| c[k + 1]),
|
||||
rot: std::array::from_fn(|k| (omega[k].cos(), -omega[k].sin())),
|
||||
out_w: std::array::from_fn(|k| ((omega[k] * jf).cos(), -(omega[k] * jf).sin())),
|
||||
in_w: std::array::from_fn(|k| ((omega[k] * jf).cos(), (omega[k] * jf).sin())),
|
||||
full,
|
||||
omega,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convolves one line (zero outside it) into `dst`.
|
||||
fn line(&self, src: &[f32], dst: &mut [f32], runs: &mut Vec<usize>) {
|
||||
let n = src.len().min(dst.len());
|
||||
match self {
|
||||
Kernel::Direct(taps) => {
|
||||
let r = taps.len() / 2;
|
||||
dst[..n].fill(0.0);
|
||||
for (i, k) in taps.iter().enumerate() {
|
||||
// Output x reads src[x + i - r].
|
||||
let (d0, s0) = if i >= r { (0, i - r) } else { (r - i, 0) };
|
||||
if d0 >= n || s0 >= n {
|
||||
continue;
|
||||
}
|
||||
let len = (n - d0).min(n - s0);
|
||||
for (d, s) in dst[d0..d0 + len].iter_mut().zip(&src[s0..s0 + len]) {
|
||||
*d += k * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
Kernel::Cos(k) => k.line(&src[..n], &mut dst[..n], runs),
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical pass over columns `c0..c0 + sw` (zero outside the window).
|
||||
fn strip(&self, rows: &Rows, c0: usize, sw: usize) -> Vec<f32> {
|
||||
match self {
|
||||
Kernel::Direct(taps) => {
|
||||
let r = taps.len() / 2;
|
||||
let mut out = vec![0.0f32; rows.gh * sw];
|
||||
if sw == 0 {
|
||||
return out;
|
||||
}
|
||||
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
|
||||
for (i, k) in taps.iter().enumerate() {
|
||||
let Some(src) = (yy + i).checked_sub(r).and_then(|y| rows.get(y, c0, sw)) else { continue };
|
||||
for (d, s) in dst.iter_mut().zip(src) {
|
||||
*d += k * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Kernel::Cos(k) => k.strip(rows, c0, sw),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CosKernel {
|
||||
#[allow(clippy::needless_range_loop)] // `x` and `k` index several arrays in lockstep
|
||||
fn line(&self, src: &[f32], dst: &mut [f32], runs: &mut Vec<usize>) {
|
||||
let n = src.len();
|
||||
let r = self.r;
|
||||
// runs[i]: end (exclusive) of the constant run containing i.
|
||||
runs.clear();
|
||||
runs.resize(n, n);
|
||||
for i in (0..n.saturating_sub(1)).rev() {
|
||||
runs[i] = if src[i] == src[i + 1] { runs[i + 1] } else { i + 1 };
|
||||
}
|
||||
let at = |i: usize| f64::from(src.get(i).copied().unwrap_or(0.0));
|
||||
// Window sums for the last position handled (valid when `have`).
|
||||
let mut have = false;
|
||||
let mut sb = 0.0f64;
|
||||
let mut s = [(0.0f64, 0.0f64); TERMS];
|
||||
for x in 0..n {
|
||||
let lo = x.saturating_sub(r);
|
||||
let hi = (x + r + 1).min(n);
|
||||
let c = src[lo];
|
||||
if runs[lo] >= hi && (c == 0.0 || (x >= r && x + r < n)) {
|
||||
// The whole window is one value (zeros may run off the line; ones may not).
|
||||
dst[x] = c;
|
||||
let cf = f64::from(c);
|
||||
sb = cf * (2 * r + 1) as f64;
|
||||
for (sk, fk) in s.iter_mut().zip(&self.full) {
|
||||
*sk = (cf * fk, 0.0);
|
||||
}
|
||||
have = true;
|
||||
continue;
|
||||
}
|
||||
if have {
|
||||
// Slide from x - 1: drop src[x - 1 - r], take src[x + r].
|
||||
let gone = if x > r { at(x - 1 - r) } else { 0.0 };
|
||||
let come = at(x + r);
|
||||
sb += come - gone;
|
||||
for k in 0..TERMS {
|
||||
let (re, im) = (s[k].0 - gone * self.out_w[k].0, s[k].1 - gone * self.out_w[k].1);
|
||||
let (cr, ci) = self.rot[k];
|
||||
s[k] = (re * cr - im * ci + come * self.in_w[k].0, re * ci + im * cr + come * self.in_w[k].1);
|
||||
}
|
||||
} else {
|
||||
// Direct sums over the in-line part of the window.
|
||||
sb = 0.0;
|
||||
s = [(0.0, 0.0); TERMS];
|
||||
for (i, v) in src.iter().enumerate().take(hi).skip(lo) {
|
||||
let v = f64::from(*v);
|
||||
if v == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let j = i as f64 - x as f64;
|
||||
sb += v;
|
||||
for (sk, w) in s.iter_mut().zip(&self.omega) {
|
||||
sk.0 += v * (w * j).cos();
|
||||
sk.1 += v * (w * j).sin();
|
||||
}
|
||||
}
|
||||
have = true;
|
||||
}
|
||||
let v = self.a0 * sb + s.iter().zip(&self.a).map(|(sk, a)| a * sk.0).sum::<f64>();
|
||||
dst[x] = v as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CosKernel {
|
||||
/// [`CosKernel::line`] down columns `c0..c0 + sw`, all columns of a row at once.
|
||||
#[allow(clippy::needless_range_loop)] // `k` indexes several term arrays in lockstep
|
||||
fn strip(&self, rows: &Rows, c0: usize, sw: usize) -> Vec<f32> {
|
||||
let gh = rows.gh;
|
||||
let r = self.r;
|
||||
let mut out = vec![0.0f32; gh * sw];
|
||||
if sw == 0 {
|
||||
return out;
|
||||
}
|
||||
// Rows whose strip segment is one value, and the runs of equal such rows.
|
||||
let flag: Vec<Option<f32>> = (0..gh)
|
||||
.map(|yy| match rows.get(yy, c0, sw) {
|
||||
None => Some(0.0),
|
||||
Some(s) => {
|
||||
let c = s.first().copied().unwrap_or(0.0);
|
||||
s.iter().all(|v| *v == c).then_some(c)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut runs = vec![gh; gh];
|
||||
for i in (0..gh.saturating_sub(1)).rev() {
|
||||
runs[i] = match (flag[i], flag[i + 1]) {
|
||||
(Some(a), Some(b)) if a == b => runs[i + 1],
|
||||
_ => i + 1,
|
||||
};
|
||||
}
|
||||
let zeros = vec![0.0f32; sw];
|
||||
let mut sb = vec![0.0f64; sw];
|
||||
let mut re = vec![vec![0.0f64; sw]; TERMS];
|
||||
let mut im = vec![vec![0.0f64; sw]; TERMS];
|
||||
let mut acc = vec![0.0f64; sw];
|
||||
let mut have = false;
|
||||
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
|
||||
let lo = yy.saturating_sub(r);
|
||||
let hi = (yy + r + 1).min(gh);
|
||||
// The whole window is one value (zero rows may run off the window; others may not).
|
||||
if let Some(c) = flag[lo]
|
||||
&& runs[lo] >= hi
|
||||
&& (c == 0.0 || (yy >= r && yy + r < gh))
|
||||
{
|
||||
dst.fill(c);
|
||||
let cf = f64::from(c);
|
||||
sb.fill(cf * (2 * r + 1) as f64);
|
||||
for k in 0..TERMS {
|
||||
re[k].fill(cf * self.full[k]);
|
||||
im[k].fill(0.0);
|
||||
}
|
||||
have = true;
|
||||
continue;
|
||||
}
|
||||
if have {
|
||||
// Slide from yy - 1: drop row yy - 1 - r, take row yy + r.
|
||||
let gone = if yy > r { rows.get(yy - 1 - r, c0, sw) } else { None }.unwrap_or(&zeros);
|
||||
let come = rows.get(yy + r, c0, sw).unwrap_or(&zeros);
|
||||
for ((s, g), c) in sb.iter_mut().zip(gone).zip(come) {
|
||||
*s += f64::from(*c) - f64::from(*g);
|
||||
}
|
||||
for k in 0..TERMS {
|
||||
let (cr, ci) = self.rot[k];
|
||||
let (o0, o1) = self.out_w[k];
|
||||
let (i0, i1) = self.in_w[k];
|
||||
for (((sr, si), g), c) in re[k].iter_mut().zip(im[k].iter_mut()).zip(gone).zip(come) {
|
||||
let (g, c) = (f64::from(*g), f64::from(*c));
|
||||
let (a, b) = (*sr - g * o0, *si - g * o1);
|
||||
*sr = a * cr - b * ci + c * i0;
|
||||
*si = a * ci + b * cr + c * i1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct sums over the in-window rows.
|
||||
sb.fill(0.0);
|
||||
for k in 0..TERMS {
|
||||
re[k].fill(0.0);
|
||||
im[k].fill(0.0);
|
||||
}
|
||||
for i in lo..hi {
|
||||
let Some(row) = rows.get(i, c0, sw) else { continue };
|
||||
let j = i as f64 - yy as f64;
|
||||
add_row(&mut sb, rows, i, c0, 1.0);
|
||||
for k in 0..TERMS {
|
||||
let (cs, sn) = ((self.omega[k] * j).cos(), (self.omega[k] * j).sin());
|
||||
for ((sr, si), v) in re[k].iter_mut().zip(im[k].iter_mut()).zip(row) {
|
||||
*sr += f64::from(*v) * cs;
|
||||
*si += f64::from(*v) * sn;
|
||||
}
|
||||
}
|
||||
}
|
||||
have = true;
|
||||
}
|
||||
for (a, s) in acc.iter_mut().zip(&sb) {
|
||||
*a = self.a0 * s;
|
||||
}
|
||||
for k in 0..TERMS {
|
||||
for (a, s) in acc.iter_mut().zip(&re[k]) {
|
||||
*a += self.a[k] * s;
|
||||
}
|
||||
}
|
||||
for (d, a) in dst.iter_mut().zip(&acc) {
|
||||
*d = *a as f32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius / 2`, the kernel
|
||||
/// truncated at `⌈3σ⌉` and normalized, zero beyond the canvas.
|
||||
pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
let sigma = if radius.is_finite() {
|
||||
radius.max(0.0) / 2.0
|
||||
} else if radius > 0.0 {
|
||||
MAX_SIGMA
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if sigma < 0.1 || m.len() != n || n == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let sigma = sigma.min(MAX_SIGMA);
|
||||
let r = (sigma * 3.0).ceil() as usize;
|
||||
let Some(b) = nonzero_bounds(m, w, h) else {
|
||||
return m.to_vec();
|
||||
};
|
||||
let win = grow(b, r, w, h);
|
||||
let (gw, gh) = (win.w(), win.h());
|
||||
let s = f64::from(sigma);
|
||||
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
|
||||
// Rows: only the bounding box's rows are non-zero.
|
||||
let mut tmp = vec![0.0f32; b.h() * gw];
|
||||
par_rows(&mut tmp, gw, 1, |y, row| {
|
||||
let o = (b.y0 + y) * w + win.x0;
|
||||
if let Some(s) = m.get(o..o + gw) {
|
||||
kh.line(s, row, &mut Vec::new());
|
||||
}
|
||||
});
|
||||
// Columns, a strip of columns at a time.
|
||||
let rows = Rows { tmp: &tmp, gw, bh: b.h(), off: b.y0 - win.y0, gh };
|
||||
let res = strips(gw, |c0, sw| kv.strip(&rows, c0, sw));
|
||||
drop(tmp);
|
||||
// Rounding residue of the sliding sums is not coverage.
|
||||
assemble(&res, win, w, h, 0.0, |v| if v.abs() < 1e-7 { 0.0 } else { v })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::photo_util::Rng;
|
||||
|
||||
/// The previous direct implementations, kept as the oracle.
|
||||
mod oracle {
|
||||
pub fn box_blur(m: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
|
||||
if r == 0 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
let win = (2 * r + 1) as f32;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in x as i64 - r as i64..=x as i64 + r as i64 {
|
||||
s += m[y * w + k.clamp(0, w as i64 - 1) as usize];
|
||||
}
|
||||
tmp[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut s = 0.0;
|
||||
for k in y as i64 - r as i64..=y as i64 + r as i64 {
|
||||
s += tmp[k.clamp(0, h as i64 - 1) as usize * w + x];
|
||||
}
|
||||
out[y * w + x] = s / win;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let sigma = radius.max(0.0) / 2.0;
|
||||
if sigma < 0.1 {
|
||||
return m.to_vec();
|
||||
}
|
||||
let r = (sigma * 3.0).ceil() as i64;
|
||||
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
|
||||
let s: f32 = k.iter().sum();
|
||||
let k: Vec<f32> = k.iter().map(|v| v / s).collect();
|
||||
let mut tmp = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let xx = x as i64 + i as i64 - r;
|
||||
if xx >= 0 && xx < w as i64 {
|
||||
a += m[y * w + xx as usize] * kv;
|
||||
}
|
||||
}
|
||||
tmp[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
let mut out = vec![0.0f32; m.len()];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let mut a = 0.0;
|
||||
for (i, kv) in k.iter().enumerate() {
|
||||
let yy = y as i64 + i as i64 - r;
|
||||
if yy >= 0 && yy < h as i64 {
|
||||
a += tmp[yy as usize * w + x] * kv;
|
||||
}
|
||||
}
|
||||
out[y * w + x] = a;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Random masks of several kinds: binary noise, soft noise, blobs with soft edges, a sparse
|
||||
/// scatter and an off-centre rectangle.
|
||||
fn masks(w: usize, h: usize, seed: u64) -> Vec<(&'static str, Vec<f32>)> {
|
||||
let mut rng = Rng(seed);
|
||||
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
|
||||
let binary: Vec<f32> = (0..w * h).map(|_| if u() < 0.5 { 1.0 } else { 0.0 }).collect();
|
||||
let soft: Vec<f32> = (0..w * h).map(|_| u()).collect();
|
||||
let (cx, cy, rad) = (w as f32 * u(), h as f32 * u(), (w.min(h) as f32 * 0.4).max(1.0));
|
||||
let blob: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5);
|
||||
(rad - ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
let sparse: Vec<f32> = (0..w * h).map(|_| if u() < 0.02 { 1.0 } else { 0.0 }).collect();
|
||||
let rect: Vec<f32> = (0..w * h).map(|i| if (i % w) * 3 >= w && (i / w) * 4 < h * 3 { 1.0 } else { 0.0 }).collect();
|
||||
vec![("binary", binary), ("soft", soft), ("blob", blob), ("sparse", sparse), ("rect", rect)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feather_matches_direct_gaussian() {
|
||||
for (w, h, seed) in [(97, 61, 1), (40, 150, 2), (13, 7, 3)] {
|
||||
for (kind, m) in masks(w, h, seed) {
|
||||
for radius in [0.1, 0.3, 1.0, 2.5, 8.0, 9.0, 17.0, 25.0, 50.0, 120.0, 1000.0] {
|
||||
let d = max_diff(&feather(&m, w, h, radius), &oracle::feather(&m, w, h, radius));
|
||||
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_matches_direct_box() {
|
||||
for (w, h, seed) in [(97, 61, 4), (40, 150, 5), (13, 7, 6)] {
|
||||
for (kind, m) in masks(w, h, seed) {
|
||||
for radius in [0.0, 0.4, 1.0, 2.0, 5.0, 16.0, 50.0, 500.0] {
|
||||
let d = max_diff(&smooth(&m, w, h, radius), &oracle::smooth(&m, w, h, radius));
|
||||
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_cases() {
|
||||
// Empty selection stays empty; nothing selected far from a selection stays exactly 0.
|
||||
let z = vec![0.0; 50 * 40];
|
||||
assert_eq!(feather(&z, 50, 40, 30.0), z);
|
||||
assert_eq!(smooth(&z, 50, 40, 10.0), z);
|
||||
let mut dot = z.clone();
|
||||
dot[20 * 50 + 25] = 1.0;
|
||||
let f = feather(&dot, 50, 40, 4.0);
|
||||
assert_eq!(f[0], 0.0);
|
||||
assert!(f[20 * 50 + 25] > 0.0);
|
||||
// A full canvas: smooth keeps it; feather fades only at the canvas edge, as before.
|
||||
let one = vec![1.0; 50 * 40];
|
||||
assert_eq!(smooth(&one, 50, 40, 25.0), one);
|
||||
let f = feather(&one, 50, 40, 6.0);
|
||||
assert!((f[20 * 50 + 25] - 1.0).abs() < 1e-5);
|
||||
let f30 = feather(&one, 50, 40, 30.0);
|
||||
assert!(max_diff(&f30, &oracle::feather(&one, 50, 40, 30.0)) <= 1.0 / 255.0);
|
||||
assert!(f[0] < 0.5);
|
||||
assert!(max_diff(&f, &oracle::feather(&one, 50, 40, 6.0)) <= 1.0 / 255.0);
|
||||
// Radius 0 is identity (feather) / the bare contrast curve (smooth).
|
||||
let m: Vec<f32> = (0..50 * 40).map(|i| (i % 7) as f32 / 6.0).collect();
|
||||
assert_eq!(feather(&m, 50, 40, 0.0), m);
|
||||
assert_eq!(smooth(&m, 50, 40, 0.0), oracle::smooth(&m, 50, 40, 0.0));
|
||||
// 1×1 canvas and empty canvases.
|
||||
for r in [0.0, 1.0, 50.0, 1000.0] {
|
||||
assert!((feather(&[1.0], 1, 1, r)[0] - oracle::feather(&[1.0], 1, 1, r)[0]).abs() <= 1.0 / 255.0);
|
||||
assert_eq!(smooth(&[1.0], 1, 1, r), vec![1.0]);
|
||||
}
|
||||
assert!(feather(&[], 0, 0, 5.0).is_empty());
|
||||
assert!(smooth(&[], 0, 5, 5.0).is_empty());
|
||||
// Non-finite or absurd radii and a mismatched buffer return without panicking.
|
||||
for r in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 1e30] {
|
||||
let f = feather(&dot, 50, 40, r);
|
||||
assert_eq!(f.len(), dot.len());
|
||||
assert!(f.iter().all(|v| v.is_finite()), "{r}");
|
||||
let s = smooth(&dot, 50, 40, r);
|
||||
assert_eq!(s.len(), dot.len());
|
||||
}
|
||||
assert_eq!(feather(&dot, 50, 41, 5.0).len(), dot.len());
|
||||
assert_eq!(smooth(&dot, 49, 40, 5.0).len(), dot.len());
|
||||
}
|
||||
|
||||
/// Timings on a 24 MP ellipse (run with `--release -- --ignored --nocapture`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn bench_24mp() {
|
||||
let (w, h) = (6000usize, 4000usize);
|
||||
let m: Vec<f32> = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32 / w as f32 - 0.5, (i / w) as f32 / h as f32 - 0.5);
|
||||
if x * x + y * y < 0.11 { 1.0 } else { 0.0 }
|
||||
})
|
||||
.collect();
|
||||
// `SEL_BENCH=feather:250` runs one case (e.g. under `time` for its CPU cost).
|
||||
let only = std::env::var("SEL_BENCH").ok();
|
||||
for (name, r) in [("smooth", 50.0), ("feather", 50.0), ("feather", 250.0), ("feather", 1000.0)] {
|
||||
if only.as_ref().is_some_and(|o| *o != format!("{name}:{r}")) {
|
||||
continue;
|
||||
}
|
||||
let mut best = f64::MAX;
|
||||
for _ in 0..5 {
|
||||
let t = std::time::Instant::now();
|
||||
let out = if name == "smooth" { smooth(&m, w, h, r) } else { feather(&m, w, h, r) };
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
|
||||
assert_eq!(out.len(), m.len());
|
||||
}
|
||||
eprintln!("{name} r {r}: {best:.1} ms (best of 5)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cosine_fit_is_used_and_accurate() {
|
||||
for sigma in [7.0, 25.0, 125.0] {
|
||||
let r = (sigma * 3.0f64).ceil() as usize;
|
||||
match Kernel::new(sigma, r, 4000) {
|
||||
Kernel::Cos(_) => {}
|
||||
_ => panic!("σ {sigma}: expected the sliding cosine kernel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,26 @@ const CELL: f64 = 4.0;
|
||||
/// Most cells along one axis (bounds memory and time on huge layers).
|
||||
const MAX_CELLS: usize = 512;
|
||||
|
||||
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
|
||||
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
|
||||
/// pass, an exact shift for whole-pixel translations (so conversions and re-renders are
|
||||
/// lossless), bicubic otherwise. Shared by the engine's re-render and PSD export's filter cache.
|
||||
pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
|
||||
let [a, b, c, d, e, f] = t.m;
|
||||
if let Some(w) = warp.filter(|w| !w.is_identity()) {
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
(a * u + c * v + e, b * u + d * v + f)
|
||||
};
|
||||
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
|
||||
}
|
||||
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
|
||||
return crate::resample::translate_surface(src, e.round() as i32, f.round() as i32);
|
||||
}
|
||||
crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// Warps the content of `src` inside `src_rect` through the forward map `f` (source document
|
||||
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
|
||||
/// added; pixels outside the warped area are transparent.
|
||||
|
||||
@@ -61,10 +61,8 @@ pub fn save(
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let r = photocraft_io::export(doc, &ext, opts)?;
|
||||
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
}
|
||||
std::fs::write(path, &r.bytes).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
|
||||
// Crash-safe: a failed write never destroys the previous file.
|
||||
photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?;
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ impl Headless {
|
||||
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
|
||||
match &self.filesystem {
|
||||
Filesystem::Denied => Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
|
||||
Filesystem::TrustedLocal => std::fs::write(path, bytes).map_err(|error| AutomationError::Io(format!("{}: {error}", path.display()))),
|
||||
Filesystem::TrustedLocal => photocraft_format::atomic_write(path, bytes).map_err(|error| AutomationError::Io(error.to_string())),
|
||||
Filesystem::Workspace(workspace) => workspace.write(requested, bytes),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@ impl PhotocraftMcp {
|
||||
let Some(b) = self.bridge_client() else {
|
||||
return Ok(no_backend());
|
||||
};
|
||||
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": {}})).await)
|
||||
let params = p.index.map(|index| json!({"document": index})).unwrap_or_else(|| json!({}));
|
||||
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": params})).await)
|
||||
}
|
||||
|
||||
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use cap_std::ambient_authority;
|
||||
use cap_std::fs::{Dir, OpenOptions};
|
||||
use photocraft_format::atomic::RenameRetry;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::AutomationError;
|
||||
@@ -50,7 +51,10 @@ impl AuthorizedWorkspace {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Create or replace one file below the configured write root.
|
||||
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
|
||||
/// a temporary file beside the target, which is synced and renamed over it (the same steps
|
||||
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
|
||||
/// the previous file is untouched and the temporary file is removed.
|
||||
///
|
||||
/// The parent directory must already exist. `cap-std` performs path
|
||||
/// resolution and file creation relative to the held directory handle, so
|
||||
@@ -58,11 +62,29 @@ impl AuthorizedWorkspace {
|
||||
pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> {
|
||||
let relative = relative_path(path)?;
|
||||
let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?;
|
||||
let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
let mut file = root.dir.open_with(&relative, &options).map_err(|e| file_error("write", path, e))?;
|
||||
file.write_all(bytes).map_err(|e| file_error("write", path, e))?;
|
||||
file.flush().map_err(|e| file_error("write", path, e))
|
||||
options.write(true).create_new(true);
|
||||
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
});
|
||||
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
|
||||
if let Err(e) = renamed {
|
||||
let _ = root.dir.remove_file(&tmp);
|
||||
return Err(file_error("write", path, e));
|
||||
}
|
||||
// Flush the directory entry (Unix); best effort, the new bytes are already in place.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dir = if parent.as_os_str().is_empty() { PathBuf::from(".") } else { parent };
|
||||
if let Ok(d) = root.dir.open(&dir) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +278,43 @@ mod tests {
|
||||
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
|
||||
}
|
||||
|
||||
fn temp_files(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_replaces_atomically_and_leaves_no_temp_file() {
|
||||
let (inside, _, workspace) = roots("atomic");
|
||||
std::fs::create_dir_all(inside.join("sub")).unwrap();
|
||||
workspace.write("sub/out.psd", b"original").unwrap();
|
||||
workspace.write("sub/out.psd", b"replacement").unwrap();
|
||||
assert_eq!(std::fs::read(inside.join("sub/out.psd")).unwrap(), b"replacement");
|
||||
assert!(temp_files(&inside.join("sub")).is_empty());
|
||||
// Renaming over a directory fails: nothing is left behind and the directory survives.
|
||||
std::fs::create_dir_all(inside.join("adir")).unwrap();
|
||||
assert!(workspace.write("adir", b"x").is_err());
|
||||
assert!(inside.join("adir").is_dir());
|
||||
assert!(temp_files(&inside).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_into_read_only_folder_keeps_the_original() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (inside, _, workspace) = roots("readonly");
|
||||
let ro = inside.join("ro");
|
||||
std::fs::create_dir_all(&ro).unwrap();
|
||||
std::fs::write(ro.join("doc.psd"), b"original").unwrap();
|
||||
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let root_user = std::fs::File::create(ro.join("probe")).is_ok();
|
||||
if !root_user {
|
||||
assert!(workspace.write("ro/doc.psd", b"new").is_err());
|
||||
assert_eq!(std::fs::read(ro.join("doc.psd")).unwrap(), b"original");
|
||||
assert!(temp_files(&ro).is_empty());
|
||||
}
|
||||
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_traversal_mixed_prefix_and_malformed_paths_without_panicking() {
|
||||
let (_, _, workspace) = roots("reject");
|
||||
|
||||
@@ -317,7 +317,7 @@ async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
|
||||
json!({"id": id, "ok": true, "result": {"tool": "brush", "panels": ["layers"]}})
|
||||
}
|
||||
"engine.execute" => {
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"]}})
|
||||
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"]}})
|
||||
}
|
||||
"engine.commands" => {
|
||||
json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]})
|
||||
@@ -349,6 +349,9 @@ async fn bridge_forwards_to_control_protocol() {
|
||||
assert_eq!(ui["tool"], "brush");
|
||||
let r = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "X"}})).await);
|
||||
assert_eq!(r["ran"], "layer.new.layer");
|
||||
let inspected = json_of(&call(&client, "doc_inspect", json!({"index": 1})).await);
|
||||
assert_eq!(inspected["ran"], "document.inspect");
|
||||
assert_eq!(inspected["params"], json!({"document": 1}));
|
||||
let l = json_of(&call(&client, "command_list", json!({})).await);
|
||||
assert_eq!(l[0]["id"], "file.new");
|
||||
let shot = call(&client, "ui_screenshot", json!({"max_side": 20})).await;
|
||||
|
||||
@@ -7,6 +7,8 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[features]
|
||||
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
|
||||
corpus = []
|
||||
default = []
|
||||
# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF
|
||||
# stays read-unsupported and therefore asymmetric; it is off by default.
|
||||
|
||||
@@ -196,14 +196,16 @@ fn tiff_f32_output_matches_oracle() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Optional PngSuite corpus (repo-root corpus/pngsuite/*.png)
|
||||
// PngSuite corpus (repo-root corpus/pngsuite/*.png; feature `corpus`, fetched by
|
||||
// `cargo xtask corpus --all`; missing = failure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn pngsuite_corpus_matches_oracle() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/pngsuite");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
panic!("{} is missing: run `cargo xtask corpus --all`", dir.display());
|
||||
};
|
||||
let mut checked = 0;
|
||||
for e in entries.flatten() {
|
||||
@@ -224,4 +226,5 @@ fn pngsuite_corpus_matches_oracle() {
|
||||
}
|
||||
}
|
||||
eprintln!("pngsuite: {checked} files compared");
|
||||
assert!(checked > 100, "{}: only {checked} PNGs compared: run `cargo xtask corpus --all`", dir.display());
|
||||
}
|
||||
@@ -10,6 +10,17 @@ pub mod convert;
|
||||
|
||||
pub use blend::BlendMode;
|
||||
|
||||
/// Per-pixel gradient dither noise in `[0, 1)` from a position hash (decorrelated, no visible
|
||||
/// pattern). Shared by the Gradient tool and Gradient Fill layers (and mirrored in the GPU
|
||||
/// compositor), so a live gradient dithers exactly like the painted one.
|
||||
pub fn dither_noise(x: i32, y: i32) -> f32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
|
||||
h ^= h >> 15;
|
||||
h = h.wrapping_mul(0x2C1B_3C6D);
|
||||
h ^= h >> 12;
|
||||
f32::from((h & 0xFFFF) as u16) / 65535.0
|
||||
}
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Storage type of one channel sample.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+268
-61
@@ -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
|
||||
@@ -411,6 +417,22 @@ fn offset(angle: f32, distance: f32) -> (f32, f32) {
|
||||
((-a.cos() * distance).round(), (a.sin() * distance).round())
|
||||
}
|
||||
|
||||
/// The lengths a 100 % gradient at `angle` spans in a `w` × `h` frame: the chord (Linear and
|
||||
/// Reflected) and the ellipse-norm length (Radial, Angle, Diamond; its half is the radius).
|
||||
pub fn gradient_units(angle: f32, w: f32, h: f32) -> (f32, f32) {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
// Gradient length: the bounds' extent along the angle as an ellipse
|
||||
// norm (a unit gradient scaled to the bounds); fitted on psd-tools
|
||||
// gradient-styles.psd (cached Photoshop renderings of every style).
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0);
|
||||
// Linear / Reflected span the chord of the bounds through their centre along the angle:
|
||||
// min(w / |cos|, h / |sin|). A 45° gradient on a 29 px square runs 41 px (psd-tools
|
||||
// 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);
|
||||
(chord, len)
|
||||
}
|
||||
|
||||
/// Gradient parameter `t` in `0..=1` for pixel centre `(x, y)` inside `bounds`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, offset: (f32, f32), bounds: Rect, x: f32, y: f32) -> f32 {
|
||||
@@ -423,18 +445,15 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
|
||||
// Distance along the gradient direction (y axis points down).
|
||||
let along = dx * c - dy * s;
|
||||
let across = dx * s + dy * c;
|
||||
// Gradient length: the bounds' extent along the angle as an ellipse
|
||||
// norm (a unit gradient scaled to the bounds); fitted on psd-tools
|
||||
// gradient-styles.psd (cached Photoshop renderings of every style).
|
||||
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0) * scale.max(1e-3);
|
||||
// Linear / Reflected span the chord of the bounds through their centre along the angle:
|
||||
// min(w / |cos|, h / |sin|). A 45° gradient on a 29 px square runs 41 px (psd-tools
|
||||
// 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);
|
||||
let (chord, len) = gradient_units(angle, w, h);
|
||||
let (chord, len) = (chord * scale.max(1e-3), len * 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 +634,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,
|
||||
@@ -704,8 +724,9 @@ fn tent_line(src: &[f32], dst: &mut [f32], bw: f32) {
|
||||
}
|
||||
|
||||
/// Rows of a `w`-wide row-major buffer, each through `f(src_row, dst_row)`, in parallel on
|
||||
/// scoped OS threads. Not rayon: maps are built inside a `OnceLock` while rayon renders tiles,
|
||||
/// and a rayon worker that stole another tile here would block on that same lock (deadlock).
|
||||
/// scoped OS threads. Not rayon: maps can be built from inside a rayon tile, where a waiting
|
||||
/// worker steals other tiles. Map builds no longer block on each other (#276), but the blur
|
||||
/// keeps out of the tile pool.
|
||||
fn rows_par(src: &[f32], dst: &mut [f32], w: usize, f: impl Fn(&[f32], &mut [f32]) + Sync) {
|
||||
if w == 0 {
|
||||
return;
|
||||
@@ -811,7 +832,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 +851,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 +946,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 +965,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 +978,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)
|
||||
@@ -970,6 +1011,17 @@ pub struct FxMaps {
|
||||
dout: Option<Vec<f32>>,
|
||||
/// Shape layers: distance outside the vector outline (from local coverage).
|
||||
vdout: Option<Vec<f32>>,
|
||||
/// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`).
|
||||
outline: bool,
|
||||
/// Frames of gradient strokes by outside width (bits): see [`stroke_frame`].
|
||||
frames: Vec<(u32, Rect)>,
|
||||
}
|
||||
|
||||
impl FxMaps {
|
||||
/// The frame of a gradient stroke `st` of this layer (see [`stroke_frame`]), if built.
|
||||
pub fn stroke_frame(&self, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
|
||||
self.frames.iter().find(|f| f.0 == stroke_widths(st).1.to_bits()).map(|f| f.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl FxMaps {
|
||||
@@ -1059,8 +1111,21 @@ pub(crate) fn build_maps_prepared(
|
||||
let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
|
||||
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
|
||||
let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Chamfer))) } else { (None, None) };
|
||||
let vdout = (has_stroke && vector_shape).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
|
||||
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout }
|
||||
// Without an outline (unfilled shapes), estimate the vector outline from local coverage.
|
||||
let outline = crate::effect_outline(layer).is_some();
|
||||
let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
|
||||
let mut frames: Vec<(u32, Rect)> = Vec::new();
|
||||
for e in &items {
|
||||
if let Effect::Stroke(st) = e
|
||||
&& matches!(st.paint, FxPaint::Gradient(_))
|
||||
{
|
||||
let out_w = stroke_widths(st).1;
|
||||
if !frames.iter().any(|f| f.0 == out_w.to_bits()) {
|
||||
frames.push((out_w.to_bits(), stroke_frame(&shape, out_w, rect)));
|
||||
}
|
||||
}
|
||||
}
|
||||
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames }
|
||||
}
|
||||
|
||||
/// Far outside any shape (distance fill for cropped distance fields).
|
||||
@@ -1070,7 +1135,7 @@ const FAR: f32 = 1.0e9;
|
||||
/// masked, clipped layers applied) plus its effects into `backdrop`.
|
||||
pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) {
|
||||
let prepared = PreparedPatterns::new(patterns, PREPARED_PATTERN_BYTES);
|
||||
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared);
|
||||
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared, None);
|
||||
}
|
||||
|
||||
pub(crate) fn composite_with_effects_prepared(
|
||||
@@ -1080,10 +1145,21 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
maps: &FxMaps,
|
||||
layer_bounds: Rect,
|
||||
patterns: &PreparedPatterns<'_>,
|
||||
vstroke: Option<VectorStroke<'_>>,
|
||||
) {
|
||||
let big = content.rect;
|
||||
let (w, h) = (big.width() as usize, big.height() as usize);
|
||||
let shape = Map { w, h, v: content.px.iter().map(|p| p[3]).collect() };
|
||||
// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the
|
||||
// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
|
||||
let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0);
|
||||
let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a)));
|
||||
let shape = if maps.outline {
|
||||
let o = maps.crop(&maps.shape, big, 0.0);
|
||||
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() }
|
||||
} else {
|
||||
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() }
|
||||
};
|
||||
let relative = maps.outline || vstroke.is_some();
|
||||
let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0);
|
||||
// Layer bounds (gradients aligned with the layer use the whole layer,
|
||||
// independent of the render rect).
|
||||
@@ -1132,7 +1208,18 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
// keeps its alpha, as in Photoshop.
|
||||
let fill = layer.fill_opacity;
|
||||
let inside = |a: f32| a > INSIDE_EPS;
|
||||
let mut lay = Buffer { rect: big, px: content.px.iter().map(|p| [p[0], p[1], p[2], if inside(p[3]) { fill } else { 0.0 }]).collect() };
|
||||
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
|
||||
// gradient fill) acts like fill opacity: the effects still cover the whole shape.
|
||||
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
|
||||
if !inside(a) {
|
||||
0.0
|
||||
} else if relative {
|
||||
fill * (kmask(i) * p[3] / a).min(1.0)
|
||||
} else {
|
||||
fill
|
||||
}
|
||||
};
|
||||
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
|
||||
let rel = |m: Map| -> Map {
|
||||
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
|
||||
Map { w: m.w, h: m.h, v }
|
||||
@@ -1171,6 +1258,15 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
|
||||
}
|
||||
}
|
||||
// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and
|
||||
// interior effects, below the stroke effect (psd-tools stroke-composite).
|
||||
if let Some(vs) = &vstroke {
|
||||
for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() {
|
||||
if s[3] > 0.0 && inside(*a) {
|
||||
*p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (kmask(i) * s[3] / a).min(1.0)], 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Strokes. Inside parts are painted over the layer (bottom instance first); outside parts
|
||||
// are slid beneath it, so the first listed (top) instance is processed first.
|
||||
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
|
||||
@@ -1178,11 +1274,9 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
(Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let widths = |st: &photocraft_doc::StrokeFx| match st.position {
|
||||
StrokePosition::Outside => (0.0, st.size),
|
||||
StrokePosition::Inside => (st.size, 0.0),
|
||||
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
|
||||
};
|
||||
let widths = stroke_widths;
|
||||
// A gradient stroke aligned with the layer spans the stroke's own extent.
|
||||
let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb);
|
||||
for st in strokes.iter().rev().copied() {
|
||||
let (in_w, _) = widths(st);
|
||||
if in_w <= 0.0 {
|
||||
@@ -1192,11 +1286,11 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) {
|
||||
*mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
paint_fx(&mut lay, &m, &st.paint, sb, anchor, big, st.common.blend, st.common.opacity, patterns);
|
||||
paint_fx(&mut lay, &m, &st.paint, frame(st), anchor, big, st.common.blend, st.common.opacity, patterns);
|
||||
}
|
||||
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);
|
||||
@@ -1222,19 +1316,27 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
if out_w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Shape layers: Photoshop strokes the vector outline, estimated from
|
||||
// local coverage; the stroke never shows through the shape's pixels.
|
||||
// Shape layers: Photoshop strokes the vector outline (estimated from local coverage
|
||||
// for unfilled shapes); the stroke never shows through the shape's pixels. Along a
|
||||
// filled shape's outline it covers the part of each edge pixel outside the path.
|
||||
let d = vdout.as_ref().unwrap_or(&dout);
|
||||
let mut share = vec![0.0f32; w * h];
|
||||
for (i, sh) in share.iter_mut().enumerate() {
|
||||
let k = if inside(shape.v[i]) { if vector_shape { 0.0 } else { 1.0 } } else { (out_w + 0.5 - d[i]).clamp(0.0, 1.0) };
|
||||
let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0);
|
||||
let k = if maps.outline {
|
||||
band * outline_share(shape.v[i], lay.px[i][3])
|
||||
} else if inside(shape.v[i]) {
|
||||
if vector_shape { 0.0 } else { 1.0 }
|
||||
} else {
|
||||
band
|
||||
};
|
||||
*sh = k * st.common.opacity * (1.0 - cover[i]);
|
||||
cover[i] += *sh;
|
||||
}
|
||||
// The stroke blended over the backdrop at full coverage.
|
||||
let mut blended = base.clone();
|
||||
let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() };
|
||||
paint_fx(&mut blended, &ones, &st.paint, sb, anchor, big, st.common.blend, 1.0, patterns);
|
||||
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, st.common.blend, 1.0, patterns);
|
||||
for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) {
|
||||
if *s > 0.0 {
|
||||
for c in 0..3 {
|
||||
@@ -1257,10 +1359,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 +1375,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 {
|
||||
@@ -1286,6 +1398,55 @@ pub(crate) fn composite_with_effects_prepared(
|
||||
}
|
||||
}
|
||||
|
||||
/// Coverage of an outside stroke beneath a filled shape's edge pixel, for a pixel the outline
|
||||
/// covers `cov` of and the layer (with its interior effects) ends at alpha `l`. Photoshop treats
|
||||
/// the stroke and the shape as disjoint areas: the stroke fills the part outside the path and
|
||||
/// the layer adds its own alpha (psd-tools stroke-effects: a 58 % covered edge pixel of an
|
||||
/// opaque fill ends opaque, one of a faded fill at 42 % + its alpha). Composited beneath the
|
||||
/// layer, `share + l (1 - share) = (1 - cov) + l`.
|
||||
pub fn outline_share(cov: f32, l: f32) -> f32 {
|
||||
let outside = (1.0 - cov.clamp(0.0, 1.0)).max(0.0);
|
||||
let rest = 1.0 - l.clamp(0.0, 1.0);
|
||||
if rest <= 1e-6 { 1.0 } else { (outside / rest).min(1.0) }
|
||||
}
|
||||
|
||||
/// (inside width, outside width) of a stroke.
|
||||
pub fn stroke_widths(st: &photocraft_doc::StrokeFx) -> (f32, f32) {
|
||||
match st.position {
|
||||
StrokePosition::Outside => (0.0, st.size),
|
||||
StrokePosition::Inside => (st.size, 0.0),
|
||||
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a gradient stroke aligned with the layer is laid out in: the pixel bounds of the
|
||||
/// effect shape (over `rect`) grown by the stroke's outside width less one pixel. Photoshop
|
||||
/// spans the gradient over the stroke's extent, not the layer's: a 4 px outside stroke around a
|
||||
/// 22 px square runs its 90° gradient over 28 px (psd-tools stroke-effects), a 3 px one around
|
||||
/// 36 px of type over 40 px (psd-tools effect-stroke-gradient).
|
||||
fn stroke_frame(shape: &Map, out_w: f32, rect: Rect) -> Rect {
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
|
||||
for y in 0..shape.h {
|
||||
for x in 0..shape.w {
|
||||
if shape.v[y * shape.w + x] > INSIDE_EPS {
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if x0 == usize::MAX {
|
||||
return rect;
|
||||
}
|
||||
let grow = if out_w.is_finite() { (out_w.min(MAX_REACH).ceil() as i32 - 1).max(0) } else { 0 };
|
||||
Rect::new(rect.x0 + x0 as i32, rect.y0 + y0 as i32, rect.x0 + x1 as i32, rect.y0 + y1 as i32).inflate(grow)
|
||||
}
|
||||
|
||||
/// A stroked shape's vector stroke, composited apart from its fill (which the compositor then
|
||||
/// passes unmasked): the stroke's pixels over the render rect and the layer's mask values.
|
||||
pub(crate) struct VectorStroke<'a> {
|
||||
pub stroke: &'a Buffer,
|
||||
pub mask: Option<&'a [f32]>,
|
||||
}
|
||||
|
||||
/// Premultiplied linear interpolation between two straight-alpha pixels.
|
||||
fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
|
||||
let alpha = a[3] + (b[3] - a[3]) * k;
|
||||
@@ -1305,15 +1466,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 +1487,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 +1535,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 +1549,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 +1576,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 +1681,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,156 @@
|
||||
//! 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 offset_centres_snap_like_the_frame_centre() {
|
||||
// A zero offset is the plain layout exactly.
|
||||
let f = Rect::new(0, 0, 7, 5);
|
||||
for style in [GradientStyle::Linear, GradientStyle::Reflected] {
|
||||
assert_eq!(gradient_layout(style, 30.0, 1.0, (0.0, 0.0), f), fill_gradient_layout(style, 30.0, 1.0, f));
|
||||
}
|
||||
// Moving the centre by whole pixels moves the snapped layout by the same pixels.
|
||||
let big = Rect::new(0, 0, 40, 20);
|
||||
let (a0, s0, o0) = gradient_layout(GradientStyle::Linear, 30.0, 0.5, (0.0, 0.0), big);
|
||||
let (a1, s1, o1) = gradient_layout(GradientStyle::Linear, 30.0, 0.5, (0.25, -0.1), big);
|
||||
assert!((a0 - a1).abs() < 1e-4 && (s0 - s1).abs() < 1e-4, "{a0} {a1} {s0} {s1}");
|
||||
assert!((o1.0 - o0.0 - 0.25).abs() < 1e-4 && (o1.1 - o0.1 + 0.1).abs() < 1e-4, "{o0:?} {o1:?}");
|
||||
// Non-snapping styles and bad offsets pass through.
|
||||
assert_eq!(gradient_layout(GradientStyle::Radial, 30.0, 1.0, (0.2, 0.1), big), (30.0, 1.0, (0.2, 0.1)));
|
||||
let (_, _, o) = gradient_layout(GradientStyle::Linear, 30.0, 1.0, (f32::NAN, 0.0), big);
|
||||
assert!(o.0.is_nan());
|
||||
}
|
||||
|
||||
#[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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Gradient Fill layers: their colour ramp (colour stops with midpoints, opacity stops, dither)
|
||||
//! and the on-canvas geometry of the Gradient tool's live gradients, where a start and an end
|
||||
//! point stand for the fill's angle, scale and centre offset.
|
||||
//!
|
||||
//! The mapping matches the Gradient tool's destructive drag (`photocraft_algo::paint`): a
|
||||
//! Linear gradient runs from the start (t = 0) to the end point (t = 1); Radial, Angle,
|
||||
//! Reflected and Diamond gradients are centred on the start point and reach t = 1 at the end
|
||||
//! point's distance. A fill made from a drag therefore renders the same pixels as painting that
|
||||
//! drag (see the tests).
|
||||
|
||||
use photocraft_doc::{Fill, GradientStyle};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::effects::gradient_units;
|
||||
|
||||
/// Smallest gradient length a handle drag produces (pixels), so the fill stays invertible.
|
||||
const MIN_LEN: f32 = 0.5;
|
||||
|
||||
/// A gradient fill's colour ramp, prepared for one render (colours converted once, while the
|
||||
/// document's CMYK profile is active).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ramp {
|
||||
color: Vec<(f32, [f32; 4])>,
|
||||
mids: Vec<f32>,
|
||||
opacity: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
impl Ramp {
|
||||
/// The ramp of a gradient fill (`None` for other fills).
|
||||
pub fn new(f: &Fill) -> Option<Ramp> {
|
||||
let Fill::Gradient { stops, opacity_stops, midpoints, .. } = f else { return None };
|
||||
let mut color: Vec<(f32, [f32; 4])> = stops
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let rgb = c.to_rgb();
|
||||
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
})
|
||||
.collect();
|
||||
color.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut opacity = opacity_stops.clone();
|
||||
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Some(Ramp { color, mids: midpoints.clone(), opacity })
|
||||
}
|
||||
|
||||
/// Colour and alpha at `t` (`0..=1`).
|
||||
pub fn sample(&self, t: f32) -> [f32; 4] {
|
||||
let mut c = sample_color(&self.color, &self.mids, t);
|
||||
if !self.opacity.is_empty() {
|
||||
c[3] *= sample_opacity(&self.opacity, t);
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a segment of relative position `u` lands once its midpoint is `m` (0.5 = unchanged):
|
||||
/// the colour is half-way at `m`, linear on either side.
|
||||
pub fn midpoint_remap(u: f32, m: f32) -> f32 {
|
||||
let m = m.clamp(0.05, 0.95);
|
||||
if (m - 0.5).abs() < 1e-6 {
|
||||
u
|
||||
} else if u <= m {
|
||||
0.5 * u / m
|
||||
} else {
|
||||
0.5 + 0.5 * (u - m) / (1.0 - m)
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
for (i, w) in stops.windows(2).enumerate() {
|
||||
let (a, b) = (&w[0], &w[1]);
|
||||
if t <= b.0 {
|
||||
let u = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 0.0 };
|
||||
let k = midpoint_remap(u, mids.get(i).copied().unwrap_or(0.5));
|
||||
return std::array::from_fn(|c| a.1[c] + (b.1[c] - a.1[c]) * k);
|
||||
}
|
||||
}
|
||||
last.1
|
||||
}
|
||||
|
||||
fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return 1.0 };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
for w in stops.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let u = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 };
|
||||
return w[0].1 + (w[1].1 - w[0].1) * u;
|
||||
}
|
||||
}
|
||||
last.1
|
||||
}
|
||||
|
||||
/// Adds one level of the shared dither noise to a colour (as the Gradient tool does).
|
||||
pub fn dither(c: &mut [f32; 4], x: i32, y: i32) {
|
||||
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
|
||||
for ch in c.iter_mut().take(3) {
|
||||
*ch = (*ch + n).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a gradient fill over `rect`, laid out in `frame` (row-major RGBA).
|
||||
pub fn render(f: &Fill, rect: Rect, frame: Rect) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize];
|
||||
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (Ramp::new(f), f) else { return px };
|
||||
let w = rect.width() as usize;
|
||||
if w == 0 {
|
||||
return px;
|
||||
}
|
||||
// Linear and Reflected end points on whole pixels, as Photoshop (crate::fill_layout).
|
||||
let (angle, scale, offset) = &crate::fill_layout::gradient_layout(*style, *angle, *scale, *offset, frame);
|
||||
let row = |y: i32, out: &mut [[f32; 4]]| {
|
||||
for (i, p) in out.iter_mut().enumerate() {
|
||||
let x = rect.x0 + i as i32;
|
||||
let t = crate::effects::gradient_t(*style, *angle, *scale, *reverse, *offset, frame, x as f32 + 0.5, y as f32 + 0.5);
|
||||
*p = ramp.sample(t);
|
||||
if *dith {
|
||||
dither(p, x, y);
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
px.par_chunks_mut(w).enumerate().for_each(|(j, out)| row(rect.y0 + j as i32, out));
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
for (j, out) in px.chunks_mut(w).enumerate() {
|
||||
row(rect.y0 + j as i32, out);
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
fn frame_dims(frame: Rect) -> (f32, f32) {
|
||||
(frame.width().max(1) as f32, frame.height().max(1) as f32)
|
||||
}
|
||||
|
||||
/// The centre a gradient is laid out around: the frame's centre moved by `offset`.
|
||||
fn centre(offset: (f32, f32), frame: Rect) -> [f32; 2] {
|
||||
let (w, h) = frame_dims(frame);
|
||||
[frame.x0 as f32 + w / 2.0 + offset.0 * w, frame.y0 as f32 + h / 2.0 + offset.1 * h]
|
||||
}
|
||||
|
||||
/// The gradient's reach from its centre at scale 1: the full chord for Linear, half the chord
|
||||
/// for Reflected, the radius for the others.
|
||||
fn unit_reach(style: GradientStyle, angle: f32, frame: Rect) -> f32 {
|
||||
let (w, h) = frame_dims(frame);
|
||||
let (chord, len) = gradient_units(angle, w, h);
|
||||
match style {
|
||||
GradientStyle::Linear => chord,
|
||||
GradientStyle::Reflected => chord / 2.0,
|
||||
GradientStyle::Radial | GradientStyle::Angle | GradientStyle::Diamond => len / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The start and end points (document pixels) of a gradient laid out in `frame`.
|
||||
pub fn handles(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: Rect) -> ([f32; 2], [f32; 2]) {
|
||||
let c = centre(offset, frame);
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let dir = [co, -s];
|
||||
let reach = unit_reach(style, angle, frame) * scale.max(1e-3);
|
||||
match style {
|
||||
GradientStyle::Linear => {
|
||||
let h = reach / 2.0;
|
||||
([c[0] - dir[0] * h, c[1] - dir[1] * h], [c[0] + dir[0] * h, c[1] + dir[1] * h])
|
||||
}
|
||||
_ => (c, [c[0] + dir[0] * reach, c[1] + dir[1] * reach]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The handles of a gradient fill in `frame` (`None` for other fills).
|
||||
pub fn fill_handles(f: &Fill, frame: Rect) -> Option<([f32; 2], [f32; 2])> {
|
||||
match f {
|
||||
Fill::Gradient { style, angle, scale, offset, .. } => Some(handles(*style, *angle, *scale, *offset, frame)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Angle (degrees), scale and centre offset that put a gradient's start and end points at
|
||||
/// `from` and `to` in `frame` (inverse of [`handles`]). A zero-length drag keeps the direction
|
||||
/// of `fallback_angle`.
|
||||
pub fn from_handles(style: GradientStyle, from: [f32; 2], to: [f32; 2], frame: Rect, fallback_angle: f32) -> (f32, f32, (f32, f32)) {
|
||||
let (dx, dy) = (to[0] - from[0], to[1] - from[1]);
|
||||
let len = dx.hypot(dy);
|
||||
let angle = if len > 1e-4 { (-dy).atan2(dx).to_degrees() } else { fallback_angle };
|
||||
let len = len.max(MIN_LEN);
|
||||
let c = match style {
|
||||
GradientStyle::Linear => [(from[0] + to[0]) / 2.0, (from[1] + to[1]) / 2.0],
|
||||
_ => from,
|
||||
};
|
||||
let (w, h) = frame_dims(frame);
|
||||
let offset = ((c[0] - frame.x0 as f32 - w / 2.0) / w, (c[1] - frame.y0 as f32 - h / 2.0) / h);
|
||||
let scale = len / unit_reach(style, angle, frame);
|
||||
(angle, scale, offset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::Color;
|
||||
|
||||
const STYLES: [GradientStyle; 5] = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
|
||||
|
||||
#[test]
|
||||
fn handles_round_trip() {
|
||||
let frame = Rect::new(10, -4, 210, 96);
|
||||
for style in STYLES {
|
||||
for (from, to) in [([30.0, 40.0], [180.0, 10.0]), ([100.0, 50.0], [100.0, 90.0]), ([5.0, 5.0], [-40.0, 70.0])] {
|
||||
let (a, s, o) = from_handles(style, from, to, frame, 0.0);
|
||||
let (f2, t2) = handles(style, a, s, o, frame);
|
||||
for (p, q) in [(from, f2), (to, t2)] {
|
||||
assert!((p[0] - q[0]).abs() < 1e-2 && (p[1] - q[1]).abs() < 1e-2, "{style:?}: {p:?} vs {q:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_map_to_the_ends_of_the_ramp() {
|
||||
let frame = Rect::new(0, 0, 100, 60);
|
||||
let (a, s, o) = from_handles(GradientStyle::Linear, [20.0, 30.0], [80.0, 30.0], frame, 0.0);
|
||||
// Linear gradients sample each pixel's top-left corner (effects::gradient_t): the pixel
|
||||
// whose corner is the handle, centred half a pixel further, is where t is 0 or 1.
|
||||
let t = |x: f32| crate::effects::gradient_t(GradientStyle::Linear, a, s, false, o, frame, x + 0.5, 30.5);
|
||||
assert!(t(20.0).abs() < 1e-4 && (t(80.0) - 1.0).abs() < 1e-4 && (t(50.0) - 0.5).abs() < 1e-4);
|
||||
let (a, s, o) = from_handles(GradientStyle::Radial, [40.0, 20.0], [40.0, 50.0], frame, 0.0);
|
||||
let r = |x: f32, y: f32| crate::effects::gradient_t(GradientStyle::Radial, a, s, false, o, frame, x, y);
|
||||
assert!(r(40.0, 20.0) < 1e-4 && (r(70.0, 20.0) - 1.0).abs() < 1e-3 && (r(55.0, 20.0) - 0.5).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_midpoints_and_opacity() {
|
||||
let f = Fill::Gradient {
|
||||
stops: vec![(1.0, Color::WHITE), (0.0, Color::BLACK)],
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: GradientStyle::Linear,
|
||||
reverse: false,
|
||||
opacity_stops: vec![(0.0, 1.0), (1.0, 0.0)],
|
||||
midpoints: vec![0.25],
|
||||
offset: (0.0, 0.0),
|
||||
dither: false,
|
||||
align: true,
|
||||
};
|
||||
let r = Ramp::new(&f).unwrap();
|
||||
// Unsorted stops are sorted; the midpoint puts 50 % gray at t = 0.25.
|
||||
assert!((r.sample(0.25)[0] - 0.5).abs() < 1e-5);
|
||||
assert!((r.sample(0.25)[3] - 0.75).abs() < 1e-5);
|
||||
assert_eq!(r.sample(1.0), [1.0, 1.0, 1.0, 0.0]);
|
||||
assert_eq!(midpoint_remap(0.3, 0.5), 0.3);
|
||||
assert!(Ramp::new(&Fill::Solid(Color::BLACK)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_handles_empty_and_degenerate_rects() {
|
||||
let f = Fill::gradient(vec![], 0.0, 0.0, GradientStyle::Angle, true);
|
||||
assert!(render(&f, Rect::new(0, 0, 0, 5), Rect::new(0, 0, 0, 0)).is_empty());
|
||||
let px = render(&f, Rect::new(0, 0, 3, 2), Rect::new(0, 0, 0, 0));
|
||||
assert_eq!(px.len(), 6);
|
||||
}
|
||||
}
|
||||
+318
-86
@@ -19,6 +19,8 @@
|
||||
pub mod adjust;
|
||||
pub mod bounds;
|
||||
pub mod effects;
|
||||
pub mod fill_layout;
|
||||
pub mod gradient_fill;
|
||||
pub mod masks;
|
||||
pub mod multichannel;
|
||||
pub mod pattern;
|
||||
@@ -108,6 +110,14 @@ fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer
|
||||
buf
|
||||
});
|
||||
}
|
||||
// Effect maps are built once, here, before any tile needs them (#276).
|
||||
prepare_effects(&doc.layers, rect, cx, |f| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
psblend::LAB_MIX.with(|l| l.set(lab));
|
||||
f();
|
||||
psblend::LAB_MIX.with(|l| l.set(false));
|
||||
});
|
||||
});
|
||||
let run = |t: Rect| {
|
||||
photocraft_color::convert::with_cmyk_space(cmyk, || {
|
||||
let mut b = multichannel::backdrop(doc, t);
|
||||
@@ -258,6 +268,7 @@ pub fn render_layer(layer: &Layer, rect: Rect) -> Buffer {
|
||||
mode: photocraft_color::ColorMode::Rgb,
|
||||
depth: photocraft_color::SampleType::F32,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
},
|
||||
);
|
||||
buf
|
||||
@@ -425,6 +436,9 @@ struct Ctx<'a> {
|
||||
mode: photocraft_color::ColorMode,
|
||||
depth: photocraft_color::SampleType,
|
||||
vector_masks: RenderVectorMasks,
|
||||
/// Effect maps used by this render, by cache key: built before the parallel tiles (see
|
||||
/// [`prepare_effects`]) and held for the whole render, so eviction can't force a rebuild.
|
||||
fx_maps: std::sync::Mutex<std::collections::HashMap<u64, std::sync::Arc<effects::FxMaps>>>,
|
||||
}
|
||||
|
||||
impl<'a> Ctx<'a> {
|
||||
@@ -437,6 +451,54 @@ impl<'a> Ctx<'a> {
|
||||
mode: doc.mode,
|
||||
depth: doc.depth,
|
||||
vector_masks: RenderVectorMasks::default(),
|
||||
fx_maps: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest group nesting [`prepare_effects`] walks (deeper layers build their maps on demand).
|
||||
const PREPARE_DEPTH: u32 = 64;
|
||||
|
||||
/// Build the effect maps of every visible layer in `layers` (groups included) that can reach
|
||||
/// `rect` before tiles render in parallel, layers concurrently on native targets, each build
|
||||
/// inside `setting` (the tiles' thread-local colour setting). Tiles then only read finished maps:
|
||||
/// none builds one while others need it (#276), and none duplicates another's build. Builds never
|
||||
/// wait on each other (`cached_effect_maps`), so building them in parallel can't deadlock.
|
||||
fn prepare_effects(layers: &[Layer], rect: Rect, cx: &Ctx, setting: impl Fn(&mut dyn FnMut()) + Sync) {
|
||||
let mut todo = Vec::new();
|
||||
effect_layers(layers, rect, 0, &mut todo);
|
||||
let build = |l: &&Layer| {
|
||||
setting(&mut || {
|
||||
let _ = effect_maps(l, cx);
|
||||
});
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if todo.len() > 1 {
|
||||
use rayon::prelude::*;
|
||||
todo.par_iter().for_each(build);
|
||||
return;
|
||||
}
|
||||
todo.iter().for_each(build);
|
||||
}
|
||||
|
||||
/// The visible layers of `layers` (groups included) with effects that can reach `rect`.
|
||||
fn effect_layers<'l>(layers: &'l [Layer], rect: Rect, depth: u32, out: &mut Vec<&'l Layer>) {
|
||||
let mut base_visible = true;
|
||||
for l in layers {
|
||||
if !l.clipped {
|
||||
base_visible = l.visible;
|
||||
}
|
||||
// A clipping group is drawn only when its base is.
|
||||
if !l.visible || !base_visible {
|
||||
continue;
|
||||
}
|
||||
if effects::has_effects(l) && !empty_in(l, rect) {
|
||||
out.push(l);
|
||||
}
|
||||
if let LayerContent::Group(g) = &l.content
|
||||
&& depth < PREPARE_DEPTH
|
||||
{
|
||||
effect_layers(&g.children, rect, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -510,24 +572,90 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
|
||||
}
|
||||
}),
|
||||
LayerContent::Fill(_) => canvas,
|
||||
_ => layer.surface().map_or(Rect::EMPTY, bounds::content_bounds),
|
||||
_ => {
|
||||
let b = layer.surface().map_or(Rect::EMPTY, bounds::content_bounds);
|
||||
// Effects follow a filled shape's outline, also where its fill is transparent.
|
||||
match effect_outline(layer).and_then(|_| paint_bounds(layer)).filter(|_| effects::has_effects(layer)) {
|
||||
Some(p) if !b.is_empty() => b.union(&p),
|
||||
Some(p) => p,
|
||||
None => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path a shape layer's effects are shaped by: its fill path. Photoshop builds a filled
|
||||
/// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the
|
||||
/// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other
|
||||
/// layers, shapes without fill, empty and inverted paths, and shapes whose vector stroke reaches
|
||||
/// past the path (their pixels give the outline: psd-tools double-stroke-effects).
|
||||
pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
let stroke_inside = sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.align == photocraft_doc::vector::StrokeAlign::Inside);
|
||||
(sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted).then_some(&sh.path)
|
||||
}
|
||||
|
||||
/// The shape a layer's effects are built from over `rect` (row-major): its content's alpha
|
||||
/// (masks applied), completed for shapes with an [`effect_outline`] by the outline's coverage
|
||||
/// where the fill is see-through. With `l` the alpha and `f` the fill's own opacity (the largest
|
||||
/// alpha around), the shape is `l + (1 - f) × coverage`: Photoshop's own rasterization where the
|
||||
/// fill is opaque (it can differ from ours by a fraction of a pixel: psd-tools shape-fx2), the
|
||||
/// path where the fill fades out (psd-tools stroke-effects).
|
||||
fn effect_shape(layer: &Layer, rect: Rect, cx: &Ctx) -> Vec<f32> {
|
||||
let n = rect.width() as usize * rect.height() as usize;
|
||||
let Some(path) = effect_outline(layer).filter(|_| n > 0) else {
|
||||
return render_content(layer, rect, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
|
||||
};
|
||||
// Unmasked: the masks scale the shape, they aren't the fill's transparency.
|
||||
let a: Vec<f32> = layer.surface().map(|s| surface_to_buffer(s, rect).px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; n]);
|
||||
let cov = photocraft_vector::path_coverage(path, rect);
|
||||
let mv = mask_vals(layer, rect, cx);
|
||||
let (w, h) = (rect.width() as usize, rect.height() as usize);
|
||||
let mut out = vec![0.0; n];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
// A pixel the outline covers is inside, whatever the fill's opacity.
|
||||
let v = if cov[i] >= 1.0 - 1e-3 {
|
||||
1.0
|
||||
} else {
|
||||
let mut f = 0.0f32;
|
||||
for yy in y.saturating_sub(1)..(y + 2).min(h) {
|
||||
for xx in x.saturating_sub(1)..(x + 2).min(w) {
|
||||
f = f.max(a[yy * w + xx]);
|
||||
}
|
||||
}
|
||||
(a[i] + (1.0 - f.min(1.0)) * cov[i]).clamp(0.0, 1.0)
|
||||
};
|
||||
out[i] = v * mask_k(&mv, i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -573,12 +701,27 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// The alpha of `layer`'s own content over `rect` (masks applied, row-major): the shape its
|
||||
/// effect maps are built from (for the GPU compositor). Zero for adjustment layers.
|
||||
/// The shape `layer`'s effect maps are built from over `rect` (row-major; for the GPU
|
||||
/// compositor): its content's alpha (masks applied), joined with the [`effect_outline`] of a
|
||||
/// filled shape. Zero for adjustment layers.
|
||||
///
|
||||
/// See also [`stroke_frame`].
|
||||
pub fn layer_shape(doc: &Document, layer: &Layer, rect: Rect) -> Vec<f32> {
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
render_content(layer, rect, &cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_else(|| vec![0.0; rect.width() as usize * rect.height() as usize])
|
||||
effect_shape(layer, rect, &cx)
|
||||
}
|
||||
|
||||
/// The frame a gradient stroke `st` of `layer` is laid out in (`effects::FxMaps::stroke_frame`),
|
||||
/// from the layer's cached effect maps; `None` when `st` isn't a gradient stroke of the layer.
|
||||
/// For the GPU compositor.
|
||||
pub fn stroke_frame(doc: &Document, layer: &Layer, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
|
||||
if !matches!(st.paint, photocraft_doc::FxPaint::Gradient(_)) || !effects::has_effects(layer) {
|
||||
return None;
|
||||
}
|
||||
let patterns = pattern::PreparedPatterns::new(&doc.patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
let cx = Ctx::for_doc(doc, &patterns);
|
||||
effect_maps(layer, &cx).stroke_frame(st)
|
||||
}
|
||||
|
||||
pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
||||
@@ -592,6 +735,10 @@ pub fn surface_to_buffer(s: &Surface, rect: Rect) -> Buffer {
|
||||
/// the vector mask's path; otherwise the canvas.
|
||||
pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect {
|
||||
let mut frame = canvas;
|
||||
// "Align with layer" off: the canvas (the Gradient tool's live gradients).
|
||||
if let LayerContent::Fill(Fill::Gradient { align: false, .. }) = &layer.content {
|
||||
return frame;
|
||||
}
|
||||
if let Some(m) = &layer.mask
|
||||
&& m.enabled
|
||||
&& m.surface.default_pixel().first().is_some_and(|v| *v <= 0.0)
|
||||
@@ -627,26 +774,8 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
|
||||
let rgb = c.to_rgb();
|
||||
Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
}
|
||||
Fill::Gradient { stops, angle, scale, style, reverse } => {
|
||||
// Convert once per render call, while its document CMYK profile is active.
|
||||
let stops: Vec<_> = stops
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let rgb = c.to_rgb();
|
||||
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
})
|
||||
.collect();
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
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 i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
|
||||
b.px[i] = sample_stops(&stops, t);
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render(f, rect, canvas) },
|
||||
// Laid out from the layer's frame when linked; transparent if the pattern is missing.
|
||||
Fill::Pattern { name, scale, id, angle, link, phase } => match patterns.get(id, name) {
|
||||
Some(tile) => Buffer { rect, px: pattern::render(&tile, &pattern::Placement::new(canvas, *link, *phase, *scale, *angle), rect) },
|
||||
@@ -655,27 +784,6 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_stops(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
|
||||
match stops {
|
||||
[] => [0.0; 4],
|
||||
[only] => only.1,
|
||||
_ => {
|
||||
if t <= stops[0].0 {
|
||||
return stops[0].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 { 0.0 };
|
||||
let (ca, cb) = (a.1, b.1);
|
||||
return std::array::from_fn(|i| ca[i] + (cb[i] - ca[i]) * k);
|
||||
}
|
||||
}
|
||||
stops[stops.len() - 1].1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when a pixel-backed layer has nothing to contribute in `rect`:
|
||||
/// no allocated tiles there, a transparent default, and no effects (which
|
||||
/// could reach in from outside). Clipped layers depend on the base, so they
|
||||
@@ -726,6 +834,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.
|
||||
@@ -738,6 +895,37 @@ pub fn transparent_outside(layer: &Layer) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `layer` replaces everything beneath it in its stack: a visible, unclipped fill layer
|
||||
/// in Normal mode at full opacity and fill, without masks, effects, channel restrictions or Blend
|
||||
/// If, whose fill is opaque everywhere (a solid colour, or a gradient whose colour and opacity
|
||||
/// stops are all opaque). Normal blending at alpha 1 returns the layer's colour exactly, so the
|
||||
/// layers below can be skipped (the live Gradient tool's preview recomposites the whole canvas on
|
||||
/// every pointer move).
|
||||
pub fn occludes_below(layer: &Layer, mode: photocraft_color::ColorMode) -> bool {
|
||||
let LayerContent::Fill(f) = &layer.content else { return false };
|
||||
let opaque = match f {
|
||||
Fill::Solid(c) => c.alpha >= 1.0,
|
||||
Fill::Gradient { stops, opacity_stops, .. } => {
|
||||
!stops.is_empty() && stops.iter().all(|(_, c)| c.alpha >= 1.0) && opacity_stops.iter().all(|(_, o)| *o >= 1.0)
|
||||
}
|
||||
Fill::Pattern { .. } => false,
|
||||
};
|
||||
// A cached render in use (a PSD's stored fill pixels) may not be opaque.
|
||||
let cached = layer.fill_cache.as_ref().is_some_and(|c| c.fill == *f);
|
||||
opaque
|
||||
&& !cached
|
||||
&& layer.visible
|
||||
&& !layer.clipped
|
||||
&& layer.blend == photocraft_color::BlendMode::Normal
|
||||
&& layer.opacity >= 1.0
|
||||
&& layer.fill_opacity >= 1.0
|
||||
&& !layer.mask.as_ref().is_some_and(|m| m.enabled)
|
||||
&& !layer.vector_mask.as_ref().is_some_and(|m| m.enabled)
|
||||
&& !effects::has_effects(layer)
|
||||
&& layer.excluded_channels == 0
|
||||
&& !blend_if_active(layer, mode)
|
||||
}
|
||||
|
||||
/// Whether Blending Options › Blend If changes how `layer` composites in a `mode` document.
|
||||
/// RGB documents test Gray and R, G, B; grayscale (and duotone) documents their one channel.
|
||||
/// Other modes composite in display RGB, where ranges over their own channels have no exact
|
||||
@@ -865,9 +1053,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 +1115,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);
|
||||
@@ -950,10 +1141,18 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
if effects::has_effects(layer) {
|
||||
// Neighbourhoods are already captured by the full-region effect maps;
|
||||
// content and effect application only need the output rectangle.
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
// A stroked shape's vector stroke stays above its clipped layers and interior effects.
|
||||
let (mut content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects_prepared(
|
||||
layer,
|
||||
@@ -962,15 +1161,17 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some((mut content, stroke)) = shape_parts(layer, clipped, rect, cx) {
|
||||
if let Some((mut content, stroke, mask)) = shape_parts(layer, clipped, rect, cx) {
|
||||
for c in clipped.iter().filter(|c| c.visible) {
|
||||
composite_atop(c, &mut content, cx);
|
||||
}
|
||||
for (p, s) in content.px.iter_mut().zip(&stroke.px) {
|
||||
for (i, (p, s)) in content.px.iter_mut().zip(&stroke.px).enumerate() {
|
||||
*p = psblend::composite(BlendMode::Normal, *p, *s, 1.0);
|
||||
p[3] *= mask_k(&mask, i);
|
||||
}
|
||||
blend_into(backdrop, &content, layer.blend, opacity);
|
||||
return;
|
||||
@@ -990,22 +1191,23 @@ pub fn text_gamma(layer: &Layer) -> f32 {
|
||||
|
||||
/// A stroked shape layer with visible clipped layers: Photoshop draws the shape's vector stroke
|
||||
/// above the clipped layers, so the base content is the fill alone and the stroke is laid on top
|
||||
/// after clipping. Returns (fill, stroke) buffers over `rect`, masks applied.
|
||||
fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer)> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
if sh.stroke.is_none() || !clipped.iter().any(|c| c.visible) {
|
||||
/// after clipping. See [`split_parts`].
|
||||
fn shape_parts(layer: &Layer, clipped: &[Layer], rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
|
||||
if !clipped.iter().any(|c| c.visible) {
|
||||
return None;
|
||||
}
|
||||
split_parts(layer, rect, cx)
|
||||
}
|
||||
|
||||
/// A stroked shape layer's (fill, vector stroke, mask values) over `rect`; `None` for other
|
||||
/// layers. Fill and stroke are unmasked: the mask applies to them together. Layer effects paint
|
||||
/// the fill's interior effects beneath the vector stroke (psd-tools stroke-composite: a colour
|
||||
/// overlay leaves the stroke, the stroke effect covers it).
|
||||
fn split_parts(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<(Buffer, Buffer, Option<Vec<f32>>)> {
|
||||
let LayerContent::Shape(sh) = &layer.content else { return None };
|
||||
sh.stroke.as_ref()?;
|
||||
let (fs, ss) = shape_split::split(sh, cx.canvas)?;
|
||||
let mut f = surface_to_buffer(&fs, rect);
|
||||
let mut s = surface_to_buffer(&ss, rect);
|
||||
if let Some(m) = mask_vals(layer, rect, cx) {
|
||||
for ((a, b), k) in f.px.iter_mut().zip(s.px.iter_mut()).zip(&m) {
|
||||
a[3] *= k;
|
||||
b[3] *= k;
|
||||
}
|
||||
}
|
||||
Some((f, s))
|
||||
Some((surface_to_buffer(&fs, rect), surface_to_buffer(&ss, rect), mask_vals(layer, rect, cx)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1062,8 +1264,8 @@ struct FxEntry {
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
/// Global cache of effect maps (bounded by bytes). Tiles rendered in parallel share one build per
|
||||
/// layer state via a per-key `OnceLock`.
|
||||
/// Global cache of effect maps (bounded by bytes), one slot per layer state. Slots are filled
|
||||
/// with `set`, never `get_or_init`: nothing may block on a build (see `cached_effect_maps`).
|
||||
type FxSlot = std::sync::Arc<std::sync::OnceLock<FxEntry>>;
|
||||
struct FxCache {
|
||||
map: std::collections::HashMap<u64, FxSlot>,
|
||||
@@ -1113,7 +1315,7 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
let m = effects::margin(layer);
|
||||
let region = layer_bounds(layer, cx.canvas).inflate(m).intersect(&cx.canvas.inflate(m));
|
||||
if std::env::var_os("PHOTOCRAFT_FX_NOCACHE").is_some() {
|
||||
let shape = render_content(layer, region, cx).map(|b| b.px.iter().map(|p| p[3]).collect()).unwrap_or_default();
|
||||
let shape = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
return std::sync::Arc::new(effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns));
|
||||
}
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
@@ -1121,6 +1323,22 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
(region.x0, region.y0, region.x1, region.y1).hash(&mut h);
|
||||
(cx.light.angle.to_bits(), cx.light.altitude.to_bits()).hash(&mut h);
|
||||
let key = h.finish();
|
||||
// Prepared for this render (see `prepare_effects`): no global lookup, no build.
|
||||
if let Some(m) = cx.fx_maps.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return m.clone();
|
||||
}
|
||||
let maps = cached_effect_maps(layer, region, key, cx);
|
||||
cx.fx_maps.lock().unwrap_or_else(|e| e.into_inner()).entry(key).or_insert(maps).clone()
|
||||
}
|
||||
|
||||
/// The layer's effect maps from the global cache, built on a miss.
|
||||
///
|
||||
/// Never waits for another thread's build (#276): this can run on a Rayon worker inside a tile,
|
||||
/// and the build itself runs Rayon work. A worker waiting on that work may steal another tile of
|
||||
/// the same layer; had it blocked on a once-init held further up its own stack, it would deadlock.
|
||||
/// Instead a miss builds the maps here and the first finished build is kept. Renders build every
|
||||
/// layer's maps before their parallel tiles (`prepare_effects`), so tiles rarely miss.
|
||||
fn cached_effect_maps(layer: &Layer, region: Rect, key: u64, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
let slot = {
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(s) = c.map.get(&key) {
|
||||
@@ -1132,21 +1350,25 @@ fn effect_maps(layer: &Layer, cx: &Ctx) -> std::sync::Arc<effects::FxMaps> {
|
||||
s
|
||||
}
|
||||
};
|
||||
let entry = slot.get_or_init(|| {
|
||||
let shape: Vec<f32> = if region.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
render_content(layer, region, cx)
|
||||
.map(|b| b.px.iter().map(|p| p[3]).collect())
|
||||
.unwrap_or_else(|| vec![0.0; region.width() as usize * region.height() as usize])
|
||||
};
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let bytes = maps.bytes();
|
||||
// Counted exactly once, when the entry is built.
|
||||
fx_cache().lock().unwrap_or_else(|e| e.into_inner()).bytes += bytes;
|
||||
FxEntry { maps: std::sync::Arc::new(maps), _pin: layer.clone(), bytes }
|
||||
});
|
||||
let maps = entry.maps.clone();
|
||||
let maps = match slot.get() {
|
||||
Some(e) => e.maps.clone(),
|
||||
None => {
|
||||
let shape: Vec<f32> = if region.is_empty() { Vec::new() } else { effect_shape(layer, region, cx) };
|
||||
let maps = effects::build_maps_prepared(layer, shape, region, &cx.light, &texture_ctx(layer, region, cx), cx.patterns);
|
||||
let bytes = maps.bytes();
|
||||
let maps = std::sync::Arc::new(maps);
|
||||
if slot.set(FxEntry { maps: maps.clone(), _pin: layer.clone(), bytes }).is_ok() {
|
||||
// Counted exactly once, by the build that filled the slot, and only while the
|
||||
// slot is still cached (eviction may have dropped it meanwhile).
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.map.get(&key).is_some_and(|s| std::sync::Arc::ptr_eq(s, &slot)) {
|
||||
c.bytes += bytes;
|
||||
}
|
||||
}
|
||||
// A concurrent build that finished first wins, so every tile shares one map.
|
||||
slot.get().map_or(maps, |e| e.maps.clone())
|
||||
}
|
||||
};
|
||||
// Evict the oldest entries over budget (never the one just used).
|
||||
let mut c = fx_cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let budget = effect_cache_budget();
|
||||
@@ -1189,7 +1411,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);
|
||||
@@ -1205,7 +1427,14 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
// Effects of a clipped layer are clipped to the base too: render
|
||||
// them over the base (treated as opaque) and keep the base's alpha.
|
||||
// The full-region effect maps already capture their neighbourhoods.
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
let (content, vstroke) = match split_parts(layer, rect, cx) {
|
||||
Some((fill, stroke, mask)) => (fill, Some((stroke, mask))),
|
||||
None => {
|
||||
let Some(content) = render_content(layer, rect, cx) else { return };
|
||||
(content, None)
|
||||
}
|
||||
};
|
||||
let vstroke = vstroke.as_ref().map(|(s, m)| effects::VectorStroke { stroke: s, mask: m.as_deref() });
|
||||
let mut opaque = Buffer { rect, px: base.px.iter().map(|p| [p[0], p[1], p[2], 1.0]).collect() };
|
||||
let maps = effect_maps(layer, cx);
|
||||
effects::composite_with_effects_prepared(
|
||||
@@ -1215,6 +1444,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
|
||||
&maps,
|
||||
paint_bounds(layer).unwrap_or_else(|| layer_bounds(layer, cx.canvas)),
|
||||
cx.patterns,
|
||||
vstroke,
|
||||
);
|
||||
for (p, o) in base.px.iter_mut().zip(&opaque.px) {
|
||||
if p[3] > 0.0 {
|
||||
@@ -1265,5 +1495,7 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+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)
|
||||
|
||||
@@ -11,7 +11,9 @@ use photocraft_doc::vector::ShapeLayer;
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
type Entry = ((Surface, Surface), u64);
|
||||
/// (fill, stroke), last use, and the shape's pixels the stroke was fitted to (kept alive so
|
||||
/// their tile addresses, part of the key, can't be reused).
|
||||
type Entry = ((Surface, Surface), u64, Option<Surface>);
|
||||
|
||||
fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
|
||||
static C: OnceLock<Mutex<(HashMap<u64, Entry>, u64)>> = OnceLock::new();
|
||||
@@ -21,12 +23,20 @@ fn cache() -> &'static Mutex<(HashMap<u64, Entry>, u64)> {
|
||||
const CAPACITY: usize = 32;
|
||||
|
||||
/// (fill only, stroke only) of a stroked shape, rendered over `canvas` (RGBA8, as the CPU
|
||||
/// compositor's split always was). `None` without a stroke.
|
||||
/// compositor's split always was). `None` without a stroke. Where the shape's pixels
|
||||
/// (Photoshop's rendering, `sh.cache`) reach past our fill, the part the fill doesn't explain is
|
||||
/// stroke: the stroke follows Photoshop's coverage (psd-tools double-stroke-effects).
|
||||
pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
|
||||
sh.stroke.as_ref()?;
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
|
||||
format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h);
|
||||
if let Some(c) = &sh.cache {
|
||||
format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h);
|
||||
for (k, t) in c.tiles() {
|
||||
(k.tx, k.ty, std::sync::Arc::as_ptr(t) as usize).hash(&mut h);
|
||||
}
|
||||
}
|
||||
let key = h.finish();
|
||||
{
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -40,7 +50,10 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
|
||||
let fmt = PixelFormat::RGBA8;
|
||||
let fill_only = ShapeLayer { stroke: None, cache: None, ..sh.clone() };
|
||||
let stroke_only = ShapeLayer { fill: None, cache: None, ..sh.clone() };
|
||||
let parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
|
||||
let mut parts = (photocraft_vector::render_shape(&fill_only, fmt, canvas), photocraft_vector::render_shape(&stroke_only, fmt, canvas));
|
||||
if let Some(cache) = &sh.cache {
|
||||
fit_stroke(&parts.0, &mut parts.1, cache, canvas);
|
||||
}
|
||||
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
|
||||
if c.0.len() >= CAPACITY
|
||||
&& let Some(old) = c.0.iter().min_by_key(|e| (e.1).1).map(|e| *e.0)
|
||||
@@ -48,6 +61,40 @@ pub fn split(sh: &ShapeLayer, canvas: Rect) -> Option<(Surface, Surface)> {
|
||||
c.0.remove(&old);
|
||||
}
|
||||
let tick = c.1;
|
||||
c.0.insert(key, (parts.clone(), tick));
|
||||
c.0.insert(key, (parts.clone(), tick, sh.cache.clone()));
|
||||
Some(parts)
|
||||
}
|
||||
|
||||
/// Fits the stroke part to the shape's own pixels: where they reach past the fill, the stroke
|
||||
/// covers what the fill doesn't (`(pixels - fill) / (1 - fill)`), in the pixels' colour where we
|
||||
/// drew no stroke.
|
||||
fn fit_stroke(fill: &Surface, stroke: &mut Surface, cache: &Surface, canvas: Rect) {
|
||||
// Where either has pixels (the stroke may reach past the shape's pixels, and vice versa).
|
||||
let (cb, sb) = (crate::bounds::content_bounds(cache), crate::bounds::content_bounds(stroke));
|
||||
let r = if cb.is_empty() {
|
||||
sb
|
||||
} else if sb.is_empty() {
|
||||
cb
|
||||
} else {
|
||||
cb.union(&sb)
|
||||
}
|
||||
.intersect(&canvas);
|
||||
if r.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = r.width() as usize * r.height() as usize;
|
||||
let (mut f, mut s, mut c) = (vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n], vec![[0.0f32; 4]; n]);
|
||||
fill.read_rgba_into(r, &mut f);
|
||||
stroke.read_rgba_into(r, &mut s);
|
||||
cache.read_rgba_into(r, &mut c);
|
||||
let mut out = Vec::with_capacity(n * 4);
|
||||
for ((fp, sp), cp) in f.iter().zip(&s).zip(&c) {
|
||||
let mut p = *sp;
|
||||
if fp[3] < 1.0 - 1e-3 {
|
||||
let a = ((cp[3] - fp[3]) / (1.0 - fp[3])).clamp(0.0, 1.0);
|
||||
p = if sp[3] <= 0.0 { [cp[0], cp[1], cp[2], a] } else { [sp[0], sp[1], sp[2], a] };
|
||||
}
|
||||
out.extend_from_slice(&p);
|
||||
}
|
||||
stroke.write_region(r, &out);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Stroke effects on shape layers: the outline, the vector stroke's place, gradient frames.
|
||||
|
||||
use super::*;
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, StrokeAlign, Subpath};
|
||||
use photocraft_doc::{Effect, FxCommon, FxPaint, Gradient, GradientStyle, Layer, LayerContent, LayerMask, StrokeFx, StrokePosition};
|
||||
use photocraft_geom::Size;
|
||||
|
||||
const E: f32 = 2.0 / 255.0;
|
||||
|
||||
fn close4(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() <= E)
|
||||
}
|
||||
|
||||
fn doc(w: u32, h: u32) -> Document {
|
||||
Document::with_background("t", Size::new(w, h), ColorMode::Rgb, SampleType::U8, Color::WHITE)
|
||||
}
|
||||
|
||||
fn px(doc: &Document, x: i32, y: i32) -> [f32; 4] {
|
||||
render(doc, Rect::from_xywh(x, y, 1, 1)).px[0]
|
||||
}
|
||||
|
||||
fn stroke(size: f32, position: StrokePosition, paint: FxPaint) -> Effect {
|
||||
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint })
|
||||
}
|
||||
|
||||
fn blue() -> FxPaint {
|
||||
FxPaint::Color(Color::rgb(0.0, 0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A shape layer over the square `x0..x1` with its Photoshop-style cached pixels.
|
||||
fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option<ShapeStroke>) -> Layer {
|
||||
let path = Path::new(vec![Subpath::polygon(&[(x0, x0), (x1, x0), (x1, x1), (x0, x1)])]);
|
||||
let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None };
|
||||
sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 40, 40)));
|
||||
Layer::new("shape", LayerContent::Shape(sh))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_share_treats_stroke_and_shape_as_disjoint_areas() {
|
||||
// Outside the shape the stroke covers the pixel; inside it nothing.
|
||||
assert_eq!(effects::outline_share(0.0, 0.0), 1.0);
|
||||
assert_eq!(effects::outline_share(1.0, 0.3), 0.0);
|
||||
// Beneath an opaque edge pixel the result ends opaque: share + l (1 - share) = 1 - cov + l.
|
||||
let (cov, l) = (0.6, 0.6);
|
||||
let s = effects::outline_share(cov, l);
|
||||
assert!((s + l * (1.0 - s) - (1.0 - cov + l).min(1.0)).abs() < 1e-6);
|
||||
// A faded fill keeps the edge part of the stroke only.
|
||||
let s = effects::outline_share(0.6, 0.0);
|
||||
assert!((s - 0.4).abs() < 1e-6);
|
||||
assert_eq!(effects::outline_share(0.5, 1.0), 1.0);
|
||||
assert!(effects::outline_share(f32::NAN, 0.5).is_finite() || effects::outline_share(f32::NAN, 0.5).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_shape_stroke_follows_the_outline_where_the_fill_fades_out() {
|
||||
let mut d = doc(40, 40);
|
||||
let mut transparent = Color::rgb(1.0, 0.0, 0.0);
|
||||
transparent.alpha = 0.0;
|
||||
let fade = Fill::gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, transparent)], 0.0, 1.0, GradientStyle::Linear, false);
|
||||
let mut l = shape(10.0, 30.0, fade, None);
|
||||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue()), stroke(2.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0)))];
|
||||
d.layers.push(l);
|
||||
// The opaque side and the faded side both carry the outside stroke along the path.
|
||||
assert!(close4(px(&d, 8, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 8, 20));
|
||||
assert!(close4(px(&d, 31, 20), [0.0, 0.0, 1.0, 1.0]), "{:?}", px(&d, 31, 20));
|
||||
// ... and the inside stroke, even where the fill is transparent.
|
||||
assert!(close4(px(&d, 28, 20), [0.0, 1.0, 0.0, 1.0]), "{:?}", px(&d, 28, 20));
|
||||
// Between them the faded fill shows the background.
|
||||
assert!(px(&d, 25, 20)[0] > 0.9 && px(&d, 25, 20)[1] > 0.5, "{:?}", px(&d, 25, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_stroke_fills_the_rest_of_a_shapes_edge_pixel() {
|
||||
// The right edge at x = 30.5 covers half of column 30: an opaque fill and the stroke beneath
|
||||
// it end opaque there (no seam of backdrop between them).
|
||||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||||
let mut l = shape(10.0, 30.5, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), None);
|
||||
l.effects.items = vec![stroke(3.0, StrokePosition::Outside, blue())];
|
||||
d.layers.clear();
|
||||
d.layers.push(l);
|
||||
let p = px(&d, 30, 20);
|
||||
assert!((p[3] - 1.0).abs() <= E, "{p:?}");
|
||||
assert!((p[0] - 0.5).abs() <= 0.02 && (p[2] - 0.5).abs() <= 0.02, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_stroke_stays_above_interior_effects_and_below_the_stroke_effect() {
|
||||
let mut d = doc(40, 40);
|
||||
let vs = ShapeStroke { width: 4.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(0.0, 1.0, 0.0)), ..ShapeStroke::default() };
|
||||
let mut l = shape(10.0, 30.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), Some(vs));
|
||||
l.effects.items = vec![
|
||||
Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(0.0, 0.0, 1.0) },
|
||||
stroke(1.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(1.0, 1.0, 0.0))),
|
||||
];
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 10, 20), [1.0, 1.0, 0.0, 1.0]), "stroke effect on top: {:?}", px(&d, 10, 20));
|
||||
assert!(close4(px(&d, 12, 20), [0.0, 1.0, 0.0, 1.0]), "vector stroke over the overlay: {:?}", px(&d, 12, 20));
|
||||
assert!(close4(px(&d, 20, 20), [0.0, 0.0, 1.0, 1.0]), "overlay on the fill: {:?}", px(&d, 20, 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroked_shapes_mask_applies_to_fill_and_stroke_together() {
|
||||
let mut d = Document::new("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8);
|
||||
d.layers.clear();
|
||||
let vs = ShapeStroke { width: 4.0, align: StrokeAlign::Inside, paint: Fill::Solid(Color::rgb(0.0, 1.0, 0.0)), ..ShapeStroke::default() };
|
||||
let mut l = shape(10.0, 30.0, Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), Some(vs));
|
||||
let mut m = LayerMask::reveal_all();
|
||||
m.surface = photocraft_raster::Surface::with_default(PixelFormat::GRAY8, &[0.5]);
|
||||
l.mask = Some(m);
|
||||
let mut clip = Layer::raster("clip", PixelFormat::RGBA8);
|
||||
clip.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 40, 40), &[0.0, 0.0, 1.0, 1.0]);
|
||||
clip.clipped = true;
|
||||
d.layers.push(l.clone());
|
||||
d.layers.push(clip.clone());
|
||||
// On the vector stroke: the stroke's colour at the mask's 50 %, not 75 %.
|
||||
let p = px(&d, 12, 20);
|
||||
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "{p:?}");
|
||||
// With effects too.
|
||||
l.effects.items = vec![Effect::ColorOverlay { common: FxCommon::new(BlendMode::Normal, 1.0), color: Color::rgb(1.0, 1.0, 1.0) }];
|
||||
d.layers = vec![l.clone()];
|
||||
let p = px(&d, 12, 20);
|
||||
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "without clipped layers: {p:?}");
|
||||
d.layers = vec![l, clip];
|
||||
let p = px(&d, 12, 20);
|
||||
assert!(close4(p, [0.0, 1.0, 0.0, 0.5]), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stroke_spans_the_strokes_extent() {
|
||||
let mut d = doc(40, 40);
|
||||
let mut l = Layer::raster("sq", PixelFormat::RGBA8);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let g = Gradient { stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], angle: 90.0, ..Gradient::default() };
|
||||
l.effects.items = vec![stroke(4.0, StrokePosition::Outside, FxPaint::Gradient(g))];
|
||||
d.layers.push(l);
|
||||
// The frame is the square grown by 3 px (7..33), laid out like every linear gradient
|
||||
// (fill_layout::gradient_layout); the layer's own bounds would give other values.
|
||||
let t_in = |frame: Rect, x: i32, y: i32| {
|
||||
let (a, s, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 90.0, 1.0, (0.0, 0.0), frame);
|
||||
effects::gradient_t(GradientStyle::Linear, a, s, false, o, frame, x as f32 + 0.5, y as f32 + 0.5)
|
||||
};
|
||||
let (stroke_frame, layer_frame) = (Rect::new(7, 7, 33, 33), Rect::new(10, 10, 30, 30));
|
||||
for y in [7, 9, 31, 32] {
|
||||
let p = px(&d, 20, y);
|
||||
assert!((p[0] - t_in(stroke_frame, 20, y)).abs() <= 0.01, "row {y}: {p:?}");
|
||||
}
|
||||
assert!((t_in(stroke_frame, 20, 9) - t_in(layer_frame, 20, 9)).abs() > 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_strokes_stack_top_instance_first() {
|
||||
// Multiple instances: the first listed is on top; an outer wider stroke shows past it.
|
||||
let mut d = doc(60, 60);
|
||||
let mut l = Layer::raster("sq", PixelFormat::RGBA8);
|
||||
l.surface_mut().unwrap().fill_rect(Rect::new(20, 20, 40, 40), &[1.0, 0.0, 0.0, 1.0]);
|
||||
l.effects.items = vec![
|
||||
stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 1.0, 0.0))),
|
||||
stroke(5.0, StrokePosition::Outside, blue()),
|
||||
stroke(2.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(1.0, 1.0, 0.0))),
|
||||
];
|
||||
d.layers.push(l);
|
||||
assert!(close4(px(&d, 19, 30), [0.0, 1.0, 0.0, 1.0]));
|
||||
assert!(close4(px(&d, 16, 30), [0.0, 0.0, 1.0, 1.0]));
|
||||
assert!(close4(px(&d, 20, 30), [1.0, 1.0, 0.0, 1.0]));
|
||||
assert!(close4(px(&d, 14, 30), [1.0; 4]));
|
||||
}
|
||||
+213
-7
@@ -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);
|
||||
@@ -276,13 +311,7 @@ fn solid_and_gradient_fill_layers() {
|
||||
d.layers.push(Layer::new("fill", LayerContent::Fill(Fill::Solid(Color::rgb(0.0, 0.0, 1.0)))));
|
||||
assert!(close4(px(&d, 5, 0), [0.0, 0.0, 1.0, 1.0]));
|
||||
|
||||
let g = Fill::Gradient {
|
||||
stops: vec![(0.0, Color::BLACK), (1.0, Color::WHITE)],
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: photocraft_doc::GradientStyle::Linear,
|
||||
reverse: false,
|
||||
};
|
||||
let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
// tile independence: a 1px render of the right edge equals the full render
|
||||
@@ -748,6 +777,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 +1118,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.clone(), 30.0, 1.0, photocraft_doc::GradientStyle::Reflected, 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);
|
||||
@@ -1186,3 +1326,69 @@ fn large_documents_thumbnail_from_a_proxy() {
|
||||
d.layers[1].effects.items.push(photocraft_doc::Effect::default_drop_shadow());
|
||||
assert!(!proxy::proxy_faithful(&d));
|
||||
}
|
||||
|
||||
/// Layers whose effect maps are built with nested Rayon work (a gradient fill's rows render in
|
||||
/// parallel), each carrying a different effect.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn nested_parallel_fx_doc() -> Document {
|
||||
use photocraft_doc::{Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, StrokeFx, StrokePosition};
|
||||
let mut d = doc_white(512, 512);
|
||||
let g = |a: f32| Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::rgb(0.2, 0.5, 0.9))], a, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
let mut shadow = Layer::new("shadow", LayerContent::Fill(g(0.0)));
|
||||
shadow.effects.items = vec![Effect::default_drop_shadow()];
|
||||
let mut stroke = Layer::new("stroke", LayerContent::Fill(g(45.0)));
|
||||
stroke.opacity = 0.6;
|
||||
stroke.effects.items = vec![Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 3.0,
|
||||
position: StrokePosition::Inside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
})];
|
||||
let mut glow = Layer::new("glow", LayerContent::Fill(g(90.0)));
|
||||
glow.opacity = 0.5;
|
||||
glow.effects.items = vec![Effect::InnerGlow(Glow {
|
||||
common: FxCommon::new(BlendMode::Screen, 0.75),
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 0.7)),
|
||||
technique: GlowTechnique::Softer,
|
||||
spread: 0.0,
|
||||
size: 9.0,
|
||||
contour: Contour::Linear,
|
||||
anti_alias: false,
|
||||
range: 0.5,
|
||||
jitter: 0.0,
|
||||
noise: 0.0,
|
||||
source: GlowSource::Edge,
|
||||
})];
|
||||
// Unaffected layers whose rows also render in parallel keep both workers stealing.
|
||||
let base = Layer::new("base", LayerContent::Fill(g(30.0)));
|
||||
let mut top = Layer::new("top", LayerContent::Fill(g(60.0)));
|
||||
top.opacity = 0.3;
|
||||
d.layers.extend([base, shadow, stroke, glow, top]);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn effect_maps_with_nested_rayon_work_do_not_deadlock() {
|
||||
// #276: a tile building a layer's effect maps inside a once-init ran Rayon work; waiting on
|
||||
// it, its worker stole another tile of the same layer, which then blocked on the init held
|
||||
// further up its own stack. Eight threads (two rarely steal across that wait) and 64 tiles make that stealing likely; the cache is
|
||||
// purged every round so the maps are rebuilt. A watchdog turns a hang into a failure.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let Ok(pool) = rayon::ThreadPoolBuilder::new().num_threads(8).build() else { return };
|
||||
let d = nested_parallel_fx_doc();
|
||||
let first = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||||
for _ in 0..100 {
|
||||
purge_effect_cache();
|
||||
let out = pool.install(|| render_tiled(&d, d.bounds(), 64));
|
||||
assert!(out.px == first.px, "renders differ between rounds");
|
||||
}
|
||||
let _ = tx.send(());
|
||||
});
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(120)) {
|
||||
Ok(()) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => panic!("effect-map rendering deadlocked (#276)"),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("render thread panicked"),
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -174,13 +174,31 @@ pub struct Group {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Fill {
|
||||
Solid(Color),
|
||||
/// Gradient between stops at `angle` degrees (`style` geometry, optionally reversed).
|
||||
/// Gradient between stops at `angle` degrees (`style` geometry, optionally reversed), laid
|
||||
/// out in the layer's frame (or the canvas when `align` is off), its centre moved by `offset`.
|
||||
Gradient {
|
||||
stops: Vec<(f32, Color)>,
|
||||
angle: f32,
|
||||
scale: f32,
|
||||
style: GradientStyle,
|
||||
reverse: bool,
|
||||
/// Opacity stops `(location, opacity)` (both `0..=1`); empty = each colour stop's alpha.
|
||||
/// When set, a pixel's alpha is the colour's alpha times the opacity at that location.
|
||||
#[serde(default)]
|
||||
opacity_stops: Vec<(f32, f32)>,
|
||||
/// Colour midpoints, one per segment between consecutive (sorted) colour stops: where
|
||||
/// the segment is half-way (`0..=1` of the segment). Missing entries are 0.5.
|
||||
#[serde(default)]
|
||||
midpoints: Vec<f32>,
|
||||
/// Centre offset as a fraction of the frame (PSD `Ofst`).
|
||||
#[serde(default)]
|
||||
offset: (f32, f32),
|
||||
/// Dither (breaks 8-bit banding with one level of noise).
|
||||
#[serde(default)]
|
||||
dither: bool,
|
||||
/// "Align with layer": lay out in the layer's frame (its masks' bounds), else the canvas.
|
||||
#[serde(default = "effects::yes")]
|
||||
align: bool,
|
||||
},
|
||||
/// A pattern from [`Document::patterns`] (looked up by `id`, then `name`).
|
||||
Pattern {
|
||||
@@ -200,6 +218,14 @@ pub enum Fill {
|
||||
},
|
||||
}
|
||||
|
||||
impl Fill {
|
||||
/// A gradient fill with the classic settings: opaque stops at their alpha, 50 % midpoints,
|
||||
/// centred, no dither, aligned with the layer.
|
||||
pub fn gradient(stops: Vec<(f32, Color)>, angle: f32, scale: f32, style: GradientStyle, reverse: bool) -> Fill {
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops: Vec::new(), midpoints: Vec::new(), offset: (0.0, 0.0), dither: false, align: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextLayer {
|
||||
pub text: String,
|
||||
|
||||
@@ -18,9 +18,9 @@ pub enum Kerning {
|
||||
/// Font kerning (`kern` feature).
|
||||
#[default]
|
||||
Metrics,
|
||||
/// Photoshop's optical kerning; rendered like `Metrics` for now.
|
||||
/// Photoshop's optical kerning: pair spacing computed from the glyph outlines.
|
||||
Optical,
|
||||
/// No kerning.
|
||||
/// No automatic kerning (Photoshop's "0" / manual kerning).
|
||||
Off,
|
||||
}
|
||||
|
||||
@@ -73,7 +73,11 @@ pub struct CharStyle {
|
||||
pub strikethrough: bool,
|
||||
pub faux_bold: bool,
|
||||
pub faux_italic: bool,
|
||||
/// Automatic (pair) kerning mode.
|
||||
pub kerning: Kerning,
|
||||
/// Manual kerning in 1/1000 em between this character and the next one (Photoshop stores it
|
||||
/// per character run as `Kerning`). Added to the automatic kerning of the pair.
|
||||
pub kern: f32,
|
||||
pub caps: Caps,
|
||||
/// Standard ligatures (`liga`/`clig`).
|
||||
pub ligatures: bool,
|
||||
@@ -111,6 +115,7 @@ impl Default for CharStyle {
|
||||
faux_bold: false,
|
||||
faux_italic: false,
|
||||
kerning: Kerning::Metrics,
|
||||
kern: 0.0,
|
||||
caps: Caps::Normal,
|
||||
ligatures: true,
|
||||
discretionary_ligatures: false,
|
||||
@@ -217,7 +222,7 @@ pub enum TextShape {
|
||||
pub enum Orientation {
|
||||
#[default]
|
||||
Horizontal,
|
||||
/// Vertical type: modelled for round trip, rendered horizontally for now.
|
||||
/// Vertical type (tategaki): columns top to bottom, advancing right to left.
|
||||
Vertical,
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! Path operations follow Photoshop: every subpath is filled on its own (with the path's
|
||||
//! [`FillRule`]) and folded, in order, into the result with its [`PathOp`]; the first subpath
|
||||
//! always acts as "combine" (see [`Path::effective_op`]; verified against Photoshop's pixels in
|
||||
//! the PSD corpus). [`Path::inverted`] inverts the final area. A vector mask without subpaths
|
||||
//! the PSD corpus). Subpaths marked [`PathOp::Join`] belong to the shape component before them
|
||||
//! and are filled together with it (a custom shape's holes). [`Path::inverted`] inverts the final area. A vector mask without subpaths
|
||||
//! reveals everything (Photoshop's fresh "Add Vector Mask").
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -60,6 +61,10 @@ pub enum PathOp {
|
||||
Intersect,
|
||||
/// Exclude overlapping shapes (xor).
|
||||
Exclude,
|
||||
/// Part of the previous subpath's shape component (PSD operation -1 after a component's
|
||||
/// first record): the component's subpaths are filled together, with the path's fill rule,
|
||||
/// so inner subpaths wound the other way cut holes (custom shapes).
|
||||
Join,
|
||||
}
|
||||
|
||||
/// Winding rule used to fill each subpath.
|
||||
@@ -131,6 +136,19 @@ impl Path {
|
||||
pub fn effective_op(&self, i: usize) -> PathOp {
|
||||
if i == 0 { PathOp::Combine } else { self.subpaths[i].op }
|
||||
}
|
||||
/// Shape components: ranges of subpaths filled together (a subpath starts a new component
|
||||
/// unless it is a [`PathOp::Join`]), each folded in with its first subpath's
|
||||
/// [`Path::effective_op`].
|
||||
pub fn components(&self) -> Vec<std::ops::Range<usize>> {
|
||||
let mut out: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
for (i, s) in self.subpaths.iter().enumerate() {
|
||||
match out.last_mut() {
|
||||
Some(r) if s.op == PathOp::Join => r.end = i + 1,
|
||||
_ => out.push(i..i + 1),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn transform(&self, a: &Affine) -> Path {
|
||||
let mut p = self.clone();
|
||||
for s in &mut p.subpaths {
|
||||
|
||||
@@ -7,6 +7,10 @@ license.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
|
||||
corpus = []
|
||||
|
||||
[dependencies]
|
||||
photocraft-geom = { workspace = true }
|
||||
photocraft-color = { workspace = true }
|
||||
|
||||
@@ -204,9 +204,30 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
/// Image › Adjustments run as a smart filter (`image.adjustments.<kind>` recorded on a smart
|
||||
/// object): the adjustment applied to a copy of `surf`. `None` for kinds or params it can't run.
|
||||
pub(crate) fn adjust_as_filter(kind: &str, p: &Value, surf: &Surface) -> Option<Surface> {
|
||||
let mut out = surf.clone();
|
||||
if kind == "shadowsHighlights" {
|
||||
let sh = shadows_highlights_params(p);
|
||||
process_surface(&mut out, None, &mut |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh));
|
||||
} else {
|
||||
let mode = surf.format().mode;
|
||||
let adj = crate::adjust_params::from_params(kind, p, None, mode).ok()?;
|
||||
crate::pixels::adjust_surface(&mut out, &adj, None, mode);
|
||||
}
|
||||
out.prune();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn shadows_highlights(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = shadows_highlights_params(p);
|
||||
rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh))
|
||||
}
|
||||
|
||||
fn shadows_highlights_params(p: &Value) -> ShadowsHighlights {
|
||||
let d = ShadowsHighlights::default();
|
||||
let sh = ShadowsHighlights {
|
||||
ShadowsHighlights {
|
||||
shadow_amount: num(p, "shadowAmount", d.shadow_amount).clamp(0.0, 100.0),
|
||||
shadow_tone: num(p, "shadowTone", d.shadow_tone).clamp(0.0, 100.0),
|
||||
shadow_radius: num(p, "shadowRadius", d.shadow_radius).clamp(0.0, 2500.0),
|
||||
@@ -217,8 +238,7 @@ fn shadows_highlights(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
midtone: num(p, "midtone", d.midtone).clamp(-100.0, 100.0),
|
||||
black_clip: num(p, "blackClip", d.black_clip).clamp(0.0, 50.0),
|
||||
white_clip: num(p, "whiteClip", d.white_clip).clamp(0.0, 50.0),
|
||||
};
|
||||
rgba_edit(s, "Shadows/Highlights", p, |px, r| tone::shadows_highlights(px, r.width() as usize, r.height() as usize, &sh))
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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()}))
|
||||
@@ -307,7 +307,7 @@ fn write_shim(droplet: &str) -> Result<String> {
|
||||
"#!/bin/sh\n# PhotoCraft droplet: runs the action on the files given (or dropped).\nexec \"${{PHOTOCRAFT_CLI:-photocraft-cli}}\" droplet \"{}\" \"$@\"\n",
|
||||
abs.replace('"', "\\\"")
|
||||
);
|
||||
std::fs::write(&shim, body).map_err(|e| EngineError::Other(format!("{shim}: {e}")))?;
|
||||
crate::file_cmds::write_file(&shim, body.as_bytes())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -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
|
||||
),
|
||||
|
||||
+134
-17
@@ -222,17 +222,37 @@ fn stroke_with(s: &mut Session, p: &Value, label: &str, brush: BrushSettings, pt
|
||||
let mut brush = brush;
|
||||
erase_locked(&mut brush, lock, bg);
|
||||
if auto_erase {
|
||||
// Pencil Auto Erase: starting on foreground-coloured pixels paints the background colour.
|
||||
let c = surf.rgba(pts[0].x.floor() as i32, pts[0].y.floor() as i32);
|
||||
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
|
||||
brush.color = bg;
|
||||
}
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, bg);
|
||||
}
|
||||
Ok(render_stroke(surf, &brush, &pts, sel.as_ref(), lock, zoom))
|
||||
})?;
|
||||
Ok(damage_json(s, dmg))
|
||||
}
|
||||
|
||||
/// Pencil Auto Erase: a stroke that starts on a pixel of the foreground colour paints the
|
||||
/// background colour instead (Photoshop).
|
||||
fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&StrokePoint>, fg: [f32; 4], bg: [f32; 4]) {
|
||||
let Some(p0) = start else { return };
|
||||
let c = surf.rgba(p0.x.floor() as i32, p0.y.floor() as i32);
|
||||
if c[3] > 0.0 && (0..3).all(|i| (c[i] - fg[i]).abs() < 1.5 / 255.0) {
|
||||
brush.color = bg;
|
||||
}
|
||||
}
|
||||
|
||||
/// The Pencil's brush: the session brush made aliased (every pixel fully painted or untouched,
|
||||
/// dabs on the pixel grid), hard and at full flow, with the options-bar mode.
|
||||
fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
}
|
||||
if num(p, "flow").is_none() {
|
||||
brush.flow = 1.0;
|
||||
}
|
||||
Ok(brush)
|
||||
}
|
||||
|
||||
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
|
||||
/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal.
|
||||
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
|
||||
@@ -273,15 +293,29 @@ pub struct LiveStroke {
|
||||
impl LiveStroke {
|
||||
/// Start from `paint.stroke` params; their `points` are rendered.
|
||||
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
|
||||
Self::begin_with(s, "paint.stroke", p)
|
||||
}
|
||||
|
||||
/// Start a live stroke of `cmd`: `paint.stroke` (the Brush and Eraser) or `paint.pencil`.
|
||||
/// Committing `cmd` with the same params and `"seed": live.seed` gives the same pixels.
|
||||
pub fn begin_with(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
|
||||
has_paintable(s).map_err(EngineError::Other)?;
|
||||
let pts = parse_points(p, "paint.stroke")?;
|
||||
let brush = with_blend_mode(resolve_brush(s, p, "paint.stroke")?, p);
|
||||
let seed = brush.seed;
|
||||
let pts = parse_points(p, cmd)?;
|
||||
let pencil = match cmd {
|
||||
"paint.stroke" => false,
|
||||
"paint.pencil" => true,
|
||||
other => return Err(bad(other, "live strokes are `paint.stroke` or `paint.pencil`")),
|
||||
};
|
||||
let brush = if pencil { pencil_brush(s, p)? } else { with_blend_mode(resolve_brush(s, p, cmd)?, p) };
|
||||
let (seed, fg) = (brush.seed, brush.color);
|
||||
let (layer, mut brush, zoom) = stroke_target(s, p, brush)?;
|
||||
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, layer, p)?;
|
||||
erase_locked(&mut brush, lock, s.tools.background);
|
||||
if pencil && flag(p, "autoErase", false) {
|
||||
apply_auto_erase(&mut brush, surf, pts.first(), fg, s.tools.background);
|
||||
}
|
||||
let renderer = StrokeRenderer::new(&brush, Some(surf.format()), zoom);
|
||||
let pre = surf.clone();
|
||||
let mut live = Self { doc: std::sync::Arc::new(doc), seed, renderer, pre, sel, lock, layer, params: p.clone(), tail: Rect::EMPTY };
|
||||
@@ -320,11 +354,7 @@ impl LiveStroke {
|
||||
|
||||
fn pencil(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = parse_points(p, "paint.pencil")?;
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
}
|
||||
let brush = pencil_brush(s, p)?;
|
||||
let label = if brush.erase { "Eraser" } else { "Pencil" };
|
||||
stroke_with(s, p, label, brush, pts, flag(p, "autoErase", false))
|
||||
}
|
||||
@@ -337,8 +367,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 +581,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 +684,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,209 @@ 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_pencil_matches_the_committed_pencil_and_auto_erases() {
|
||||
let mut s = session(80, 40);
|
||||
s.execute("tools.setColors", json!({"foreground": "#000000", "background": "#ffffff"})).unwrap();
|
||||
// A soft, low-flow session brush: the Pencil is still hard, aliased and full flow.
|
||||
s.execute("tools.setBrush", json!({"brush": {"size": 3, "hardness": 0.0, "flow": 0.3}})).unwrap();
|
||||
let pts = [[5.0, 5.0, 1.0], [30.2, 17.7, 0.6], [70.0, 33.0, 1.0]];
|
||||
for (auto, start) in [(false, pts[0]), (true, [30.4, 17.2, 1.0])] {
|
||||
let p = json!({"points": [start], "autoErase": auto, "smoothing": 0.0});
|
||||
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
|
||||
let rest: Vec<StrokePoint> = pts.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
live.push(&rest).unwrap();
|
||||
let mut commit = p.clone();
|
||||
let mut all = vec![start];
|
||||
all.extend(pts);
|
||||
commit["points"] = json!(all);
|
||||
commit["seed"] = json!(live.seed);
|
||||
s.execute("paint.pencil", commit).unwrap();
|
||||
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
assert!(same_pixels(&shown, &surface(&s), Rect::new(0, 0, 80, 40)), "auto erase {auto}");
|
||||
for y in 0..40 {
|
||||
for x in 0..80 {
|
||||
let a = shown.rgba(x, y)[3];
|
||||
assert!(a == 0.0 || a == 1.0, "partial alpha {a} at ({x},{y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
// The second stroke started on the first one's black: Auto Erase painted white.
|
||||
assert_eq!(rgba(&s, 70, 33), [1.0, 1.0, 1.0, 1.0]);
|
||||
// Only the Brush and the Pencil stroke live.
|
||||
assert!(LiveStroke::begin_with(&s, "paint.mixerBrush", &json!({"points": [[1, 1]]})).is_err());
|
||||
assert!(LiveStroke::begin_with(&s, "paint.pencil", &json!({"points": "none"})).is_err());
|
||||
}
|
||||
|
||||
/// Pencil latency on a 24 MP document: the time to start a live stroke, per pointer move, and
|
||||
/// to commit. `cargo test -p photocraft-engine --release --lib pencil_latency -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pencil_latency_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
|
||||
for size in [1.0f64, 9.0, 40.0] {
|
||||
let p = json!({"points": [[100.0, 100.0]], "size": size, "smoothing": 0.0});
|
||||
let t = std::time::Instant::now();
|
||||
let mut live = LiveStroke::begin_with(&s, "paint.pencil", &p).unwrap();
|
||||
let begin = t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut steps = Vec::new();
|
||||
let mut all = vec![[100.0, 100.0]];
|
||||
for i in 1..=300 {
|
||||
let q = [100.0 + i as f64 * 15.0, 100.0 + (i as f64 * 0.07).sin() * 900.0 + 1000.0];
|
||||
all.push(q);
|
||||
let t = std::time::Instant::now();
|
||||
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
|
||||
steps.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
let mut commit = p.clone();
|
||||
commit["points"] = json!(all);
|
||||
commit["seed"] = json!(live.seed);
|
||||
let t = std::time::Instant::now();
|
||||
s.execute("paint.pencil", commit).unwrap();
|
||||
let done = t.elapsed().as_secs_f64() * 1000.0;
|
||||
steps.sort_by(f64::total_cmp);
|
||||
println!(
|
||||
"pencil {size:>4} px: begin {begin:.2} ms, per move p50 {:.3} ms p95 {:.3} ms max {:.3} ms, commit {done:.1} ms",
|
||||
steps[steps.len() / 2],
|
||||
steps[steps.len() * 95 / 100],
|
||||
steps[steps.len() - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,12 @@ fn map_angle(a: &Affine, deg: f32) -> f32 {
|
||||
}
|
||||
|
||||
fn map_fill(a: &Affine, f: &mut Fill) {
|
||||
if let Fill::Gradient { angle, .. } = f {
|
||||
if let Fill::Gradient { angle, offset, .. } = f {
|
||||
*angle = map_angle(a, *angle);
|
||||
// The centre offset (a fraction of the frame) turns with the canvas: exact for 90° turns
|
||||
// and flips, where the frame's sides swap with the axes.
|
||||
let (ox, oy) = (f64::from(offset.0), f64::from(offset.1));
|
||||
*offset = ((a.m[0] * ox + a.m[2] * oy) as f32, (a.m[1] * ox + a.m[3] * oy) as f32);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ pub struct ChannelView {
|
||||
/// Visible alpha channels (missing entries are hidden).
|
||||
pub alpha_visible: Vec<bool>,
|
||||
pub quick_mask_hidden: bool,
|
||||
/// ⌥-click view of the active layer's mask (#196); see [`crate::mask_view_cmds`].
|
||||
pub layer_mask: Option<crate::mask_view_cmds::LayerMaskView>,
|
||||
}
|
||||
|
||||
impl ChannelView {
|
||||
@@ -80,7 +82,15 @@ impl ChannelView {
|
||||
/// True when the canvas shows just the normal composite (nothing extra to draw).
|
||||
pub fn is_plain(&self, doc: &Document) -> bool {
|
||||
let colors = color_count(doc);
|
||||
self.visible_colors(colors) == colors && !(0..doc.channels.len()).any(|i| self.alpha_shown(i)) && (doc.quick_mask.is_none() || self.quick_mask_hidden)
|
||||
self.visible_colors(colors) == colors
|
||||
&& !(0..doc.channels.len()).any(|i| self.alpha_shown(i))
|
||||
&& (doc.quick_mask.is_none() || self.quick_mask_hidden)
|
||||
&& self.shown_layer_mask(doc).is_none()
|
||||
}
|
||||
/// The layer mask shown on the canvas (#196) and how, if its layer still has a mask.
|
||||
pub fn shown_layer_mask<'a>(&self, doc: &'a Document) -> Option<(&'a photocraft_doc::LayerMask, crate::mask_view_cmds::MaskViewMode)> {
|
||||
let v = self.layer_mask?;
|
||||
Some((doc.layer(v.layer)?.mask.as_ref()?, v.mode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +125,7 @@ pub(crate) fn fix_view(st: &mut DocState) {
|
||||
if v.visible_colors(colors) == 0 && !(0..n).any(|i| v.alpha_shown(i)) {
|
||||
v.color_hidden.clear();
|
||||
}
|
||||
crate::mask_view_cmds::fix(st);
|
||||
}
|
||||
|
||||
/// Bump the revision for a view-only change without dirtying a clean document.
|
||||
@@ -212,6 +223,8 @@ pub enum ChanRef {
|
||||
Selection,
|
||||
Transparency,
|
||||
LayerMask,
|
||||
/// The layer's vector mask, rasterized (⌘-click its thumbnail, #196).
|
||||
VectorMask,
|
||||
}
|
||||
|
||||
pub fn parse_ref(v: &Value, doc: &Document) -> Option<ChanRef> {
|
||||
@@ -233,6 +246,7 @@ pub fn parse_ref(v: &Value, doc: &Document) -> Option<ChanRef> {
|
||||
"selection" => return Some(ChanRef::Selection),
|
||||
"transparency" => return Some(ChanRef::Transparency),
|
||||
"mask" | "layermask" => return Some(ChanRef::LayerMask),
|
||||
"vectormask" | "vector mask" => return Some(ChanRef::VectorMask),
|
||||
_ => {}
|
||||
}
|
||||
if l == composite_name(mode).to_ascii_lowercase() {
|
||||
@@ -335,6 +349,14 @@ fn ref_planes(doc: &Document, layer: Option<LayerId>, active: Option<LayerId>, r
|
||||
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no mask", l.name)))?;
|
||||
vec![read_plane(&m.surface, area)]
|
||||
}
|
||||
ChanRef::VectorMask => {
|
||||
let id = layer.or(active).ok_or_else(|| EngineError::Other("vector mask needs a layer".into()))?;
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let m = l.vector_mask.as_ref().ok_or_else(|| EngineError::Other(format!("layer \"{}\" has no vector mask", l.name)))?;
|
||||
// The path's shape, whether or not the mask is enabled (as Photoshop loads it).
|
||||
let shape = photocraft_doc::VectorMask { enabled: true, density: 1.0, feather: 0.0, ..m.clone() };
|
||||
vec![photocraft_vector::vector_mask_values(&shape, area)]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -517,6 +539,20 @@ fn routed(id: &str) -> bool {
|
||||
|| id == "plugin.run"
|
||||
|| id.starts_with("image.adjustments.")
|
||||
|| matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage")
|
||||
|| crate::fill_key_cmds::IDS.contains(&id)
|
||||
|| matches!(
|
||||
id,
|
||||
"paint.cloneStamp"
|
||||
| "paint.healingBrush"
|
||||
| "paint.spotHealing"
|
||||
| "paint.dodge"
|
||||
| "paint.burn"
|
||||
| "paint.sponge"
|
||||
| "paint.blur"
|
||||
| "paint.sharpen"
|
||||
| "paint.smudge"
|
||||
| "paint.historyBrush"
|
||||
)
|
||||
}
|
||||
|
||||
/// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none.
|
||||
@@ -528,6 +564,8 @@ pub(crate) fn inject_target(s: &Session, id: &str, params: Value) -> Value {
|
||||
let t = match st.channel_view.target {
|
||||
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }),
|
||||
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
|
||||
// Viewing the active layer's mask (⌥-click): pixel commands edit the mask.
|
||||
ChannelTarget::Composite if crate::mask_view_cmds::current(st).is_some() => json!("mask"),
|
||||
_ => return params,
|
||||
};
|
||||
match params {
|
||||
@@ -1416,6 +1454,7 @@ pub fn channels_json(st: &DocState) -> Value {
|
||||
"quickMask": doc.quick_mask.as_ref().map(|q| json!({ "visible": !v.quick_mask_hidden, "color": q.color.to_rgb(), "opacity": q.opacity, "indicates": q.indicates })),
|
||||
"target": v.target,
|
||||
"compositeVisible": v.visible_colors(colors) == colors,
|
||||
"layerMaskView": crate::mask_view_cmds::view_json(st),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1448,7 +1487,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Load Selection…",
|
||||
["Select"],
|
||||
None,
|
||||
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
|
||||
r##"{"channel":index|name|"composite"|"red|green|blue|…"|"transparency"|"mask"|"vectorMask"|"quickMask"|"selection","layer":id?,"document":index?,"invert":bool=false,"operation":"new|add|subtract|intersect"="new"}"##,
|
||||
has_doc,
|
||||
load_selection
|
||||
),
|
||||
|
||||
@@ -217,8 +217,16 @@ fn build() -> Vec<CommandSpec> {
|
||||
r##"{"width":u32=1920,"height":u32=1080,"mode":"rgb|gray|cmyk|lab"="rgb","depth":8|16|32=8,"background":"white|black|backgroundColor|transparent|#rrggbb"="white","resolution":ppi=72,"name":str}"##,
|
||||
always,
|
||||
|s, p| {
|
||||
let w = p.get("width").and_then(Value::as_u64).unwrap_or(1920).clamp(1, 300_000) as u32;
|
||||
let h = p.get("height").and_then(Value::as_u64).unwrap_or(1080).clamp(1, 300_000) as u32;
|
||||
// A size given as a float (`512.0`, as JSON from a UI field) is still that size (#254).
|
||||
let px = |k: &str, d: u32| match p.get(k) {
|
||||
Some(v) => v
|
||||
.as_u64()
|
||||
.map(|n| n.clamp(1, 300_000) as u32)
|
||||
.or_else(|| v.as_f64().filter(|f| f.is_finite()).map(|f| f.round().clamp(1.0, 300_000.0) as u32))
|
||||
.unwrap_or(d),
|
||||
None => d,
|
||||
};
|
||||
let (w, h) = (px("width", 1920), px("height", 1080));
|
||||
let mode = match p.get("mode").and_then(Value::as_str).unwrap_or("rgb") {
|
||||
"gray" | "grayscale" => ColorMode::Grayscale,
|
||||
"cmyk" => ColorMode::Cmyk,
|
||||
@@ -264,32 +272,17 @@ fn build() -> Vec<CommandSpec> {
|
||||
"Fill…",
|
||||
["Edit"],
|
||||
Some("Shift+F5"),
|
||||
r##"{"contents":"color|pattern"="color","color":"#rrggbb|[r,g,b,a]"=foreground,"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"opacity":%=100,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
|
||||
has_pixel_or_channel,
|
||||
|s, p| {
|
||||
if p.get("contents").and_then(Value::as_str) == Some("pattern") {
|
||||
return crate::pattern_cmds::fill_with_pattern(s, p);
|
||||
}
|
||||
let color = color_param(p, "color", s.tools.foreground);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) };
|
||||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
pixels::fill_surface(surf, area, color, sel.as_ref(), lock);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
crate::fill_cmds::fill
|
||||
),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
let bg = s.tools.background;
|
||||
s.edit("Clear", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
pixels::clear_surface(l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?, area, sel.as_ref());
|
||||
Ok(())
|
||||
crate::edit_cmds::clear_area(doc, id, area, sel.as_ref(), bg)
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
@@ -339,6 +332,10 @@ fn build() -> Vec<CommandSpec> {
|
||||
let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true);
|
||||
let feather = p.get("feather").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1000.0) as f32;
|
||||
s.edit("Rectangular Marquee", |doc, _| {
|
||||
let area = doc.bounds();
|
||||
// A marquee dragged past the canvas stops at its edge (Photoshop); the ellipse
|
||||
// keeps the dragged shape and is only cut there.
|
||||
let cut = r.intersect(&area);
|
||||
let mut shape = Surface::new(photocraft_color::PixelFormat::GRAY8);
|
||||
if ellipse {
|
||||
let (cx, cy) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0);
|
||||
@@ -347,8 +344,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
let (dx, dy) = ((x - cx) / rx, (y - cy) / ry);
|
||||
dx * dx + dy * dy <= 1.0
|
||||
};
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
for y in cut.y0..cut.y1 {
|
||||
for x in cut.x0..cut.x1 {
|
||||
let (fx, fy) = (x as f32, y as f32);
|
||||
let corners = [(fx, fy), (fx + 1.0, fy), (fx, fy + 1.0), (fx + 1.0, fy + 1.0)].iter().filter(|(a, b)| inside(*a, *b)).count();
|
||||
let cov = if !aa {
|
||||
@@ -365,9 +362,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
shape.fill_rect(r, &[1.0]);
|
||||
shape.fill_rect(cut, &[1.0]);
|
||||
}
|
||||
let area = doc.bounds();
|
||||
if feather > 0.0 {
|
||||
use photocraft_algo::selection as sel;
|
||||
let m = sel::feather(&sel::mask_from_surface(Some(&shape), area), area.width() as usize, area.height() as usize, feather);
|
||||
@@ -381,7 +377,8 @@ fn build() -> Vec<CommandSpec> {
|
||||
("intersect", Some(o)) => combine(&o, &shape, area, |a, b| a.min(b)),
|
||||
_ => shape,
|
||||
};
|
||||
doc.selection = Some(combined);
|
||||
// Nothing selected on the canvas (e.g. dragged entirely outside it) deselects.
|
||||
doc.selection = (!combined.content_bounds().is_empty()).then_some(combined);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
@@ -468,6 +465,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 +510,9 @@ fn build() -> Vec<CommandSpec> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if local {
|
||||
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
),
|
||||
@@ -620,13 +623,13 @@ fn build() -> Vec<CommandSpec> {
|
||||
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
|
||||
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.edit("New Gradient Fill Layer", |doc, active| {
|
||||
let fill = Fill::Gradient {
|
||||
stops: vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
|
||||
let fill = Fill::gradient(
|
||||
vec![(0.0, Color::rgba(a[0], a[1], a[2], a[3])), (1.0, Color::rgba(b[0], b[1], b[2], b[3]))],
|
||||
angle,
|
||||
scale: 1.0,
|
||||
1.0,
|
||||
style,
|
||||
reverse,
|
||||
};
|
||||
);
|
||||
let id = doc.insert_above(*active, Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)));
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
@@ -933,6 +936,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::filters::specs());
|
||||
v.extend(crate::filters_ext::specs());
|
||||
v.extend(crate::gallery_cmds::specs());
|
||||
v.extend(crate::gradient_fill_cmds::specs());
|
||||
v.extend(crate::type_cmds::specs());
|
||||
v.extend(crate::transform_cmds::specs());
|
||||
v.extend(crate::vector_cmds::specs());
|
||||
@@ -940,6 +944,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());
|
||||
@@ -956,6 +961,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::layer_multi_cmds::specs());
|
||||
v.extend(crate::prefs::specs());
|
||||
v.extend(crate::edit_menu_cmds::specs());
|
||||
v.extend(crate::fill_key_cmds::specs());
|
||||
v.extend(crate::align_cmds::specs());
|
||||
v.extend(crate::photo_cmds::specs());
|
||||
v.extend(crate::lens_cmds::specs());
|
||||
@@ -990,6 +996,7 @@ fn build() -> Vec<CommandSpec> {
|
||||
v.extend(crate::plugin_cmds::specs());
|
||||
v.extend(crate::group_view_cmds::specs());
|
||||
v.extend(crate::fx_view_cmds::specs());
|
||||
v.extend(crate::mask_view_cmds::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -1051,6 +1058,9 @@ fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
|
||||
fn set_mask(s: &mut Session, p: &Value, label: &str, mask: Option<LayerMask>) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
s.edit(label, |doc, _| {
|
||||
if mask.is_some() {
|
||||
crate::extra_cmds::background_to_layer_for_mask(doc, id);
|
||||
}
|
||||
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = mask;
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -1070,7 +1080,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));
|
||||
|
||||
@@ -88,12 +88,24 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
|
||||
Ok(json!({"bounds": [b.x0, b.y0, b.width(), b.height()]}))
|
||||
}
|
||||
|
||||
fn clear_selected(doc: &mut Document, id: LayerId) -> Result<()> {
|
||||
fn clear_selected(doc: &mut Document, id: LayerId, background: [f32; 4]) -> Result<()> {
|
||||
let sel = doc.selection.clone();
|
||||
let canvas = doc.bounds();
|
||||
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or(EngineError::Other("the active layer has no pixels".into()))?;
|
||||
let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
|
||||
crate::pixels::clear_surface(surf, area, sel.as_ref());
|
||||
clear_area(doc, id, area, sel.as_ref(), background)
|
||||
}
|
||||
|
||||
/// Cut / Clear on a layer: makes the selected pixels transparent. The Background can't hold
|
||||
/// transparency, so there the area is filled with the background colour instead (Photoshop).
|
||||
pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Option<&Surface>, background: [f32; 4]) -> Result<()> {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let bg = crate::extra_cmds::is_background(l);
|
||||
let surf = l.surface_mut().ok_or(EngineError::Other("the active layer has no pixels".into()))?;
|
||||
if bg {
|
||||
crate::pixels::fill_surface(surf, area, background, sel, true);
|
||||
} else {
|
||||
crate::pixels::clear_surface(surf, area, sel);
|
||||
}
|
||||
surf.prune();
|
||||
Ok(())
|
||||
}
|
||||
@@ -149,10 +161,11 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
|
||||
if clip.bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not complete the command: the selected area is empty".into()));
|
||||
}
|
||||
let bg = s.tools.background;
|
||||
let label = if cut { "Layer Via Cut" } else { "Layer Via Copy" };
|
||||
let nid = s.edit(label, |doc, active| {
|
||||
if cut {
|
||||
clear_selected(doc, id)?;
|
||||
clear_selected(doc, id, bg)?;
|
||||
}
|
||||
let mut l = Layer::raster(doc.next_layer_name("Layer"), clip.surface.format());
|
||||
*crate::pixels_mut(&mut l)? = clip.surface;
|
||||
@@ -396,7 +409,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{}", has_pixels, |s, _| {
|
||||
let r = copy(s, false)?;
|
||||
let id = active_id(s)?;
|
||||
s.edit("Cut Pixels", |doc, _| clear_selected(doc, id))?;
|
||||
let bg = s.tools.background;
|
||||
s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?;
|
||||
Ok(r)
|
||||
}),
|
||||
spec!("edit.copy", "Copy", &["Edit"], Some("Cmd+C"), "{}", has_pixels, |s, _| copy(s, false)),
|
||||
@@ -525,6 +539,36 @@ mod tests {
|
||||
assert!((cb.width() as i32 - 80).abs() <= 3 && (cb.height() as i32 - 40).abs() <= 3, "size ~ 80x40: {cb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_and_clear_on_the_background_fill_with_the_background_colour() {
|
||||
// The Background keeps its transparency lock: cutting fills with the background colour.
|
||||
let fresh = || {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30})).unwrap();
|
||||
s.tools.background = [1.0, 0.0, 0.0, 1.0];
|
||||
s.execute("select.rect", json!({"x": 5, "y": 5, "width": 10, "height": 10})).unwrap();
|
||||
s
|
||||
};
|
||||
let px = |s: &Session, i: usize, x: i32, y: i32| s.active().unwrap().doc.layers[i].surface().unwrap().pixel(x, y);
|
||||
for cmd in ["edit.cut", "edit.clear", "layer.new.layerViaCut"] {
|
||||
let mut s = fresh();
|
||||
s.execute(cmd, json!({})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert!(crate::extra_cmds::is_background(&d.layers[0]), "{cmd}: still the Background");
|
||||
assert_eq!(px(&s, 0, 8, 8), vec![1.0, 0.0, 0.0, 1.0], "{cmd}: cut area is the background colour");
|
||||
assert_eq!(px(&s, 0, 30, 20), vec![1.0, 1.0, 1.0, 1.0], "{cmd}: outside untouched");
|
||||
}
|
||||
// Layer via Cut still lifts the original pixels onto the new layer.
|
||||
let mut s = fresh();
|
||||
s.execute("layer.new.layerViaCut", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 1, 8, 8), vec![1.0, 1.0, 1.0, 1.0]);
|
||||
// Once it's a normal layer, cutting makes transparency again.
|
||||
let mut s = fresh();
|
||||
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 0, 8, 8)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_clears_and_layer_via_copy_cut() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -42,7 +42,7 @@ fn has_clip_and_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
d.doc.selection.as_ref().map(|_| ()).ok_or_else(|| "Paste Into needs a selection".into())
|
||||
}
|
||||
|
||||
fn is_background(l: &Layer) -> bool {
|
||||
pub(crate) fn is_background(l: &Layer) -> bool {
|
||||
l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_))
|
||||
}
|
||||
|
||||
@@ -333,15 +333,27 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
||||
fn layer_from_background(s: &mut Session) -> Result<Value> {
|
||||
let id = s.edit("Layer From Background", |doc, active| {
|
||||
let l = doc.layers.first_mut().filter(|l| is_background(l)).ok_or(EngineError::Other("the document has no Background layer".into()))?;
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
unlock_background(l);
|
||||
*active = Some(l.id);
|
||||
Ok(l.id)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
fn unlock_background(l: &mut Layer) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
}
|
||||
|
||||
/// Before `id` gets a layer mask: the Background can't have one, so it becomes a normal layer
|
||||
/// first ("Layer 0"), as when adding a mask to it in Photoshop. Other layers are left alone.
|
||||
pub(crate) fn background_to_layer_for_mask(doc: &mut photocraft_doc::Document, id: photocraft_doc::LayerId) {
|
||||
if let Some(l) = doc.layers.first_mut().filter(|l| l.id == id && is_background(l)) {
|
||||
unlock_background(l);
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_mask(s: &mut Session, p: &Value, key: &str) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
let label = if key == "enabled" { "Enable/Disable Layer Mask" } else { "Link/Unlink Layer Mask" };
|
||||
@@ -818,6 +830,38 @@ mod tests {
|
||||
assert_eq!((m.value(0, 0), m.value(22, 12)), (1.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_turns_the_background_into_a_layer() {
|
||||
for (cmd, needs_selection) in [
|
||||
("layer.layerMask.revealAll", false),
|
||||
("layer.layerMask.hideAll", false),
|
||||
("layer.layerMask.revealSelection", true),
|
||||
("layer.layerMask.hideSelection", true),
|
||||
] {
|
||||
let mut s = session(8);
|
||||
let bg = doc(&s).layers[0].id;
|
||||
if needs_selection {
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
s.execute(cmd, json!({"layer": bg.0})).unwrap();
|
||||
let l = &doc(&s).layers[0];
|
||||
assert!(l.mask.is_some() && l.name == "Layer 0" && !l.locks.transparency && !l.locks.position, "{cmd}");
|
||||
assert!(!s.is_enabled("layer.new.layerFromBackground"), "{cmd}");
|
||||
// One step: undo brings the locked Background back, without a mask.
|
||||
assert!(s.undo());
|
||||
let l = &doc(&s).layers[0];
|
||||
assert!(l.mask.is_none() && l.name == "Background" && l.locks.transparency, "{cmd}");
|
||||
}
|
||||
// Other layers keep their name and locks.
|
||||
let mut s = session(8);
|
||||
let top = doc(&s).layers[1].id;
|
||||
s.execute("layer.setProps", json!({"layer": top.0, "locks": {"position": true}})).unwrap();
|
||||
s.execute("layer.layerMask.revealAll", json!({"layer": top.0})).unwrap();
|
||||
let l = doc(&s).layer(top).unwrap();
|
||||
assert!(l.mask.is_some() && l.name != "Layer 0" && l.locks.position);
|
||||
assert_eq!(doc(&s).layers[0].name, "Background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_utilities() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -84,11 +84,10 @@ pub(crate) fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Every file the engine writes goes through here: crash-safe (temp file + fsync + rename, see
|
||||
/// [`photocraft_format::atomic`]), so a failed save never destroys the previous file.
|
||||
pub(crate) fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
photocraft_format::atomic_write(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Edit › Fill… (`edit.fill`): Photoshop's Fill dialog as one command.
|
||||
//!
|
||||
//! Contents: Foreground / Background / Color / Content-Aware / Pattern / History / Black /
|
||||
//! 50% Gray / White, blended with `mode` at `opacity`, optionally preserving the target's
|
||||
//! transparency. The selection (or the whole canvas) is filled, as one history step ("Fill").
|
||||
//! Every parameter is validated: a bad one is an error, never a panic.
|
||||
|
||||
use photocraft_color::BlendMode;
|
||||
use photocraft_geom::{Rect, TILE_SIZE};
|
||||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{blend_from_str, color_param, layer_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "edit.fill";
|
||||
|
||||
/// Every `contents` value, in the dialog's order.
|
||||
pub const CONTENTS: [&str; 9] = ["foreground", "background", "color", "contentAware", "pattern", "history", "black", "gray", "white"];
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// What a pixel of the filled area gets before blending.
|
||||
enum Source {
|
||||
Color([f32; 4]),
|
||||
/// Straight RGBA per pixel of `rect` (row-major).
|
||||
Pixels(Rect, Vec<[f32; 4]>),
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn at(&self, x: i32, y: i32) -> [f32; 4] {
|
||||
match self {
|
||||
Source::Color(c) => *c,
|
||||
Source::Pixels(r, px) => {
|
||||
if !r.contains(x, y) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let i = (y - r.y0) as usize * r.width() as usize + (x - r.x0) as usize;
|
||||
px.get(i).copied().unwrap_or([0.0; 4])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `contents` param; without one, `color` (the old `{"color": …}` form, foreground by default).
|
||||
fn contents(p: &Value) -> Result<&str> {
|
||||
match p.get("contents") {
|
||||
None | Some(Value::Null) => Ok("color"),
|
||||
Some(Value::String(c)) => {
|
||||
CONTENTS.iter().find(|k| k.eq_ignore_ascii_case(c)).copied().ok_or_else(|| bad(format!("unknown contents `{c}` ({})", CONTENTS.join("|"))))
|
||||
}
|
||||
Some(v) => Err(bad(format!("`contents` must be a string, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode(p: &Value) -> Result<BlendMode> {
|
||||
match p.get("mode") {
|
||||
None | Some(Value::Null) => Ok(BlendMode::Normal),
|
||||
Some(Value::String(m)) => blend_from_str(m).filter(|m| *m != BlendMode::PassThrough).ok_or_else(|| bad(format!("unknown blend mode `{m}`"))),
|
||||
Some(v) => Err(bad(format!("`mode` must be a blend mode name, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity in percent (0–100) → 0..1.
|
||||
fn opacity(p: &Value) -> Result<f32> {
|
||||
match p.get("opacity") {
|
||||
None | Some(Value::Null) => Ok(1.0),
|
||||
Some(v) => {
|
||||
let o = v.as_f64().filter(|o| o.is_finite()).ok_or_else(|| bad(format!("`opacity` must be a number 0..100, not {v}")))?;
|
||||
Ok((o.clamp(0.0, 100.0) / 100.0) as f32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flag(p: &Value, k: &str, d: bool) -> Result<bool> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(d),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(v) => Err(bad(format!("`{k}` must be true or false, not {v}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit › Fill.
|
||||
pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let contents = contents(p)?;
|
||||
let mode = mode(p)?;
|
||||
let opacity = opacity(p)?;
|
||||
let preserve = flag(p, "preserveTransparency", false)?;
|
||||
let channel = crate::channel_cmds::is_channel_target(p);
|
||||
let id = if channel { None } else { Some(layer_param(s, p)?) };
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let doc_bounds = st.doc.bounds();
|
||||
let sel = st.doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc_bounds);
|
||||
let mut out = json!({ "contents": contents });
|
||||
let restore = contents == "history";
|
||||
let source = match contents {
|
||||
"foreground" => Source::Color(s.tools.foreground),
|
||||
"background" => Source::Color(s.tools.background),
|
||||
"color" => Source::Color(color_param(p, "color", s.tools.foreground)),
|
||||
"black" => Source::Color([0.0, 0.0, 0.0, 1.0]),
|
||||
"gray" => Source::Color([128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
|
||||
"white" => Source::Color([1.0; 4]),
|
||||
"pattern" => {
|
||||
let pat = crate::pattern_cmds::resolve_param(s, CMD, p)?;
|
||||
let (scale, angle, _, phase) = crate::pattern_cmds::placement(p);
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("the pattern is empty"))?;
|
||||
// Photoshop's Fill uses the canvas origin.
|
||||
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
|
||||
out["pattern"] = json!(pat.id);
|
||||
Source::Pixels(area, photocraft_compose::pattern::render(&tile, &place, area))
|
||||
}
|
||||
"history" => {
|
||||
let explicit = match p.get("state") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_u64().ok_or_else(|| bad(format!("`state` must be a history state index, not {v}")))? as usize),
|
||||
};
|
||||
let past = st.history.past_len();
|
||||
let source_doc = match explicit {
|
||||
Some(i) if i == past => st.doc.clone(),
|
||||
Some(i) => st.history.state(i).ok_or_else(|| bad(format!("no history state {i} (0..={past})")))?,
|
||||
// The History Brush's default source: the oldest state still held.
|
||||
None => st.history.state(0).unwrap_or_else(|| st.doc.clone()),
|
||||
};
|
||||
let mut source_doc = (*source_doc).clone();
|
||||
let (src, _) = crate::channel_cmds::target_surface(&mut source_doc, id, p)
|
||||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
|
||||
Source::Pixels(area, read_rgba(src, area))
|
||||
}
|
||||
"contentAware" => {
|
||||
let sel = sel.as_ref().ok_or_else(|| EngineError::Other("Content-Aware fill needs a selection".into()))?;
|
||||
if channel || crate::commands::is_mask_target(p) {
|
||||
return Err(EngineError::Other("Content-Aware fill works on layer pixels only".into()));
|
||||
}
|
||||
let adapt = flag(p, "colorAdaptation", true)?;
|
||||
let lid = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let surf = st.doc.layer(lid).and_then(|l| l.surface()).ok_or_else(|| EngineError::Other("Content-Aware fill needs a pixel layer".into()))?;
|
||||
let seed = p.get("seed").and_then(Value::as_u64).unwrap_or(1);
|
||||
content_aware(surf, sel, doc_bounds, adapt, seed)?
|
||||
}
|
||||
_ => return Err(bad(format!("unknown contents `{contents}`"))),
|
||||
};
|
||||
let area = area.intersect(&doc_bounds);
|
||||
if area.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore });
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_rgba(src: &Surface, area: Rect) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
|
||||
src.read_rgba_into(area, &mut px);
|
||||
px
|
||||
}
|
||||
|
||||
/// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight
|
||||
/// RGBA over the sampling window. Only the selected pixels are used.
|
||||
fn content_aware(surf: &Surface, sel: &Surface, canvas: Rect, color_adaptation: bool, seed: u64) -> Result<Source> {
|
||||
use photocraft_algo::content_aware::{FillOptions, color_level, fill, rotation_level};
|
||||
let hole_bounds = sel.content_bounds().intersect(&canvas);
|
||||
if hole_bounds.is_empty() {
|
||||
return Err(EngineError::Other("the selection is outside the canvas".into()));
|
||||
}
|
||||
let ext = hole_bounds.width().max(hole_bounds.height());
|
||||
let window = hole_bounds.inflate(i32::try_from(ext * 3 / 4).unwrap_or(i32::MAX).max(32)).intersect(&canvas);
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
let (w, h) = (window.width() as usize, window.height() as usize);
|
||||
// Same cap as other whole-image operations: refuse absurd windows instead of allocating.
|
||||
if w.saturating_mul(h) > 400_000_000 {
|
||||
return Err(EngineError::Other("the selection is too large for Content-Aware fill".into()));
|
||||
}
|
||||
let img = surf.read_region(window);
|
||||
let mut hole = vec![false; w * h];
|
||||
for (i, hv) in hole.iter_mut().enumerate() {
|
||||
*hv = sel.sample_channel(window.x0 + (i % w) as i32, window.y0 + (i / w) as i32, 0) > 0.0;
|
||||
}
|
||||
if hole.iter().all(|h| *h) {
|
||||
return Err(EngineError::Other("Content-Aware fill needs pixels outside the selection to sample from".into()));
|
||||
}
|
||||
let source = vec![true; w * h];
|
||||
let opts = FillOptions {
|
||||
color_adaptation: color_level(if color_adaptation { "default" } else { "none" }),
|
||||
rotations: rotation_level("none"),
|
||||
scale: false,
|
||||
mirror: false,
|
||||
seed,
|
||||
};
|
||||
if img.len() != w * h * n {
|
||||
return Err(EngineError::Other("could not read the layer pixels".into()));
|
||||
}
|
||||
let filled = fill(w, h, n, &img, &hole, &source, &opts);
|
||||
let px = filled.chunks_exact(n.max(1)).map(|c| to_rgba(&fmt, c)).collect();
|
||||
Ok(Source::Pixels(window, px))
|
||||
}
|
||||
|
||||
/// How the source goes onto the target.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Blend {
|
||||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
/// Leave every pixel's alpha as it was (Preserve Transparency, or locked transparency).
|
||||
keep_alpha: bool,
|
||||
/// Normal mode replaces the pixels, transparency included (History: back to that state).
|
||||
restore: bool,
|
||||
}
|
||||
|
||||
/// `d` moved towards `c` by `k`, in premultiplied space (straight RGBA in and out).
|
||||
fn lerp(d: [f32; 4], c: [f32; 4], k: f32) -> [f32; 4] {
|
||||
let a = d[3] * (1.0 - k) + c[3] * k;
|
||||
if a <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let ch = |i: usize| (d[i] * d[3] * (1.0 - k) + c[i] * c[3] * k) / a;
|
||||
[ch(0), ch(1), ch(2), a]
|
||||
}
|
||||
|
||||
/// Blend `source` into `surf` over `area`: per pixel, the selection coverage times the opacity,
|
||||
/// in the blend mode.
|
||||
/// Works tile by tile in parallel on the encoded pixels (no full-surface f32 round trip).
|
||||
fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) {
|
||||
use rayon::prelude::*;
|
||||
let fmt = surf.format();
|
||||
// An opaque colour in Normal mode at 100 % that may replace alpha: fully covered tiles become
|
||||
// one shared solid tile (copy-on-write), which costs nothing per pixel.
|
||||
let solid = match source {
|
||||
Source::Color(c) if c[3] >= 1.0 && b.mode == BlendMode::Normal && b.opacity >= 1.0 && (!b.keep_alpha || !fmt.alpha) => {
|
||||
let mut enc = [0.0f32; 8];
|
||||
let n = from_rgba_into(&fmt, *c, &mut enc);
|
||||
Some(surf.solid_tile(enc.get(..n).unwrap_or(&[])))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let tiles = surf.take_tiles(area);
|
||||
let done: Vec<_> = tiles
|
||||
.into_par_iter()
|
||||
.map(|(tc, tile)| {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() });
|
||||
if let Some(s) = &solid
|
||||
&& tr == tc.rect()
|
||||
&& mask.as_ref().is_none_or(|m| m.full(tr))
|
||||
{
|
||||
return (tc, s.clone());
|
||||
}
|
||||
let mut tile = std::sync::Arc::unwrap_or_clone(tile);
|
||||
blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b);
|
||||
(tc, std::sync::Arc::new(tile))
|
||||
})
|
||||
.collect();
|
||||
surf.put_tiles(done);
|
||||
}
|
||||
|
||||
/// The selection's coverage over one tile (its channel 0).
|
||||
struct Mask<'a> {
|
||||
bytes: &'a [u8],
|
||||
fmt: photocraft_color::PixelFormat,
|
||||
origin: Rect,
|
||||
}
|
||||
|
||||
impl Mask<'_> {
|
||||
fn at(&self, x: i32, y: i32) -> f32 {
|
||||
let bpp = self.fmt.bytes_per_pixel();
|
||||
// A default-pixel mask is one pixel long: every position reads it.
|
||||
let i = if self.bytes.len() == bpp { 0 } else { ((y - self.origin.y0) as usize * TILE_SIZE as usize + (x - self.origin.x0) as usize) * bpp };
|
||||
self.bytes.get(i..i + bpp).map_or(0.0, |px| photocraft_color::read_sample(px, self.fmt.sample, 0))
|
||||
}
|
||||
/// Fully selected over `r`?
|
||||
fn full(&self, r: Rect) -> bool {
|
||||
(r.y0..r.y1).all(|y| (r.x0..r.x1).all(|x| self.at(x, y) >= 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend into one tile's encoded pixels (`bytes`, the tile at `origin`) over `tr`.
|
||||
fn blend_tile(bytes: &mut [u8], fmt: &photocraft_color::PixelFormat, origin: Rect, tr: Rect, source: &Source, mask: Option<&Mask>, b: Blend) {
|
||||
let Blend { mode, opacity, keep_alpha, restore } = b;
|
||||
let n = fmt.channels();
|
||||
let bpp = fmt.bytes_per_pixel();
|
||||
let blend = if mode == BlendMode::Dissolve { BlendMode::Normal } else { mode };
|
||||
let mut px = [0.0f32; 8];
|
||||
let mut enc = [0.0f32; 8];
|
||||
// An opaque colour at full coverage in Normal mode just replaces the colour (and the alpha
|
||||
// unless it is kept): copy its encoded bytes.
|
||||
let opaque = match source {
|
||||
Source::Color(c) if c[3] >= 1.0 && mode == BlendMode::Normal => {
|
||||
let m = from_rgba_into(fmt, *c, &mut enc);
|
||||
let mut bytes = vec![0u8; bpp];
|
||||
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), &mut bytes);
|
||||
// Keeping alpha: copy the colour samples only (alpha is the last sample).
|
||||
let keep = if (keep_alpha || !fmt.alpha) && fmt.alpha { bpp - bpp / n.max(1) } else { bpp };
|
||||
Some((bytes, keep))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
for y in tr.y0..tr.y1 {
|
||||
let row = (y - origin.y0) as usize * TILE_SIZE as usize;
|
||||
for x in tr.x0..tr.x1 {
|
||||
let mut k = mask.map_or(1.0, |m| m.at(x, y)) * opacity;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let i = (row + (x - origin.x0) as usize) * bpp;
|
||||
let Some(pb) = bytes.get_mut(i..i + bpp) else { continue };
|
||||
if k >= 1.0
|
||||
&& let Some((enc_px, keep)) = &opaque
|
||||
{
|
||||
if let (Some(dst), Some(src)) = (pb.get_mut(..*keep), enc_px.get(..*keep)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (c, v) in px.iter_mut().enumerate().take(n) {
|
||||
*v = photocraft_color::read_sample(pb, fmt.sample, c);
|
||||
}
|
||||
let d = to_rgba(fmt, &px[..n.min(8)]);
|
||||
let mut c = source.at(x, y);
|
||||
if mode == BlendMode::Dissolve {
|
||||
// Dissolve: each pixel is either fully painted or untouched, with the coverage as odds.
|
||||
k = if photocraft_color::dither_noise(x, y) < c[3] * k { 1.0 } else { 0.0 };
|
||||
c[3] = 1.0;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let mut o = if restore && blend == BlendMode::Normal { lerp(d, c, k) } else { photocraft_color::blend::composite(blend, d, c, k) };
|
||||
if keep_alpha || !fmt.alpha {
|
||||
o[3] = d[3];
|
||||
}
|
||||
let m = from_rgba_into(fmt, o, &mut enc);
|
||||
photocraft_raster::encode_pixel(fmt, enc.get(..m.min(n)).unwrap_or(&[]), pb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 30, "background": "transparent"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let st = s.active().unwrap();
|
||||
let l = st.doc.layer(st.active_layer.unwrap()).unwrap();
|
||||
l.surface().unwrap().rgba(x, y)
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1.5 / 255.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_constant_contents() {
|
||||
let mut s = session();
|
||||
s.execute("tools.setColors", json!({"foreground": [1.0, 0.0, 0.0, 1.0], "background": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
for (c, want) in [
|
||||
("foreground", [1.0, 0.0, 0.0, 1.0]),
|
||||
("background", [0.0, 0.0, 1.0, 1.0]),
|
||||
("black", [0.0, 0.0, 0.0, 1.0]),
|
||||
("gray", [128.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0, 1.0]),
|
||||
("white", [1.0; 4]),
|
||||
] {
|
||||
s.execute(CMD, json!({"contents": c})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), want), "{c}: {:?}", px(&s, 5, 5));
|
||||
}
|
||||
s.execute(CMD, json!({"contents": "color", "color": "#00ff00"})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [0.0, 1.0, 0.0, 1.0]));
|
||||
// The old form: a colour, or the foreground.
|
||||
s.execute(CMD, json!({"color": "#ffff00"})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [1.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mode_and_preserve_transparency() {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
// Preserve Transparency: the empty right half stays empty.
|
||||
s.execute(CMD, json!({"contents": "black", "opacity": 50, "preserveTransparency": true})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [0.5, 0.5, 0.5, 1.0]), "{:?}", px(&s, 5, 5));
|
||||
assert_eq!(px(&s, 30, 5)[3], 0.0);
|
||||
// Multiply with white leaves the grey.
|
||||
s.execute(CMD, json!({"contents": "white", "mode": "multiply", "preserveTransparency": true})).unwrap();
|
||||
assert!(close(px(&s, 5, 5), [0.5, 0.5, 0.5, 1.0]));
|
||||
// Without it, the transparent half is filled.
|
||||
s.execute(CMD, json!({"contents": "white", "opacity": 100})).unwrap();
|
||||
assert_eq!(px(&s, 30, 5), [1.0; 4]);
|
||||
// Dissolve paints whole pixels only.
|
||||
s.execute(CMD, json!({"contents": "black", "mode": "dissolve", "opacity": 50})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap();
|
||||
let blacks = (0..40).flat_map(|x| (0..30).map(move |y| (x, y))).filter(|&(x, y)| surf.rgba(x, y)[0] < 0.01).count();
|
||||
assert!(blacks > 300 && blacks < 900, "{blacks}");
|
||||
assert!((0..40).all(|x| {
|
||||
let v = surf.rgba(x, 3)[0];
|
||||
v < 0.01 || v > 0.99
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_pattern_and_content_aware() {
|
||||
let mut s = session();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "black"})).unwrap();
|
||||
// History: back to state 1 (after the white fill).
|
||||
s.execute(CMD, json!({"contents": "history", "state": 1})).unwrap();
|
||||
assert_eq!(px(&s, 5, 5), [1.0; 4]);
|
||||
// The default source is the oldest state: the empty layer.
|
||||
s.execute(CMD, json!({"contents": "history"})).unwrap();
|
||||
assert_eq!(px(&s, 5, 5)[3], 0.0);
|
||||
assert!(s.execute(CMD, json!({"contents": "history", "state": 999})).is_err());
|
||||
let r = s.execute(CMD, json!({"contents": "pattern", "pattern": "Diagonal Lines"})).unwrap();
|
||||
assert!(r["pattern"].is_string());
|
||||
assert!(px(&s, 5, 5)[3] > 0.0);
|
||||
// Content-Aware fills the hole from what is around it.
|
||||
s.execute(CMD, json!({"contents": "color", "color": "#336699"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 15, "y": 10, "width": 6, "height": 6})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "contentAware", "colorAdaptation": false})).unwrap();
|
||||
assert!(close(px(&s, 17, 12), [0x33 as f32 / 255.0, 0x66 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]), "{:?}", px(&s, 17, 12));
|
||||
}
|
||||
|
||||
/// `cargo test -p photocraft-engine --release --lib fill_cmds::tests::timing_24mp -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn timing_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000, "background": "white"})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute(CMD, json!({"contents": "white"})).unwrap();
|
||||
let time = |s: &mut Session, label: &str, p: Value| {
|
||||
let mut v = Vec::new();
|
||||
for i in 0..5 {
|
||||
let mut p = p.clone();
|
||||
if p.get("contents").is_none() {
|
||||
p["color"] = json!(if i % 2 == 0 { "#336699" } else { "#996633" });
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
s.execute(CMD, p).unwrap();
|
||||
v.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
println!("{label:<48} median {:>8.1} ms min {:>8.1} ms", v[2], v[0]);
|
||||
};
|
||||
time(&mut s, "colour, no selection", json!({}));
|
||||
time(&mut s, "colour, preserve transparency", json!({"preserveTransparency": true}));
|
||||
time(&mut s, "colour 50% multiply", json!({"opacity": 50, "mode": "multiply"}));
|
||||
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 5000, "height": 3000})).unwrap();
|
||||
time(&mut s, "colour, 15 MP rectangle selection", json!({}));
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
// The old per-pixel f32 path, for comparison.
|
||||
let st = s.active().unwrap();
|
||||
let mut surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
let t = std::time::Instant::now();
|
||||
crate::pixels::fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false);
|
||||
println!("{:<48} {:>8.1} ms", "old fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_errors() {
|
||||
let mut s = session();
|
||||
for p in [
|
||||
json!({"contents": "plaid"}),
|
||||
json!({"contents": 3}),
|
||||
json!({"mode": "sideways"}),
|
||||
json!({"mode": "passThrough"}),
|
||||
json!({"opacity": "lots"}),
|
||||
json!({"preserveTransparency": "yes"}),
|
||||
json!({"contents": "contentAware"}),
|
||||
json!({"contents": "pattern", "pattern": "no such pattern"}),
|
||||
json!({"contents": "history", "state": -1}),
|
||||
json!({"contents": "contentAware", "colorAdaptation": 1}),
|
||||
] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// Everything selected leaves nothing to sample from.
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
assert!(s.execute(CMD, json!({"contents": "contentAware"})).is_err());
|
||||
// Out-of-range opacity is clamped.
|
||||
s.execute(CMD, json!({"contents": "white", "opacity": 1e300})).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Photoshop's fill keys (#249): ⌥⌫ fills the selection (or the whole layer) with the foreground
|
||||
//! colour, ⌘⌫ with the background colour, and with ⇧ added they preserve transparency. Photoshop
|
||||
//! has no menu item for them; here they are ordinary commands with default shortcuts, so they
|
||||
//! appear in Edit › Keyboard Shortcuts (Tools) and can be rebound, recorded and driven by agents.
|
||||
//!
|
||||
//! Each one is `edit.fill` with the colour and Preserve Transparency fixed: one "Fill" history
|
||||
//! step, the Channels panel target (alpha channel, Quick Mask) honoured, locked layers refused.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
|
||||
pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"];
|
||||
|
||||
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|{"channel":i}|"quickMask"?}"##;
|
||||
|
||||
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| "Fill is unavailable".to_string())?;
|
||||
(edit_fill.enabled)(s)
|
||||
}
|
||||
|
||||
/// Run `edit.fill` with `color` (the foreground or background colour) and Preserve Transparency.
|
||||
fn fill(s: &mut Session, p: &Value, cmd: &str, background: bool, preserve: bool) -> Result<Value> {
|
||||
let mut q: Map<String, Value> = match p {
|
||||
Value::Object(m) => m.clone(),
|
||||
Value::Null => Map::new(),
|
||||
_ => return Err(EngineError::BadParams { cmd: cmd.into(), msg: "expected an object".into() }),
|
||||
};
|
||||
// The colour and transparency handling are what the key means; don't let params override them.
|
||||
for k in ["color", "contents", "pattern", "preserveTransparency", "opacity"] {
|
||||
q.remove(k);
|
||||
}
|
||||
let c = if background { s.tools.background } else { s.tools.foreground };
|
||||
q.insert("color".into(), json!([c[0], c[1], c[2], 1.0]));
|
||||
q.insert("preserveTransparency".into(), json!(preserve));
|
||||
let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| EngineError::Other("Fill is unavailable".into()))?;
|
||||
(edit_fill.run)(s, &Value::Object(q))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: "edit.fillForeground",
|
||||
label: "Fill with Foreground Color",
|
||||
menu: &[],
|
||||
shortcut: Some("Alt+Backspace"),
|
||||
params: PARAMS,
|
||||
enabled,
|
||||
run: |s, p| fill(s, p, "edit.fillForeground", false, false),
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "edit.fillBackground",
|
||||
label: "Fill with Background Color",
|
||||
menu: &[],
|
||||
shortcut: Some("Cmd+Backspace"),
|
||||
params: PARAMS,
|
||||
enabled,
|
||||
run: |s, p| fill(s, p, "edit.fillBackground", true, false),
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "edit.fillForegroundPreserve",
|
||||
label: "Fill with Foreground Color, Preserve Transparency",
|
||||
menu: &[],
|
||||
shortcut: Some("Alt+Shift+Backspace"),
|
||||
params: PARAMS,
|
||||
enabled,
|
||||
run: |s, p| fill(s, p, "edit.fillForegroundPreserve", false, true),
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "edit.fillBackgroundPreserve",
|
||||
label: "Fill with Background Color, Preserve Transparency",
|
||||
menu: &[],
|
||||
shortcut: Some("Cmd+Shift+Backspace"),
|
||||
params: PARAMS,
|
||||
enabled,
|
||||
run: |s, p| fill(s, p, "edit.fillBackgroundPreserve", true, true),
|
||||
journal: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
session_at(8)
|
||||
}
|
||||
|
||||
fn session_at(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6, "background": "transparent", "depth": depth})).unwrap();
|
||||
s.execute("tools.setColors", json!({"foreground": "#ff0000", "background": "#0000ff"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session, x: i32, y: i32) -> [f32; 4] {
|
||||
let st = s.active().unwrap();
|
||||
let l = st.doc.layers.last().unwrap();
|
||||
let surf = l.surface().unwrap();
|
||||
let fmt = surf.format();
|
||||
let v = surf.read_region(photocraft_geom::Rect::new(x, y, x + 1, y + 1));
|
||||
photocraft_raster::to_rgba(&fmt, &v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreground_fills_the_layer_without_a_selection() {
|
||||
let mut s = session();
|
||||
s.execute("edit.fillForeground", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 0, 0), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(px(&s, 7, 5), [1.0, 0.0, 0.0, 1.0]);
|
||||
// One undoable "Fill" step.
|
||||
assert!(s.execute("edit.undo", json!({})).is_ok());
|
||||
assert_eq!(px(&s, 0, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_at_every_bit_depth() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session_at(depth);
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 4, "height": 6})).unwrap();
|
||||
s.execute("edit.fillForeground", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("edit.fillBackgroundPreserve", json!({})).unwrap();
|
||||
let [r, g, b, a] = px(&s, 1, 1);
|
||||
assert!(r.abs() < 1e-3 && g.abs() < 1e-3 && (b - 1.0).abs() < 1e-3 && (a - 1.0).abs() < 1e-3, "{depth}-bit: {:?}", px(&s, 1, 1));
|
||||
assert_eq!(px(&s, 6, 1)[3], 0.0, "{depth}-bit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_fills_only_the_selection() {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 1, "width": 3, "height": 2})).unwrap();
|
||||
s.execute("edit.fillBackground", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 3, 2), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(px(&s, 0, 0)[3], 0.0);
|
||||
assert_eq!(px(&s, 5, 1)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserve_transparency_recolours_only_existing_pixels() {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
s.execute("edit.fillBackground", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("edit.fillForegroundPreserve", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 1, 1), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(px(&s, 4, 4)[3], 0.0, "transparent pixels stay transparent");
|
||||
s.execute("edit.fillBackgroundPreserve", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 0, 0), [0.0, 0.0, 1.0, 1.0]);
|
||||
assert_eq!(px(&s, 4, 4)[3], 0.0);
|
||||
// Without ⇧ the whole layer fills.
|
||||
s.execute("edit.fillForeground", json!({})).unwrap();
|
||||
assert_eq!(px(&s, 4, 4), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_cannot_change_the_colour_and_bad_params_fail_gracefully() {
|
||||
let mut s = session();
|
||||
s.execute("edit.fillForeground", json!({"color": "#00ff00", "opacity": 10})).unwrap();
|
||||
assert_eq!(px(&s, 0, 0), [1.0, 0.0, 0.0, 1.0]);
|
||||
for id in IDS {
|
||||
assert!(s.execute(id, json!([1, 2])).is_err(), "{id}");
|
||||
assert!(s.execute(id, json!("x")).is_err(), "{id}");
|
||||
assert!(s.execute(id, json!({"layer": 99_999})).is_err(), "{id}");
|
||||
assert!(s.execute(id, json!({"target": {"channel": 42}})).is_err(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_layer_and_no_document_are_errors() {
|
||||
let mut s = Session::new();
|
||||
for id in IDS {
|
||||
assert!(s.execute(id, json!({})).is_err(), "{id} without a document");
|
||||
}
|
||||
let mut s = session();
|
||||
s.execute("layer.lockLayers", json!({"pixels": true})).unwrap();
|
||||
for id in IDS {
|
||||
let e = s.execute(id, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("locked"), "{id}: {e}");
|
||||
}
|
||||
assert_eq!(px(&s, 0, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
/// `cargo test --release -p photocraft-engine --lib fill_keys_24mp -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn fill_keys_24mp() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 6000, "height": 4000})).unwrap();
|
||||
for (id, sel) in [("edit.fillForeground", false), ("edit.fillBackgroundPreserve", false), ("edit.fillForeground", true)] {
|
||||
if sel {
|
||||
s.execute("select.rect", json!({"x": 500, "y": 500, "width": 4000, "height": 3000})).unwrap();
|
||||
}
|
||||
let t = std::time::Instant::now();
|
||||
s.execute(id, json!({})).unwrap();
|
||||
println!("{id} 24 MP (selection: {sel}): {:.0} ms", t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_with_photoshop_default_shortcuts() {
|
||||
let sc = |id| crate::commands::find(id).and_then(|c| c.shortcut);
|
||||
assert_eq!(sc("edit.fillForeground"), Some("Alt+Backspace"));
|
||||
assert_eq!(sc("edit.fillBackground"), Some("Cmd+Backspace"));
|
||||
assert_eq!(sc("edit.fillForegroundPreserve"), Some("Alt+Shift+Backspace"));
|
||||
assert_eq!(sc("edit.fillBackgroundPreserve"), Some("Cmd+Shift+Backspace"));
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,10 @@ pub fn apply_filter_to_surface(
|
||||
if let Some(out) = crate::lens_cmds::apply_to_surface(id, params, surf, canvas) {
|
||||
return Some(out);
|
||||
}
|
||||
// Image › Adjustments recorded as smart filters (Levels, Curves, Shadows/Highlights… from PSD).
|
||||
if let Some(kind) = id.strip_prefix("image.adjustments.") {
|
||||
return crate::adjust_cmds::adjust_as_filter(kind, params, surf);
|
||||
}
|
||||
// WebAssembly plug-in smart filters (plugin_cmds).
|
||||
if id == crate::plugin_cmds::RUN {
|
||||
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
|
||||
|
||||
@@ -43,7 +43,7 @@ fn colour(p: &Value, k: &str, d: [f32; 4]) -> [f32; 4] {
|
||||
[c(0, 0.0), c(1, 0.0), c(2, 0.0), c(3, 1.0)]
|
||||
}
|
||||
Some(Value::String(h)) if h.len() == 7 && h.starts_with('#') => {
|
||||
let ch = |j: usize| u8::from_str_radix(&h[j..j + 2], 16).map_or(0.0, |v| v as f32 / 255.0);
|
||||
let ch = |j: usize| h.get(j..j + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map_or(0.0, |v| v as f32 / 255.0);
|
||||
[ch(1), ch(3), ch(5), 1.0]
|
||||
}
|
||||
_ => d,
|
||||
|
||||
@@ -21,7 +21,7 @@ fn colour(v: Option<&Value>) -> Option<[f32; 4]> {
|
||||
Some([c(0, 0.0), c(1, 0.0), c(2, 0.0), c(3, 1.0)])
|
||||
}
|
||||
Value::String(h) if h.len() == 7 && h.starts_with('#') => {
|
||||
let ch = |j: usize| u8::from_str_radix(&h[j..j + 2], 16).map_or(0.0, |v| v as f32 / 255.0);
|
||||
let ch = |j: usize| h.get(j..j + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map_or(0.0, |v| v as f32 / 255.0);
|
||||
Some([ch(1), ch(3), ch(5), 1.0])
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
//! Live gradients: the Gradient tool's non-destructive mode (Photoshop 2023+). A drag makes a
|
||||
//! Gradient Fill layer above the active layer, masked by the selection, whose start and end
|
||||
//! points, colour stops, opacity stops and midpoints stay editable on canvas afterwards. The
|
||||
//! classic destructive mode is `paint.gradient`.
|
||||
//!
|
||||
//! A live gradient is laid out on the canvas ("Align with layer" off), so its handles stay put
|
||||
//! when its mask changes. Its start/end points are stored as the fill's angle, scale and centre
|
||||
//! offset (`photocraft_compose::gradient_fill`), which is what a PSD Gradient Fill holds, and it
|
||||
//! renders the same pixels as painting the same drag with `paint.gradient`.
|
||||
//!
|
||||
//! Every edit is one command, so one history step per gesture: the UI previews a drag with
|
||||
//! [`apply_set`] and commits it once on release.
|
||||
|
||||
use photocraft_color::Color;
|
||||
use photocraft_compose::gradient_fill as gf;
|
||||
use photocraft_doc::{Document, Fill, GradientStyle, Layer, LayerContent, LayerId, LayerMask};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, blend_from_str, layer_param};
|
||||
use crate::presets::gradients::GradientPreset;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub const CREATE: &str = "gradient.fill.create";
|
||||
pub const SET: &str = "gradient.fill.set";
|
||||
pub const STOP: &str = "gradient.fill.stop";
|
||||
pub const GET: &str = "gradient.fill.get";
|
||||
|
||||
/// Largest coordinate accepted for a start/end point (pixels).
|
||||
const MAX_COORD: f32 = 1.0e7;
|
||||
/// Scale range of a gradient fill (a fraction): Photoshop allows 10–150 % (the PSD stores a
|
||||
/// percentage). A handle drag past it clamps, so the far handle stops where the scale does.
|
||||
const SCALE_RANGE: (f32, f32) = (0.1, 1.5);
|
||||
|
||||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn num(p: &Value, k: &str) -> Option<f32> {
|
||||
p.get(k).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn point(cmd: &str, p: &Value, k: &str) -> Result<Option<[f32; 2]>> {
|
||||
let Some(v) = p.get(k) else { return Ok(None) };
|
||||
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, format!("`{k}` is [x, y]")))?;
|
||||
let c = |i: usize| a.get(i).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
match (c(0), c(1)) {
|
||||
(Some(x), Some(y)) => Ok(Some([x, y])),
|
||||
_ => Err(bad(cmd, format!("`{k}` needs finite coordinates within ±{MAX_COORD}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn style_param(cmd: &str, p: &Value) -> Result<Option<GradientStyle>> {
|
||||
match p.get("style") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => match s.to_ascii_lowercase().as_str() {
|
||||
"linear" => Ok(Some(GradientStyle::Linear)),
|
||||
"radial" => Ok(Some(GradientStyle::Radial)),
|
||||
"angle" => Ok(Some(GradientStyle::Angle)),
|
||||
"reflected" => Ok(Some(GradientStyle::Reflected)),
|
||||
"diamond" => Ok(Some(GradientStyle::Diamond)),
|
||||
o => Err(bad(cmd, format!("unknown style `{o}` (linear|radial|angle|reflected|diamond)"))),
|
||||
},
|
||||
Some(_) => Err(bad(cmd, "`style` is a string")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn style_name(s: GradientStyle) -> &'static str {
|
||||
match s {
|
||||
GradientStyle::Linear => "linear",
|
||||
GradientStyle::Radial => "radial",
|
||||
GradientStyle::Angle => "angle",
|
||||
GradientStyle::Reflected => "reflected",
|
||||
GradientStyle::Diamond => "diamond",
|
||||
}
|
||||
}
|
||||
|
||||
/// A stop colour: `"#rrggbb"`, `"#rrggbbaa"`, `[r, g, b, a?]` (0..1), `"foreground"` or
|
||||
/// `"background"`.
|
||||
fn parse_color(v: &Value, fg: [f32; 4], bg: [f32; 4]) -> Option<Color> {
|
||||
let c = match v {
|
||||
Value::String(s) if s == "foreground" => fg,
|
||||
Value::String(s) if s == "background" => bg,
|
||||
Value::String(s) => {
|
||||
let h = s.trim_start_matches('#');
|
||||
let b = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f32::from(v) / 255.0);
|
||||
match h.len() {
|
||||
6 => [b(0)?, b(2)?, b(4)?, 1.0],
|
||||
8 => [b(0)?, b(2)?, b(4)?, b(6)?],
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
Value::Array(a) if (3..=4).contains(&a.len()) => {
|
||||
let f = |i: usize| a.get(i).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite());
|
||||
[f(0)?, f(1)?, f(2)?, if a.len() == 4 { f(3)? } else { 1.0 }]
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let c = c.map(|v| v.clamp(0.0, 1.0));
|
||||
Some(Color::rgba(c[0], c[1], c[2], c[3]))
|
||||
}
|
||||
|
||||
fn hex(c: &Color) -> String {
|
||||
let rgb = c.to_rgb();
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
if c.alpha >= 1.0 {
|
||||
format!("#{:02x}{:02x}{:02x}", q(rgb[0]), q(rgb[1]), q(rgb[2]))
|
||||
} else {
|
||||
format!("#{:02x}{:02x}{:02x}{:02x}", q(rgb[0]), q(rgb[1]), q(rgb[2]), q(c.alpha))
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient a `gradient.fill.create` uses: a preset (`"gradient"`), explicit `"stops"`
|
||||
/// (+ `"transparency"`), else the current gradient (Gradients panel).
|
||||
fn preset_param(s: &Session, p: &Value, cmd: &str) -> Result<GradientPreset> {
|
||||
if let Some(name) = p.get("gradient").and_then(Value::as_str) {
|
||||
let groups = &s.presets.gradients;
|
||||
return groups
|
||||
.iter()
|
||||
.flat_map(|g| g.items.iter())
|
||||
.find(|g| g.name.eq_ignore_ascii_case(name))
|
||||
.cloned()
|
||||
.ok_or_else(|| bad(cmd, format!("no gradient preset \"{name}\" (see gradient.presets.list)")));
|
||||
}
|
||||
if p.get("stops").is_some() {
|
||||
return GradientPreset::from_params("Custom", p, cmd);
|
||||
}
|
||||
Ok(s.presets.gradient.clone())
|
||||
}
|
||||
|
||||
/// The fill and frame of gradient fill layer `id`.
|
||||
fn gradient_of(s: &Session, id: LayerId, cmd: &str) -> Result<(Fill, Rect)> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
match &l.content {
|
||||
LayerContent::Fill(f @ Fill::Gradient { .. }) => Ok((f.clone(), photocraft_compose::fill_frame(l, d.doc.bounds()))),
|
||||
_ => Err(bad(cmd, format!("layer \"{}\" is not a gradient fill layer", l.name))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the active layer is a gradient fill layer (the commands' `enabled`).
|
||||
pub fn has_gradient_fill(s: &Session) -> std::result::Result<(), String> {
|
||||
let l = crate::active_layer_of(s)?;
|
||||
if matches!(l.content, LayerContent::Fill(Fill::Gradient { .. })) { Ok(()) } else { Err("the active layer is not a gradient fill layer".into()) }
|
||||
}
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
|
||||
fn clamp_scale(v: f32) -> f32 {
|
||||
v.clamp(SCALE_RANGE.0, SCALE_RANGE.1)
|
||||
}
|
||||
|
||||
/// The frame a gradient fill of `layer` (with `align`) is laid out in.
|
||||
fn frame_with_align(layer: &Layer, f: &Fill, canvas: Rect) -> Rect {
|
||||
let mut l = layer.clone();
|
||||
l.content = LayerContent::Fill(f.clone());
|
||||
photocraft_compose::fill_frame(&l, canvas)
|
||||
}
|
||||
|
||||
/// Applies `gradient.fill.set` params to a gradient fill (`layer` gives its frame; `fg`/`bg`
|
||||
/// resolve stop colours). Pure, so the canvas previews a handle drag with exactly what the
|
||||
/// command will commit.
|
||||
pub fn apply_set(layer: &Layer, f: &Fill, canvas: Rect, p: &Value, fg: [f32; 4], bg: [f32; 4]) -> Result<Fill> {
|
||||
const CMD: &str = SET;
|
||||
let mut out = f.clone();
|
||||
let Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } = &mut out else {
|
||||
return Err(bad(CMD, "not a gradient fill"));
|
||||
};
|
||||
let old_frame = frame_with_align(layer, f, canvas);
|
||||
let old_handles = gf::handles(*style, *angle, *scale, *offset, old_frame);
|
||||
if let Some(v) = p.get("align") {
|
||||
*align = v.as_bool().ok_or_else(|| bad(CMD, "`align` is a boolean"))?;
|
||||
}
|
||||
let new_style = style_param(CMD, p)?;
|
||||
let explicit_geometry = ["angle", "scale", "offset"].iter().any(|k| p.get(*k).is_some());
|
||||
if let Some(a) = p.get("angle") {
|
||||
*angle = a.as_f64().map(|v| v as f32).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`angle` is a number (degrees)"))?.rem_euclid(360.0);
|
||||
if *angle > 180.0 {
|
||||
*angle -= 360.0;
|
||||
}
|
||||
}
|
||||
if p.get("scale").is_some() {
|
||||
let v = num(p, "scale").filter(|v| *v > 0.0).ok_or_else(|| bad(CMD, "`scale` is a positive percentage"))?;
|
||||
*scale = clamp_scale(v / 100.0);
|
||||
}
|
||||
if let Some(o) = p.get("offset") {
|
||||
let a = o.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(CMD, "`offset` is [x%, y%]"))?;
|
||||
let c = |i: usize| a.get(i).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite() && v.abs() <= 1.0e5);
|
||||
let (Some(x), Some(y)) = (c(0), c(1)) else { return Err(bad(CMD, "`offset` needs finite percentages")) };
|
||||
*offset = (x / 100.0, y / 100.0);
|
||||
}
|
||||
if let Some(v) = p.get("reverse") {
|
||||
*reverse = v.as_bool().ok_or_else(|| bad(CMD, "`reverse` is a boolean"))?;
|
||||
}
|
||||
if let Some(v) = p.get("dither") {
|
||||
*dither = v.as_bool().ok_or_else(|| bad(CMD, "`dither` is a boolean"))?;
|
||||
}
|
||||
let from = point(CMD, p, "from")?;
|
||||
let to = point(CMD, p, "to")?;
|
||||
let frame = {
|
||||
let tmp = Fill::Gradient {
|
||||
stops: Vec::new(),
|
||||
angle: *angle,
|
||||
scale: *scale,
|
||||
style: *style,
|
||||
reverse: *reverse,
|
||||
opacity_stops: Vec::new(),
|
||||
midpoints: Vec::new(),
|
||||
offset: *offset,
|
||||
dither: *dither,
|
||||
align: *align,
|
||||
};
|
||||
frame_with_align(layer, &tmp, canvas)
|
||||
};
|
||||
// A style change alone keeps the handles where they are (they are what the user placed).
|
||||
let style_changed = new_style.is_some_and(|ns| ns != *style);
|
||||
if let Some(ns) = new_style {
|
||||
*style = ns;
|
||||
}
|
||||
if from.is_some() || to.is_some() || (style_changed && !explicit_geometry) {
|
||||
let cur = if from.is_some() || to.is_some() { gf::handles(*style, *angle, *scale, *offset, frame) } else { old_handles };
|
||||
let (a, s, o) = gf::from_handles(*style, from.unwrap_or(cur.0), to.unwrap_or(cur.1), frame, *angle);
|
||||
(*angle, *scale, *offset) = (a, clamp_scale(s), o);
|
||||
}
|
||||
if let Some(v) = p.get("stops") {
|
||||
let arr = v.as_array().ok_or_else(|| bad(CMD, "`stops` is [[location, colour], …]"))?;
|
||||
let mut new = Vec::with_capacity(arr.len());
|
||||
for s in arr {
|
||||
let pair = s.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(CMD, "a stop is [location 0..1, colour]"))?;
|
||||
let t = pair.first().and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "stop location is a number"))?;
|
||||
let c = pair
|
||||
.get(1)
|
||||
.and_then(|c| parse_color(c, fg, bg))
|
||||
.ok_or_else(|| bad(CMD, "stop colour is \"#rrggbb\", [r,g,b,a], \"foreground\" or \"background\""))?;
|
||||
new.push((t.clamp(0.0, 1.0), c));
|
||||
}
|
||||
if new.len() < 2 {
|
||||
return Err(bad(CMD, "a gradient needs at least 2 colour stops"));
|
||||
}
|
||||
if new.len() > 1024 {
|
||||
return Err(bad(CMD, "too many colour stops (max 1024)"));
|
||||
}
|
||||
new.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if new.len() != stops.len() {
|
||||
midpoints.clear();
|
||||
}
|
||||
*stops = new;
|
||||
}
|
||||
if let Some(v) = p.get("transparency") {
|
||||
let arr = v.as_array().ok_or_else(|| bad(CMD, "`transparency` is [[location, opacity %], …]"))?;
|
||||
let mut new = Vec::with_capacity(arr.len());
|
||||
for s in arr {
|
||||
let pair = s.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(CMD, "an opacity stop is [location 0..1, opacity 0..100]"))?;
|
||||
let f = |i: usize| pair.get(i).and_then(Value::as_f64).map(|v| v as f32).filter(|v| v.is_finite());
|
||||
let (Some(t), Some(o)) = (f(0), f(1)) else { return Err(bad(CMD, "opacity stops are numbers")) };
|
||||
new.push((t.clamp(0.0, 1.0), (o / 100.0).clamp(0.0, 1.0)));
|
||||
}
|
||||
if new.len() > 1024 {
|
||||
return Err(bad(CMD, "too many opacity stops (max 1024)"));
|
||||
}
|
||||
new.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
*opacity_stops = new;
|
||||
}
|
||||
if let Some(v) = p.get("midpoints") {
|
||||
let arr = v.as_array().ok_or_else(|| bad(CMD, "`midpoints` is [0..1, …] (one per segment)"))?;
|
||||
let mids: Option<Vec<f32>> = arr.iter().map(|m| m.as_f64().map(|v| v as f32).filter(|v| v.is_finite()).map(|v| v.clamp(0.05, 0.95))).collect();
|
||||
let mids = mids.ok_or_else(|| bad(CMD, "midpoints are numbers"))?;
|
||||
if mids.len() > stops.len().saturating_sub(1) {
|
||||
return Err(bad(CMD, format!("{} midpoints for {} segments", mids.len(), stops.len().saturating_sub(1))));
|
||||
}
|
||||
*midpoints = mids;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Gradient Fill layer `gradient.fill.create` adds to `doc` for `p` (also the canvas's live
|
||||
/// preview of a Gradient tool drag): laid out on the canvas, so the handles land exactly where
|
||||
/// the drag was, and masked by the selection (Photoshop keeps the selection, as a mask).
|
||||
pub fn new_layer(s: &Session, doc: &Document, p: &Value) -> Result<Layer> {
|
||||
const CMD: &str = CREATE;
|
||||
let from = point(CMD, p, "from")?.ok_or_else(|| bad(CMD, "missing `from` [x, y]"))?;
|
||||
let to = point(CMD, p, "to")?.ok_or_else(|| bad(CMD, "missing `to` [x, y]"))?;
|
||||
let style = style_param(CMD, p)?.unwrap_or_default();
|
||||
let g = preset_param(s, p, CMD)?;
|
||||
let (stops, opacity_stops) = g.fill_stops(s.tools.foreground, s.tools.background);
|
||||
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
|
||||
let dither = p.get("dither").and_then(Value::as_bool).unwrap_or(true);
|
||||
let opacity = num(p, "opacity").unwrap_or(100.0).clamp(0.0, 100.0) / 100.0;
|
||||
let blend = match p.get("mode").and_then(Value::as_str) {
|
||||
None | Some("") => photocraft_color::BlendMode::Normal,
|
||||
Some(m) => blend_from_str(m).ok_or_else(|| bad(CMD, format!("unknown blend mode `{m}`")))?,
|
||||
};
|
||||
let canvas = doc.bounds();
|
||||
if canvas.is_empty() {
|
||||
return Err(bad(CMD, "the document is empty"));
|
||||
}
|
||||
let (angle, scale, offset) = gf::from_handles(style, from, to, canvas, 0.0);
|
||||
let fill = Fill::Gradient { stops, angle, scale: clamp_scale(scale), style, reverse, opacity_stops, midpoints: Vec::new(), offset, dither, align: false };
|
||||
let mut l = Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill));
|
||||
l.opacity = opacity;
|
||||
l.blend = blend;
|
||||
if let Some(sel) = &doc.selection {
|
||||
l.mask = Some(LayerMask { surface: sel.clone(), ..LayerMask::reveal_all() });
|
||||
}
|
||||
Ok(l)
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let layer = {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
new_layer(s, &d.doc, p)?
|
||||
};
|
||||
let id = s.edit("New Gradient Fill Layer", |doc, active| {
|
||||
let id = doc.insert_above(*active, layer);
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "layer": id.0 }))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
let (fill, _) = gradient_of(s, id, SET)?;
|
||||
let (fg, bg) = (s.tools.foreground, s.tools.background);
|
||||
let (layer, canvas) = {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
(d.doc.layer(id).cloned().ok_or(EngineError::NoLayer(id))?, d.doc.bounds())
|
||||
};
|
||||
let new = apply_set(&layer, &fill, canvas, p, fg, bg)?;
|
||||
if new != fill {
|
||||
replace_fill(s, id, "Edit Gradient Fill", new)?;
|
||||
}
|
||||
describe(s, id)
|
||||
}
|
||||
|
||||
fn replace_fill(s: &mut Session, id: LayerId, label: &str, new: Fill) -> Result<()> {
|
||||
s.edit(label, |doc, _| {
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
l.content = LayerContent::Fill(new);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Sorted colour stops paired with the midpoint of the segment that follows each.
|
||||
fn paired(stops: &[(f32, Color)], mids: &[f32]) -> Vec<(f32, Color, f32)> {
|
||||
let mut v: Vec<(f32, Color, f32)> = stops.iter().enumerate().map(|(i, (t, c))| (*t, *c, mids.get(i).copied().unwrap_or(0.5))).collect();
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
v
|
||||
}
|
||||
|
||||
fn unpair(v: Vec<(f32, Color, f32)>) -> (Vec<(f32, Color)>, Vec<f32>) {
|
||||
let mut v = v;
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let n = v.len();
|
||||
let mids: Vec<f32> = v.iter().take(n.saturating_sub(1)).map(|s| s.2).collect();
|
||||
let mids = if mids.iter().all(|m| (m - 0.5).abs() < 1e-6) { Vec::new() } else { mids };
|
||||
(v.into_iter().map(|(t, c, _)| (t, c)).collect(), mids)
|
||||
}
|
||||
|
||||
/// Applies a `gradient.fill.stop` edit to a gradient fill. Pure (see [`apply_set`]).
|
||||
pub fn apply_stop(f: &Fill, p: &Value, fg: [f32; 4], bg: [f32; 4]) -> Result<Fill> {
|
||||
const CMD: &str = STOP;
|
||||
let mut out = f.clone();
|
||||
let Fill::Gradient { stops, opacity_stops, midpoints, .. } = &mut out else { return Err(bad(CMD, "not a gradient fill")) };
|
||||
let action = p.get("action").and_then(Value::as_str).ok_or_else(|| bad(CMD, "missing `action` (add|move|delete|color|opacity|midpoint)"))?;
|
||||
let opacity_kind = match p.get("kind").and_then(Value::as_str).unwrap_or("color") {
|
||||
"color" => false,
|
||||
"opacity" => true,
|
||||
o => return Err(bad(CMD, format!("unknown kind `{o}` (color|opacity)"))),
|
||||
};
|
||||
let index = || -> Result<usize> {
|
||||
p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(CMD, "missing `index` (a stop number from 0)"))
|
||||
};
|
||||
let location = || num(p, "location").map(|v| v.clamp(0.0, 1.0)).ok_or_else(|| bad(CMD, "missing `location` (0..1)"));
|
||||
let ramp = gf::Ramp::new(f).ok_or_else(|| bad(CMD, "not a gradient fill"))?;
|
||||
if opacity_kind {
|
||||
// Opacity stops: an empty list means "opaque" (two 100 % stops when first edited).
|
||||
let mut os = opacity_stops.clone();
|
||||
if os.is_empty() {
|
||||
os = vec![(0.0, 1.0), (1.0, 1.0)];
|
||||
}
|
||||
os.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let opacity = || num(p, "opacity").map(|v| (v / 100.0).clamp(0.0, 1.0)).ok_or_else(|| bad(CMD, "missing `opacity` (0..100)"));
|
||||
match action {
|
||||
"add" => {
|
||||
let t = location()?;
|
||||
let a = num(p, "opacity").map_or_else(|| ramp.sample(t)[3] / ramp_color_alpha(f, t), |v| (v / 100.0).clamp(0.0, 1.0));
|
||||
os.push((t, a.clamp(0.0, 1.0)));
|
||||
}
|
||||
"move" => {
|
||||
let i = index()?;
|
||||
let t = location()?;
|
||||
os.get_mut(i).ok_or_else(|| bad(CMD, format!("no opacity stop {i}")))?.0 = t;
|
||||
}
|
||||
"delete" => {
|
||||
let i = index()?;
|
||||
if i >= os.len() {
|
||||
return Err(bad(CMD, format!("no opacity stop {i}")));
|
||||
}
|
||||
if os.len() <= 2 {
|
||||
return Err(bad(CMD, "a gradient keeps at least 2 opacity stops"));
|
||||
}
|
||||
os.remove(i);
|
||||
}
|
||||
"opacity" => {
|
||||
let i = index()?;
|
||||
let a = opacity()?;
|
||||
os.get_mut(i).ok_or_else(|| bad(CMD, format!("no opacity stop {i}")))?.1 = a;
|
||||
}
|
||||
o => return Err(bad(CMD, format!("unknown action `{o}` for opacity stops (add|move|delete|opacity)"))),
|
||||
}
|
||||
os.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if os.len() > 1024 {
|
||||
return Err(bad(CMD, "too many opacity stops (max 1024)"));
|
||||
}
|
||||
*opacity_stops = os;
|
||||
return Ok(out);
|
||||
}
|
||||
let mut v = paired(stops, midpoints);
|
||||
match action {
|
||||
"add" => {
|
||||
let t = location()?;
|
||||
// A new stop takes the colour the gradient has there (Photoshop), unless given.
|
||||
let c = match p.get("color") {
|
||||
Some(c) => parse_color(c, fg, bg).ok_or_else(|| bad(CMD, "bad `color`"))?,
|
||||
None => {
|
||||
let s = ramp.sample(t);
|
||||
Color::rgba(s[0], s[1], s[2], ramp_color_alpha(f, t))
|
||||
}
|
||||
};
|
||||
// Splitting a segment resets its midpoint.
|
||||
if let Some(prev) = v.iter_mut().rev().find(|s| s.0 <= t) {
|
||||
prev.2 = 0.5;
|
||||
}
|
||||
v.push((t, c, 0.5));
|
||||
if v.len() > 1024 {
|
||||
return Err(bad(CMD, "too many colour stops (max 1024)"));
|
||||
}
|
||||
}
|
||||
"move" => {
|
||||
let i = index()?;
|
||||
let t = location()?;
|
||||
v.get_mut(i).ok_or_else(|| bad(CMD, format!("no colour stop {i}")))?.0 = t;
|
||||
}
|
||||
"delete" => {
|
||||
let i = index()?;
|
||||
if i >= v.len() {
|
||||
return Err(bad(CMD, format!("no colour stop {i}")));
|
||||
}
|
||||
if v.len() <= 2 {
|
||||
return Err(bad(CMD, "a gradient keeps at least 2 colour stops"));
|
||||
}
|
||||
v.remove(i);
|
||||
}
|
||||
"color" => {
|
||||
let i = index()?;
|
||||
let c = p.get("color").and_then(|c| parse_color(c, fg, bg)).ok_or_else(|| bad(CMD, "missing or bad `color`"))?;
|
||||
v.get_mut(i).ok_or_else(|| bad(CMD, format!("no colour stop {i}")))?.1 = c;
|
||||
}
|
||||
"midpoint" => {
|
||||
let i = index()?;
|
||||
let m = num(p, "location").ok_or_else(|| bad(CMD, "missing `location` (0..1 of the segment)"))?.clamp(0.05, 0.95);
|
||||
if i + 1 >= v.len() {
|
||||
return Err(bad(CMD, format!("no segment {i} (there are {})", v.len().saturating_sub(1))));
|
||||
}
|
||||
if let Some(s) = v.get_mut(i) {
|
||||
s.2 = m;
|
||||
}
|
||||
}
|
||||
o => return Err(bad(CMD, format!("unknown action `{o}` (add|move|delete|color|midpoint)"))),
|
||||
}
|
||||
let (st, mids) = unpair(v);
|
||||
*stops = st;
|
||||
*midpoints = mids;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The colour stops' own alpha at `t` (without the opacity stops).
|
||||
fn ramp_color_alpha(f: &Fill, t: f32) -> f32 {
|
||||
let Fill::Gradient { stops, midpoints, .. } = f else { return 1.0 };
|
||||
let bare = Fill::Gradient {
|
||||
stops: stops.clone(),
|
||||
angle: 0.0,
|
||||
scale: 1.0,
|
||||
style: GradientStyle::Linear,
|
||||
reverse: false,
|
||||
opacity_stops: Vec::new(),
|
||||
midpoints: midpoints.clone(),
|
||||
offset: (0.0, 0.0),
|
||||
dither: false,
|
||||
align: true,
|
||||
};
|
||||
gf::Ramp::new(&bare).map_or(1.0, |r| r.sample(t)[3]).max(1e-6)
|
||||
}
|
||||
|
||||
fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
let (fill, _) = gradient_of(s, id, STOP)?;
|
||||
let new = apply_stop(&fill, p, s.tools.foreground, s.tools.background)?;
|
||||
if new != fill {
|
||||
replace_fill(s, id, "Edit Gradient Fill", new)?;
|
||||
}
|
||||
describe(s, id)
|
||||
}
|
||||
|
||||
/// A gradient fill layer's settings as JSON (`gradient.fill.get`).
|
||||
pub fn describe_fill(f: &Fill, frame: Rect) -> Value {
|
||||
let Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } = f else { return Value::Null };
|
||||
let (from, to) = gf::handles(*style, *angle, *scale, *offset, frame);
|
||||
json!({
|
||||
"style": style_name(*style),
|
||||
"angle": angle,
|
||||
"scale": scale * 100.0,
|
||||
"offset": [offset.0 * 100.0, offset.1 * 100.0],
|
||||
"reverse": reverse,
|
||||
"dither": dither,
|
||||
"align": align,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::<Vec<_>>(),
|
||||
"transparency": opacity_stops.iter().map(|(t, a)| json!([t, a * 100.0])).collect::<Vec<_>>(),
|
||||
"midpoints": midpoints,
|
||||
})
|
||||
}
|
||||
|
||||
fn describe(s: &Session, id: LayerId) -> Result<Value> {
|
||||
let (f, frame) = gradient_of(s, id, GET)?;
|
||||
let mut v = describe_fill(&f, frame);
|
||||
v["layer"] = json!(id.0);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
describe(s, id)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
id: CREATE,
|
||||
label: "Gradient (Live)",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"from":[x,y],"to":[x,y],"style":"linear|radial|angle|reflected|diamond"="linear","gradient":preset name?,"stops":[[t,"#rrggbb"|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]? (default: the current gradient),"reverse":bool=false,"dither":bool=true,"opacity":0..100=100,"mode":"normal|multiply|…"="normal"} → {"layer":id}; a Gradient Fill layer above the active layer, masked by the selection"##,
|
||||
enabled: has_doc,
|
||||
run: create,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: SET,
|
||||
label: "Edit Gradient Fill",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"from":[x,y]?,"to":[x,y]?,"style":"linear|radial|angle|reflected|diamond"?,"angle":deg?,"scale":%?,"offset":[x%,y%]?,"reverse":bool?,"dither":bool?,"align":bool?,"stops":[[t,"#rrggbb"|"#rrggbbaa"|[r,g,b,a]|"foreground"|"background"],…]?,"transparency":[[t,0..100],…]?,"midpoints":[0.05..0.95 per segment]?} → the gradient (see gradient.fill.get)"##,
|
||||
enabled: has_gradient_fill,
|
||||
run: set,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: STOP,
|
||||
label: "Edit Gradient Stop",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"action":"add|move|delete|color|opacity|midpoint","kind":"color|opacity"="color","index":stop (midpoint: segment) number,"location":0..1 (midpoint: of the segment),"color":"#rrggbb"|[r,g,b,a]|"foreground"|"background" (add: default the colour there),"opacity":0..100} → the gradient"##,
|
||||
enabled: has_gradient_fill,
|
||||
run: stop,
|
||||
journal: true,
|
||||
},
|
||||
CommandSpec {
|
||||
id: GET,
|
||||
label: "Gradient Fill Settings",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?} → {"style","angle","scale":%,"offset":[%,%],"reverse","dither","align","from":[x,y],"to":[x,y],"stops":[[t,"#hex"]],"transparency":[[t,%]],"midpoints":[…]}"##,
|
||||
enabled: has_gradient_fill,
|
||||
run: get,
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,283 @@
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
fn session(depth: u32, background: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 48, "height": 32, "depth": depth, "background": background})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn active_fill(s: &Session) -> Fill {
|
||||
let d = s.active().unwrap();
|
||||
match &d.doc.layer(d.active_layer.unwrap()).unwrap().content {
|
||||
LayerContent::Fill(f) => f.clone(),
|
||||
other => panic!("not a fill layer: {}", other.kind_name()),
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_count(s: &Session) -> usize {
|
||||
s.active().unwrap().doc.layers.len()
|
||||
}
|
||||
|
||||
const STYLES: [&str; 5] = ["linear", "radial", "angle", "reflected", "diamond"];
|
||||
|
||||
#[test]
|
||||
fn create_makes_a_canvas_aligned_fill_layer_with_the_drag_as_handles() {
|
||||
let mut s = session(8, "white");
|
||||
let r = s
|
||||
.execute(
|
||||
CREATE,
|
||||
json!({"from": [14, 16], "to": [34, 8], "style": "radial", "stops": [[0, "#ff0000"], [1, "#0000ff"]], "opacity": 50, "mode": "multiply"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(layer_count(&s), 2);
|
||||
let d = s.active().unwrap();
|
||||
let l = d.doc.layer(LayerId(r["layer"].as_u64().unwrap())).unwrap();
|
||||
assert_eq!(d.active_layer, Some(l.id));
|
||||
assert!(l.mask.is_none(), "no selection, no mask");
|
||||
assert_eq!(l.opacity, 0.5);
|
||||
assert_eq!(l.blend, photocraft_color::BlendMode::Multiply);
|
||||
let Fill::Gradient { style, align, dither, .. } = active_fill(&s) else { panic!() };
|
||||
assert_eq!(style, GradientStyle::Radial);
|
||||
assert!(!align && dither);
|
||||
let g = s.execute(GET, json!({})).unwrap();
|
||||
let (from, to) = (g["from"].as_array().unwrap(), g["to"].as_array().unwrap());
|
||||
for (v, want) in from.iter().chain(to).zip([14.0, 16.0, 34.0, 8.0]) {
|
||||
assert!((v.as_f64().unwrap() - want).abs() < 1e-3, "{g}");
|
||||
}
|
||||
// One undo step removes the layer.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer_count(&s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_masks_the_layer_with_the_selection() {
|
||||
let mut s = session(8, "white");
|
||||
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 10, "height": 8})).unwrap();
|
||||
s.execute(CREATE, json!({"from": [0, 0], "to": [48, 0]})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let l = d.doc.layer(d.active_layer.unwrap()).unwrap();
|
||||
let m = l.mask.as_ref().expect("selection becomes the mask");
|
||||
assert_eq!(m.value(5, 5), 1.0);
|
||||
assert_eq!(m.value(30, 20), 0.0);
|
||||
// Still laid out on the canvas, not the mask's bounds.
|
||||
assert_eq!(photocraft_compose::fill_frame(l, d.doc.bounds()), d.doc.bounds());
|
||||
// The composite outside the selection is the white background.
|
||||
let flat = photocraft_compose::flatten(&d.doc);
|
||||
assert_eq!(flat.px[20 * 48 + 30], [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// Pixel parity: a live gradient composites exactly like painting the same drag.
|
||||
fn parity(depth: u32, background: &str, extra: Value, select: bool, tol: f32) {
|
||||
for style in STYLES {
|
||||
for (from, to) in [([10.0, 9.0], [34.0, 22.0]), ([30.0, 16.0], [22.0, 3.0])] {
|
||||
let mut p = json!({"from": from, "to": to, "style": style});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
let run = |live: bool| {
|
||||
let mut s = session(depth, background);
|
||||
if background != "transparent" {
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
}
|
||||
if select {
|
||||
s.execute("select.rect", json!({"x": 6, "y": 3, "width": 30, "height": 22, "ellipse": true, "antiAlias": true})).unwrap();
|
||||
}
|
||||
// Linear and Reflected fill layers sample each pixel's top-left corner
|
||||
// (compose::effects::gradient_t, fitted on Photoshop's fills); the painted
|
||||
// gradient samples centres, so its drag is half a pixel further along.
|
||||
let mut p = p.clone();
|
||||
if !live && matches!(style, "linear" | "reflected") {
|
||||
p["from"] = json!([from[0] + 0.5, from[1] + 0.5]);
|
||||
p["to"] = json!([to[0] + 0.5, to[1] + 0.5]);
|
||||
}
|
||||
s.execute(if live { CREATE } else { "paint.gradient" }, p).unwrap();
|
||||
photocraft_compose::flatten(&s.active().unwrap().doc)
|
||||
};
|
||||
let (classic, live) = (run(false), run(true));
|
||||
let mut worst = 0.0f32;
|
||||
for (a, b) in classic.px.iter().zip(&live.px) {
|
||||
for c in 0..4 {
|
||||
worst = worst.max((a[c] * a[3] - b[c] * b[3]).abs());
|
||||
}
|
||||
}
|
||||
assert!(worst <= tol, "{style} {from:?}->{to:?} {extra} select={select} depth={depth}: worst {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_gradient_renders_like_the_classic_gradient() {
|
||||
let stops = json!({"stops": [[0, "#ff2000"], [0.4, "#20c040"], [1, "#1010e0"]], "dither": false});
|
||||
parity(32, "transparent", stops.clone(), false, 1e-4);
|
||||
parity(8, "transparent", stops.clone(), false, 0.51 / 255.0);
|
||||
parity(8, "white", json!({"stops": [[0, "#ff2000"], [1, "#1010e0"]], "reverse": true, "dither": true}), true, 1.01 / 255.0);
|
||||
parity(16, "transparent", json!({"gradient": "Foreground to Transparent", "dither": true}), false, 1.01 / 255.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_moves_handles_and_restyles_in_one_undo_step() {
|
||||
let mut s = session(8, "white");
|
||||
s.execute(CREATE, json!({"from": [4, 4], "to": [40, 4]})).unwrap();
|
||||
let before = active_fill(&s);
|
||||
let h0 = s.active().unwrap().history.past_len();
|
||||
let g = s.execute(SET, json!({"to": [20, 22]})).unwrap();
|
||||
assert!((g["to"][0].as_f64().unwrap() - 20.0).abs() < 1e-3 && (g["from"][0].as_f64().unwrap() - 4.0).abs() < 1e-3, "{g}");
|
||||
assert_eq!(s.active().unwrap().history.past_len(), h0 + 1);
|
||||
// Changing the style alone keeps the handles where they are.
|
||||
let g = s.execute(SET, json!({"style": "angle"})).unwrap();
|
||||
assert_eq!(g["style"], "angle");
|
||||
assert!((g["to"][1].as_f64().unwrap() - 22.0).abs() < 1e-3, "{g}");
|
||||
let g = s.execute(SET, json!({"angle": 45, "scale": 80, "offset": [10, -5], "reverse": true, "dither": false, "midpoints": [0.3]})).unwrap();
|
||||
assert_eq!(g["angle"], 45.0);
|
||||
assert!((g["scale"].as_f64().unwrap() - 80.0).abs() < 1e-3);
|
||||
assert_eq!(g["reverse"], true);
|
||||
// Each edit is one step: three undos restore the original.
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
assert_eq!(active_fill(&s), before);
|
||||
// A no-op set adds no history.
|
||||
let h = s.active().unwrap().history.past_len();
|
||||
s.execute(SET, json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len(), h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_add_move_recolour_delete_and_midpoints() {
|
||||
let mut s = session(8, "white");
|
||||
s.execute(CREATE, json!({"from": [0, 0], "to": [48, 0], "stops": [[0, "#000000"], [1, "#ffffff"]], "dither": false})).unwrap();
|
||||
let stops = |s: &Session| match active_fill(s) {
|
||||
Fill::Gradient { stops, midpoints, opacity_stops, .. } => (stops, midpoints, opacity_stops),
|
||||
_ => panic!("not a gradient fill"),
|
||||
};
|
||||
// Add: the new stop takes the colour the gradient has there.
|
||||
let g = s.execute(STOP, json!({"action": "add", "location": 0.5})).unwrap();
|
||||
assert_eq!(g["stops"].as_array().unwrap().len(), 3);
|
||||
let (st, _, _) = stops(&s);
|
||||
assert!((st[1].1.c[0] - 0.5).abs() < 1e-3);
|
||||
s.execute(STOP, json!({"action": "color", "index": 1, "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(stops(&s).0[1].1.to_rgb(), [1.0, 0.0, 0.0]);
|
||||
s.execute(STOP, json!({"action": "midpoint", "index": 0, "location": 0.25})).unwrap();
|
||||
assert_eq!(stops(&s).1, vec![0.25, 0.5]);
|
||||
// Moving a stop past another re-sorts them (the midpoint travels with its stop).
|
||||
s.execute(STOP, json!({"action": "move", "index": 0, "location": 0.75})).unwrap();
|
||||
let (st, mids, _) = stops(&s);
|
||||
assert_eq!(st.iter().map(|x| x.0).collect::<Vec<_>>(), vec![0.5, 0.75, 1.0]);
|
||||
assert_eq!(mids, vec![0.5, 0.25]);
|
||||
s.execute(STOP, json!({"action": "delete", "index": 2})).unwrap();
|
||||
assert_eq!(stops(&s).0.len(), 2);
|
||||
assert!(s.execute(STOP, json!({"action": "delete", "index": 0})).is_err(), "keeps 2 stops");
|
||||
// Opacity stops start from an opaque pair.
|
||||
s.execute(STOP, json!({"action": "opacity", "kind": "opacity", "index": 1, "opacity": 25})).unwrap();
|
||||
assert_eq!(stops(&s).2, vec![(0.0, 1.0), (1.0, 0.25)]);
|
||||
s.execute(STOP, json!({"action": "add", "kind": "opacity", "location": 0.5})).unwrap();
|
||||
assert!((stops(&s).2[1].1 - 0.625).abs() < 1e-4);
|
||||
// Each edit is one undo step.
|
||||
let n = s.active().unwrap().history.past_len();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().history.past_len() + 1, n);
|
||||
assert_eq!(stops(&s).2.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_equals_the_committed_edit() {
|
||||
let mut s = session(8, "white");
|
||||
s.execute(CREATE, json!({"from": [4, 4], "to": [40, 20], "style": "diamond"})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
let l = d.doc.layer(d.active_layer.unwrap()).unwrap().clone();
|
||||
let LayerContent::Fill(f) = &l.content else { panic!() };
|
||||
let p = json!({"from": [10, 12], "to": [30, 2]});
|
||||
let preview = apply_set(&l, f, d.doc.bounds(), &p, [0.0; 4], [1.0; 4]).unwrap();
|
||||
s.execute(SET, p).unwrap();
|
||||
assert_eq!(active_fill(&s), preview);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_gradient_still_paints_pixels() {
|
||||
let mut s = session(8, "white");
|
||||
s.execute("paint.gradient", json!({"from": [0, 0], "to": [48, 0], "colors": ["#000000", "#ffffff"], "dither": false})).unwrap();
|
||||
assert_eq!(layer_count(&s), 1, "no fill layer");
|
||||
let d = s.active().unwrap();
|
||||
let px = d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(1, 5);
|
||||
assert!(px[0] < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_fail_gracefully() {
|
||||
let mut empty = Session::new();
|
||||
for id in [CREATE, SET, STOP, GET] {
|
||||
assert!(empty.execute(id, json!({"from": [0, 0], "to": [1, 1]})).is_err(), "{id} without a document");
|
||||
}
|
||||
let mut s = session(8, "white");
|
||||
// Not a gradient fill layer.
|
||||
for id in [SET, STOP, GET] {
|
||||
assert!(s.execute(id, json!({})).is_err(), "{id} on a pixel layer");
|
||||
}
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"from": [0, 0]}),
|
||||
json!({"from": [0], "to": [1, 1]}),
|
||||
json!({"from": [f64::MAX, 0], "to": [1, 1]}),
|
||||
json!({"from": ["a", 0], "to": [1, 1]}),
|
||||
json!({"from": [0, 0], "to": [1, 1], "style": "spiral"}),
|
||||
json!({"from": [0, 0], "to": [1, 1], "style": 3}),
|
||||
json!({"from": [0, 0], "to": [1, 1], "gradient": "No Such Preset"}),
|
||||
json!({"from": [0, 0], "to": [1, 1], "stops": []}),
|
||||
json!({"from": [0, 0], "to": [1, 1], "mode": "nonsense"}),
|
||||
] {
|
||||
assert!(s.execute(CREATE, p.clone()).is_err(), "create {p}");
|
||||
}
|
||||
// A zero-length drag still makes a valid gradient.
|
||||
s.execute(CREATE, json!({"from": [3, 3], "to": [3, 3]})).unwrap();
|
||||
for p in [
|
||||
json!({"layer": 999_999}),
|
||||
json!({"from": [0]}),
|
||||
json!({"to": [1e30, 0]}),
|
||||
json!({"angle": "x"}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": -5}),
|
||||
json!({"offset": [1]}),
|
||||
json!({"offset": ["a", "b"]}),
|
||||
json!({"reverse": 1}),
|
||||
json!({"stops": [[0, "#000000"]]}),
|
||||
json!({"stops": [[0, "#zzzzzz"], [1, "#ffffff"]]}),
|
||||
json!({"stops": [[0, [1, 2]], [1, "#ffffff"]]}),
|
||||
json!({"stops": "x"}),
|
||||
json!({"transparency": [[0]]}),
|
||||
json!({"midpoints": [0.5, 0.5, 0.5]}),
|
||||
json!({"midpoints": ["x"]}),
|
||||
json!({"style": "spiral"}),
|
||||
json!({"align": "no"}),
|
||||
] {
|
||||
assert!(s.execute(SET, p.clone()).is_err(), "set {p}");
|
||||
}
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"action": "explode"}),
|
||||
json!({"action": "add"}),
|
||||
json!({"action": "move", "index": 99, "location": 0.5}),
|
||||
json!({"action": "delete", "index": 99}),
|
||||
json!({"action": "color", "index": 0}),
|
||||
json!({"action": "color", "index": 0, "color": 7}),
|
||||
json!({"action": "midpoint", "index": 1, "location": 0.5}),
|
||||
json!({"action": "midpoint", "index": 0}),
|
||||
json!({"action": "add", "kind": "sparkle", "location": 0.5}),
|
||||
json!({"action": "color", "kind": "opacity", "index": 0}),
|
||||
json!({"action": "opacity", "kind": "opacity", "index": 7, "opacity": 3}),
|
||||
json!({"action": "delete", "kind": "opacity", "index": 0}),
|
||||
json!({"action": "move", "index": -1, "location": 0.5}),
|
||||
] {
|
||||
assert!(s.execute(STOP, p.clone()).is_err(), "stop {p}");
|
||||
}
|
||||
// Huge-but-finite values clamp instead of failing.
|
||||
s.execute(SET, json!({"scale": 1e30, "angle": 1e9, "offset": [1e4, -1e4]})).unwrap();
|
||||
let g = s.execute(GET, json!({})).unwrap();
|
||||
assert!(g["scale"].as_f64().unwrap().is_finite() && g["from"][0].as_f64().unwrap().is_finite());
|
||||
// Scale stays within Photoshop's 10–150 %, also for a handle dragged far away.
|
||||
assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}");
|
||||
let g = s.execute(SET, json!({"from": [0, 0], "to": [1e6, 0]})).unwrap();
|
||||
assert!((g["scale"].as_f64().unwrap() - 150.0).abs() < 1e-3, "{g}");
|
||||
let g = s.execute(SET, json!({"scale": 1})).unwrap();
|
||||
assert!((g["scale"].as_f64().unwrap() - 10.0).abs() < 1e-3, "{g}");
|
||||
}
|
||||
@@ -44,6 +44,7 @@ pub fn document(d: &DocState) -> Value {
|
||||
"revision": d.revision,
|
||||
"channels": crate::channel_cmds::channels_json(d),
|
||||
"quickMask": doc.quick_mask.is_some(),
|
||||
"layerMaskView": crate::mask_view_cmds::view_json(d),
|
||||
"layerComps": doc.layer_comps.iter().map(|c| json!({"id": c.id, "name": c.name})).collect::<Vec<_>>(),
|
||||
"lastAppliedComp": doc.last_applied_comp,
|
||||
"measurement": {"scale": doc.measurement.scale.describe(), "ruler": doc.measurement.ruler.map(|r| json!({"start": r.start, "end": r.end, "protractor": r.protractor})), "count": doc.measurement.count_total(), "countGroups": doc.measurement.count_groups.len()},
|
||||
|
||||
@@ -179,6 +179,7 @@ fn hide_selection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut mask = LayerMask::reveal_all();
|
||||
mask.surface.write_region(bounds, &v);
|
||||
mask.surface.prune();
|
||||
crate::extra_cmds::background_to_layer_for_mask(doc, id);
|
||||
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask);
|
||||
doc.selection = None;
|
||||
Ok(Value::Null)
|
||||
@@ -709,7 +710,7 @@ fn export_layer(s: &mut Session, p: &Value, cmd: &str, png_only: bool) -> Result
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| other(format!("{path}: {e}")))
|
||||
crate::file_cmds::write_file(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -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;
|
||||
@@ -27,11 +28,14 @@ pub mod edit_menu_cmds;
|
||||
pub mod eraser_cmds;
|
||||
pub mod extra_cmds;
|
||||
pub mod file_cmds;
|
||||
pub mod fill_cmds;
|
||||
pub mod fill_key_cmds;
|
||||
pub mod filters;
|
||||
pub mod filters_ext;
|
||||
mod frame_cmds;
|
||||
pub mod fx_view_cmds;
|
||||
pub mod gallery_cmds;
|
||||
pub mod gradient_fill_cmds;
|
||||
pub mod group_view_cmds;
|
||||
pub mod image_cmds;
|
||||
pub mod inspect;
|
||||
@@ -39,6 +43,7 @@ pub mod layer_menu_cmds;
|
||||
pub mod layer_multi_cmds;
|
||||
pub mod layer_style;
|
||||
pub mod lens_cmds;
|
||||
pub mod mask_view_cmds;
|
||||
mod migrate_cmds;
|
||||
pub mod mode_cmds;
|
||||
pub mod multichannel_cmds;
|
||||
@@ -198,6 +203,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 +221,7 @@ impl Default for ToolState {
|
||||
presets: photocraft_paint::presets::builtin(),
|
||||
presets_rev: 0,
|
||||
mixer: Default::default(),
|
||||
brush_gesture: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,10 +342,14 @@ impl Session {
|
||||
self.coalesce_request = None;
|
||||
self.color_restrict = None;
|
||||
let r = r?;
|
||||
// A layer-mask view ends when another layer becomes active (#196).
|
||||
if let Some(st) = self.active_mut() {
|
||||
mask_view_cmds::fix(st);
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! View › layer mask (#196): ⌥-click a layer-mask thumbnail shows the mask as a grayscale image
|
||||
//! instead of the composite; ⇧⌥-click (or the eye on the Channels panel's temporary mask row)
|
||||
//! shows it as a rubylith overlay over the composite.
|
||||
//!
|
||||
//! This is view state on [`crate::channel_cmds::ChannelView::layer_mask`], like the Channels
|
||||
//! panel's eyes: no history step, a clean document stays clean, and it never changes a pixel.
|
||||
//! While a mask is shown, pixel commands without an explicit `target` edit that mask
|
||||
//! ([`crate::channel_cmds`]'s target injection), so painting in mask view paints the mask.
|
||||
//! The view ends when its layer stops being the active layer or loses its mask ([`fix`]).
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub const ID: &str = "view.layerMask";
|
||||
|
||||
/// How a shown layer mask is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MaskViewMode {
|
||||
/// The mask alone, as a grayscale image (⌥-click).
|
||||
Gray,
|
||||
/// The composite with the mask as a red overlay over hidden areas (⇧⌥-click, `\`).
|
||||
Overlay,
|
||||
}
|
||||
|
||||
/// Which layer's mask is shown, and how.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct LayerMaskView {
|
||||
pub layer: LayerId,
|
||||
pub mode: MaskViewMode,
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: ID.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
|
||||
/// The mask view of `st` when it is live: its layer is the active layer and still has a mask.
|
||||
pub fn current(st: &DocState) -> Option<LayerMaskView> {
|
||||
let v = st.channel_view.layer_mask?;
|
||||
let live = st.active_layer == Some(v.layer) && st.doc.layer(v.layer).is_some_and(|l| l.mask.is_some());
|
||||
live.then_some(v)
|
||||
}
|
||||
|
||||
/// Drop a mask view that no longer applies (another layer became active, the layer or its mask
|
||||
/// was deleted, undo).
|
||||
pub(crate) fn fix(st: &mut DocState) {
|
||||
if st.channel_view.layer_mask.is_some() && current(st).is_none() {
|
||||
st.channel_view.layer_mask = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_name(v: Option<LayerMaskView>) -> &'static str {
|
||||
match v.map(|v| v.mode) {
|
||||
None => "off",
|
||||
Some(MaskViewMode::Gray) => "gray",
|
||||
Some(MaskViewMode::Overlay) => "overlay",
|
||||
}
|
||||
}
|
||||
|
||||
/// The view as JSON (`{"layer","mode"}`, or `null` when off); part of `document.inspect`.
|
||||
pub fn view_json(st: &DocState) -> Value {
|
||||
match current(st) {
|
||||
Some(v) => json!({"layer": v.layer.0, "mode": mode_name(Some(v))}),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// `{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"}`.
|
||||
fn set_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mode = match p.get("mode") {
|
||||
None | Some(Value::Null) => "toggleGray",
|
||||
Some(v) => v.as_str().ok_or_else(|| bad("`mode` must be a string"))?,
|
||||
};
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let layer = match p.get("layer") {
|
||||
None | Some(Value::Null) => st.active_layer.ok_or_else(|| bad("no layer given and no active layer"))?,
|
||||
Some(v) => LayerId(v.as_u64().ok_or_else(|| bad("`layer` must be a layer id"))?),
|
||||
};
|
||||
let l = st.doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
|
||||
let has_mask = l.mask.is_some();
|
||||
let name = l.name.clone();
|
||||
let shown = current(st).filter(|v| v.layer == layer).map(|v| v.mode);
|
||||
let want = match mode {
|
||||
"off" => None,
|
||||
"gray" => Some(MaskViewMode::Gray),
|
||||
"overlay" => Some(MaskViewMode::Overlay),
|
||||
"toggleGray" => (shown != Some(MaskViewMode::Gray)).then_some(MaskViewMode::Gray),
|
||||
"toggleOverlay" => (shown != Some(MaskViewMode::Overlay)).then_some(MaskViewMode::Overlay),
|
||||
other => return Err(bad(format!("unknown mode `{other}` (off|gray|overlay|toggleGray|toggleOverlay)"))),
|
||||
};
|
||||
if want.is_some() && !has_mask {
|
||||
return Err(bad(format!("layer \"{name}\" has no layer mask")));
|
||||
}
|
||||
// ⌥-clicking another layer's mask selects that layer, as in Photoshop.
|
||||
if want.is_some() && s.active().and_then(|st| st.active_layer) != Some(layer) {
|
||||
s.select_layer(layer)?;
|
||||
}
|
||||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
let next = want.map(|mode| LayerMaskView { layer, mode });
|
||||
// "off" for a layer whose mask isn't the one shown leaves the other view alone.
|
||||
let applies = next.is_some() || st.channel_view.layer_mask.is_some_and(|v| v.layer == layer);
|
||||
if applies && st.channel_view.layer_mask != next {
|
||||
st.channel_view.layer_mask = next;
|
||||
// A view change: redraw without recompositing, and keep a clean document clean.
|
||||
let clean = st.saved_revision == st.revision;
|
||||
st.revision += 1;
|
||||
if clean {
|
||||
st.saved_revision = st.revision;
|
||||
}
|
||||
st.last_damage = Some(photocraft_geom::Rect::EMPTY);
|
||||
}
|
||||
let v = current(st);
|
||||
Ok(json!({"layer": layer.0, "mode": mode_name(v.filter(|v| v.layer == layer))}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: ID,
|
||||
label: "View Layer Mask",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"mode":"off"|"gray"|"overlay"|"toggleGray"|"toggleOverlay"="toggleGray"} (gray: the mask alone, ⌥-click its thumbnail; overlay: red rubylith over the composite, ⇧⌥-click; view state, not an undo step; painting while shown paints the mask)"##,
|
||||
enabled: has_doc,
|
||||
run: set_view,
|
||||
journal: false,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// 40×20 document: Background, then "Masked" (red, mask hiding the left half) on top.
|
||||
fn session() -> (Session, u64, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 40, "height": 20})).unwrap();
|
||||
let bg = s.active().unwrap().doc.layers[0].id.0;
|
||||
let masked = s.execute("layer.new.layer", json!({"name": "Masked"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 20, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.saved_revision = st.revision;
|
||||
(s, masked, bg)
|
||||
}
|
||||
|
||||
/// The view as `document.inspect` reports it.
|
||||
fn view(s: &Session) -> Value {
|
||||
crate::inspect::document(s.active().unwrap())["layerMaskView"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_toggle_and_show_in_inspect_without_history() {
|
||||
let (mut s, masked, _) = session();
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
assert_eq!(view(&s), Value::Null);
|
||||
assert_eq!(s.execute(ID, json!({})).unwrap()["mode"], "gray", "no mode: toggle the grayscale view");
|
||||
assert_eq!(view(&s), json!({"layer": masked, "mode": "gray"}));
|
||||
assert_eq!(s.execute(ID, json!({"mode": "toggleGray"})).unwrap()["mode"], "off", "⌥-click again returns");
|
||||
assert_eq!(s.execute(ID, json!({"layer": masked, "mode": "overlay"})).unwrap()["mode"], "overlay");
|
||||
assert_eq!(s.active().unwrap().channel_view.layer_mask.unwrap().mode, MaskViewMode::Overlay);
|
||||
let ch = crate::channel_cmds::channels_json(s.active().unwrap());
|
||||
assert_eq!(ch["layerMaskView"]["mode"], "overlay", "the Channels state carries it too");
|
||||
assert_eq!(s.execute(ID, json!({"mode": "toggleGray"})).unwrap()["mode"], "gray", "overlay → gray");
|
||||
assert_eq!(s.execute(ID, json!({"mode": "off"})).unwrap()["mode"], "off");
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.history.entries().len(), steps, "view changes are not history steps");
|
||||
assert!(!st.is_dirty(), "a clean document stays clean");
|
||||
assert_eq!(st.last_damage, Some(Rect::EMPTY), "no recomposite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mask_view_never_alters_pixels() {
|
||||
let (mut s, masked, _) = session();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
let px = |s: &Session| photocraft_compose::render(&s.active().unwrap().doc, Rect::new(0, 0, 40, 20)).px;
|
||||
let composite = px(&s);
|
||||
for mode in ["gray", "overlay", "toggleOverlay", "toggleGray", "off"] {
|
||||
s.execute(ID, json!({"layer": masked, "mode": mode})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.active().unwrap().doc), "{mode}: the document is untouched");
|
||||
assert_eq!(px(&s), composite, "{mode}: same composite");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn painting_in_mask_view_paints_the_mask() {
|
||||
let (mut s, masked, _) = session();
|
||||
s.execute(ID, json!({"mode": "gray"})).unwrap();
|
||||
let id = photocraft_doc::LayerId(masked);
|
||||
let pixels_before = s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
s.execute("paint.stroke", json!({"points": [[30, 10]], "size": 6, "hardness": 1.0, "color": "#000000"})).unwrap();
|
||||
let l = s.active().unwrap().doc.layer(id).unwrap().clone();
|
||||
assert!(l.mask.as_ref().unwrap().value(30, 10) < 0.01, "the stroke went into the mask");
|
||||
let mut a = Vec::new();
|
||||
let mut b = Vec::new();
|
||||
pixels_before.read_region_into(Rect::new(0, 0, 40, 20), &mut a);
|
||||
l.surface().unwrap().read_region_into(Rect::new(0, 0, 40, 20), &mut b);
|
||||
assert_eq!(a, b, "the layer's pixels are untouched");
|
||||
// An explicit target still wins.
|
||||
s.execute("paint.stroke", json!({"points": [[5, 5]], "size": 4, "color": "#00ff00", "target": "pixels"})).unwrap();
|
||||
let l = s.active().unwrap().doc.layer(id).unwrap();
|
||||
assert!(l.surface().unwrap().sample_channel(5, 5, 1) > 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_view_ends_with_its_layer_or_mask() {
|
||||
let (mut s, masked, bg) = session();
|
||||
s.execute(ID, json!({"mode": "gray"})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": bg})).unwrap();
|
||||
assert_eq!(view(&s), Value::Null, "selecting another layer leaves mask view");
|
||||
assert!(s.active().unwrap().channel_view.layer_mask.is_none());
|
||||
// ⌥-clicking a mask of an unselected layer selects it.
|
||||
s.execute(ID, json!({"layer": masked, "mode": "overlay"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(photocraft_doc::LayerId(masked)));
|
||||
s.execute("layer.layerMask.delete", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().channel_view.layer_mask.is_none(), "deleting the mask ends the view");
|
||||
s.undo();
|
||||
assert!(s.active().unwrap().channel_view.layer_mask.is_none(), "undo doesn't bring a stale view back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_and_no_mask_fail_gracefully() {
|
||||
let (mut s, masked, bg) = session();
|
||||
for p in [
|
||||
json!({"layer": bg, "mode": "gray"}),
|
||||
json!({"layer": bg}),
|
||||
json!({"layer": 999_999, "mode": "gray"}),
|
||||
json!({"layer": "x"}),
|
||||
json!({"layer": -1}),
|
||||
json!({"mode": 3}),
|
||||
json!({"mode": "sideways"}),
|
||||
json!({"layer": masked, "mode": ["gray"]}),
|
||||
] {
|
||||
assert!(s.execute(ID, p.clone()).is_err(), "{p} should fail");
|
||||
}
|
||||
assert_eq!(view(&s), Value::Null, "failures change nothing");
|
||||
// "off" is always fine, even for a layer without a mask, and leaves another view alone.
|
||||
s.execute(ID, json!({"layer": masked, "mode": "gray"})).unwrap();
|
||||
assert_eq!(s.execute(ID, json!({"layer": bg, "mode": "off"})).unwrap()["mode"], "off");
|
||||
assert_eq!(view(&s)["mode"], "gray");
|
||||
// No document.
|
||||
let mut empty = Session::new();
|
||||
assert!(empty.execute(ID, json!({"mode": "gray"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_selection_from_a_vector_mask() {
|
||||
let (mut s, masked, _) = session();
|
||||
let path = json!({"subpaths": [{"knots": [[4, 4], [16, 4], [16, 16], [4, 16]]}]});
|
||||
s.execute("layer.vectorMask.add", json!({"layer": masked, "path": path})).unwrap();
|
||||
s.execute("layer.vectorMask.enabled", json!({"enabled": false})).unwrap();
|
||||
s.execute("select.loadSelection", json!({"channel": "vectorMask", "layer": masked})).unwrap();
|
||||
let b = s.active().unwrap().doc.selection.as_ref().unwrap().content_bounds();
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (4, 4, 12, 12), "the path's shape, even when disabled");
|
||||
// ⌘⌥: subtract, ⌘⇧⌥: intersect.
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
s.execute("select.loadSelection", json!({"channel": "vectorMask", "operation": "subtract"})).unwrap();
|
||||
let sel = s.active().unwrap().doc.selection.clone().unwrap();
|
||||
assert!(sel.sample_channel(10, 10, 0) < 0.01 && sel.sample_channel(30, 10, 0) > 0.99);
|
||||
s.execute("select.loadSelection", json!({"channel": "vectorMask", "operation": "intersect"})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_none(), "nothing left");
|
||||
// No vector mask: an error, not a crash.
|
||||
s.execute("layer.vectorMask.delete", json!({})).unwrap();
|
||||
assert!(s.execute("select.loadSelection", json!({"channel": "vectorMask"})).is_err());
|
||||
}
|
||||
@@ -306,9 +306,11 @@ mod tests {
|
||||
let n = &s.active().unwrap().doc.notes[0];
|
||||
assert_eq!(n.text, "from pcraft");
|
||||
assert!(n.position[0] <= 84.0 && n.position[1] <= 60.0);
|
||||
// A PSD with notes (corpus sample) appends two more.
|
||||
let psd = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/psd/ag-psd/read-write/annotations/src.psd");
|
||||
if std::path::Path::new(psd).exists() {
|
||||
// A PSD with notes (corpus sample, feature `corpus`) appends two more.
|
||||
#[cfg(feature = "corpus")]
|
||||
{
|
||||
let psd = concat!(env!("CARGO_MANIFEST_DIR"), "/../../corpus/psd/ag-psd/read-write/annotations/src.psd");
|
||||
assert!(std::path::Path::new(psd).exists(), "{psd} is missing: run `cargo xtask corpus --all`");
|
||||
s.execute("file.import.notes", json!({"path": psd})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.notes.len(), 3);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use photocraft_doc::{Color, Document, Fill, Layer, LayerContent, Pattern, Rect};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{CommandSpec, layer_param};
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The app-level pattern library (Window › Patterns).
|
||||
@@ -288,7 +288,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.patterns.items.clone(),
|
||||
};
|
||||
let bytes = photocraft_io::pattern_map::write_pat(&pats).map_err(EngineError::Other)?;
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::file_cmds::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "count": pats.len(), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -315,50 +315,6 @@ fn new_fill_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0, "pattern": pat.id}))
|
||||
}
|
||||
|
||||
/// Edit › Fill with `"contents": "pattern"`: tiles the pattern over the selection (or the whole
|
||||
/// target), composited at `opacity` (Normal), respecting transparency locks.
|
||||
pub fn fill_with_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "edit.fill", p)?;
|
||||
let (scale, angle, _, phase) = placement(p);
|
||||
let opacity = (p.get("opacity").and_then(Value::as_f64).unwrap_or(100.0) as f32 / 100.0).clamp(0.0, 1.0);
|
||||
let id = if crate::channel_cmds::is_channel_target(p) { None } else { Some(layer_param(s, p)?) };
|
||||
let tile = photocraft_compose::pattern::Tile::new(&pat).ok_or_else(|| bad("edit.fill", "the pattern is empty"))?;
|
||||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
|
||||
// Photoshop's Fill uses the canvas origin.
|
||||
let place = photocraft_compose::pattern::Placement::new(Rect::EMPTY, false, phase, scale, angle);
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
let mut region = surf.read_region(area);
|
||||
let src = photocraft_compose::pattern::render(&tile, &place, area);
|
||||
let w = area.width() as usize;
|
||||
for (i, (px, c)) in region.chunks_exact_mut(n).zip(&src).enumerate() {
|
||||
let k = sel.as_ref().map_or(1.0, |m| m.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0)) * opacity;
|
||||
if k <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let d = photocraft_raster::to_rgba(&fmt, px);
|
||||
let sa = c[3] * k;
|
||||
let oa = sa + d[3] * (1.0 - sa);
|
||||
let mut o = [0.0f32; 4];
|
||||
if oa > 0.0 {
|
||||
for ch in 0..3 {
|
||||
o[ch] = (c[ch] * sa + d[ch] * d[3] * (1.0 - sa)) / oa;
|
||||
}
|
||||
}
|
||||
o[3] = if lock || !fmt.alpha { d[3] } else { oa };
|
||||
let mut enc = [0.0f32; 8];
|
||||
photocraft_raster::from_rgba_into(&fmt, o, &mut enc);
|
||||
px.copy_from_slice(&enc[..n]);
|
||||
}
|
||||
surf.write_region(area, ®ion);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"pattern": pat.id}))
|
||||
}
|
||||
|
||||
fn brush_texture(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pat = resolve_param(s, "brush.texturePattern", p)?;
|
||||
// Brush textures are grey: luminance of the composite over white.
|
||||
|
||||
+141
-10
@@ -59,6 +59,13 @@ choice!(Theme { Pro = "pro", ProMedium = "proMedium", Studio = "studio", StudioL
|
||||
choice!(CanvasColor { Default = "default", Black = "black", DarkGray = "darkGray", MediumGray = "mediumGray", LightGray = "lightGray", Custom = "custom" } default Default);
|
||||
choice!(CanvasBorder { DropShadow = "dropShadow", Line = "line", None = "none" } default DropShadow);
|
||||
choice!(UiScale { Auto = "auto", P100 = "100", P200 = "200" } default Auto);
|
||||
choice!(
|
||||
/// Graphics backend of the desktop app's window and GPU canvas (applies at next launch).
|
||||
/// `auto` lets PhotoCraft pick (DX12 for Intel adapters on Windows); `cpu` composites on the
|
||||
/// CPU and draws the window with a software adapter where the platform has one. A start that
|
||||
/// crashes inside the graphics driver moves this to the next safer choice.
|
||||
GpuBackend { Auto = "auto", Vulkan = "vulkan", Dx12 = "dx12", Metal = "metal", Gl = "gl", Cpu = "cpu" } default Auto
|
||||
);
|
||||
choice!(UiFontSize { Tiny = "tiny", Small = "small", Medium = "medium", Large = "large" } default Small);
|
||||
choice!(LogDestination { Metadata = "metadata", TextFile = "textFile", Both = "both" } default Metadata);
|
||||
choice!(LogDetail { SessionsOnly = "sessionsOnly", Concise = "concise", Detailed = "detailed" } default Concise);
|
||||
@@ -194,6 +201,10 @@ pub struct Interface {
|
||||
/// Document border in standard screen mode.
|
||||
pub canvas_border: CanvasBorder,
|
||||
pub ui_scale: UiScale,
|
||||
/// UI language: `auto` (follow the system) or a language code such as `en`, `ja`. The list of
|
||||
/// languages belongs to the shell (`ui-egui` i18n); an unknown code falls back to `auto`.
|
||||
/// Command ids and the control protocol stay English.
|
||||
pub language: String,
|
||||
pub ui_font_size: UiFontSize,
|
||||
pub show_channels_in_color: bool,
|
||||
pub dynamic_color_sliders: bool,
|
||||
@@ -210,6 +221,7 @@ impl Default for Interface {
|
||||
canvas_custom_color: "#282828".into(),
|
||||
canvas_border: CanvasBorder::DropShadow,
|
||||
ui_scale: UiScale::Auto,
|
||||
language: "auto".into(),
|
||||
ui_font_size: UiFontSize::Small,
|
||||
show_channels_in_color: false,
|
||||
dynamic_color_sliders: true,
|
||||
@@ -261,6 +273,8 @@ pub struct Tools {
|
||||
pub double_click_layer_mask_launches_select_and_mask: bool,
|
||||
/// What the right mouse button does on the canvas with the Brush and other painting tools.
|
||||
pub right_click_with_painting_tools: RightClickPaint,
|
||||
/// Pen tablets: pressure, tilt and rotation reach the brush (off: a pen paints like a mouse).
|
||||
pub use_tablet_pressure: bool,
|
||||
}
|
||||
|
||||
impl Default for Tools {
|
||||
@@ -276,6 +290,7 @@ impl Default for Tools {
|
||||
overscroll: true,
|
||||
double_click_layer_mask_launches_select_and_mask: true,
|
||||
right_click_with_painting_tools: RightClickPaint::BrushPicker,
|
||||
use_tablet_pressure: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +385,8 @@ pub struct Performance {
|
||||
pub cache_tile_size: u32,
|
||||
/// Draw the canvas with the GPU (applies at next launch).
|
||||
pub use_gpu: bool,
|
||||
/// Graphics backend (applies at next launch; see [`GpuBackend`]).
|
||||
pub gpu_backend: GpuBackend,
|
||||
/// Memory budget of the layer-effect cache, in MB.
|
||||
pub effect_cache_mb: u32,
|
||||
pub legacy_compositing: bool,
|
||||
@@ -391,6 +408,7 @@ impl Default for Performance {
|
||||
cache_levels: 4,
|
||||
cache_tile_size: 8192,
|
||||
use_gpu: true,
|
||||
gpu_backend: GpuBackend::Auto,
|
||||
effect_cache_mb: 768,
|
||||
legacy_compositing: false,
|
||||
}
|
||||
@@ -726,6 +744,9 @@ pub struct Preferences {
|
||||
/// as the user changes it and restored at launch when Workspace › Remember Workspace
|
||||
/// Changes is on. JSON owned by the shell.
|
||||
pub panel_layout: Value,
|
||||
/// The last choices of dialogs that remember them across restarts, by command id (Edit ›
|
||||
/// Fill…: `"edit.fill"` → its params). JSON owned by the shell.
|
||||
pub dialogs: BTreeMap<String, Value>,
|
||||
/// File › Scripts › Script Events Manager: event → script bindings.
|
||||
pub script_events: crate::automate_cmds::ScriptEvents,
|
||||
}
|
||||
@@ -752,6 +773,84 @@ pub const SECTIONS: [(&str, &str); 18] = [
|
||||
("integrations", "Integrations"),
|
||||
];
|
||||
|
||||
/// Preferences that nothing reads yet, so Edit › Preferences doesn't show them: a control that
|
||||
/// does nothing is worse than a missing one (https://github.com/storytold/photocraft/issues/204).
|
||||
/// They still load, save and round-trip through `prefs.get` / `prefs.set` unchanged.
|
||||
///
|
||||
/// When you implement one, remove it here; the `prefs_usage` test fails while a listed
|
||||
/// preference is read anywhere outside `prefs.rs`, and while an unlisted one is read nowhere.
|
||||
pub const HIDDEN_UNTIL_IMPLEMENTED: &[&str] = &[
|
||||
"general.colorPicker",
|
||||
"general.beepWhenDone",
|
||||
"general.exportClipboard",
|
||||
"general.resizeImageDuringPlace",
|
||||
"general.alwaysCreateSmartObjectsWhenPlacing",
|
||||
"general.animatedZoom",
|
||||
"general.zoomResizesWindows",
|
||||
"general.useLegacyFreeTransform",
|
||||
"interface.uiFontSize",
|
||||
"interface.showChannelsInColor",
|
||||
"interface.dynamicColorSliders",
|
||||
"workspace.autoCollapseIconPanels",
|
||||
"workspace.autoShowHiddenPanels",
|
||||
"workspace.openDocumentsAsTabs",
|
||||
"workspace.enableFloatingDocumentWindowDocking",
|
||||
"workspace.largeTabs",
|
||||
"workspace.enableNarrowOptionsBar",
|
||||
"tools.zoomClickedPointToCenter",
|
||||
"tools.enableFlickPanning",
|
||||
"tools.varyRoundBrushHardnessOnHud",
|
||||
"tools.showTransformationValues",
|
||||
"tools.overscroll",
|
||||
"tools.doubleClickLayerMaskLaunchesSelectAndMask",
|
||||
"fileHandling.imagePreviews",
|
||||
"fileHandling.lowercaseExtension",
|
||||
"fileHandling.saveInBackground",
|
||||
"fileHandling.ignoreExifProfileTag",
|
||||
"fileHandling.askBeforeSavingLayeredTiff",
|
||||
"fileHandling.maximizePsdCompatibility",
|
||||
"performance.cacheLevels",
|
||||
"performance.effectCacheMb",
|
||||
"performance.legacyCompositing",
|
||||
"scratchDisks.disks",
|
||||
"cursors.brushPreviewColor",
|
||||
"unitsAndRulers.typeUnits",
|
||||
"unitsAndRulers.columnWidth",
|
||||
"unitsAndRulers.gutter",
|
||||
"unitsAndRulers.printResolution",
|
||||
"unitsAndRulers.screenResolution",
|
||||
"plugIns.showExtensionPanels",
|
||||
"plugIns.allowScriptsToConnect",
|
||||
"plugIns.generatorEnabled",
|
||||
"type.smartQuotes",
|
||||
"type.missingGlyphProtection",
|
||||
"type.showFontNamesInEnglish",
|
||||
"type.useEscToCommit",
|
||||
"type.textEngine",
|
||||
"type.fontPreview",
|
||||
"type.fillNewTypeLayersWithPlaceholder",
|
||||
"type.recentFonts",
|
||||
"enhancedControls.scrubbySliderAcceleration",
|
||||
"enhancedControls.touchGestures",
|
||||
"enhancedControls.zoomWithTrackpadPinch",
|
||||
"enhancedControls.rotateViewWithTrackpad",
|
||||
"rawDefaults.colorSpace",
|
||||
"rawDefaults.bitDepth",
|
||||
"rawDefaults.resolution",
|
||||
"rawDefaults.sharpenFor",
|
||||
"rawDefaults.openAsSmartObject",
|
||||
"rawDefaults.applyAutoTone",
|
||||
// Agent access is governed by the launch flags (`--control`, the automation roots), not
|
||||
// by these yet.
|
||||
"integrations.allowAgentControl",
|
||||
"integrations.controlPort",
|
||||
];
|
||||
|
||||
/// Is the preference at `path` (`"section.key"`) hidden from the Preferences dialog?
|
||||
pub fn is_hidden(path: &str) -> bool {
|
||||
HIDDEN_UNTIL_IMPLEMENTED.contains(&path)
|
||||
}
|
||||
|
||||
/// Choices of an enumerated preference (dotted path, e.g. `"cursors.painting"`).
|
||||
pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match path {
|
||||
@@ -782,6 +881,7 @@ pub fn choices(path: &str) -> Option<&'static [&'static str]> {
|
||||
"rawDefaults.colorSpace" => RawColorSpace::NAMES,
|
||||
"rawDefaults.bitDepth" => RawDepth::NAMES,
|
||||
"rawDefaults.sharpenFor" => RawSharpen::NAMES,
|
||||
"performance.gpuBackend" => GpuBackend::NAMES,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -833,7 +933,7 @@ pub fn parse_hex(s: &str) -> Option<[u8; 3]> {
|
||||
if s.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let b = |i: usize| u8::from_str_radix(&s[i..i + 2], 16).ok();
|
||||
let b = |i: usize| s.get(i..i + 2).and_then(|s| u8::from_str_radix(s, 16).ok());
|
||||
Some([b(0)?, b(2)?, b(4)?])
|
||||
}
|
||||
|
||||
@@ -892,6 +992,12 @@ fn check_value(path: &str, v: &Value) -> std::result::Result<(), String> {
|
||||
return Err(format!("`{path}` must be one of {} (got `{s}`)", c.join("|")));
|
||||
}
|
||||
}
|
||||
if path == "interface.language" {
|
||||
let ok = v.as_str().is_some_and(|s| !s.is_empty() && s.len() <= 16 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
|
||||
if !ok {
|
||||
return Err("`interface.language` must be `auto` or a language code such as `en` or `ja`".into());
|
||||
}
|
||||
}
|
||||
if let Some((lo, hi)) = range(path) {
|
||||
let x = v.as_f64().ok_or_else(|| format!("`{path}` must be a number"))?;
|
||||
if !(lo..=hi).contains(&x) {
|
||||
@@ -1040,6 +1146,22 @@ fn capitalize(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold-and-release gestures (#249): temporary tools that last while their key is held and give
|
||||
/// the previous tool back on release. They are bound like shortcuts (Edit › Keyboard Shortcuts ›
|
||||
/// Tools › Temporary, `edit.keyboardShortcuts`) but held rather than pressed, so they are not
|
||||
/// commands; the UI reads their keys through [`Preferences::shortcut`]. While a selection or
|
||||
/// shape is being dragged, the Hand key repositions it instead of panning (Photoshop's Space).
|
||||
pub const TEMPORARY_TOOLS: &[(&str, &str, &str)] = &[
|
||||
("tools.temporary.hand", "Hand Tool (hold)", "Space"),
|
||||
("tools.temporary.zoomIn", "Zoom In (hold, drag to scrub)", "Cmd+Space"),
|
||||
("tools.temporary.zoomOut", "Zoom Out (hold)", "Cmd+Alt+Space"),
|
||||
];
|
||||
|
||||
/// Every bindable id with its default: commands, then the temporary tools.
|
||||
fn bindable() -> impl Iterator<Item = (&'static str, Option<&'static str>)> {
|
||||
crate::command_specs().iter().map(|c| (c.id, c.shortcut)).chain(TEMPORARY_TOOLS.iter().map(|t| (t.0, Some(t.2))))
|
||||
}
|
||||
|
||||
/// Shortcuts bound to more than one command: (shortcut, ids). `bindings` are (id, shortcut).
|
||||
pub fn conflicts<'a>(bindings: impl IntoIterator<Item = (&'a str, &'a str)>) -> Vec<(String, Vec<String>)> {
|
||||
let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
@@ -1270,7 +1392,7 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(m) = p.get("set").and_then(Value::as_object) {
|
||||
let mut next = s.prefs().clone();
|
||||
for (id, v) in m {
|
||||
if crate::commands::find(id).is_none() && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) {
|
||||
if !bindable().any(|(b, _)| b == id) && !p.get("allowUnknown").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return Err(bad(cmd, format!("unknown command `{id}`")));
|
||||
}
|
||||
next.set(&format!("shortcuts.{id}"), v.clone()).map_err(|e| bad(cmd, e))?;
|
||||
@@ -1279,9 +1401,9 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("removeConflicts").and_then(Value::as_bool).unwrap_or(true) {
|
||||
for (id, v) in m {
|
||||
let Some(sc) = v.as_str().and_then(normalize_shortcut) else { continue };
|
||||
for c in crate::command_specs() {
|
||||
if c.id != id && next.shortcut(c.id, c.shortcut).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) {
|
||||
next.shortcuts.insert(c.id.to_string(), String::new());
|
||||
for (c, def) in bindable() {
|
||||
if c != id && next.shortcut(c, def).and_then(normalize_shortcut).as_deref() == Some(sc.as_str()) {
|
||||
next.shortcuts.insert(c.to_string(), String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1290,13 +1412,22 @@ fn keyboard_shortcuts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let prefs = s.prefs();
|
||||
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
|
||||
let temporary = TEMPORARY_TOOLS.iter().map(|t| (t.0, t.1, &["Tools", "Temporary"][..], Some(t.2), true));
|
||||
let list: Vec<Value> = crate::command_specs()
|
||||
.iter()
|
||||
.filter(|c| filter.as_ref().is_none_or(|f| c.id.to_ascii_lowercase().contains(f) || c.label.to_ascii_lowercase().contains(f)))
|
||||
.filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.id))
|
||||
.map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "default": c.shortcut, "shortcut": prefs.shortcut(c.id, c.shortcut)}))
|
||||
.map(|c| (c.id, c.label, c.menu, c.shortcut, false))
|
||||
.chain(temporary)
|
||||
.filter(|c| filter.as_ref().is_none_or(|f| c.0.to_ascii_lowercase().contains(f) || c.1.to_ascii_lowercase().contains(f)))
|
||||
.filter(|c| p.get("list").and_then(Value::as_bool).unwrap_or(false) || filter.is_some() || prefs.shortcuts.contains_key(c.0))
|
||||
.map(|(id, label, menu, def, hold)| {
|
||||
let mut v = json!({"id": id, "label": label, "menu": menu, "default": def, "shortcut": prefs.shortcut(id, def)});
|
||||
if hold {
|
||||
v["hold"] = json!(true);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let bindings: Vec<(&str, &str)> = crate::command_specs().iter().filter_map(|c| Some((c.id, prefs.shortcut(c.id, c.shortcut)?))).collect();
|
||||
let bindings: Vec<(&str, &str)> = bindable().filter_map(|(id, def)| Some((id, prefs.shortcut(id, def)?))).collect();
|
||||
let conflicts: Vec<Value> = conflicts(bindings).into_iter().map(|(sc, ids)| json!({"shortcut": sc, "commands": ids})).collect();
|
||||
Ok(json!({"overrides": prefs.shortcuts, "commands": list, "conflicts": conflicts}))
|
||||
}
|
||||
@@ -1419,7 +1550,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Keyboard Shortcuts…",
|
||||
["Edit"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
r##"{"set":{"<command id>":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false}"##,
|
||||
r##"{"set":{"<command id>|tools.temporary.hand|zoomIn|zoomOut":"Cmd+Shift+X"|""(remove)|null(default)}?,"reset":true|["<id>",…]?,"removeConflicts":bool=true,"filter":str?,"list":bool=false}"##,
|
||||
keyboard_shortcuts,
|
||||
true
|
||||
),
|
||||
|
||||
@@ -143,6 +143,32 @@ fn keyboard_shortcuts_reassign_and_reset() {
|
||||
assert!(r["commands"].as_array().unwrap().iter().any(|c| c["id"] == "filter.blur.gaussianBlur"));
|
||||
}
|
||||
|
||||
/// #249: temporary tools (held keys) and the fill / colour keys are bindable like commands.
|
||||
#[test]
|
||||
fn temporary_tools_and_fill_keys_are_rebindable() {
|
||||
let mut s = session();
|
||||
let r = s.execute("edit.keyboardShortcuts", json!({"filter": "tools.temporary"})).unwrap();
|
||||
let ids: Vec<&str> = r["commands"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
|
||||
assert!(ids.contains(&"tools.temporary.hand") && ids.contains(&"tools.temporary.zoomIn") && ids.contains(&"tools.temporary.zoomOut"), "{ids:?}");
|
||||
assert!(r["commands"].as_array().unwrap().iter().all(|c| c["hold"] == true));
|
||||
// Rebind the temporary zoom; an unknown id still fails.
|
||||
s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.zoomIn": "Ctrl+Space"}})).unwrap();
|
||||
assert_eq!(s.prefs().shortcut("tools.temporary.zoomIn", Some("Cmd+Space")), Some("Ctrl+Space"));
|
||||
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.unknown": "Space"}})).is_err());
|
||||
// Taking a temporary tool's key for a command removes it from the temporary tool.
|
||||
s.execute("edit.keyboardShortcuts", json!({"set": {"edit.fillForeground": "Space"}})).unwrap();
|
||||
assert_eq!(s.prefs().shortcut("tools.temporary.hand", Some("Space")), None);
|
||||
assert_eq!(s.prefs().shortcut("edit.fillForeground", Some("Alt+Backspace")), Some("Space"));
|
||||
// D / X and the fill keys are listed with their Photoshop defaults.
|
||||
let r = s.execute("edit.keyboardShortcuts", json!({"list": true})).unwrap();
|
||||
let def = |id: &str| r["commands"].as_array().unwrap().iter().find(|c| c["id"] == id).map(|c| c["default"].clone());
|
||||
assert_eq!(def("tools.swapColors"), Some(json!("X")));
|
||||
assert_eq!(def("tools.defaultColors"), Some(json!("D")));
|
||||
assert_eq!(def("edit.fillBackground"), Some(json!("Cmd+Backspace")));
|
||||
// Bad values fail.
|
||||
assert!(s.execute("edit.keyboardShortcuts", json!({"set": {"tools.temporary.hand": 7}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menus_and_toolbar_customisation_persist() {
|
||||
let mut s = session();
|
||||
@@ -204,3 +230,29 @@ fn right_click_with_painting_tools_pref() {
|
||||
let old: Tools = serde_json::from_value(json!({"showTooltips": false})).unwrap();
|
||||
assert_eq!(old.right_click_with_painting_tools, RightClickPaint::BrushPicker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backend_round_trips_and_validates() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.prefs().performance.gpu_backend, GpuBackend::Auto);
|
||||
assert_eq!(s.execute("prefs.get", json!({"path": "performance.gpuBackend"})).unwrap(), json!("auto"));
|
||||
s.execute("prefs.set", json!({"path": "performance.gpuBackend", "value": "dx12"})).unwrap();
|
||||
assert_eq!(s.prefs().performance.gpu_backend, GpuBackend::Dx12);
|
||||
// Unknown names and wrong types are errors, and leave the value alone.
|
||||
assert!(s.execute("prefs.set", json!({"path": "performance.gpuBackend", "value": "directx"})).is_err());
|
||||
assert!(s.execute("prefs.set", json!({"path": "performance.gpuBackend", "value": 3})).is_err());
|
||||
assert_eq!(s.prefs().performance.gpu_backend, GpuBackend::Dx12);
|
||||
// Persisted and restored with the rest of the preferences.
|
||||
let text = s.prefs_to_json();
|
||||
let mut t = Session::new();
|
||||
t.load_prefs_json(&text).unwrap();
|
||||
assert_eq!(t.prefs().performance.gpu_backend, GpuBackend::Dx12);
|
||||
// Files from before the setting load as `auto`.
|
||||
let mut u = Session::new();
|
||||
u.load_prefs_json(r#"{"performance": {"useGpu": true}}"#).unwrap();
|
||||
assert_eq!(u.prefs().performance.gpu_backend, GpuBackend::Auto);
|
||||
assert_eq!(choices("performance.gpuBackend"), Some(GpuBackend::NAMES));
|
||||
for n in GpuBackend::NAMES {
|
||||
assert_eq!(GpuBackend::parse(n).map(GpuBackend::name), Some(*n));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -182,13 +183,7 @@ impl PresetBackend for DirBackend {
|
||||
let path = self.path(name)?;
|
||||
let dir = path.parent().ok_or_else(|| format!("{name}: no parent directory"))?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let leaf = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let tmp = dir.join(format!(".{leaf}.tmp"));
|
||||
std::fs::write(&tmp, bytes).map_err(|e| format!("{name}: {e}"))?;
|
||||
std::fs::rename(&tmp, &path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
format!("{name}: {e}")
|
||||
})
|
||||
photocraft_format::atomic_write(&path, bytes).map_err(|e| format!("{name}: {e}"))
|
||||
}
|
||||
fn remove(&self, name: &str) -> Result<(), String> {
|
||||
match std::fs::remove_file(self.path(name)?) {
|
||||
@@ -324,6 +319,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 +400,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 +514,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 +615,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 +707,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 +717,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");
|
||||
|
||||
@@ -122,6 +122,27 @@ impl GradientPreset {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Colour stops (live colours resolved now, sorted, opaque) and opacity stops (sorted; empty
|
||||
/// when fully opaque) kept apart, as an editable Gradient Fill layer holds them.
|
||||
pub fn fill_stops(&self, fg: [f32; 4], bg: [f32; 4]) -> FillStops {
|
||||
let rgb = |c: StopColor| match c {
|
||||
StopColor::Foreground => Color::rgb(fg[0], fg[1], fg[2]),
|
||||
StopColor::Background => Color::rgb(bg[0], bg[1], bg[2]),
|
||||
StopColor::Rgb(c) => Color::rgb(c[0], c[1], c[2]),
|
||||
};
|
||||
let mut cs: Vec<(f32, Color)> = self.stops.iter().map(|(t, c)| (t.clamp(0.0, 1.0), rgb(*c))).collect();
|
||||
cs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if cs.is_empty() {
|
||||
cs = vec![(0.0, rgb(StopColor::Foreground)), (1.0, rgb(StopColor::Background))];
|
||||
}
|
||||
let mut os: Vec<(f32, f32)> = self.opacity.iter().map(|(t, a)| (t.clamp(0.0, 1.0), a.clamp(0.0, 1.0))).collect();
|
||||
os.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
if os.iter().all(|o| (o.1 - 1.0).abs() < 1e-6) {
|
||||
os.clear();
|
||||
}
|
||||
(cs, os)
|
||||
}
|
||||
|
||||
/// Stops for a Gradient Fill layer / overlay (live colours resolved now, like Photoshop).
|
||||
pub fn doc_stops(&self, fg: [f32; 4], bg: [f32; 4]) -> Vec<(f32, Color)> {
|
||||
self.resolve(fg, bg).into_iter().map(|(t, c)| (t, Color::rgba(c[0], c[1], c[2], c[3]))).collect()
|
||||
@@ -307,6 +328,9 @@ pub fn builtin() -> Vec<Group<GradientPreset>> {
|
||||
/// RGBA stops `(location, colour)` ready to paint.
|
||||
pub type Stops = Vec<(f32, [f32; 4])>;
|
||||
|
||||
/// Colour stops and opacity stops of a Gradient Fill layer.
|
||||
pub type FillStops = (Vec<(f32, Color)>, Vec<(f32, f32)>);
|
||||
|
||||
const CMD_SELECT: &str = "gradient.presets.select";
|
||||
|
||||
/// Gradient for the Gradient tool: `"gradient": preset name`, else explicit `"stops"`, else the
|
||||
@@ -361,12 +385,14 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("applyToLayer").and_then(Value::as_bool).unwrap_or(true)
|
||||
&& let Some(id) = active_gradient_fill(s)
|
||||
{
|
||||
let stops_new = g.doc_stops(s.tools.foreground, s.tools.background);
|
||||
let (stops_new, opacity_new) = g.fill_stops(s.tools.foreground, s.tools.background);
|
||||
s.edit("Change Gradient Fill", |doc, _| {
|
||||
if let Some(l) = doc.layer_mut(id)
|
||||
&& let LayerContent::Fill(Fill::Gradient { stops, .. }) = &mut l.content
|
||||
&& let LayerContent::Fill(Fill::Gradient { stops, opacity_stops, midpoints, .. }) = &mut l.content
|
||||
{
|
||||
*stops = stops_new;
|
||||
*opacity_stops = opacity_new;
|
||||
midpoints.clear();
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -384,7 +410,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let style = crate::layer_style::gradient_style(p.get("style").and_then(Value::as_str).unwrap_or("linear"));
|
||||
let reverse = p.get("reverse").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.edit("New Gradient Fill Layer", |doc, active| {
|
||||
let fill = Fill::Gradient { stops, angle, scale, style, reverse };
|
||||
let fill = Fill::gradient(stops, angle, scale, style, reverse);
|
||||
let id = doc.insert_above(*active, Layer::new(doc.next_layer_name("Gradient Fill"), LayerContent::Fill(fill)));
|
||||
*active = Some(id);
|
||||
Ok(id)
|
||||
|
||||
@@ -22,7 +22,7 @@ fn parse_colour(v: &Value) -> Option<[f32; 4]> {
|
||||
Some([c(0, 0.0), c(1, 0.0), c(2, 0.0), c(3, 1.0)])
|
||||
}
|
||||
Value::String(h) if h.len() == 7 && h.starts_with('#') => {
|
||||
let ch = |j: usize| u8::from_str_radix(&h[j..j + 2], 16).map_or(0.0, |v| v as f32 / 255.0);
|
||||
let ch = |j: usize| h.get(j..j + 2).and_then(|s| u8::from_str_radix(s, 16).ok()).map_or(0.0, |v| v as f32 / 255.0);
|
||||
Some([ch(1), ch(3), ch(5), 1.0])
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//!
|
||||
//! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`,
|
||||
//! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and
|
||||
//! works at any depth (8/16/32f) and colour model. The pixel algorithms live in `photocraft-algo`
|
||||
//! works at any depth (8/16/32f) and colour model. Like the Brush, each paints the targeted surface:
|
||||
//! the layer's pixels, its mask (`"target":"mask"`), an alpha channel or the Quick Mask (`"target"`,
|
||||
//! filled in from the Channels panel when absent). The pixel algorithms live in `photocraft-algo`
|
||||
//! (`poisson`, `inpaint`, `retouch`) and the dab machinery in `photocraft-paint::retouch`; this module
|
||||
//! only parses parameters and wires them together.
|
||||
|
||||
@@ -25,10 +27,23 @@ fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush).
|
||||
fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> {
|
||||
if crate::channel_cmds::edits_channel(s) {
|
||||
return Ok(());
|
||||
}
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) }
|
||||
if matches!(l.content, LayerContent::Raster(_)) || l.mask.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke paints layer pixels (not a mask or channel), so other layers can be sampled.
|
||||
fn targets_pixels(p: &Value) -> bool {
|
||||
crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels
|
||||
}
|
||||
|
||||
fn num(p: &Value, k: &str, d: f32) -> f32 {
|
||||
@@ -45,8 +60,9 @@ fn point(p: &Value, k: &str) -> Option<(f64, f64)> {
|
||||
Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
/// Parse the shared brush params into a stroke on a layer.
|
||||
fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, LayerId)> {
|
||||
/// Parse the shared brush params into a stroke on a layer (`None` when an alpha channel or the
|
||||
/// Quick Mask is targeted).
|
||||
fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option<LayerId>)> {
|
||||
let pts: Vec<StrokePoint> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
@@ -71,24 +87,29 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, LayerId)> {
|
||||
erase: false,
|
||||
..base
|
||||
};
|
||||
if crate::channel_cmds::is_channel_target(p) {
|
||||
return Ok((Stroke { brush, points: pts }, None));
|
||||
}
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(id) => LayerId(id),
|
||||
None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?,
|
||||
};
|
||||
Ok((Stroke { brush, points: pts }, id))
|
||||
Ok((Stroke { brush, points: pts }, Some(id)))
|
||||
}
|
||||
|
||||
/// Run `f` on the target layer's surface inside one undoable step. `f` gets the pre-stroke document
|
||||
/// (a cheap copy-on-write snapshot), the surface, the selection and the transparency lock.
|
||||
fn run_stroke(s: &mut Session, label: &str, id: LayerId, f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result<Rect>) -> Result<Rect> {
|
||||
/// Run `f` on the targeted surface (layer pixels, layer mask, alpha channel or Quick Mask: the
|
||||
/// Brush's paint target) inside one undoable step. `f` gets the pre-stroke document (a cheap
|
||||
/// copy-on-write snapshot), the surface, the selection and the transparency lock.
|
||||
fn run_stroke(
|
||||
s: &mut Session,
|
||||
label: &str,
|
||||
id: Option<LayerId>,
|
||||
p: &Value,
|
||||
f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result<Rect>,
|
||||
) -> Result<Rect> {
|
||||
let dmg = s.edit(label, |doc, _| {
|
||||
let pre = doc.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
if l.locks.all || l.locks.pixels {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
let lock = l.locks.transparency;
|
||||
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?;
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
// No prune: retouching only rewrites pixels inside the stroke, and scanning every tile of a
|
||||
// large layer would dominate the cost of a dab.
|
||||
f(&pre, surf, pre.selection.as_ref(), lock)
|
||||
@@ -126,9 +147,11 @@ fn sample_layers(p: &Value, cmd: &str) -> Result<SampleLayers> {
|
||||
|
||||
/// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer
|
||||
/// and everything below it.
|
||||
fn composite_region(pre: &Document, id: LayerId, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region {
|
||||
fn composite_region(pre: &Document, id: Option<LayerId>, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region {
|
||||
let mut doc = pre.clone();
|
||||
if which == SampleLayers::CurrentAndBelow {
|
||||
if which == SampleLayers::CurrentAndBelow
|
||||
&& let Some(id) = id
|
||||
{
|
||||
// Hide everything composited after (above) the target in the bottom-to-top walk.
|
||||
let walk: Vec<(Vec<usize>, LayerId)> = pre.walk().into_iter().map(|(p, _, l)| (p, l.id)).collect();
|
||||
if let Some(pos) = walk.iter().position(|(_, l)| *l == id) {
|
||||
@@ -151,7 +174,7 @@ fn composite_region(pre: &Document, id: LayerId, which: SampleLayers, rect: Rect
|
||||
}
|
||||
|
||||
/// Pixels to sample at `rect` (document coordinates, already offset to the source position).
|
||||
fn sample(pre: &Document, id: LayerId, surf: &Surface, which: SampleLayers, rect: Rect) -> Region {
|
||||
fn sample(pre: &Document, id: Option<LayerId>, surf: &Surface, which: SampleLayers, rect: Rect) -> Region {
|
||||
match which {
|
||||
SampleLayers::Current => Region::read(surf, rect),
|
||||
_ => composite_region(pre, id, which, rect, surf.format()),
|
||||
@@ -167,7 +190,7 @@ fn clone_mapping(s: &mut Session, p: &Value, stroke: &Stroke, cmd: &str) -> Resu
|
||||
|
||||
/// Source pixels for destination `rect`: a translated read, or a bilinear resample when the
|
||||
/// clone source is scaled, rotated or flipped.
|
||||
fn clone_sample(pre: &Document, id: LayerId, surf: &Surface, which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping) -> Region {
|
||||
fn clone_sample(pre: &Document, id: Option<LayerId>, surf: &Surface, which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping) -> Region {
|
||||
if map.is_translation() {
|
||||
let off = map.offset();
|
||||
let mut r = sample(pre, id, surf, which, rect.translate(off.0, off.1));
|
||||
@@ -220,7 +243,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let aligned = flag(p, "aligned", true);
|
||||
let map = clone_mapping(s, p, &stroke, CMD)?;
|
||||
let off = map.offset();
|
||||
let dmg = run_stroke(s, "Clone Stamp", id, |pre, surf, sel, lock| {
|
||||
let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
|
||||
let (bounds, cov) = stroke_coverage(&stroke);
|
||||
// Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned.
|
||||
let paint = clone_sample(pre, id, surf, which, bounds, &map);
|
||||
@@ -275,7 +298,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let aligned = flag(p, "aligned", true);
|
||||
let map = clone_mapping(s, p, &stroke, CMD)?;
|
||||
let off = map.offset();
|
||||
let dmg = run_stroke(s, "Healing Brush", id, |pre, surf, sel, lock| {
|
||||
let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| {
|
||||
let (bounds, cov) = stroke_coverage(&stroke);
|
||||
let (g, cov) = pad_coverage(bounds, &cov, 2);
|
||||
let fmt = surf.format();
|
||||
@@ -302,12 +325,12 @@ fn history_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Default: the oldest state still held (the "Open" snapshot unless it was trimmed).
|
||||
None => st.history.state(0).unwrap_or_else(|| st.doc.clone()),
|
||||
};
|
||||
let src_surface = source_doc
|
||||
.layer(id)
|
||||
.and_then(|l| l.surface())
|
||||
.cloned()
|
||||
.ok_or_else(|| EngineError::Other("the layer did not exist (as a pixel layer) in that history state".into()))?;
|
||||
let dmg = run_stroke(s, "History Brush", id, |_, surf, sel, lock| {
|
||||
// The same target (pixels, mask, channel) as it was in that state.
|
||||
let mut source_doc = (*source_doc).clone();
|
||||
let src_surface = crate::channel_cmds::target_surface(&mut source_doc, id, p)
|
||||
.map(|(surf, _)| surf.clone())
|
||||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
|
||||
let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) };
|
||||
let (bounds, cov) = stroke_coverage(&stroke);
|
||||
@@ -422,7 +445,7 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"proximityMatch" => SpotType::ProximityMatch,
|
||||
o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))),
|
||||
};
|
||||
let dmg = run_stroke(s, "Spot Healing Brush", id, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre.bounds(), &stroke, kind, sel, lock)))?;
|
||||
let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre.bounds(), &stroke, kind, sel, lock)))?;
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
}
|
||||
|
||||
@@ -479,7 +502,7 @@ fn dodge_burn_cmd(s: &mut Session, p: &Value, burn: bool) -> Result<Value> {
|
||||
let exposure = num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0;
|
||||
let protect = flag(p, "protectTones", true);
|
||||
let label = if burn { "Burn Tool" } else { "Dodge Tool" };
|
||||
let dmg = run_stroke(s, label, id, |_, surf, sel, lock| {
|
||||
let dmg = run_stroke(s, label, id, p, |_, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let sp = stroke.brush.spacing;
|
||||
Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| color_dab(&fmt, work, fp, exposure, sp, |c, k| dodge_burn(c, k, range, burn, protect))))
|
||||
@@ -496,7 +519,7 @@ fn sponge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad(CMD, format!("unknown mode `{o}` (desaturate|saturate)"))),
|
||||
};
|
||||
let vibrance = flag(p, "vibrance", true);
|
||||
let dmg = run_stroke(s, "Sponge Tool", id, |_, surf, sel, lock| {
|
||||
let dmg = run_stroke(s, "Sponge Tool", id, p, |_, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let sp = stroke.brush.spacing;
|
||||
// Flow is already in each dab's coverage; a full-flow pass moves colours half-way.
|
||||
@@ -505,6 +528,45 @@ fn sponge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
}
|
||||
|
||||
/// "Sample All Layers" for Blur, Sharpen and Smudge (layer pixels only: a mask or channel has
|
||||
/// nothing to sample from other layers).
|
||||
fn sample_all_layers(p: &Value) -> bool {
|
||||
flag(p, "sampleAllLayers", false) && targets_pixels(p)
|
||||
}
|
||||
|
||||
/// Sample All Layers for the sequential-dab tools: each dab's effect runs on a copy of the visible
|
||||
/// composite (kept across dabs, so a dab sees the earlier ones), and the result is laid onto the
|
||||
/// layer under the dab. An empty layer above the image so picks up the blurred or smudged picture,
|
||||
/// and pixels the effect didn't change look as before (they are what was already visible).
|
||||
struct AllLayers<'a> {
|
||||
pre: &'a Document,
|
||||
fmt: PixelFormat,
|
||||
visible: Option<Region>,
|
||||
}
|
||||
|
||||
impl<'a> AllLayers<'a> {
|
||||
fn new(pre: &'a Document, fmt: PixelFormat) -> Self {
|
||||
Self { pre, fmt, visible: None }
|
||||
}
|
||||
|
||||
fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) {
|
||||
let (pre, fmt) = (self.pre, self.fmt);
|
||||
let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt));
|
||||
if visible.rect != work.rect || visible.ch != work.ch {
|
||||
return effect(work, fp);
|
||||
}
|
||||
effect(visible, fp);
|
||||
let r = fp.rect.intersect(&work.rect);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
if fp.at(x, y) > 0.0 {
|
||||
work.px_mut(x, y).copy_from_slice(visible.px(x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blur (`sharpen = false`) or Sharpen each dab's footprint in the working copy.
|
||||
fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result<Value> {
|
||||
let cmd = if sharpen { "paint.sharpen" } else { "paint.blur" };
|
||||
@@ -514,11 +576,13 @@ fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result<Value> {
|
||||
let sigma = (stroke.brush.size * 0.03).clamp(1.0, 4.0);
|
||||
let halo = (sigma * 3.0).ceil() as i32 + 1;
|
||||
let label = if sharpen { "Sharpen Tool" } else { "Blur Tool" };
|
||||
let dmg = run_stroke(s, label, id, |_, surf, sel, lock| {
|
||||
let all = sample_all_layers(p);
|
||||
let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let a = alpha_index(&fmt);
|
||||
let sp = stroke.brush.spacing;
|
||||
Ok(apply_dab_stroke(surf, &stroke, sel, lock, halo, |work, fp| {
|
||||
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
|
||||
let focus = |work: &mut Region, fp: &Footprint| {
|
||||
let r = fp.rect.intersect(&work.rect);
|
||||
if r.is_empty() {
|
||||
return;
|
||||
@@ -540,6 +604,10 @@ fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(apply_dab_stroke(surf, &stroke, sel, lock, halo, |work, fp| match &mut sampler {
|
||||
Some(all) => all.dab(work, fp, focus),
|
||||
None => focus(work, fp),
|
||||
}))
|
||||
})?;
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
@@ -551,12 +619,18 @@ fn smudge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0;
|
||||
let finger = flag(p, "fingerPainting", false);
|
||||
let fg = s.tools.foreground;
|
||||
let dmg = run_stroke(s, "Smudge Tool", id, |_, surf, sel, lock| {
|
||||
let all = sample_all_layers(p);
|
||||
let dmg = run_stroke(s, "Smudge Tool", id, p, |pre, surf, sel, lock| {
|
||||
let fmt = surf.format();
|
||||
let finger_px = finger.then(|| photocraft_raster::from_rgba(&fmt, fg));
|
||||
let max_size = stroke.brush.size;
|
||||
let mut sm = Smudge::new(strength, finger_px, max_size);
|
||||
Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| sm.dab(work, fp)))
|
||||
// Premultiplied mixing: transparent pixels carry no colour, so no dark fringes.
|
||||
let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt));
|
||||
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
|
||||
Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| match &mut sampler {
|
||||
Some(all) => all.dab(work, fp, |w, f| sm.dab(w, f)),
|
||||
None => sm.dab(work, fp),
|
||||
}))
|
||||
})?;
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
}
|
||||
@@ -568,7 +642,7 @@ fn smudge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The brush every retouching command shares, plus the tool's own params.
|
||||
macro_rules! brush_params {
|
||||
($extra:literal) => {
|
||||
concat!("{", r#""points":[[x,y,pressure?],…],"size":px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=25,"layer":id?=active"#, $extra, "}")
|
||||
concat!("{", r#""points":[[x,y,pressure?],…],"size":px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=25,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -644,7 +718,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Blur",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(r#","strength":1..100=50"#),
|
||||
params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#),
|
||||
enabled: has_pixel_layer,
|
||||
run: |s, p| focus_cmd(s, p, false),
|
||||
journal: true,
|
||||
@@ -654,7 +728,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Sharpen",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true"#),
|
||||
params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#),
|
||||
enabled: has_pixel_layer,
|
||||
run: |s, p| focus_cmd(s, p, true),
|
||||
journal: true,
|
||||
@@ -664,7 +738,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Smudge",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour)"#),
|
||||
params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#),
|
||||
enabled: has_pixel_layer,
|
||||
run: smudge_cmd,
|
||||
journal: true,
|
||||
|
||||
@@ -305,3 +305,236 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() {
|
||||
assert!(s.execute("paint.dodge", json!({"points": [[1, 1]], "range": "bogus"})).is_err());
|
||||
assert!(s.execute("paint.spotHealing", json!({"points": [[1, 1]], "type": "bogus"})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Paint target (#207): mask, alpha channel, Quick Mask
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
const RETOUCH_IDS: [&str; 10] = [
|
||||
"paint.cloneStamp",
|
||||
"paint.healingBrush",
|
||||
"paint.spotHealing",
|
||||
"paint.dodge",
|
||||
"paint.burn",
|
||||
"paint.sponge",
|
||||
"paint.blur",
|
||||
"paint.sharpen",
|
||||
"paint.smudge",
|
||||
"paint.historyBrush",
|
||||
];
|
||||
|
||||
/// Grey noise in 0.3..0.7 (no period along the clone offset used below).
|
||||
fn gray_noise(x: i32, y: i32) -> f32 {
|
||||
0.3 + ((x * 7 + y * 13).rem_euclid(10)) as f32 * 0.04
|
||||
}
|
||||
|
||||
fn texture_surface(surf: &mut Surface, b: Rect) {
|
||||
let mut data = Vec::new();
|
||||
for y in b.y0..b.y1 {
|
||||
for x in b.x0..b.x1 {
|
||||
data.push(gray_noise(x, y));
|
||||
}
|
||||
}
|
||||
surf.write_region(b, &data);
|
||||
}
|
||||
|
||||
fn active_layer(s: &Session) -> &photocraft_doc::Layer {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn read_all(surf: &Surface, b: Rect) -> Vec<f32> {
|
||||
surf.read_region(b)
|
||||
}
|
||||
|
||||
/// A textured pixel layer with a textured mask and a textured alpha channel. Returns the history
|
||||
/// index of the state before the mask and channel were textured (for the History Brush).
|
||||
fn targeted_session() -> (Session, usize) {
|
||||
let mut s = session(60, 40, 8, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint_layer(&mut s, texture);
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("channel.new", json!({"fill": "white"})).unwrap();
|
||||
let before = s.active().unwrap().history.past_len();
|
||||
s.edit("texture", |doc, active| {
|
||||
let b = doc.bounds();
|
||||
texture_surface(&mut doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface, b);
|
||||
texture_surface(&mut doc.channels[0].surface, b);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(s, before)
|
||||
}
|
||||
|
||||
fn retouch_params(id: &str, history_state: usize, target: Value) -> Value {
|
||||
let mut p = json!({"points": [[20, 20], [40, 20]], "size": 12, "hardness": 100, "strength": 100, "exposure": 100, "target": target});
|
||||
match id {
|
||||
"paint.cloneStamp" | "paint.healingBrush" => p["offset"] = json!([-13, 3]),
|
||||
"paint.historyBrush" => p["state"] = json!(history_state),
|
||||
"paint.sponge" => p["mode"] = json!("saturate"),
|
||||
_ => {}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Tools whose stroke must visibly change grey noise (Sponge has no colour to change on grey, and
|
||||
/// healing a noise texture with itself may land on the same values).
|
||||
fn changes_gray(id: &str) -> bool {
|
||||
!matches!(id, "paint.sponge" | "paint.healingBrush" | "paint.spotHealing")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_tools_paint_only_the_targeted_mask() {
|
||||
for id in RETOUCH_IDS {
|
||||
let (mut s, state) = targeted_session();
|
||||
let b = s.active().unwrap().doc.bounds();
|
||||
let px0 = read_all(active_layer(&s).surface().unwrap(), b);
|
||||
let mask0 = read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b);
|
||||
let ch0 = read_all(&s.active().unwrap().doc.channels[0].surface, b);
|
||||
s.execute(id, retouch_params(id, state, json!("mask"))).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "{id}: layer pixels untouched");
|
||||
assert_eq!(read_all(&s.active().unwrap().doc.channels[0].surface, b), ch0, "{id}: channel untouched");
|
||||
if changes_gray(id) {
|
||||
assert_ne!(read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b), mask0, "{id}: the mask changed");
|
||||
}
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b), mask0, "{id}: one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_tools_paint_only_the_targeted_alpha_channel() {
|
||||
for id in RETOUCH_IDS {
|
||||
let (mut s, state) = targeted_session();
|
||||
let b = s.active().unwrap().doc.bounds();
|
||||
let px0 = read_all(active_layer(&s).surface().unwrap(), b);
|
||||
let mask0 = read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b);
|
||||
let ch0 = read_all(&s.active().unwrap().doc.channels[0].surface, b);
|
||||
s.execute(id, retouch_params(id, state, json!({"channel": 0}))).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "{id}: layer pixels untouched");
|
||||
assert_eq!(read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b), mask0, "{id}: mask untouched");
|
||||
if changes_gray(id) {
|
||||
assert_ne!(read_all(&s.active().unwrap().doc.channels[0].surface, b), ch0, "{id}: the channel changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_tools_follow_the_channels_panel_target() {
|
||||
// No "target" param: the targeted alpha channel (Channels panel) is painted, like the Brush.
|
||||
for id in RETOUCH_IDS.into_iter().filter(|id| changes_gray(id)) {
|
||||
let (mut s, state) = targeted_session();
|
||||
let b = s.active().unwrap().doc.bounds();
|
||||
let px0 = read_all(active_layer(&s).surface().unwrap(), b);
|
||||
let ch0 = read_all(&s.active().unwrap().doc.channels[0].surface, b);
|
||||
s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::Alpha(0);
|
||||
let mut p = retouch_params(id, state, Value::Null);
|
||||
p.as_object_mut().unwrap().remove("target");
|
||||
assert!(s.is_enabled(id), "{id}");
|
||||
s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "{id}: layer pixels untouched");
|
||||
assert_ne!(read_all(&s.active().unwrap().doc.channels[0].surface, b), ch0, "{id}: the channel changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_tools_paint_the_quick_mask() {
|
||||
let (mut s, _) = targeted_session();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
let b = s.active().unwrap().doc.bounds();
|
||||
let px0 = read_all(active_layer(&s).surface().unwrap(), b);
|
||||
let q0 = read_all(&s.active().unwrap().doc.quick_mask.as_ref().unwrap().surface, b);
|
||||
s.execute("paint.smudge", json!({"points": [[20, 20], [45, 20]], "size": 12, "hardness": 100, "strength": 90})).unwrap();
|
||||
assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "layer pixels untouched");
|
||||
assert_ne!(read_all(&s.active().unwrap().doc.quick_mask.as_ref().unwrap().surface, b), q0, "the Quick Mask changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_targets_fail_gracefully() {
|
||||
let mut s = session(30, 30, 8, "rgb");
|
||||
for id in RETOUCH_IDS {
|
||||
let p = |t: Value| json!({"points": [[5, 5], [10, 5]], "size": 6, "state": 0, "offset": [1, 1], "target": t});
|
||||
assert!(s.execute(id, p(json!("mask"))).is_err(), "{id}: no mask");
|
||||
assert!(s.execute(id, p(json!({"channel": 7}))).is_err(), "{id}: no channel 7");
|
||||
assert!(s.execute(id, p(json!("quickMask"))).is_err(), "{id}: not in Quick Mask mode");
|
||||
let _ = s.execute(id, p(json!(42)));
|
||||
let _ = s.execute(id, p(json!({"channel": "x"})));
|
||||
let _ = s.execute(id, json!({"points": [[5, 5]], "sampleAllLayers": "yes"}));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Sample All Layers (#207)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// A Background with `bottom` and an empty layer above it (active).
|
||||
fn two_layers(bottom: impl Fn(i32, i32) -> [f32; 4]) -> Session {
|
||||
let mut s = session(100, 30, 8, "rgb");
|
||||
paint_layer(&mut s, bottom);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_all_layers_blur_and_sharpen_work_on_an_empty_layer() {
|
||||
let checker = |x: i32, y: i32| if (x + y) % 2 == 0 { [0.4, 0.4, 0.4, 1.0] } else { [0.6, 0.6, 0.6, 1.0] };
|
||||
for (id, extra) in [("paint.blur", json!({})), ("paint.sharpen", json!({"protectDetail": false}))] {
|
||||
let stroke = |all: bool| {
|
||||
let mut p = json!({"points": [[10, 15], [40, 15]], "size": 16, "hardness": 100, "strength": 100, "sampleAllLayers": all});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
// Off: the empty layer has nothing to blur.
|
||||
let mut s = two_layers(checker);
|
||||
s.execute(id, stroke(false)).unwrap();
|
||||
assert_eq!(rgba(&s, 25, 15)[3], 0.0, "{id}: without Sample All Layers the layer stays empty");
|
||||
// On: the layer picks up the blurred / sharpened picture.
|
||||
let mut s = two_layers(checker);
|
||||
s.execute(id, stroke(true)).unwrap();
|
||||
let (a, b) = (rgba(&s, 25, 15), rgba(&s, 26, 15));
|
||||
assert!(a[3] > 0.99, "{id}: {a:?}");
|
||||
let contrast = (a[0] - b[0]).abs();
|
||||
if id == "paint.blur" {
|
||||
assert!(contrast < 0.1, "{id}: blurred ({contrast})");
|
||||
} else {
|
||||
assert!(contrast > 0.25, "{id}: sharpened ({contrast})");
|
||||
}
|
||||
assert_eq!(rgba(&s, 80, 15)[3], 0.0, "{id}: outside the stroke untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_all_layers_smudge_drags_colour_from_below() {
|
||||
let edge = |x: i32, _: i32| if x < 30 { [1.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] };
|
||||
let stroke = |all: bool| json!({"points": [[20, 15], [70, 15]], "size": 12, "hardness": 100, "strength": 80, "sampleAllLayers": all});
|
||||
let mut s = two_layers(edge);
|
||||
s.execute("paint.smudge", stroke(false)).unwrap();
|
||||
assert_eq!(rgba(&s, 40, 15)[3], 0.0, "without Sample All Layers the layer stays empty");
|
||||
let mut s = two_layers(edge);
|
||||
s.execute("paint.smudge", stroke(true)).unwrap();
|
||||
let p = rgba(&s, 36, 15);
|
||||
assert!(p[3] > 0.99 && p[1] < 0.6, "red dragged onto the empty layer: {p:?}");
|
||||
// A mask target ignores Sample All Layers (nothing to sample from other layers).
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("paint.smudge", json!({"points": [[20, 25], [70, 25]], "size": 6, "sampleAllLayers": true, "target": "mask"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smudge_across_a_transparent_edge_has_no_dark_fringe() {
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(100, 30, depth, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint_layer(&mut s, |x, _| if x < 30 { [1.0, 0.8, 0.0, 1.0] } else { [0.0, 0.0, 0.0, 0.0] });
|
||||
s.execute("paint.smudge", json!({"points": [[20, 15], [70, 15]], "size": 12, "hardness": 50, "strength": 80})).unwrap();
|
||||
let mut n = 0;
|
||||
for x in 30..80 {
|
||||
let p = rgba(&s, x, 15);
|
||||
if p[3] > 0.02 {
|
||||
n += 1;
|
||||
assert!(p[0] > 0.97 && (p[1] - 0.8).abs() < 0.03, "depth {depth} x {x}: {p:?}");
|
||||
}
|
||||
}
|
||||
assert!(n > 5, "depth {depth}: colour dragged into the transparent area");
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,17 @@ fn color_range(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn modify(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
let r = f(p, "radius", 1.0).max(0.0);
|
||||
// The dialogs' ranges (Photoshop's limits); anything else is refused, never clamped silently.
|
||||
let max = match op {
|
||||
"border" => 200.0,
|
||||
"feather" => 1000.0,
|
||||
_ => 500.0,
|
||||
};
|
||||
let r = f(p, "radius", 1.0);
|
||||
if !r.is_finite() || r > max {
|
||||
return Err(EngineError::BadParams { cmd: format!("select.modify.{op}"), msg: format!("radius must be a number in 0..{max}") });
|
||||
}
|
||||
let r = r.max(0.0);
|
||||
let (area, m) = current_mask(&s.active().ok_or(EngineError::NoDocument)?.doc);
|
||||
let (w, h) = (area.width() as usize, area.height() as usize);
|
||||
let out = match op {
|
||||
@@ -512,6 +522,13 @@ mod tests {
|
||||
s.execute("select.modify.smooth", json!({"radius": 2})).unwrap();
|
||||
assert!(s.active().unwrap().doc.selection.is_some());
|
||||
assert!(!s.is_enabled("select.nothing"));
|
||||
// Out-of-range or non-finite radii are refused and leave the selection alone.
|
||||
for op in ["feather", "smooth", "expand", "contract", "border"] {
|
||||
for r in [json!(1e300), json!(5000), json!(f64::MAX)] {
|
||||
assert!(s.execute(&format!("select.modify.{op}"), json!({"radius": r})).is_err(), "{op} {r}");
|
||||
}
|
||||
}
|
||||
assert!(s.active().unwrap().doc.selection.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
//! Converting a layer embeds it as an in-memory `.pcraft` bundle of a nested document (exactly
|
||||
//! lossless, layered, any depth/model). PSD placed layers keep their source in the preserved
|
||||
//! global `lnk2` block (found by uuid), and keep Photoshop's rendering until something changes,
|
||||
//! so an unedited PSD still round-trips byte for byte.
|
||||
//! so an unedited PSD still round-trips byte for byte. PSD export (`photocraft_io::smart_map`)
|
||||
//! writes every smart object back as one: its source embedded in `lnk2` (a `.pcraft` source as a
|
||||
//! PSB), its smart filters in `filterFX` and its filter mask in `FEid`.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use photocraft_algo::resample::translate_surface;
|
||||
use photocraft_algo::transform::{Homography, Interp, warp_surface};
|
||||
use photocraft_color::{BlendMode, PixelFormat};
|
||||
use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, LayerMask, Metadata, SmartObject, SmartSource};
|
||||
use photocraft_geom::{Affine, Rect, Size};
|
||||
@@ -42,6 +43,9 @@ pub struct SmartLink {
|
||||
/// PSD placed-layer keys that describe the smart object's source; stale once we change it.
|
||||
const PLACED_KEYS: [&[u8; 4]; 3] = [b"SoLd", b"PlLd", b"SoLE"];
|
||||
|
||||
/// Command id of a Photoshop smart filter PhotoCraft does not implement (kept verbatim from PSD).
|
||||
pub use photocraft_io::smart_map::UNSUPPORTED_FILTER;
|
||||
|
||||
fn other(msg: impl Into<String>) -> EngineError {
|
||||
EngineError::Other(msg.into())
|
||||
}
|
||||
@@ -210,28 +214,6 @@ pub fn stack_image(file_name: &str, bytes: &[u8], fmt: PixelFormat, mode: photoc
|
||||
|
||||
// ---------- rendering ----------
|
||||
|
||||
/// Places the source image in the document: an exact shift for whole-pixel translations
|
||||
/// (conversion and re-render are then lossless), a bicubic warp otherwise.
|
||||
fn place(img: &SourceImage, t: &Affine) -> Surface {
|
||||
let [a, b, c, d, e, f] = t.m;
|
||||
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
|
||||
return translate_surface(&img.surface, e.round() as i32, f.round() as i32);
|
||||
}
|
||||
warp_surface(&img.surface, img.bounds, &Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// Places the source image through its warp (source space) and then its transform, in one
|
||||
/// resampling pass (Edit › Transform › Warp on a smart object stays lossless).
|
||||
fn place_warped(img: &SourceImage, w: &photocraft_geom::warp::Warp, t: &Affine) -> Surface {
|
||||
let [a, b, c, d, e, f] = t.m;
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
(a * u + c * v + e, b * u + d * v + f)
|
||||
};
|
||||
photocraft_algo::warp::warp_mesh_surface(&img.surface, img.bounds, &map, Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// `top` blended over `base` with `mode` at `opacity` (a smart filter's blending options).
|
||||
fn blend_surfaces(base: &Surface, top: &Surface, mode: BlendMode, opacity: f32) -> Surface {
|
||||
let fmt = base.format();
|
||||
@@ -313,10 +295,8 @@ pub fn render(doc: &Document, sm: &SmartObject) -> Result<Option<Surface>> {
|
||||
Some(mode) => stack_image(&name, &bytes, doc.pixel_format(), mode)?,
|
||||
None => source_image(&name, &bytes, doc.pixel_format())?,
|
||||
};
|
||||
let placed = match sm.warp.as_ref().filter(|w| !w.is_identity()) {
|
||||
Some(w) => place_warped(&img, w, &sm.transform),
|
||||
None => place(&img, &sm.transform),
|
||||
};
|
||||
// Through the warp (source space) and the transform in one pass; whole-pixel moves are exact.
|
||||
let placed = photocraft_algo::warp::place_source(&img.surface, img.bounds, &sm.transform, sm.warp.as_ref());
|
||||
Ok(Some(apply_smart_filters(&placed, sm, doc.bounds())))
|
||||
}
|
||||
|
||||
@@ -403,15 +383,15 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget PSD placed-layer data that no longer describes the smart object (PSD export then
|
||||
/// writes the rendered pixels).
|
||||
/// Forget PSD placed-layer data that no longer describes the smart object (its source changed):
|
||||
/// PSD export then writes fresh placed-layer blocks and embeds the new source.
|
||||
fn detach_psd(l: &mut Layer) {
|
||||
if let LayerContent::Smart(sm) = &mut l.content {
|
||||
sm.psd_raw = None;
|
||||
@@ -605,7 +585,7 @@ fn export_contents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (name, bytes) = source_bytes(&st.doc.metadata, &smart(&st.doc, id)?.source).ok_or_else(|| other("the smart object's contents are unavailable"))?;
|
||||
std::fs::write(path, &*bytes).map_err(|e| other(format!("can't write {path}: {e}")))?;
|
||||
crate::file_cmds::write_file(path, &bytes)?;
|
||||
Ok(json!({"path": path, "fileName": name, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -729,6 +709,9 @@ fn set_filter_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_filters(s, p, "Edit Smart Filter", |sm| {
|
||||
let i = filter_index(CMD, p, sm)?;
|
||||
let f = &mut sm.smart_filters[i];
|
||||
if f.command == photocraft_io::smart_map::UNSUPPORTED_FILTER {
|
||||
return Err(bad(CMD, "this Photoshop filter isn't implemented in PhotoCraft: it is kept as is (it can be hidden, moved or deleted)"));
|
||||
}
|
||||
match (&mut f.params, new) {
|
||||
(Value::Object(old), Value::Object(n)) => old.extend(n),
|
||||
(slot, n) => *slot = n,
|
||||
@@ -874,7 +857,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let path = path_param("layer.smartObjects.convertToLinked", p)?.to_string();
|
||||
set_source(s, p, "Convert to Linked", false, |meta, src| {
|
||||
let (_, bytes) = source_bytes(meta, src).ok_or_else(|| other("the smart object's contents are unavailable"))?;
|
||||
std::fs::write(&path, &*bytes).map_err(|e| other(format!("can't write {path}: {e}")))?;
|
||||
crate::file_cmds::write_file(&path, &bytes)?;
|
||||
Ok(SmartSource::Linked { path })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,18 @@ fn unknown_and_disabled_commands_error() {
|
||||
assert!(!s.is_enabled("edit.undo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_new_takes_whole_floats_and_survives_odd_sizes() {
|
||||
// #254: the New dialog sent `512.0`; it must not fall back to 1920 x 1080.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 512.0, "height": 511.6})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!((d.size.width, d.size.height), (512, 512));
|
||||
s.execute("file.new", json!({"width": -5.0, "height": "x"})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!((d.size.width, d.size.height), (1, 1080));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_new_variants() {
|
||||
let mut s = Session::new();
|
||||
@@ -126,6 +138,19 @@ fn paint_stroke_and_selection() {
|
||||
assert_eq!(px(&mut s, 50, 30), vec![0.0, 0.0, 0.0, 1.0], "foreground is black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_dragged_past_the_canvas_stops_at_its_edge() {
|
||||
let mut s = session_with_doc(); // 64 × 48
|
||||
let bounds = |s: &mut Session| s.execute("document.inspect", json!({})).unwrap()["selectionBounds"].clone();
|
||||
s.execute("select.rect", json!({"x": -20, "y": 10, "width": 200, "height": 100})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([0, 10, 64, 38]));
|
||||
s.execute("select.rect", json!({"x": -30, "y": -30, "width": 200, "height": 200, "ellipse": true})).unwrap();
|
||||
assert_eq!(bounds(&mut s), json!([0, 0, 64, 48]));
|
||||
// Entirely off the canvas: nothing is selected.
|
||||
s.execute("select.rect", json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(bounds(&mut s), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_modes() {
|
||||
let mut s = session_with_doc();
|
||||
|
||||
@@ -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,
|
||||
@@ -63,6 +65,101 @@ pub(crate) fn warp_gray(s: &Surface, h: &Homography, interp: Interp) -> Surface
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a single-channel surface whose outside reads as its default (a mask, a channel) by a
|
||||
/// selection: (the selected values as grey + alpha = selection, the surface with the selected
|
||||
/// values replaced by the default). `None` for surfaces that aren't a single grey channel.
|
||||
pub fn split_gray_selected(s: &Surface, sel: &Surface) -> Option<(Surface, Surface)> {
|
||||
let fmt = s.format();
|
||||
if fmt.alpha || fmt.channels() != 1 {
|
||||
return None;
|
||||
}
|
||||
let default = s.default_pixel().first().copied().unwrap_or(0.0);
|
||||
let mut rest = s.clone();
|
||||
let mut lifted = Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true));
|
||||
// Everything selected moves, including default-valued parts outside the painted content, so
|
||||
// the moved region stays aligned with the moved pixels.
|
||||
let area = sel.content_bounds();
|
||||
if area.is_empty() {
|
||||
return Some((lifted, rest));
|
||||
}
|
||||
let v = s.read_region(area);
|
||||
let w = area.width() as usize;
|
||||
let mut lp = Vec::with_capacity(v.len() * 2);
|
||||
let mut rp = Vec::with_capacity(v.len());
|
||||
for (i, g) in v.iter().enumerate() {
|
||||
let (x, y) = (area.x0 + (i % w) as i32, area.y0 + (i / w) as i32);
|
||||
let k = sel.sample_channel(x, y, 0);
|
||||
lp.extend([*g, k]);
|
||||
rp.push(g * (1.0 - k) + default * k);
|
||||
}
|
||||
lifted.write_region(area, &lp);
|
||||
lifted.prune();
|
||||
rest.write_region(area, &rp);
|
||||
rest.prune();
|
||||
Some((lifted, rest))
|
||||
}
|
||||
|
||||
/// [`warp_gray`] limited to a selection: only the selected values move (the vacated area reads as
|
||||
/// the default), as the selected pixels of the layer do.
|
||||
pub(crate) fn warp_gray_selected(s: &Surface, sel: &Surface, h: &Homography, interp: Interp) -> Surface {
|
||||
let Some((lifted, mut out)) = split_gray_selected(s, sel) else { return warp_gray(s, h, interp) };
|
||||
let src = lifted.content_bounds();
|
||||
if src.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let w = warp_surface(&lifted, src, h, interp);
|
||||
let b = w.content_bounds();
|
||||
if !b.is_empty() {
|
||||
let moved = w.read_region(b);
|
||||
let under = out.read_region(b);
|
||||
let flat: Vec<f32> = moved.as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect();
|
||||
out.write_region(b, &flat);
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
}
|
||||
|
||||
/// The surface Free Transform moves by itself when the params target one (`"target"`): an
|
||||
/// unlinked layer mask, an alpha channel or the Quick Mask. `None` means the layer, with its
|
||||
/// linked masks (a targeted *linked* mask moves together with its layer, as in Photoshop).
|
||||
pub fn lone_target<'a>(doc: &'a Document, layer: Option<LayerId>, p: &Value) -> Result<Option<&'a Surface>> {
|
||||
use crate::channel_cmds::Target;
|
||||
match crate::channel_cmds::target_of(p) {
|
||||
Target::Pixels => Ok(None),
|
||||
Target::Mask => {
|
||||
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?;
|
||||
Ok((!m.linked).then_some(&m.surface))
|
||||
}
|
||||
Target::Alpha(i) => doc
|
||||
.channels
|
||||
.get(i)
|
||||
.map(|c| Some(&c.surface))
|
||||
.ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len()))),
|
||||
Target::QuickMask => doc.quick_mask.as_ref().map(|c| Some(&c.surface)).ok_or_else(|| EngineError::Other("not in Quick Mask mode".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`lone_target`], mutably.
|
||||
pub fn lone_target_mut<'a>(doc: &'a mut Document, layer: Option<LayerId>, p: &Value) -> Result<Option<&'a mut Surface>> {
|
||||
if lone_target(doc, layer, p)?.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::channel_cmds::target_surface(doc, layer, p).map(|(s, _)| Some(s))
|
||||
}
|
||||
|
||||
/// The frame Free Transform starts from on a lone target: its painted content (the whole canvas
|
||||
/// when nothing is painted), within the selection.
|
||||
pub fn target_bounds(doc: &Document, surf: &Surface) -> Rect {
|
||||
let content = surf.content_bounds();
|
||||
let content = if content.is_empty() { doc.bounds() } else { content };
|
||||
match &doc.selection {
|
||||
Some(sel) => content.intersect(&sel.content_bounds()),
|
||||
None => content,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
|
||||
// Photoshop turns the Background into a normal layer before transforming it.
|
||||
if l.locks.position && l.name == "Background" {
|
||||
@@ -73,6 +170,9 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homo
|
||||
if l.locks.position || l.locks.all {
|
||||
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
|
||||
}
|
||||
// With a selection only the selected pixels move, and so only the same region of a linked
|
||||
// mask (#205). Groups, type, shapes and smart objects move whole.
|
||||
let mask_sel = doc_sel.filter(|_| !matches!(l.content, LayerContent::Group(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_)));
|
||||
match &mut l.content {
|
||||
LayerContent::Group(g) => {
|
||||
for c in g.children.iter_mut() {
|
||||
@@ -127,7 +227,10 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homo
|
||||
if let Some(m) = l.mask.as_mut()
|
||||
&& m.linked
|
||||
{
|
||||
m.surface = warp_gray(&m.surface, h, interp);
|
||||
m.surface = match mask_sel {
|
||||
Some(sel) => warp_gray_selected(&m.surface, sel, h, interp),
|
||||
None => warp_gray(&m.surface, h, interp),
|
||||
};
|
||||
}
|
||||
if let Some(vm) = l.vector_mask.as_mut()
|
||||
&& vm.linked
|
||||
@@ -217,17 +320,26 @@ fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> {
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = match p.get("layer").and_then(Value::as_u64) {
|
||||
Some(v) => LayerId(v),
|
||||
None => st.active_layer.ok_or(EngineError::Other("no active layer".into()))?,
|
||||
Some(v) => Some(LayerId(v)),
|
||||
None => st.active_layer,
|
||||
};
|
||||
let lone = lone_target(&st.doc, id, p)?;
|
||||
let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) {
|
||||
(_, true) => None,
|
||||
(Some(id), false) => Some(id),
|
||||
(None, false) => return Err(EngineError::Other("no active layer".into())),
|
||||
};
|
||||
let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let rect = match p.get("rect").and_then(Value::as_array) {
|
||||
Some(r) if r.len() == 4 => {
|
||||
let v: Vec<f64> = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
|
||||
[v[0], v[1], v[2], v[3]]
|
||||
}
|
||||
_ => {
|
||||
let b = transform_bounds(&st.doc, layer);
|
||||
let b = match (lone, id) {
|
||||
(Some(surf), _) => target_bounds(&st.doc, surf),
|
||||
(None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?),
|
||||
(None, None) => Rect::EMPTY,
|
||||
};
|
||||
if b.is_empty() {
|
||||
return Err(EngineError::Other("nothing to transform".into()));
|
||||
}
|
||||
@@ -253,8 +365,22 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let affine =
|
||||
(m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] });
|
||||
let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic"));
|
||||
let lone = lone.is_some();
|
||||
s.edit("Free Transform", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
if lone {
|
||||
// A targeted unlinked mask, alpha channel or Quick Mask transforms by itself.
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
*surf = match &sel {
|
||||
Some(sel) => warp_gray_selected(surf, sel, &h, interp),
|
||||
None => warp_gray(surf, &h, interp),
|
||||
};
|
||||
if let Some(sel) = &doc.selection {
|
||||
doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let is_group = doc.layer(id).is_some_and(Layer::is_group);
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
transform_layer(if is_group { None } else { sel.as_ref() }, l, &h, affine, interp)?;
|
||||
@@ -278,7 +404,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Free Transform",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"interpolation":"bicubic|bilinear|nearest"="bicubic"}"##,
|
||||
params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##,
|
||||
enabled: has_layer,
|
||||
journal: true,
|
||||
run: transform,
|
||||
@@ -393,4 +519,148 @@ mod tests {
|
||||
let b1 = active_bounds(&s);
|
||||
assert_eq!((b1.x0 - b0.x0, b1.y0 - b0.y0), (20, 10));
|
||||
}
|
||||
|
||||
// ---- Layer masks and targets (#205) ----
|
||||
|
||||
/// `session()` plus a reveal-all mask with `hide` painted black (linked unless `linked` is false).
|
||||
fn masked(hide: &[Rect], linked: bool) -> Session {
|
||||
let mut s = session();
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.edit("mask", |doc, active| {
|
||||
let m = doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap();
|
||||
for r in hide {
|
||||
m.surface.fill_rect(*r, &[0.0]);
|
||||
}
|
||||
m.linked = linked;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn mask_at(s: &Session, x: i32, y: i32) -> f32 {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0)
|
||||
}
|
||||
|
||||
fn alpha_at(s: &Session, x: i32, y: i32) -> f32 {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_raster_mask_moves_with_the_pixels() {
|
||||
let mut s = masked(&[Rect::new(10, 10, 20, 20)], true);
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50]})).unwrap();
|
||||
assert_eq!(active_bounds(&s), Rect::new(50, 60, 70, 70));
|
||||
assert!(mask_at(&s, 55, 65) < 0.01, "the hidden half moved with the pixels");
|
||||
assert!(mask_at(&s, 65, 65) > 0.99);
|
||||
assert!(mask_at(&s, 15, 15) > 0.99, "nothing hidden left behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_mask_with_a_selection_moves_only_the_selected_region() {
|
||||
// The selection covers the left half of the red rect; the mask hides its left quarter
|
||||
// (selected) and its right quarter (not selected).
|
||||
let mut s = masked(&[Rect::new(10, 10, 15, 20), Rect::new(25, 10, 30, 20)], true);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 50]})).unwrap();
|
||||
// Moved pixels and their mask line up.
|
||||
assert_eq!(alpha_at(&s, 12, 65), 1.0);
|
||||
assert!(mask_at(&s, 12, 65) < 0.01, "selected hidden quarter moved");
|
||||
assert!(mask_at(&s, 17, 65) > 0.99, "selected visible quarter moved");
|
||||
// The vacated area reads as revealed (default), with transparent pixels.
|
||||
assert_eq!(alpha_at(&s, 12, 15), 0.0);
|
||||
assert!(mask_at(&s, 12, 15) > 0.99);
|
||||
// The unselected part of the mask stays, aligned with the unselected pixels.
|
||||
assert_eq!(alpha_at(&s, 27, 15), 1.0);
|
||||
assert!(mask_at(&s, 27, 15) < 0.01, "unselected mask stays put");
|
||||
assert!(mask_at(&s, 27, 65) > 0.99, "and isn't copied along");
|
||||
// Every pixel's mask value is where it was relative to the pixel.
|
||||
for (x, y) in [(11, 61), (14, 69), (15, 61), (19, 69)] {
|
||||
let want = if x < 15 { 0.0 } else { 1.0 };
|
||||
assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinked_mask_stays_put() {
|
||||
let mut s = masked(&[Rect::new(10, 10, 20, 20)], false);
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50]})).unwrap();
|
||||
assert_eq!(active_bounds(&s), Rect::new(50, 60, 70, 70));
|
||||
assert!(mask_at(&s, 15, 15) < 0.01);
|
||||
assert!(mask_at(&s, 55, 65) > 0.99);
|
||||
// With a selection too.
|
||||
let mut s = masked(&[Rect::new(10, 10, 20, 20)], false);
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 50]})).unwrap();
|
||||
assert!(mask_at(&s, 15, 15) < 0.01 && mask_at(&s, 15, 65) > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_unlinked_mask_transforms_alone() {
|
||||
let mut s = masked(&[Rect::new(10, 10, 20, 20)], false);
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50], "target": "mask"})).unwrap();
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "pixels untouched");
|
||||
assert!(mask_at(&s, 55, 65) < 0.01, "mask moved");
|
||||
assert!(mask_at(&s, 15, 15) > 0.99, "vacated area reads as the default");
|
||||
s.undo();
|
||||
assert!(mask_at(&s, 15, 15) < 0.01, "one undo step");
|
||||
// With a selection, only its part of the mask.
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 10})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 50], "target": "mask"})).unwrap();
|
||||
assert!(mask_at(&s, 12, 65) < 0.01 && mask_at(&s, 12, 15) > 0.99);
|
||||
assert!(mask_at(&s, 17, 15) < 0.01, "unselected mask stays");
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "pixels untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_linked_mask_moves_with_its_layer() {
|
||||
// Photoshop: a linked mask can't move without its layer.
|
||||
let mut s = masked(&[Rect::new(10, 10, 20, 20)], true);
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50], "target": "mask"})).unwrap();
|
||||
assert_eq!(active_bounds(&s), Rect::new(50, 60, 70, 70));
|
||||
assert!(mask_at(&s, 55, 65) < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_alpha_channel_transforms_alone() {
|
||||
let mut s = session();
|
||||
s.execute("channel.new", json!({})).unwrap();
|
||||
s.edit("paint channel", |doc, _| {
|
||||
doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50], "target": {"channel": 0}})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let ch = &st.doc.channels[0].surface;
|
||||
assert!(ch.sample_channel(55, 65, 0) > 0.99 && ch.sample_channel(15, 15, 0) < 0.01);
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "layer untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_targets_fail_gracefully() {
|
||||
let mut s = session();
|
||||
let m = json!([1, 0, 0, 1, 5, 5]);
|
||||
assert!(s.execute("edit.transform", json!({"matrix": m, "target": "mask"})).is_err(), "no mask");
|
||||
assert!(s.execute("edit.transform", json!({"matrix": m, "target": {"channel": 9}})).is_err());
|
||||
assert!(s.execute("edit.transform", json!({"matrix": m, "target": "quickMask"})).is_err());
|
||||
// Unknown target shapes fall back to the pixels.
|
||||
s.execute("edit.transform", json!({"matrix": m, "target": 7})).unwrap();
|
||||
s.execute("edit.transform", json!({"matrix": m, "target": {"channel": "x"}})).unwrap();
|
||||
// Selections that miss the mask entirely transform nothing, without panicking.
|
||||
let mut s = masked(&[Rect::new(10, 10, 20, 20)], false);
|
||||
s.execute("select.rect", json!({"x": 80, "y": 80, "width": 5, "height": 5})).unwrap();
|
||||
let _ = s.execute("edit.transform", json!({"matrix": [1e9, 0, 0, 1e9, 0, 0], "target": "mask"}));
|
||||
let _ = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 1e12, 0], "target": "mask"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_gray_selected_rejects_non_gray() {
|
||||
let s = Surface::new(PixelFormat::RGBA8);
|
||||
let mut sel = Surface::new(PixelFormat::GRAY8);
|
||||
sel.fill_rect(Rect::new(0, 0, 4, 4), &[1.0]);
|
||||
assert!(split_gray_selected(&s, &sel).is_none());
|
||||
}
|
||||
}
|
||||
+211
-10
@@ -82,6 +82,24 @@ fn range_param(text: &str, p: &Value) -> (usize, usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's manual kerning range (1/1000 em).
|
||||
pub const KERN_MIN: f64 = -1000.0;
|
||||
pub const KERN_MAX: f64 = 10_000.0;
|
||||
|
||||
/// Validates the `kerning` key of character params: a number in [`KERN_MIN`]..=[`KERN_MAX`] or
|
||||
/// `"metrics"`, `"optical"`, `"off"` (`"none"`, `"0"`).
|
||||
pub fn check_kerning(p: &Value) -> std::result::Result<(), String> {
|
||||
match p.get("kerning") {
|
||||
None => Ok(()),
|
||||
Some(Value::Number(n)) => match n.as_f64() {
|
||||
Some(k) if (KERN_MIN..=KERN_MAX).contains(&k) => Ok(()),
|
||||
_ => Err(format!("kerning must be between {KERN_MIN} and {KERN_MAX} (1/1000 em)")),
|
||||
},
|
||||
Some(Value::String(v)) if matches!(v.to_ascii_lowercase().as_str(), "metrics" | "optical" | "off" | "none" | "0") => Ok(()),
|
||||
Some(_) => Err("kerning must be a number (1/1000 em) or \"metrics\", \"optical\" or \"off\"".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies character-style keys from JSON. Returns true if any key was present.
|
||||
pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
let mut any = false;
|
||||
@@ -157,13 +175,25 @@ pub fn apply_char_props(s: &mut CharStyle, p: &Value) -> bool {
|
||||
hit(true);
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("kerning").and_then(Value::as_str) {
|
||||
s.kerning = match v {
|
||||
"optical" => Kerning::Optical,
|
||||
"none" | "off" | "0" => Kerning::Off,
|
||||
_ => Kerning::Metrics,
|
||||
};
|
||||
hit(true);
|
||||
match p.get("kerning") {
|
||||
// A number is Photoshop's manual kerning: no automatic pair kerning plus the value.
|
||||
Some(v) if v.is_number() => {
|
||||
if let Some(k) = v.as_f64().filter(|k| k.is_finite()) {
|
||||
s.kerning = Kerning::Off;
|
||||
s.kern = k.clamp(KERN_MIN, KERN_MAX) as f32;
|
||||
hit(true);
|
||||
}
|
||||
}
|
||||
Some(Value::String(v)) => {
|
||||
s.kerning = match v.to_ascii_lowercase().as_str() {
|
||||
"optical" => Kerning::Optical,
|
||||
"none" | "off" | "0" => Kerning::Off,
|
||||
_ => Kerning::Metrics,
|
||||
};
|
||||
s.kern = 0.0;
|
||||
hit(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = p.get("caps").and_then(Value::as_str) {
|
||||
s.caps = match v {
|
||||
@@ -435,7 +465,7 @@ fn info(s: &Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":pt,"color":"#rrggbb"|[r,g,b,a],"tracking":1/1000em,"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":"metrics|optical|none","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
const CHAR_PARAMS: &str = r##""font":str,"fontStyle":str,"weight":100..900,"italic":bool,"size":pt,"color":"#rrggbb"|[r,g,b,a],"tracking":1/1000em,"leading":pt|"auto","baselineShift":pt,"horizontalScale":%,"verticalScale":%,"underline":bool,"strikethrough":bool,"fauxBold":bool,"fauxItalic":bool,"kerning":1/1000em (manual, after each character)|"metrics"|"optical"|"off","caps":"normal|small|all","ligatures":bool,"discretionaryLigatures":bool,"features":{"ss01":1},"variations":{"wght":650},"language":str"##;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -491,13 +521,58 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Edit Type",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?,"text":str? (replace all, styles kept),"replace":{"start":char,"end":char,"text":str}?,"runs":[{"start":char,"end":char,…character keys}]?,"box":[x,y,w,h]? (to paragraph text),"point":[x,y]? (to point text),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"antialias":"none|sharp|crisp|strong|smooth"?,"name":str?}"##,
|
||||
params: r##"{"layer":id?,"text":str? (replace all, styles kept),"replace":{"start":char,"end":char,"text":str}?,"runs":[{"start":char,"end":char,…character keys}]?,"box":[x,y,w,h]? (to paragraph text),"point":[x,y]? (to point text),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"antialias":"none|sharp|crisp|strong|smooth"?,"name":str?,"kerning":1/1000em|"metrics"|"optical"|"off"? (with "range":[startChar,endChar]?, default all),"kernPair":{"at":caretChar,"by":1/1000em}? (Photoshop Alt+←/→: the pair before the caret becomes manual, its current kerning + by)}"##,
|
||||
enabled: has_doc,
|
||||
journal: true,
|
||||
run: |s, p| {
|
||||
let id = layer_id(s, p)?;
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||||
with_text_layer(s, p, "Edit Type", |t, _| {
|
||||
check_kerning(p).map_err(|m| bad("type.edit", m))?;
|
||||
if let Some(Value::Array(runs)) = p.get("runs") {
|
||||
for r in runs {
|
||||
check_kerning(r).map_err(|m| bad("type.edit", m))?;
|
||||
}
|
||||
}
|
||||
let kern_pair = match p.get("kernPair") {
|
||||
None => None,
|
||||
Some(k) => {
|
||||
let at = k.get("at").and_then(Value::as_u64).ok_or_else(|| bad("type.edit", "kernPair.at must be a caret position (character index)"))?;
|
||||
let by = k
|
||||
.get("by")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite() && v.abs() <= KERN_MAX)
|
||||
.ok_or_else(|| bad("type.edit", "kernPair.by must be a number of 1/1000 em"))?;
|
||||
Some((at as usize, by))
|
||||
}
|
||||
};
|
||||
let label = if kern_pair.is_some() { "Kerning" } else { "Edit Type" };
|
||||
with_text_layer(s, p, label, |t, doc| {
|
||||
if let Some((at, by)) = kern_pair {
|
||||
// Photoshop's Alt+←/→: the pair before the caret becomes manually kerned,
|
||||
// starting from what it shows now (its metrics/optical or manual value).
|
||||
let n = t.text.chars().count();
|
||||
if at == 0 || at >= n {
|
||||
return Err(bad("type.edit", "kernPair needs a caret between two characters"));
|
||||
}
|
||||
let a = byte_at(&t.text, at - 1);
|
||||
let b = byte_at(&t.text, at);
|
||||
let now = {
|
||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||
eng.pair_kerning(t, doc.resolution_dpi, a)
|
||||
}
|
||||
.ok_or_else(|| bad("type.edit", "no kerning pair at the caret (line break or line end)"))?;
|
||||
let value = (f64::from(now.round()) + by).clamp(KERN_MIN, KERN_MAX) as f32;
|
||||
style_range(t, a, b, &|st| {
|
||||
st.kerning = Kerning::Off;
|
||||
st.kern = value;
|
||||
});
|
||||
}
|
||||
if p.get("kerning").is_some() {
|
||||
let (a, b) = range_param(&t.text, p);
|
||||
style_range(t, a, b, &|st| {
|
||||
apply_char_props(st, &json!({ "kerning": p.get("kerning") }));
|
||||
});
|
||||
}
|
||||
if let Some(v) = p.get("text").and_then(Value::as_str) {
|
||||
let n = t.text.len();
|
||||
replace_text(t, 0, n, v);
|
||||
@@ -569,6 +644,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
journal: true,
|
||||
run: |s, p| {
|
||||
let id = layer_id(s, p)?;
|
||||
check_kerning(p).map_err(|m| bad("type.setStyle", m))?;
|
||||
with_text_layer(s, p, "Set Type Style", |t, _| {
|
||||
let (a, b) = range_param(&t.text, p);
|
||||
let mut probe = CharStyle::default();
|
||||
@@ -793,4 +869,129 @@ mod tests {
|
||||
assert_eq!(t.runs[0].len, 3);
|
||||
assert_eq!(t.runs[0].style.size_pt, 40.0);
|
||||
}
|
||||
|
||||
fn kerning_of(s: &Session, id: u64) -> Vec<(Kerning, f32)> {
|
||||
let t = text_layer(s, id);
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
for r in t.char_runs() {
|
||||
out.extend(std::iter::repeat_n((r.style.kerning, r.style.kern), t.text[at..at + r.len].chars().count()));
|
||||
at += r.len;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// #206: `type.edit {"kerning"}` over a range takes a number (manual, 1/1000 em) or a mode.
|
||||
#[test]
|
||||
fn edit_sets_kerning_over_a_range() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "AVAT", "size": 30})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("type.edit", json!({"layer": id, "range": [0, 1], "kerning": 100})).unwrap();
|
||||
s.execute("type.edit", json!({"layer": id, "range": [2, 4], "kerning": "optical"})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id), vec![(Kerning::Off, 100.0), (Kerning::Metrics, 0.0), (Kerning::Optical, 0.0), (Kerning::Optical, 0.0)]);
|
||||
s.execute("type.edit", json!({"layer": id, "kerning": "metrics"})).unwrap();
|
||||
assert!(kerning_of(&s, id).iter().all(|k| *k == (Kerning::Metrics, 0.0)));
|
||||
// Runs and setStyle take the same values; type.info reports them.
|
||||
s.execute("type.edit", json!({"layer": id, "runs": [{"start": 1, "end": 2, "kerning": -50}]})).unwrap();
|
||||
s.execute("type.setStyle", json!({"layer": id, "range": [3, 4], "kerning": "off"})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id)[1], (Kerning::Off, -50.0));
|
||||
assert_eq!(kerning_of(&s, id)[3], (Kerning::Off, 0.0));
|
||||
let info = s.execute("type.info", json!({"layer": id})).unwrap();
|
||||
assert_eq!(info["runs"][1]["style"]["kern"], json!(-50.0));
|
||||
}
|
||||
|
||||
/// Bad kerning params are errors, never panics, and leave the layer alone.
|
||||
#[test]
|
||||
fn kerning_params_fail_gracefully() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "AV\nT", "size": 30})).unwrap()["layer"].as_u64().unwrap();
|
||||
let before = text_layer(&s, id);
|
||||
for p in [
|
||||
json!({"kerning": "tight"}),
|
||||
json!({"kerning": 1e9}),
|
||||
json!({"kerning": -5000}),
|
||||
json!({"kerning": true}),
|
||||
json!({"kerning": [1]}),
|
||||
json!({"runs": [{"start": 0, "end": 1, "kerning": "x"}]}),
|
||||
json!({"kernPair": 3}),
|
||||
json!({"kernPair": {"by": 20}}),
|
||||
json!({"kernPair": {"at": 1}}),
|
||||
json!({"kernPair": {"at": 1, "by": "20"}}),
|
||||
json!({"kernPair": {"at": 1, "by": 1e12}}),
|
||||
json!({"kernPair": {"at": 0, "by": 20}}),
|
||||
json!({"kernPair": {"at": 4, "by": 20}}),
|
||||
json!({"kernPair": {"at": 99, "by": 20}}),
|
||||
json!({"kernPair": {"at": 2, "by": 20}}),
|
||||
json!({"kernPair": {"at": 3, "by": 20}}),
|
||||
json!({"kernPair": {"at": -1, "by": 20}}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["layer"] = json!(id);
|
||||
assert!(s.execute("type.edit", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("type.setStyle", json!({"layer": id, "kerning": "tight"})).is_err());
|
||||
assert_eq!(text_layer(&s, id).runs, before.runs);
|
||||
}
|
||||
|
||||
/// Cost of one Alt+←/→ press (`kernPair`: two measuring layouts + the edit's re-render) on
|
||||
/// a headline and on a 2000-character paragraph. Release timings for the dev log:
|
||||
/// `cargo test --release -p photocraft-engine kern_pair_cost -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn kern_pair_cost() {
|
||||
let para = "The quick brown fox jumps over the lazy dog. ".repeat(45);
|
||||
for (name, text, size, bx, kerning) in [
|
||||
("headline", "AVATAR Wave", 72, false, "metrics"),
|
||||
("paragraph", para.as_str(), 12, true, "metrics"),
|
||||
("optical paragraph", para.as_str(), 12, true, "optical"),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 2000, "height": 1500})).unwrap();
|
||||
let mut p = json!({"x": 20, "y": 100, "text": text, "size": size, "font": "Inter", "kerning": kerning});
|
||||
if bx {
|
||||
p["box"] = json!([20, 20, 900, 1400]);
|
||||
}
|
||||
let id = s.execute("type.create", p).unwrap()["layer"].as_u64().unwrap();
|
||||
let n = 20;
|
||||
let t0 = std::time::Instant::now();
|
||||
for i in 0..n {
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1 + i % 3, "by": 20}})).unwrap();
|
||||
}
|
||||
let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
|
||||
let t1 = std::time::Instant::now();
|
||||
for i in 0..n {
|
||||
s.execute("type.setStyle", json!({"layer": id, "range": [1 + i % 3, 2 + i % 3], "tracking": i})).unwrap();
|
||||
}
|
||||
let base = t1.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
|
||||
eprintln!("{name} ({} chars): kernPair {per:.2} ms/press; a plain style edit {base:.2} ms", text.chars().count());
|
||||
}
|
||||
}
|
||||
|
||||
/// Photoshop's Alt+←/→: the pair before the caret becomes manual, starting from the kerning
|
||||
/// it shows (the font's pair kerning for "AV"), ±20 per press, one history step each.
|
||||
#[test]
|
||||
fn kern_pair_steps_from_the_shown_kerning() {
|
||||
let mut s = session();
|
||||
let id = s.execute("type.create", json!({"x": 5, "y": 50, "text": "AVA", "size": 40, "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let metric = {
|
||||
let t = text_layer(&s, id);
|
||||
photocraft_text::shared().lock().unwrap().pair_kerning(&t, 72.0, 0).unwrap()
|
||||
};
|
||||
assert!(metric < -10.0, "Inter kerns AV: {metric}");
|
||||
let steps = s.active().unwrap().history.entries().len();
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1, "by": 20}})).unwrap();
|
||||
let k0 = kerning_of(&s, id)[0];
|
||||
assert_eq!(k0, (Kerning::Off, metric.round() + 20.0));
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1, "by": 100}})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id)[0].1, metric.round() + 120.0);
|
||||
s.execute("type.edit", json!({"layer": id, "kernPair": {"at": 1, "by": -20}})).unwrap();
|
||||
assert_eq!(kerning_of(&s, id)[0].1, metric.round() + 100.0);
|
||||
// Other characters are untouched; each press is its own undoable step.
|
||||
assert_eq!(&kerning_of(&s, id)[1..], &[(Kerning::Metrics, 0.0), (Kerning::Metrics, 0.0)]);
|
||||
assert_eq!(s.active().unwrap().history.entries().len(), steps + 3);
|
||||
assert!(s.undo());
|
||||
assert_eq!(kerning_of(&s, id)[0].1, metric.round() + 120.0);
|
||||
assert!(s.undo() && s.undo());
|
||||
assert_eq!(kerning_of(&s, id)[0], (Kerning::Metrics, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -128,8 +128,7 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result<Value>
|
||||
t.orientation = o;
|
||||
Ok(())
|
||||
})?;
|
||||
// Vertical layout is stored and saved (PSD `Ornt`), but rendered horizontally for now.
|
||||
Ok(json!({"orientation": if o == Orientation::Vertical { "vertical" } else { "horizontal" }, "rendered": "horizontal"}))
|
||||
Ok(json!({"orientation": if o == Orientation::Vertical { "vertical" } else { "horizontal" }}))
|
||||
}
|
||||
|
||||
// ---------- point ⇄ paragraph ----------
|
||||
@@ -137,10 +136,26 @@ fn set_orientation(s: &mut Session, p: &Value, o: Orientation) -> Result<Value>
|
||||
fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = with_text(s, p, "Convert to Paragraph Text", |t, doc| {
|
||||
let l = layout(doc, t);
|
||||
let [x0, y0, x1, y1] = l.bounds().unwrap_or([0.0, -t.size_pt, t.size_pt, 0.0]);
|
||||
let size_px = t.char_runs().iter().map(|r| r.style.size_pt).fold(t.size_pt, f32::max) * doc.resolution_dpi / 72.0;
|
||||
let ps = t.paragraph_runs().first().map(|p| p.style.clone()).unwrap_or_default();
|
||||
let indents = (ps.start_indent_pt + ps.end_indent_pt).max(0.0) * doc.resolution_dpi / 72.0;
|
||||
if l.vertical {
|
||||
// Line space: u along the columns, v across them (first column centred on v = 0).
|
||||
let [u0, v0, u1, v1] = l.line_bounds().unwrap_or([0.0, -size_px / 2.0, size_px, size_px / 2.0]);
|
||||
let height = (u1 - u0).max(1.0) + indents + (size_px * 0.5).max(4.0);
|
||||
let width = (v1 - v0).max(1.0) + size_px * 0.5;
|
||||
let y = match ps.align {
|
||||
TextAlign::Center | TextAlign::JustifyCenter => -height / 2.0,
|
||||
TextAlign::Right | TextAlign::JustifyRight => -height,
|
||||
_ => 0.0,
|
||||
};
|
||||
// The box's right edge is the first column's right edge, so the columns stay put.
|
||||
let x = -v0 - width;
|
||||
t.shape = TextShape::Box { x: 0.0, y: 0.0, width, height };
|
||||
t.transform = then_translate(&t.transform, f64::from(x), f64::from(y));
|
||||
return Ok(json!([x, y, width, height]));
|
||||
}
|
||||
let [x0, y0, x1, y1] = l.bounds().unwrap_or([0.0, -t.size_pt, t.size_pt, 0.0]);
|
||||
// A little slack so no line re-wraps; the box keeps the text where it was.
|
||||
let width = (x1 - x0).max(1.0) + indents + (size_px * 0.5).max(4.0);
|
||||
let height = (y1 - y0).max(1.0) + size_px * 0.5;
|
||||
@@ -164,8 +179,10 @@ fn to_paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = with_text(s, p, "Convert to Point Text", |t, doc| {
|
||||
let TextShape::Box { x, width, .. } = t.shape else { return Err(EngineError::Other("already point text".into())) };
|
||||
let TextShape::Box { x, y, width, height } = t.shape else { return Err(EngineError::Other("already point text".into())) };
|
||||
let l = layout(doc, t);
|
||||
// Vertical type: columns run along the box height.
|
||||
let (x, width) = if l.vertical { (y, height) } else { (x, width) };
|
||||
let baseline = l.lines.first().map_or(0.0, |ln| ln.baseline);
|
||||
// Photoshop inserts a hard return at every soft line break.
|
||||
let mut breaks: Vec<usize> = l.lines.windows(2).filter(|w| w[0].paragraph == w[1].paragraph).map(|w| w[1].range.start).collect();
|
||||
@@ -181,8 +198,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => x,
|
||||
};
|
||||
t.shape = TextShape::Point;
|
||||
// `baseline` is in text space (it already includes the box top).
|
||||
t.transform = then_translate(&t.transform, f64::from(ax), f64::from(baseline));
|
||||
// `baseline` is in line space (it already includes the box edge).
|
||||
let (tx, ty) = l.to_text(ax, baseline);
|
||||
t.transform = then_translate(&t.transform, f64::from(tx), f64::from(ty));
|
||||
Ok(breaks.len())
|
||||
})?;
|
||||
Ok(json!({"inserted": r}))
|
||||
|
||||
@@ -73,6 +73,73 @@ fn point_and_paragraph_conversion_keep_the_text_in_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #199: vertical type is laid out top to bottom, columns right to left, on the canvas.
|
||||
#[test]
|
||||
fn vertical_orientation_renders_columns_inside_the_canvas() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 1000, "height": 600, "background": "#fff8e7", "name": "flyer"})).unwrap();
|
||||
// The issue's repro (its font may be missing here: fallback fonts then cover the text).
|
||||
let r = s.execute("type.create", json!({"x": 900, "y": 30, "text": "縦書きテスト", "size": 40, "font": "Noto Sans CJK JP", "name": "vertical"})).unwrap();
|
||||
let id = LayerId(r["layer"].as_u64().unwrap());
|
||||
let flat = ink(&s, id);
|
||||
assert!(flat.width() > flat.height(), "horizontal first: {flat:?}");
|
||||
let v = s.execute("type.orientation.vertical", json!({})).unwrap();
|
||||
assert_eq!(v, json!({"orientation": "vertical"}));
|
||||
let b = ink(&s, id);
|
||||
assert!(b.height() > b.width(), "{b:?}");
|
||||
assert!(b.x0 >= 0 && b.x1 <= 1000 && b.y0 >= 0 && b.y1 <= 600, "inside the canvas: {b:?}");
|
||||
// Centred on the anchor's x, running down from its y.
|
||||
assert!(b.x0 < 900 && b.x1 > 900 && b.y0 >= 25, "{b:?}");
|
||||
// Latin text: a second column of a two-line layer sits left of the first.
|
||||
let r = s.execute("type.create", json!({"x": 500, "y": 30, "text": "ABC\nDEF", "size": 40})).unwrap();
|
||||
let two = LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("type.orientation.vertical", json!({"layer": two.0})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let l = layout(&st.doc, text(&s, two));
|
||||
assert!(l.vertical && l.lines.len() == 2);
|
||||
let [x0, y0, x1, y1] = l.bounds().unwrap();
|
||||
assert!(x1 > 0.0 && x0 < -40.0 && y0 >= -1e-3 && y1 > 60.0, "{:?}", l.bounds());
|
||||
// Bad layer ids fail cleanly.
|
||||
assert!(s.execute("type.orientation.vertical", json!({"layer": 9999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_point_and_paragraph_conversion_keep_the_columns_in_place() {
|
||||
for align in ["left", "center", "right"] {
|
||||
let mut s = session(8);
|
||||
s.execute("image.canvasSize", json!({"width": 300, "height": 400})).ok();
|
||||
let r = s.execute("type.create", json!({"x": 150, "y": 100, "text": "Hello world", "size": 30, "align": align})).unwrap();
|
||||
let id = LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("type.orientation.vertical", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let before = layout(&st.doc, text(&s, id)).bounds().unwrap();
|
||||
let before_tf = text(&s, id).transform;
|
||||
let map = |t: &photocraft_geom::Affine, b: [f32; 4]| {
|
||||
let p = t.apply(photocraft_geom::Point::new(f64::from(b[0]), f64::from(b[1])));
|
||||
(p.x, p.y)
|
||||
};
|
||||
let doc_before = map(&before_tf, before);
|
||||
s.execute("type.convertToParagraphText", json!({})).unwrap();
|
||||
let TextShape::Box { height, .. } = text(&s, id).shape else { panic!("box expected") };
|
||||
let st = s.active().unwrap();
|
||||
let l = layout(&st.doc, text(&s, id));
|
||||
assert_eq!(l.lines.len(), 1, "{align}: no new wraps");
|
||||
assert!(height > 0.0);
|
||||
// The column's first glyph stays where it was (glyph positions in document space).
|
||||
let g_after = l.glyphs[0];
|
||||
let tf = text(&s, id).transform;
|
||||
let p_after = tf.apply(photocraft_geom::Point::new(f64::from(g_after.x), f64::from(g_after.y)));
|
||||
s.execute("type.convertToPointText", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let back = layout(&st.doc, text(&s, id));
|
||||
let g_back = back.glyphs[0];
|
||||
let p_back = text(&s, id).transform.apply(photocraft_geom::Point::new(f64::from(g_back.x), f64::from(g_back.y)));
|
||||
assert!((p_after.x - p_back.x).abs() < 1.5 && (p_after.y - p_back.y).abs() < 1.5, "{align}: {p_after:?} → {p_back:?}");
|
||||
let back_doc = map(&text(&s, id).transform, back.bounds().unwrap());
|
||||
assert!((back_doc.0 - doc_before.0).abs() < 1.5 && (back_doc.1 - doc_before.1).abs() < 1.5, "{align}: {doc_before:?} → {back_doc:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_to_point_hardens_soft_wraps() {
|
||||
let mut s = session(8);
|
||||
|
||||
@@ -99,6 +99,7 @@ fn perturbed_char() -> CharStyle {
|
||||
faux_bold: true,
|
||||
faux_italic: true,
|
||||
kerning: Kerning::Off,
|
||||
kern: -12345.0,
|
||||
caps: Caps::AllCaps,
|
||||
ligatures: false,
|
||||
discretionary_ligatures: true,
|
||||
|
||||
@@ -353,7 +353,7 @@ fn export_as_files(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = format!("{dir}/{stem}.{format}");
|
||||
let opts = photocraft_io::ExportOptions::default();
|
||||
let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("export `{}`: {e}", set.name)))?;
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("write `{path}`: {e}")))?;
|
||||
crate::file_cmds::write_file(&path, &bytes)?;
|
||||
files.push(path);
|
||||
}
|
||||
Ok(json!({"files": files, "count": files.len()}))
|
||||
|
||||
@@ -119,6 +119,7 @@ fn op_from(s: &str) -> Option<PathOp> {
|
||||
"subtract" | "minus" => PathOp::Subtract,
|
||||
"intersect" => PathOp::Intersect,
|
||||
"exclude" | "xor" => PathOp::Exclude,
|
||||
"join" => PathOp::Join,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -129,6 +130,7 @@ fn op_name(op: PathOp) -> &'static str {
|
||||
PathOp::Subtract => "subtract",
|
||||
PathOp::Intersect => "intersect",
|
||||
PathOp::Exclude => "exclude",
|
||||
PathOp::Join => "join",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +187,7 @@ pub fn path_json(p: &Path) -> Value {
|
||||
fn fill_json(f: &Fill) -> Value {
|
||||
match f {
|
||||
Fill::Solid(c) => json!(hex(c)),
|
||||
Fill::Gradient { stops, angle, scale, style, reverse } => json!({ "gradient": {
|
||||
Fill::Gradient { stops, angle, scale, style, reverse, .. } => json!({ "gradient": {
|
||||
"stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::<Vec<_>>(),
|
||||
"angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse } }),
|
||||
Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }),
|
||||
@@ -214,13 +216,13 @@ fn parse_fill(v: &Value) -> std::result::Result<Option<Fill>, String> {
|
||||
"diamond" => GradientStyle::Diamond,
|
||||
_ => GradientStyle::Linear,
|
||||
};
|
||||
return Ok(Some(Fill::Gradient {
|
||||
return Ok(Some(Fill::gradient(
|
||||
stops,
|
||||
angle: f64p(g, "angle").unwrap_or(90.0) as f32,
|
||||
scale: f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0,
|
||||
f64p(g, "angle").unwrap_or(90.0) as f32,
|
||||
f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0,
|
||||
style,
|
||||
reverse: g.get("reverse").and_then(Value::as_bool).unwrap_or(false),
|
||||
}));
|
||||
g.get("reverse").and_then(Value::as_bool).unwrap_or(false),
|
||||
)));
|
||||
}
|
||||
if let Some(name) = v.get("pattern").and_then(Value::as_str) {
|
||||
return Ok(Some(Fill::Pattern {
|
||||
@@ -857,11 +859,18 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let lines = vector::flatten_path(&path, 0.1);
|
||||
let id = layer_id(s, p)?;
|
||||
let bg = s.tools.background;
|
||||
let dmg = s.edit("Stroke Path", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
|
||||
let lock = l.locks.transparency;
|
||||
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Stroke Path needs a pixel layer".into()))?;
|
||||
let mut brush = brush.clone();
|
||||
if brush.erase && lock {
|
||||
// Locked transparency (e.g. the Background): the Eraser paints the background colour (#76).
|
||||
brush.erase = false;
|
||||
brush.color = bg;
|
||||
}
|
||||
let mut dmg = Rect::EMPTY;
|
||||
for pl in &lines {
|
||||
let mut pts: Vec<photocraft_paint::StrokePoint> = pl.pts.iter().map(|&(x, y)| photocraft_paint::StrokePoint::new(x, y, 1.0)).collect();
|
||||
|
||||
@@ -326,7 +326,7 @@ fn render_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let gif = photocraft_codecs::web::encode_gif_animated(w as u32, h as u32, &gframes, true).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let path = format!("{dir}/{stem}.gif");
|
||||
std::fs::write(&path, &gif).map_err(|e| EngineError::Other(format!("write `{path}`: {e}")))?;
|
||||
crate::file_cmds::write_file(&path, &gif)?;
|
||||
return Ok(json!({"frames": frames, "file": path}));
|
||||
}
|
||||
|
||||
@@ -341,7 +341,7 @@ fn render_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sync(&mut doc);
|
||||
let path = format!("{dir}/{stem}_{f:04}.{format}");
|
||||
let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("render frame {f}: {e}")))?;
|
||||
std::fs::write(&path, &bytes).map_err(|e| EngineError::Other(format!("write `{path}`: {e}")))?;
|
||||
crate::file_cmds::write_file(&path, &bytes)?;
|
||||
files.push(path);
|
||||
}
|
||||
Ok(json!({"frames": files.len(), "dir": dir}))
|
||||
|
||||
@@ -92,6 +92,30 @@ fn eraser_on_transparency_locked_layer_paints_background_colour() {
|
||||
assert_eq!(layer_px(&s, 20, 25)[3], 0.0, "transparent pixels stay transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_path_with_the_eraser_paints_background_colour_on_locked_layers() {
|
||||
let path = json!({"path": {"subpaths": [{"knots": [[5, 10], [35, 10]]}]}});
|
||||
// The Background: background colour, still opaque.
|
||||
let mut s = doc("rgb", 8);
|
||||
s.execute("path.set", path.clone()).unwrap();
|
||||
s.execute("path.stroke", json!({"tool": "eraser", "size": 6, "hardness": 1.0, "opacity": 100})).unwrap();
|
||||
assert!(close(layer_px(&s, 20, 10), [1.0, 0.0, 0.0, 1.0]), "{:?}", layer_px(&s, 20, 10));
|
||||
// A transparency-locked layer: the same, and transparent pixels stay transparent.
|
||||
let mut s = doc("rgb", 16);
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[2, 10], [38, 10]], "size": 12, "hardness": 1.0, "color": "#00ff00"})).unwrap();
|
||||
s.execute("layer.lockLayers", json!({"transparency": true})).unwrap();
|
||||
s.execute("path.set", path.clone()).unwrap();
|
||||
s.execute("path.stroke", json!({"tool": "eraser", "size": 6, "hardness": 1.0, "opacity": 100})).unwrap();
|
||||
assert!(close(layer_px(&s, 20, 10), [1.0, 0.0, 0.0, 1.0]), "{:?}", layer_px(&s, 20, 10));
|
||||
assert_eq!(layer_px(&s, 20, 25)[3], 0.0);
|
||||
// Unlocked: erased to transparency.
|
||||
s.execute("layer.lockLayers", json!({"transparency": false})).unwrap();
|
||||
s.execute("paint.stroke", json!({"points": [[2, 10], [38, 10]], "size": 12, "hardness": 1.0, "color": "#00ff00"})).unwrap();
|
||||
s.execute("path.stroke", json!({"tool": "eraser", "size": 6, "hardness": 1.0, "opacity": 100})).unwrap();
|
||||
assert_eq!(layer_px(&s, 20, 10)[3], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_bad_params_fail_gracefully() {
|
||||
let mut s = doc("rgb", 8);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Re-render oracle over the Photoshop corpus (`corpus/photoshop`): our own Photoshop-authored
|
||||
//! PSDs from https://github.com/storytold/photocraft-corpus, fetched at a pinned commit by
|
||||
//! `cargo xtask corpus --all`. Feature `corpus` (run with `cargo xtask test-corpus`); a missing
|
||||
//! corpus fails.
|
||||
//!
|
||||
//! The io corpus test (`crates/io/tests/corpus.rs`) flattens imported documents, which composite
|
||||
//! Photoshop's own cached pixels for smart objects and type layers. This test throws those caches
|
||||
//! away: every smart object is re-rendered from its embedded source through our smart-filter
|
||||
//! stack, and every type layer is re-laid-out and re-rasterised by our text engine; the flatten is
|
||||
//! then compared with Photoshop's merged composite. It is the failure map for smart filters and
|
||||
//! the text engine. Results are printed per file and per feature group; the pass count must not
|
||||
//! fall below the floor (raise it as features land, never lower it). Type re-renders only match
|
||||
//! with the corpus fonts installed (see the corpus README), so the floor counts on none of them.
|
||||
#![cfg(feature = "corpus")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use photocraft_doc::{Document, LayerContent};
|
||||
use photocraft_psd::PsdFile;
|
||||
|
||||
/// Within two 8-bit steps of Photoshop (same tolerance as the io corpus).
|
||||
const PASS_TOL: f32 = 2.0 / 255.0;
|
||||
/// Re-rendered files that match Photoshop's merged composite (of 86 with smart objects or type).
|
||||
const PASS_FLOOR: usize = 5;
|
||||
|
||||
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
collect(&p, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("psd")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied max difference over pixels visible in either image (1.0 if sizes differ).
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f32, f32) {
|
||||
if a.len() != b.len() {
|
||||
return (1.0, 100.0);
|
||||
}
|
||||
let (mut m, mut bad) = (0.0f32, 0usize);
|
||||
for (p, q) in a.iter().zip(b) {
|
||||
let mut pm = 0.0f32;
|
||||
for c in 0..4 {
|
||||
let (x, y) = if c < 3 { (p[c] * p[3], q[c] * q[3]) } else { (p[c], q[c]) };
|
||||
pm = pm.max((x - y).abs());
|
||||
}
|
||||
if pm > PASS_TOL {
|
||||
bad += 1;
|
||||
}
|
||||
m = m.max(pm);
|
||||
}
|
||||
(m, 100.0 * bad as f32 / a.len().max(1) as f32)
|
||||
}
|
||||
|
||||
/// Re-renders every smart object and type layer of `doc`; returns how many were re-rendered.
|
||||
fn rerender(doc: &mut Document) -> Result<usize, String> {
|
||||
let snapshot = doc.clone();
|
||||
let ids: Vec<_> = snapshot.walk().into_iter().map(|(_, _, l)| l.id).collect();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let Some(l) = doc.layer_mut(id) else { continue };
|
||||
match &mut l.content {
|
||||
LayerContent::Smart(_) => {
|
||||
if photocraft_engine::smart_cmds::refresh_layer(&snapshot, l).map_err(|e| e.to_string())? {
|
||||
n += 1;
|
||||
} else {
|
||||
return Err("smart object source unavailable".into());
|
||||
}
|
||||
}
|
||||
LayerContent::Text(t) => {
|
||||
photocraft_engine::type_cmds::refresh(&snapshot, t);
|
||||
n += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photoshop_oracles_rerendered() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop");
|
||||
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --all`", root.display());
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
files.sort();
|
||||
let mut groups: std::collections::BTreeMap<String, (usize, usize)> = Default::default();
|
||||
let (mut pass, mut total, mut errors) = (0, 0, 0);
|
||||
eprintln!("{:<60} {:>7} {:>9} {:>8} status", "file (re-rendered)", "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 Ok(file) = PsdFile::from_bytes(&bytes) else { continue };
|
||||
let Ok(imp) = photocraft_io::import(&name, &bytes) else {
|
||||
eprintln!("{name:<60} IMPORT-ERROR");
|
||||
errors += 1;
|
||||
continue;
|
||||
};
|
||||
let mut doc = imp.document;
|
||||
let n = match rerender(&mut doc) {
|
||||
Ok(0) => continue,
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
eprintln!("{name:<60} RERENDER-ERROR {e}");
|
||||
errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(merged) = photocraft_io::merged_composite(&file) else { continue };
|
||||
let ours = photocraft_compose::flatten(&doc).px;
|
||||
let (m, bad) = max_diff(&ours, &merged);
|
||||
let group = name.split(['/', '\\']).next().unwrap_or(".").to_string();
|
||||
let g = groups.entry(group).or_default();
|
||||
g.1 += 1;
|
||||
total += 1;
|
||||
let status = if m <= PASS_TOL {
|
||||
pass += 1;
|
||||
g.0 += 1;
|
||||
"PASS"
|
||||
} else {
|
||||
"DIFF"
|
||||
};
|
||||
eprintln!("{name:<60} {n:>7} {m:>9.4} {bad:>7.2}% {status}");
|
||||
}
|
||||
eprintln!("photoshop re-render: {total} files with smart objects or type: {pass} pass (<= 2/255), {errors} errors");
|
||||
for (g, (p, n)) in &groups {
|
||||
eprintln!("photoshop re-render: {g:<16} {p:>4} / {n:<4} pass");
|
||||
}
|
||||
assert!(pass >= PASS_FLOOR, "re-render oracle pass count {pass} fell below the floor {PASS_FLOOR}");
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Every preference does something, or the Preferences dialog hides it (issue #204).
|
||||
//!
|
||||
//! Walks every key of `Preferences::default()` serialised to JSON (`section.key`) and checks
|
||||
//! the workspace sources: each one is either read outside `prefs.rs` or listed in
|
||||
//! `prefs::HIDDEN_UNTIL_IMPLEMENTED`, never both. A setting that appears in the dialog but that
|
||||
//! nothing reads is a control that silently does nothing.
|
||||
//!
|
||||
//! "Read" is a source scan (no type information), so it is deliberately simple:
|
||||
//! - a member access `.<snake_key>` in a non-test source file that also names the section
|
||||
//! (`<snake_section>`), e.g. `p.file_handling.recent_files` or `let fh = &p.file_handling;
|
||||
//! … fh.autosave`;
|
||||
//! - or the JSON path as a string literal (`"fileHandling.autosave"`), for code that reads
|
||||
//! preferences by path;
|
||||
//! - or `self.<snake_key>` in an inherent `impl` of the section's struct in `prefs.rs` (helpers
|
||||
//! such as `TransparencyAndGamut::colors`).
|
||||
|
||||
use photocraft_engine::prefs::{HIDDEN_UNTIL_IMPLEMENTED, Preferences};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn snake(camel: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in camel.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
out.push(c.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_ident(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// `needle` occurs in `text` as a whole identifier (after `prefix`, e.g. "." or "self.").
|
||||
fn has_ident(text: &str, prefix: &str, ident: &str) -> bool {
|
||||
let needle = format!("{prefix}{ident}");
|
||||
text.match_indices(&needle).any(|(i, _)| {
|
||||
let before = text[..i].chars().next_back();
|
||||
let after = text[i + needle.len()..].chars().next();
|
||||
(!prefix.is_empty() || before.is_none_or(|c| !is_ident(c))) && after.is_none_or(|c| !is_ident(c))
|
||||
})
|
||||
}
|
||||
|
||||
/// Production source of a file: everything before its unit-test module.
|
||||
fn production(text: &str) -> &str {
|
||||
let cut = text.find("#[cfg(test)]\nmod ").or_else(|| text.find("#[cfg(test)]\npub mod ")).unwrap_or(text.len());
|
||||
&text[..cut]
|
||||
}
|
||||
|
||||
fn rust_sources(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if p.is_dir() {
|
||||
if !matches!(name.as_str(), "tests" | "examples" | "benches" | "target") {
|
||||
rust_sources(&p, out);
|
||||
}
|
||||
} else if name.ends_with(".rs") && name != "tests.rs" && !name.ends_with("_tests.rs") {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `pub struct Name { … }` field names, and the bodies of `impl Name { … }` blocks, in prefs.rs.
|
||||
fn struct_fields(src: &str, name: &str) -> Vec<String> {
|
||||
let Some(start) = src.find(&format!("pub struct {name} {{")) else { return Vec::new() };
|
||||
let body = &src[start..];
|
||||
let end = body.find("\n}").unwrap_or(body.len());
|
||||
body[..end].lines().filter_map(|l| l.trim().strip_prefix("pub ")?.split_once(':').map(|(f, _)| f.trim().to_string())).collect()
|
||||
}
|
||||
|
||||
fn inherent_impls(src: &str, name: &str) -> String {
|
||||
let marker = format!("\nimpl {name} {{");
|
||||
src.match_indices(&marker)
|
||||
.map(|(i, _)| {
|
||||
let body = &src[i + 1..];
|
||||
body[..body.find("\n}").unwrap_or(body.len())].to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preference_is_read_or_hidden() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let prefs_rs = std::fs::read_to_string(root.join("crates/engine/src/prefs.rs")).unwrap();
|
||||
let mut files = Vec::new();
|
||||
rust_sources(&root.join("crates"), &mut files);
|
||||
rust_sources(&root.join("apps"), &mut files);
|
||||
let sources: Vec<String> = files
|
||||
.iter()
|
||||
.filter(|p| !p.ends_with("engine/src/prefs.rs"))
|
||||
.filter_map(|p| std::fs::read_to_string(p).ok())
|
||||
.map(|t| production(&t).to_string())
|
||||
.collect();
|
||||
assert!(sources.len() > 100, "found only {} source files under {}", sources.len(), root.display());
|
||||
|
||||
// Section key → struct name, from `pub <field>: <Type>,` in `struct Preferences`.
|
||||
let section_types: Vec<(String, String)> = {
|
||||
let start = prefs_rs.find("pub struct Preferences {").unwrap();
|
||||
let body = &prefs_rs[start..];
|
||||
body[..body.find("\n}").unwrap()]
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let (f, ty) = l.trim().strip_prefix("pub ")?.split_once(':')?;
|
||||
Some((f.trim().to_string(), ty.trim().trim_end_matches(',').to_string()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(Preferences::default()).unwrap();
|
||||
let mut paths = Vec::new();
|
||||
for (section, v) in json.as_object().unwrap() {
|
||||
match v.as_object() {
|
||||
Some(o) if !o.is_empty() => paths.extend(o.keys().map(|k| (section.clone(), Some(k.clone())))),
|
||||
_ => paths.push((section.clone(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
let mut unread = Vec::new();
|
||||
let mut stale = Vec::new();
|
||||
for (section, key) in &paths {
|
||||
let section_rs = if section == "type" { "type_".to_string() } else { snake(section) };
|
||||
let (path, read) = match key {
|
||||
None => (section.clone(), sources.iter().any(|t| has_ident(t, ".", §ion_rs))),
|
||||
Some(k) => {
|
||||
let field = snake(k);
|
||||
let path = format!("{section}.{k}");
|
||||
let ty = section_types.iter().find(|(f, _)| *f == section_rs).map(|(_, t)| t.clone()).unwrap_or_default();
|
||||
let in_helpers =
|
||||
!ty.is_empty() && struct_fields(&prefs_rs, &ty).contains(&field) && has_ident(&inherent_impls(&prefs_rs, &ty), "self.", &field);
|
||||
let read =
|
||||
in_helpers || sources.iter().any(|t| (has_ident(t, ".", &field) && has_ident(t, "", §ion_rs)) || t.contains(&format!("\"{path}\"")));
|
||||
(path, read)
|
||||
}
|
||||
};
|
||||
let hidden = HIDDEN_UNTIL_IMPLEMENTED.contains(&path.as_str());
|
||||
if !read && !hidden {
|
||||
unread.push(path);
|
||||
} else if read && hidden {
|
||||
stale.push(path);
|
||||
}
|
||||
}
|
||||
for h in HIDDEN_UNTIL_IMPLEMENTED {
|
||||
assert!(
|
||||
paths.iter().any(|(s, k)| k.as_ref().map(|k| format!("{s}.{k}")).as_deref() == Some(*h)),
|
||||
"HIDDEN_UNTIL_IMPLEMENTED lists `{h}`, which is not a preference"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
unread.is_empty(),
|
||||
"these preferences are shown in Edit › Preferences but nothing reads them: wire them up or add them to prefs::HIDDEN_UNTIL_IMPLEMENTED: {unread:#?}"
|
||||
);
|
||||
assert!(stale.is_empty(), "these preferences are read now: remove them from prefs::HIDDEN_UNTIL_IMPLEMENTED so the dialog shows them: {stale:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scanner_finds_reads() {
|
||||
assert!(has_ident("p.file_handling.recent_files.clone()", ".", "recent_files"));
|
||||
assert!(!has_ident("p.file_handling.recent_files_x", ".", "recent_files"));
|
||||
assert!(has_ident("let fh = &p.file_handling;", "", "file_handling"));
|
||||
assert!(!has_ident("my_file_handling", "", "file_handling"));
|
||||
assert_eq!(snake("recentFileCount"), "recent_file_count");
|
||||
assert_eq!(production("fn a() {}\n#[cfg(test)]\nmod tests {\n p.x.y\n}"), "fn a() {}\n");
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Smart objects and smart filters made in PhotoCraft survive a PSD save and open (#215): the
|
||||
//! layer comes back as a live smart object (not pixels) with the same nested document (embedded
|
||||
//! in `lnk2` as a PSB), transform, filter list and filter mask, and re-renders like before.
|
||||
//!
|
||||
//! Set `PHOTOCRAFT_SMART_PSD_OUT=<dir>` to keep the exported files (for opening in Photoshop).
|
||||
|
||||
use photocraft_doc::{Document, LayerContent, SmartObject, SmartSource};
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_engine::smart_cmds::{decode_source, refresh_layer, source_bytes};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
const W: i32 = 96;
|
||||
const H: i32 = 64;
|
||||
|
||||
fn session(depth: u64) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("paint", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let r = Rect::new(8, 6, 80, 58);
|
||||
let mut data = Vec::new();
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let v = ((x * 7 + y * 13) % 23) as f32 / 22.0;
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, [v, 1.0 - v, ((x * y) % 7) as f32 / 6.0, 1.0]));
|
||||
}
|
||||
}
|
||||
surf.write_region(r, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn smart(doc: &Document) -> (u64, SmartObject) {
|
||||
doc.walk()
|
||||
.into_iter()
|
||||
.find_map(|(_, _, l)| match &l.content {
|
||||
LayerContent::Smart(sm) => Some((l.id.0, sm.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a smart object")
|
||||
}
|
||||
|
||||
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
|
||||
assert_eq!(a.len(), b.len());
|
||||
a.iter().zip(b).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3].max(0.0) - q[c] * q[3].max(0.0)).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Every block with inner structure re-parses strictly (`TaggedBlock::check_structure`).
|
||||
fn structure_errors(bytes: &[u8]) -> Vec<String> {
|
||||
let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap();
|
||||
let mut errs = Vec::new();
|
||||
for b in &f.global_blocks {
|
||||
if let Err(e) = b.check_structure() {
|
||||
errs.push(format!("global {}: {e}", b.key_str()));
|
||||
}
|
||||
}
|
||||
for l in f.layers() {
|
||||
for b in &l.blocks {
|
||||
if let Err(e) = b.check_structure() {
|
||||
errs.push(format!("layer {}: {e}", b.key_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
errs
|
||||
}
|
||||
|
||||
fn keep(name: &str, bytes: &[u8]) {
|
||||
if let Ok(dir) = std::env::var("PHOTOCRAFT_SMART_PSD_OUT") {
|
||||
std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_objects_and_filters_survive_psd() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(depth);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 64})).unwrap();
|
||||
s.execute("filter.noise.addNoise", json!({"amount": 20, "seed": 3, "distribution": "gaussian"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("layer.smartFilter.blendingOptions", json!({"opacity": 0.5, "blend": "screen"})).unwrap();
|
||||
s.execute("filter.stylize.emboss", json!({"angle": 120, "height": 2, "amount": 80})).unwrap();
|
||||
s.execute("layer.smartFilter.setVisible", json!({"index": 2, "visible": false})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap().0;
|
||||
s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let (_, before) = smart(&doc);
|
||||
let SmartSource::Embedded { bytes: pcraft, .. } = &before.source else { panic!("embedded") };
|
||||
let nested = decode_source("x.pcraft", pcraft).unwrap();
|
||||
|
||||
let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w.contains("pixels")), "{:?}", out.warnings);
|
||||
assert_eq!(structure_errors(&out.bytes), Vec::<String>::new());
|
||||
keep(&format!("smart-{depth}.psd"), &out.bytes);
|
||||
|
||||
let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document;
|
||||
let (bid, after) = smart(&back);
|
||||
assert!(matches!(after.source, SmartSource::Linked { .. }), "depth {depth}: source in lnk2");
|
||||
// Same filters, blending and settings (numbers may come back as floats, defaults spelled out).
|
||||
assert_eq!(after.smart_filters.len(), before.smart_filters.len());
|
||||
for (a, b) in after.smart_filters.iter().zip(&before.smart_filters) {
|
||||
assert_eq!((&a.command, a.blend, a.opacity, a.visible), (&b.command, b.blend, b.opacity, b.visible), "depth {depth}");
|
||||
for (k, v) in b.params.as_object().unwrap() {
|
||||
let w = &a.params[k];
|
||||
assert!(w == v || w.as_f64().zip(v.as_f64()).is_some_and(|(x, y)| x == y), "{}: {k} {v} came back as {w}", b.command);
|
||||
}
|
||||
}
|
||||
assert_eq!(after.filters_enabled, before.filters_enabled);
|
||||
// The filter mask covers the same pixels (PSD extends it with white past its bounds).
|
||||
let (ma, mb) = (after.filter_mask.as_ref().unwrap(), before.filter_mask.as_ref().unwrap());
|
||||
assert_eq!((ma.enabled, ma.linked), (mb.enabled, mb.linked));
|
||||
let area = doc.bounds().union(&before.cache.as_ref().unwrap().content_bounds());
|
||||
let (mut va, mut vb) = (Vec::new(), Vec::new());
|
||||
ma.values_into(area, &mut va);
|
||||
mb.values_into(area, &mut vb);
|
||||
assert_eq!(va, vb, "depth {depth}: filter mask");
|
||||
for (a, b) in after.transform.m.iter().zip(before.transform.m) {
|
||||
assert!((a - b).abs() < 1e-9, "depth {depth}: transform {:?} vs {:?}", after.transform, before.transform);
|
||||
}
|
||||
// The nested document came back (as the embedded PSB), layer for layer.
|
||||
let (name, src) = source_bytes(&back.metadata, &after.source).unwrap();
|
||||
assert!(name.ends_with(".psb"), "{name}");
|
||||
let inner = decode_source(&name, &src).unwrap();
|
||||
assert_eq!((inner.size, inner.layers.len(), inner.depth), (nested.size, nested.layers.len(), nested.depth));
|
||||
assert!(max_diff(&photocraft_compose::flatten(&inner).px, &photocraft_compose::flatten(&nested).px) <= 1.0 / 255.0 + 1e-6);
|
||||
// And it re-renders from that source like the original did.
|
||||
let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone();
|
||||
assert!(refresh_layer(&back, &mut l).unwrap());
|
||||
let mut rerendered = back.clone();
|
||||
*rerendered.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l;
|
||||
let d = max_diff(&photocraft_compose::flatten(&rerendered).px, &photocraft_compose::flatten(&doc).px);
|
||||
assert!(d <= 2.0 / 255.0, "depth {depth}: re-render differs by {d}");
|
||||
|
||||
// Saving the re-imported file again keeps the same embedded file (no growth).
|
||||
let again = photocraft_io::export(&back, "psd", &Default::default()).unwrap();
|
||||
assert_eq!(structure_errors(&again.bytes), Vec::<String>::new());
|
||||
assert!(again.bytes.len() <= out.bytes.len() + 64, "{} vs {}", again.bytes.len(), out.bytes.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// Every colour model: the smart object, its filters and the filter cache's unfiltered pixels
|
||||
/// (in the document's model, CMYK inverted and 16-bit Lab scaled as in layer channels) survive.
|
||||
#[test]
|
||||
fn smart_filters_survive_psd_in_every_colour_model() {
|
||||
for (mode, depth) in [("cmyk", 8), ("cmyk", 16), ("grayscale", 8), ("lab", 8), ("lab", 16)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 32, "height": 24, "mode": mode, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 20, "height": 12})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#c83c28"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 1.5})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
|
||||
assert!(!out.warnings.iter().any(|w| w.contains("smart")), "{mode} {depth}: {:?}", out.warnings);
|
||||
assert_eq!(structure_errors(&out.bytes), Vec::<String>::new());
|
||||
let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document;
|
||||
let ((_, a), (_, b)) = (smart(&doc), smart(&back));
|
||||
assert_eq!(b.smart_filters.len(), 1, "{mode} {depth}");
|
||||
assert_eq!(b.smart_filters[0].command, a.smart_filters[0].command);
|
||||
// The cache's unfiltered planes decode back to the unfiltered rendering.
|
||||
let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap();
|
||||
let fx = f.global_blocks.iter().find(|g| &g.key == b"FEid").map(|g| photocraft_psd::filter_effects::FilterEffects::parse(&g.data).unwrap()).unwrap();
|
||||
let item = &fx.items[0];
|
||||
let (w, h) = item.rect.size().unwrap();
|
||||
let bits = u16::try_from(item.depth).unwrap();
|
||||
let first = item.slots[0].as_ref().unwrap().decode(w, h, bits).unwrap();
|
||||
let mut unfiltered = a.clone();
|
||||
unfiltered.smart_filters.clear();
|
||||
let px = photocraft_engine::smart_cmds::render(&doc, &unfiltered).unwrap().unwrap();
|
||||
let (x, y) = (10 - item.rect.left, 8 - item.rect.top);
|
||||
let at = (y as usize * w + x as usize) * usize::from(bits / 8);
|
||||
let stored = if bits == 8 { f32::from(first[at]) / 255.0 } else { f32::from(u16::from_be_bytes([first[at], first[at + 1]])) / 65535.0 };
|
||||
let v = px.sample_channel(10, 8, 0);
|
||||
let expect = if mode == "cmyk" { 1.0 - v } else { v };
|
||||
assert!((stored - expect).abs() < 0.01, "{mode} {depth}: slot 0 holds {stored}, expected {expect}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes sample files for checking in Photoshop (`PHOTOCRAFT_SMART_PSD_OUT`): per depth, a
|
||||
/// plain layer, a smart object without filters, and one with filters.
|
||||
#[test]
|
||||
#[ignore = "writes files for a manual Photoshop check"]
|
||||
fn photoshop_samples() {
|
||||
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("cmyk", 8), ("lab", 16), ("grayscale", 8)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 50, "height": 40})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#c83c28"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let tag = format!("{mode}{depth}");
|
||||
keep(&format!("plain-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
keep(&format!("nofilter-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes);
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
keep(&format!("gauss-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// PSD export of a 24 MP smart object versus the same layer as pixels (release:
|
||||
/// `cargo test --release -p photocraft-engine --test smart_psd -- --ignored --nocapture perf`).
|
||||
#[test]
|
||||
#[ignore = "benchmark"]
|
||||
fn perf_24mp_smart_object_export() {
|
||||
let (w, h) = (6000, 4000);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": w, "height": h, "depth": 8})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("paint", |doc, active| {
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let r = Rect::new(0, 0, w, h);
|
||||
let mut data = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
// Photo-like: smooth gradients plus a little texture (RLE can't collapse it).
|
||||
let v = (x as f32 / w as f32) * 0.7 + (((x * 31 + y * 17) % 29) as f32 / 29.0) * 0.1;
|
||||
data.extend(photocraft_raster::from_rgba(&fmt, [v, (y as f32 / h as f32) * 0.8, 1.0 - v, 1.0]));
|
||||
}
|
||||
}
|
||||
surf.write_region(r, &data);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let time = |doc: &Document| {
|
||||
let t = std::time::Instant::now();
|
||||
let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap();
|
||||
(t.elapsed().as_secs_f64(), out.bytes.len(), out.warnings)
|
||||
};
|
||||
let (tf, sf, _) = time(&s.active().unwrap().doc);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
let (ts, ss, ws) = time(&s.active().unwrap().doc);
|
||||
assert!(ws.is_empty(), "{ws:?}");
|
||||
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
||||
let (tg, sg, wg) = time(&s.active().unwrap().doc);
|
||||
assert!(wg.is_empty(), "{wg:?}");
|
||||
let mb = |b: usize| b as f64 / 1e6;
|
||||
eprintln!(
|
||||
"24 MP PSD export: pixels {tf:.2} s {:.1} MB | smart object {ts:.2} s {:.1} MB | + Gaussian Blur smart filter {tg:.2} s {:.1} MB",
|
||||
mb(sf),
|
||||
mb(ss),
|
||||
mb(sg)
|
||||
);
|
||||
// The embedded file is about one more copy of the layer; the filter cache one more again.
|
||||
assert!(ss < sf * 5 / 2, "smart object {ss} vs pixels {sf}");
|
||||
assert!(sg < sf * 4, "with filters {sg} vs pixels {sf}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_psd_smart_objects_drop_their_old_file() {
|
||||
let mut s = session(8);
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
let first = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes;
|
||||
// Re-open, edit the contents (a new embedded file), save: one embedded file, the new one.
|
||||
let mut s2 = Session::new();
|
||||
s2.add_document(photocraft_io::import("a.psd", &first).unwrap().document, None);
|
||||
let (id, _) = smart(&s2.active().unwrap().doc);
|
||||
s2.select_layer(photocraft_doc::LayerId(id)).unwrap();
|
||||
s2.execute("layer.smartObjects.editContents", json!({})).unwrap();
|
||||
s2.execute("image.adjustments.invert", json!({})).unwrap();
|
||||
s2.execute("layer.smartObjects.saveContents", json!({})).unwrap();
|
||||
let parent = s2.documents().iter().position(|d| d.doc.layer(photocraft_doc::LayerId(id)).is_some()).unwrap();
|
||||
let doc2 = s2.documents()[parent].doc.clone();
|
||||
let second = photocraft_io::export(&doc2, "psd", &Default::default()).unwrap().bytes;
|
||||
let f = photocraft_psd::PsdFile::from_bytes(&second).unwrap();
|
||||
let uuids: Vec<String> = f.global_blocks.iter().filter(|b| &b.key == b"lnk2").flat_map(|b| photocraft_io::linked::block_uuids(&b.data)).collect();
|
||||
let back = photocraft_io::import("b.psd", &second).unwrap().document;
|
||||
let (_, sm) = smart(&back);
|
||||
let SmartSource::Linked { path } = &sm.source else { panic!() };
|
||||
assert_eq!(uuids, vec![path.clone()]);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Colour strings supplied through the same commands used by CLI/MCP clients.
|
||||
use photocraft_engine::{Session, command_specs};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(id: &str, params: Value) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 8})).unwrap();
|
||||
let spec = command_specs().iter().find(|c| c.id == id).unwrap();
|
||||
// Exercise the command directly, without the last-resort shell guard.
|
||||
(spec.run)(&mut s, ¶ms).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_pixelate_colour_uses_the_existing_fallback() {
|
||||
run("filter.pixelate.pointillize", json!({"background": "#€€"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_gallery_colour_uses_the_existing_fallback() {
|
||||
run("filter.gallery.photocopy", json!({"foreground": "#€€"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_render_colour_uses_the_existing_fallback() {
|
||||
run("filter.render.tree", json!({"leavesColor": "#€€"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_preference_colour_is_invalid() {
|
||||
assert_eq!(photocraft_engine::prefs::parse_hex("#€€"), None);
|
||||
assert_eq!(photocraft_engine::prefs::parse_hex(""), None);
|
||||
assert_eq!(photocraft_engine::prefs::parse_hex("#12aBcD"), Some([0x12, 0xab, 0xcd]));
|
||||
}
|
||||
Loaded 100 of 364 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user