Compare commits

...
Author SHA1 Message Date
Brandon Thomas 4717de1398 Smart Objects and Smart Filters survive PSD save and open (#288) 2026-10-06 07:46:27 -04:00
midasdfandmidasdf e5e2a8f2eb Enable dictionary segmentation for Japanese type (#250) (#250)
Co-authored-by: midasdf <[email protected]>
2026-10-06 07:44:08 -04:00
b5b5ce8394 i18n: add Simplified Chinese localization (#282)
* i18n: add Simplified Chinese translation catalog (#282)

* i18n: register the Simplified Chinese catalog (#282)

Adds the zh-hans row to LANGUAGES. It's marked partial (complete_menus: false): strings added
to main since the catalog was written fall back to English until translated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 07:43:52 -04:00
Multipad-cyberandMultipad-cyber 8713e26f5a Background layer: double-click, masks and cut behave like Photoshop (#266) (#266)
Co-authored-by: Multipad-cyber <[email protected]>
2026-10-06 07:43:37 -04:00
Multipad-cyberandMultipad-cyber 4c94e4d63a Marquee: stop the selection at the canvas edge (#263) (#263)
Co-authored-by: Multipad-cyber <[email protected]>
2026-10-06 07:43:21 -04:00
Cocoon-Breakandkuishou68 c34facea4d fix: forward document index in bridge inspection (#287) (#287)
Co-authored-by: kuishou68 <[email protected]>
2026-10-06 07:43:03 -04:00
Brandon Thomas a96a621dee FreeBSD release tarball and README platform lines (#284) (#284) 2026-10-05 20:54:00 -04:00
Brandon Thomas 5ada60c35b Compositor: effect maps never block inside parallel tiles (fixes #276 deadlock) (#281) (#281) 2026-10-05 20:07:16 -04:00
Brandon Thomas 3423864502 Fast Select › Modify › Smooth and Feather (part of #211) (#280) (#280) 2026-10-05 19:59:11 -04:00
Brandon Thomas 7db46219ce Pencil tool, Edit › Fill… dialog and "Rasterize?" prompt (#213) (#279) (#279) 2026-10-05 19:48:03 -04:00
17d4cd0d78 Localise the UI: i18n foundation and a Japanese catalog (menus, dialogs, panels) (#169)
* Localise the UI: i18n foundation and a Japanese catalog

Adds a small i18n layer and a complete Japanese catalog for the menus, the
command palette, dialogs and panels. English stays the default and the source
language; command ids, the control protocol, the CLI and MCP are unchanged.

- i18n (crates/ui-egui/src/i18n): one TSV catalog per language, registered with
  one row in LANGUAGES. Lookups: tr/tl! (plain), tr_ctx (context), tr_id
  (command-id keyed, English fallback), trn (plurals), fmt ({name}
  placeholders). Locale tags resolve most-specific-first; macOS falls back to
  the preferred-languages list.
- New preference interface.language (auto | language code), a dropdown in
  Preferences > Interface. The engine stores a validated code and does not
  know the language list.
- Widgets (checkbox, slider_row, dropdown, buttons, tabs, icon tooltips)
  translate their labels; direct egui calls use tl!("...").
- Japanese catalog (about 2,000 entries) written from the meaning of the
  English labels; no string table was extracted from any other product.
- Tests: catalog well-formedness (duplicates, placeholders, ellipses, plural
  forms, command ids) and coverage of menus, tl! literals, blend modes and
  generated preference labels.

Status-bar messages and errors stay English. Per-language font fallback,
right-to-left layout and locale-aware number formats are not done yet.

Rebased onto main (maintainer): conflicts resolved keeping main's newer UI
(tab strips, platform shortcut labels, menu navigation, Properties layout)
with the tl!/tr wrapping. command_label/tip_label translate their label
before appending the shortcut, shortcut hints use {key} placeholders, and
strings main added since are wrapped and translated where unambiguous.
The macOS locale lookup runs /usr/bin/defaults by absolute path and
ignores failures.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Menus widen to fit translated labels instead of wrapping them

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Workspace switcher keeps English keys so translated labels still select a workspace

The options' keys were translated too, so in Japanese the current workspace
showed as "—" and picking one never matched apply_workspace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Regenerate docs/scorecard.md for interface.language

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-05 19:40:10 -04:00
Brandon Thomas c08322b7bc PSD manual and optical kerning: import, export, render, edit (#206) (#259) (#259) 2026-10-05 19:31:53 -04:00
Brandon Thomas 352c849139 Shortcuts: rebindable fill keys, D/X and held temporary tools (#249) (#275) (#275) 2026-10-05 18:50:47 -04:00
Brandon Thomas 1520d1c527 Selection drag previews: regression tests for #253 (visible mid-drag, 1x and 2x) (#274) (#274) 2026-10-05 18:29:06 -04:00
Brandon Thomas 65c934f374 Shift rubber band: preview the straight line before the click (#257) (#273) (#273) 2026-10-05 18:28:53 -04:00
Brandon Thomas fb297945cd Ctrl+Alt-drag resizes the brush on the canvas (#231) (#271) (#271) 2026-10-05 18:28:40 -04:00
Brandon Thomas 6dc11904bd Brush Settings for every brush: options-bar toggle and real preset picker (#258) (#268) (#268) 2026-10-05 18:28:27 -04:00
Brandon Thomas 315be69dd5 Portable mode: keep settings beside the exe in the Windows portable zip (#228) (#267) (#267) 2026-10-05 18:15:29 -04:00
Brandon Thomas 9a76e74c24 Menus are as tall as their items; scroll only when the window is too short (#235) (#272) (#272) 2026-10-05 18:15:16 -04:00
Brandon Thomas ea0beac9fd New Document: a typed Width/Height reaches Create (#254) (#270) (#270) 2026-10-05 18:15:03 -04:00
TiredIsa 6266b7a96b UI: platform shortcut notation in every label and tooltip (#251) (#262) (#262) 2026-10-05 18:14:50 -04:00
Kentaro Tsutsumi 07074634f6 Align the CJK fallback font's baseline with Latin text (#260) (#260) 2026-10-05 18:14:35 -04:00
storybored379andstorybored379 3c1bab7cc1 Colours: handle non-hex Unicode values without an internal error (#261) (#261)
Co-authored-by: storybored379 <[email protected]>
2026-10-05 18:14:20 -04:00
Brandon Thomas 433b6a7184 Windows taskbar icon: .exe icon id for the MSI shortcut + icon regression tests (#248) (#269) (#269) 2026-10-05 18:14:05 -04:00
才倾梦华V 2172d085c6 Fix Windows MSI advertised shortcut icons (#238) (#238) 2026-10-05 18:13:51 -04:00
Brandon Thomas 47f4abfed4 Stylus pressure, tilt, rotation and eraser on macOS and X11 (#255) (#255) 2026-10-05 17:47:59 -04:00
Brandon Thomas 02c07eb021 Masks: Alt-click to view mask, Cmd-click vector mask to select, vector-mask target brackets (#196) (#256) (#256) 2026-10-05 17:47:46 -04:00
Brandon Thomas 968baf639e Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190) 2026-10-05 17:47:33 -04:00
Brandon Thomas c454af8d96 Text: deterministic font family order; hide private system families (fixes a flaky test) (#264) (#264) 2026-10-05 17:47:20 -04:00
Brandon Thomas 7c6a78b05a GPU: crash-safe startup and device-loss fallback (#243, #4) (#252) (#252) 2026-10-05 12:05:22 -04:00
Brandon ThomasandClaude Opus 5.5 415362df6f Marquee: Shift squares, Alt draws from the centre, live W × H readout (#208) (#247) (#247)
Merged with #240 (stroke trail reset on drag end and start).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 12:05:10 -04:00
Brandon Thomas dbb8911f2a Live gradients: Gradient tool makes editable Gradient Fill layers (on-canvas handles, stops, midpoints) + Classic mode (#180) (#197) (#197) 2026-10-05 12:04:52 -04:00
Brandon Thomas 8bac8926a3 PSD stroke effects: outlines of filled shapes, vector stroke above interior effects, joined subpaths (#233) (#233) 2026-10-05 11:48:28 -04:00
Brandon Thomas ae80df2eb4 Vertical type (tategaki): lay out, render, edit and round-trip (#199) (#246) (#246) 2026-10-05 11:48:14 -04:00
Brandon Thomas 5a5faccd4a Free Transform: selection moves only its part of a linked mask; masked preview; target masks and channels (#205) (#242) (#242) 2026-10-05 11:26:33 -04:00
Brandon Thomas e2444ceb68 Retouch tools paint the mask/channel target; Sample All Layers for Blur/Sharpen/Smudge; premultiplied Smudge (#207) (#234) (#234) 2026-10-05 11:26:20 -04:00
Brandon Thomas 866c33f7c1 Eraser on locked layers: no lock-out from the simple lock button; Stroke Path erases to the background colour (#76) (#229) (#229) 2026-10-05 11:26:06 -04:00
Brandon Thomas 34de1a5dfc Preferences: wire tooltips, Home screen, recent files; hide what nothing reads (#245) (#245) 2026-10-05 11:16:27 -04:00
Brandon Thomas 82270711de Atomic saves: one crash-safe atomic_write for every document write (#230) (#230) 2026-10-05 11:16:13 -04:00
290 changed files with 28437 additions and 2659 deletions

No files matched your search

+23
View File
@@ -66,6 +66,29 @@ jobs:
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
+15 -3
View File
@@ -1,5 +1,8 @@
# 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:
@@ -11,6 +14,9 @@ on:
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
@@ -22,12 +28,12 @@ permissions:
jobs:
freebsd:
name: FreeBSD 14 (build + test)
name: FreeBSD 14 (build + test + package)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
- name: Build, test and package in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
@@ -37,12 +43,18 @@ jobs:
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
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$'
+1
View File
@@ -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*
+45 -1
View File
@@ -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:
+7 -2
View File
@@ -15,6 +15,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `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
@@ -22,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
@@ -33,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 | perf | scorecard
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`.
@@ -45,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.
@@ -94,8 +96,11 @@ cargo xtask scorecard # if you moved a number: flip the checklist row in s
# 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.
+13 -8
View File
@@ -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,11 +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:
`cargo xtask corpus --psd-tools` fetches every PSD/PSB of the psd-tools test set
([`tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files), MIT,
Copyright (c) 2019 Kota Yamaguchi) at a pinned commit into `corpus/psd-tools/`, verified against
the sha256 list in `xtask/psd-tools-corpus.sha256`, with the upstream `LICENSE` next to them.
| 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
+16
View File
@@ -3404,6 +3404,7 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"photocraft-raster",
"photocraft-tablet",
"photocraft-ui-egui",
"rfd",
"serde_json",
@@ -3593,6 +3594,8 @@ dependencies = [
name = "photocraft-io"
version = "0.2.0"
dependencies = [
"blake3",
"photocraft-algo",
"photocraft-cms",
"photocraft-codecs",
"photocraft-color",
@@ -3607,6 +3610,7 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"proptest",
"serde_json",
"thiserror 2.0.21",
]
@@ -3673,6 +3677,18 @@ 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"
+1
View File
@@ -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"
+17 -2
View File
@@ -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>
@@ -257,13 +257,21 @@ 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]
@@ -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
+10 -2
View File
@@ -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();
+2
View File
@@ -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 }
+234
View File
@@ -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");
}
}
+122
View File
@@ -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"));
}
}
+555
View File
@@ -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);
}
}
+102 -21
View File
@@ -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,15 +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;
@@ -29,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());
@@ -47,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() {
@@ -55,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(());
@@ -107,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])
@@ -124,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| {
@@ -138,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);
@@ -153,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
}
+10 -23
View File
@@ -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,14 +57,11 @@ 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 {
@@ -123,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,
+201
View File
@@ -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);
}
}
+4 -1
View File
@@ -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
+14
View File
@@ -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

+1
View File
@@ -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;
+1 -11
View File
@@ -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
View File
@@ -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)
+788
View File
@@ -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"),
}
}
}
}
+20
View File
@@ -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.
+2 -4
View File
@@ -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)
}
+1 -1
View File
@@ -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),
}
}
+2 -1
View File
@@ -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).")]
+64 -5
View File
@@ -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");
+4 -1
View File
@@ -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;
+2
View File
@@ -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.
+5 -2
View File
@@ -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());
}
+11
View File
@@ -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.
+146 -26
View File
@@ -417,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 {
@@ -429,15 +445,8 @@ 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).
@@ -715,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;
@@ -1001,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 {
@@ -1090,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).
@@ -1101,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(
@@ -1111,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).
@@ -1163,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 }
@@ -1202,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();
@@ -1209,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 {
@@ -1223,7 +1286,7 @@ 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
@@ -1253,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 {
@@ -1327,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;
+19
View File
@@ -123,6 +123,25 @@ mod tests {
}
}
#[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);
+265
View File
@@ -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);
}
}
+250 -81
View File
@@ -20,6 +20,7 @@ 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;
@@ -109,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);
@@ -259,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
@@ -426,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> {
@@ -438,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);
}
}
}
@@ -511,10 +572,67 @@ 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, truncated to whole pixels. Its rendered
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
@@ -583,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 {
@@ -602,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)
@@ -637,27 +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 (angle, scale, offset) = fill_layout::fill_gradient_layout(*style, *angle, *scale, canvas);
let mut b = Buffer::transparent(rect);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let t = effects::gradient_t(*style, angle, scale, *reverse, offset, canvas, x as f32 + 0.5, y as f32 + 0.5);
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
b.px[i] = sample_stops(&stops, t);
}
}
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) },
@@ -666,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
@@ -798,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
@@ -1013,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,
@@ -1025,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;
@@ -1053,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)))
}
// ---------------------------------------------------------------------------------------------
@@ -1125,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>,
@@ -1176,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();
@@ -1184,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) {
@@ -1195,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();
@@ -1268,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(
@@ -1278,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 {
@@ -1328,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;
+51 -4
View File
@@ -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);
}
+167
View File
@@ -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]));
}
+68 -8
View File
@@ -311,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
@@ -1175,7 +1169,7 @@ fn small_gradient_fill_matches_photoshop_at_all_depths() {
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
let fill = Fill::Gradient { stops: stops.clone(), angle: 30.0, scale: 1.0, style: photocraft_doc::GradientStyle::Reflected, reverse: false };
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 {
@@ -1332,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
View File
@@ -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,
+8 -3
View File
@@ -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,
}
+19 -1
View File
@@ -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 {
+4
View File
@@ -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 }
+23 -3
View File
@@ -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> {
+1 -1
View File
@@ -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;
+43 -13
View File
@@ -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))
}
+71
View File
@@ -312,6 +312,77 @@ fn live_stroke_matches_the_committed_stroke() {
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
+5 -1
View File
@@ -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);
}
}
+41 -2
View File
@@ -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
),
+33 -30
View File
@@ -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)
@@ -626,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)
@@ -939,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());
@@ -963,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());
@@ -997,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
}
@@ -1058,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(())
})?;
+49 -5
View File
@@ -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();
+48 -4
View File
@@ -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);
+3 -4
View File
@@ -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<()> {
+495
View File
@@ -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();
}
}
+215
View File
@@ -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"));
}
}
+4
View File
@@ -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);
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+586
View File
@@ -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}");
}
+1
View File
@@ -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()},
+2 -1
View File
@@ -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")]
+8
View File
@@ -28,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;
@@ -40,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;
@@ -338,6 +342,10 @@ 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();
+138
View File
@@ -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;
+137
View File
@@ -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());
}
+5 -3
View File
@@ -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);
}
+2 -46
View File
@@ -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, &region);
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
View File
@@ -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
),
+52
View File
@@ -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));
}
}
+1 -7
View File
@@ -183,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)?) {
+29 -3
View File
@@ -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)
+1 -1
View File
@@ -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,
+112 -38
View File
@@ -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,
+233
View File
@@ -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");
}
}
+18 -1
View File
@@ -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]
+15 -32
View File
@@ -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())))
}
@@ -410,8 +390,8 @@ pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
}
}
/// 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 })
})
}
+25
View File
@@ -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();
+274 -6
View File
@@ -65,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" {
@@ -75,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() {
@@ -129,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
@@ -219,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()));
}
@@ -255,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)?;
@@ -280,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,
@@ -395,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
View File
@@ -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));
}
}
+24 -6
View File
@@ -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);
+1
View File
@@ -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,
+1 -1
View File
@@ -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()}))
+15 -6
View File
@@ -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();
+2 -2
View File
@@ -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}))
+24
View File
@@ -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);
+133
View File
@@ -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}");
}
+169
View File
@@ -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, ".", &section_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, "", &section_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");
}
+280
View File
@@ -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()]);
}
+33
View File
@@ -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, &params).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]));
}
+221
View File
@@ -0,0 +1,221 @@
//! Crash-safe file replacement: the one way PhotoCraft writes a document to disk.
//!
//! [`atomic_write`] never truncates the destination. It writes a temporary file in the **same
//! directory**, flushes it to stable storage (`sync_all`), renames it over the destination and,
//! on Unix, fsyncs the directory so the rename itself survives a power loss. If anything fails,
//! the destination keeps its previous bytes and the temporary file is removed.
//!
//! - **Windows:** antivirus scanners and the search indexer briefly hold files open, which makes
//! the replacing rename fail with `PermissionDenied`. The rename is retried with exponential
//! backoff before giving up. (`std::fs::rename` uses `MoveFileExW(MOVEFILE_REPLACE_EXISTING)`,
//! i.e. replace semantics.)
//! - **Cross-device (`EXDEV`):** cannot happen while the temporary file sits beside the target;
//! if it does anyway, the save fails with a clear error. It never falls back to writing the
//! destination in place.
//! - **Unwritable folder:** the save fails with a clear error instead of writing in place.
//!
//! On the web there is no file system: writes go through the platform services (downloads), so
//! this module is never reached there; on `wasm32` every call returns the `std` "unsupported"
//! error.
//!
//! [`atomic_write_with`] takes an [`AtomicFs`] so tests can inject failures at each step.
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests
/// wrap it to inject failures.
pub trait AtomicFs {
/// Create `tmp` (it must not exist), write all of `bytes` and `sync_all` it.
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>;
/// Rename `from` over `to`, replacing it.
fn rename(&self, from: &Path, to: &Path) -> io::Result<()>;
/// Remove a (temporary) file.
fn remove(&self, path: &Path) -> io::Result<()>;
/// Flush a directory entry change (Unix); a no-op elsewhere.
fn sync_dir(&self, dir: &Path) -> io::Result<()>;
/// Copy the destination's permissions onto the temporary file, so replacing a file keeps its
/// mode. Best effort.
fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()>;
}
/// The real file system.
#[derive(Debug, Clone, Copy, Default)]
pub struct RealFs;
impl AtomicFs for RealFs {
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> {
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?;
f.write_all(bytes)?;
f.sync_all()
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
std::fs::rename(from, to)
}
fn remove(&self, path: &Path) -> io::Result<()> {
std::fs::remove_file(path)
}
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
#[cfg(unix)]
{
std::fs::File::open(dir)?.sync_all()
}
#[cfg(not(unix))]
{
let _ = dir;
Ok(())
}
}
fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
let perms = std::fs::metadata(from)?.permissions();
std::fs::set_permissions(to, perms)
}
}
/// How hard to retry a rename that fails with `PermissionDenied`.
#[derive(Debug, Clone, Copy)]
pub struct RenameRetry {
/// Retries after the first attempt (0 = no retry).
pub attempts: u32,
/// Delay before the first retry; doubles each time.
pub first_delay_ms: u64,
}
impl RenameRetry {
/// The platform default: retry on Windows (about 1.3 s in total), never elsewhere, where
/// `PermissionDenied` is a real permission problem that waiting won't fix.
pub fn platform() -> Self {
if cfg!(windows) { Self { attempts: 7, first_delay_ms: 10 } } else { Self { attempts: 0, first_delay_ms: 0 } }
}
}
/// Atomically replace (or create) `path` with `bytes`. See the module docs.
///
/// Errors keep their [`io::ErrorKind`] and say what happened and that the original file is
/// unchanged.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> io::Result<()> {
atomic_write_with(&RealFs, RenameRetry::platform(), path, bytes)
}
/// [`atomic_write`] through an explicit file system and retry policy (the failure-injection seam).
pub fn atomic_write_with(fs: &dyn AtomicFs, retry: RenameRetry, path: &Path, bytes: &[u8]) -> io::Result<()> {
let target = resolve_symlink(path);
let name = target.file_name().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, format!("{}: not a file path", path.display())))?;
let dir = match target.parent() {
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
_ => PathBuf::from("."),
};
std::fs::create_dir_all(&dir).map_err(|e| context(e, format!("cannot create the folder {}", dir.display())))?;
// Create a fresh temporary file next to the target (same directory, so the rename never
// crosses a device). The name is unique per process and call; retry a few on collisions.
let mut tmp = None;
for _ in 0..8 {
let candidate = dir.join(temp_name(&name.to_string_lossy()));
match fs.write_new(&candidate, bytes) {
Ok(()) => {
tmp = Some(candidate);
break;
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => {
// A partial temp file may exist; the destination was never touched.
let _ = fs.remove(&candidate);
return Err(write_error(e, path, &dir));
}
}
}
let tmp = tmp.ok_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, format!("{}: could not create a temporary file", dir.display())))?;
// Keep the existing file's mode (e.g. 0600) instead of the process default.
if target.exists() {
let _ = fs.copy_permissions(&target, &tmp);
}
if let Err(e) = retry_rename(retry, || fs.rename(&tmp, &target)) {
let _ = fs.remove(&tmp);
return Err(rename_error(e, path));
}
// The new bytes are in place; a failed directory fsync can't be undone and must not report
// the save as failed, so it is best effort.
let _ = fs.sync_dir(&dir);
Ok(())
}
/// Run `rename`, retrying with backoff while it fails with `PermissionDenied` (see
/// [`RenameRetry`]). For callers that rename through their own handles (capability directories).
pub fn retry_rename(retry: RenameRetry, mut rename: impl FnMut() -> io::Result<()>) -> io::Result<()> {
let mut delay = retry.first_delay_ms;
let mut left = retry.attempts;
loop {
match rename() {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied && left > 0 => {
left -= 1;
sleep_ms(delay);
delay = delay.saturating_mul(2);
}
r => return r,
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn sleep_ms(ms: u64) {
std::thread::sleep(std::time::Duration::from_millis(ms));
}
#[cfg(target_arch = "wasm32")]
fn sleep_ms(_ms: u64) {}
/// Write through a symlink to the file it points at, instead of replacing the link.
fn resolve_symlink(path: &Path) -> PathBuf {
match std::fs::symlink_metadata(path) {
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()),
_ => path.to_path_buf(),
}
}
/// A unique hidden temporary file name to write beside `file` before renaming over it.
pub fn temp_name(file: &str) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
// Keep the name short: a long file name plus the suffix must stay under NAME_MAX.
let stem: String = file.chars().take(64).collect();
format!(".{stem}.{}-{n}.pcsave.tmp", std::process::id())
}
fn context(e: io::Error, msg: String) -> io::Error {
io::Error::new(e.kind(), format!("{msg}: {e}"))
}
const UNCHANGED: &str = "the original file was not changed";
fn write_error(e: io::Error, path: &Path, dir: &Path) -> io::Error {
let msg = match e.kind() {
io::ErrorKind::PermissionDenied | io::ErrorKind::ReadOnlyFilesystem => {
format!("cannot save {}: the folder {} is not writable ({e}); {UNCHANGED}", path.display(), dir.display())
}
io::ErrorKind::StorageFull => format!("cannot save {}: the disk is full ({e}); {UNCHANGED}", path.display()),
_ => format!("cannot save {}: {e}; {UNCHANGED}", path.display()),
};
io::Error::new(e.kind(), msg)
}
fn rename_error(e: io::Error, path: &Path) -> io::Error {
let msg = if is_cross_device(&e) {
format!("cannot save {}: the temporary file is on a different volume than the destination ({e}); {UNCHANGED}", path.display())
} else if e.kind() == io::ErrorKind::PermissionDenied {
format!("cannot save {}: the file is locked or read-only ({e}); {UNCHANGED}", path.display())
} else {
format!("cannot save {}: {e}; {UNCHANGED}", path.display())
};
io::Error::new(e.kind(), msg)
}
fn is_cross_device(e: &io::Error) -> bool {
// EXDEV is 18 on Linux, macOS and the BSDs; ERROR_NOT_SAME_DEVICE is 17 on Windows.
e.kind() == io::ErrorKind::CrossesDevices || (cfg!(unix) && e.raw_os_error() == Some(18)) || (cfg!(windows) && e.raw_os_error() == Some(17))
}
#[cfg(test)]
mod tests;
+212
View File
@@ -0,0 +1,212 @@
use super::*;
use std::cell::Cell;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
static N: AtomicU64 = AtomicU64::new(0);
let d = std::env::temp_dir().join(format!("pc-atomic-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Self(d)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755));
}
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Only the target remains: no temporary file was left behind.
fn assert_no_temp(dir: &Path) {
let leftovers: Vec<_> =
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect();
assert!(leftovers.is_empty(), "temporary files left behind: {leftovers:?}");
}
const ORIGINAL: &[u8] = b"the user's precious original document";
/// Real file system with failures injected at chosen steps.
#[derive(Default)]
struct Faulty {
/// Write this many bytes, then fail like a full disk.
fail_write_after: Option<usize>,
/// Fail every rename with this kind.
fail_rename: Option<io::ErrorKind>,
/// Fail the first N renames with PermissionDenied (a transient lock), then succeed.
locked_renames: Cell<u32>,
renames: Cell<u32>,
}
impl AtomicFs for Faulty {
fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> {
if let Some(n) = self.fail_write_after {
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?;
f.write_all(&bytes[..n.min(bytes.len())])?;
return Err(io::Error::new(io::ErrorKind::StorageFull, "injected: no space left on device"));
}
RealFs.write_new(tmp, bytes)
}
fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
self.renames.set(self.renames.get() + 1);
if let Some(kind) = self.fail_rename {
return Err(io::Error::new(kind, "injected rename failure"));
}
if self.locked_renames.get() > 0 {
self.locked_renames.set(self.locked_renames.get() - 1);
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected: sharing violation"));
}
RealFs.rename(from, to)
}
fn remove(&self, path: &Path) -> io::Result<()> {
RealFs.remove(path)
}
fn sync_dir(&self, dir: &Path) -> io::Result<()> {
RealFs.sync_dir(dir)
}
fn copy_permissions(&self, from: &Path, to: &Path) -> io::Result<()> {
RealFs.copy_permissions(from, to)
}
}
const NO_RETRY: RenameRetry = RenameRetry { attempts: 0, first_delay_ms: 0 };
#[test]
fn replaces_and_creates() {
let d = TempDir::new("ok");
let p = d.0.join("doc.psd");
atomic_write(&p, b"first").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"first");
atomic_write(&p, b"second, longer").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"second, longer");
assert_no_temp(&d.0);
}
#[test]
fn creates_missing_parent_folders() {
let d = TempDir::new("parent");
let p = d.0.join("a/b/out.png");
atomic_write(&p, b"x").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"x");
}
#[test]
fn write_failure_halfway_keeps_original() {
let d = TempDir::new("write");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
let fs = Faulty { fail_write_after: Some(5), ..Default::default() };
let e = atomic_write_with(&fs, NO_RETRY, &p, &[7u8; 4096]).unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::StorageFull);
assert!(e.to_string().contains("not changed"), "{e}");
assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL);
assert_eq!(fs.renames.get(), 0, "never renames after a failed write");
assert_no_temp(&d.0);
}
#[test]
fn rename_failure_keeps_original() {
let d = TempDir::new("rename");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
let fs = Faulty { fail_rename: Some(io::ErrorKind::Other), ..Default::default() };
assert!(atomic_write_with(&fs, NO_RETRY, &p, b"new").is_err());
assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL);
assert_no_temp(&d.0);
}
#[test]
fn cross_device_rename_errors_clearly_and_never_writes_in_place() {
let d = TempDir::new("exdev");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
let fs = Faulty { fail_rename: Some(io::ErrorKind::CrossesDevices), ..Default::default() };
let e = atomic_write_with(&fs, NO_RETRY, &p, b"new").unwrap_err();
assert!(e.to_string().contains("different volume"), "{e}");
assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL);
assert_no_temp(&d.0);
}
#[test]
fn locked_target_rename_is_retried() {
let d = TempDir::new("locked");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
let fs = Faulty { locked_renames: Cell::new(2), ..Default::default() };
atomic_write_with(&fs, RenameRetry { attempts: 3, first_delay_ms: 1 }, &p, b"new").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"new");
assert_eq!(fs.renames.get(), 3);
assert_no_temp(&d.0);
}
#[test]
fn lock_that_outlasts_the_retries_keeps_original() {
let d = TempDir::new("locked2");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
let fs = Faulty { locked_renames: Cell::new(10), ..Default::default() };
let e = atomic_write_with(&fs, RenameRetry { attempts: 2, first_delay_ms: 1 }, &p, b"new").unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::PermissionDenied);
assert!(e.to_string().contains("locked"), "{e}");
assert_eq!(fs.renames.get(), 3);
assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL);
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn read_only_folder_errors_clearly_and_never_writes_in_place() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("ro");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
// The file itself stays writable: an in-place fallback would succeed and must not happen.
std::fs::set_permissions(&d.0, std::fs::Permissions::from_mode(0o555)).unwrap();
if std::fs::File::create(d.0.join("probe")).is_ok() {
return; // running as root: permissions aren't enforced
}
let e = atomic_write(&p, b"new").unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::PermissionDenied);
assert!(e.to_string().contains("not writable"), "{e}");
assert_eq!(std::fs::read(&p).unwrap(), ORIGINAL);
std::fs::set_permissions(&d.0, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_no_temp(&d.0);
}
#[cfg(unix)]
#[test]
fn keeps_the_original_file_mode() {
use std::os::unix::fs::PermissionsExt;
let d = TempDir::new("mode");
let p = d.0.join("doc.psd");
std::fs::write(&p, ORIGINAL).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
atomic_write(&p, b"new").unwrap();
assert_eq!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn writes_through_a_symlink() {
let d = TempDir::new("link");
let real = d.0.join("real.psd");
let link = d.0.join("link.psd");
std::fs::write(&real, ORIGINAL).unwrap();
std::os::unix::fs::symlink(&real, &link).unwrap();
atomic_write(&link, b"new").unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&real).unwrap(), b"new");
}
#[test]
fn directory_target_or_bare_root_is_an_error_not_a_panic() {
let d = TempDir::new("dir");
assert!(atomic_write(&d.0, b"x").is_err());
assert!(atomic_write(Path::new("/"), b"x").is_err());
assert!(atomic_write(Path::new(""), b"x").is_err());
}
+2
View File
@@ -20,6 +20,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod atomic;
pub mod autosave;
mod convert;
pub mod manifest;
@@ -32,6 +33,7 @@ use std::path::Path;
use photocraft_doc::Document;
use photocraft_raster::Rgba8Image;
pub use atomic::atomic_write;
pub use autosave::{Autosaver, RecoveryEntry, discard_recovery, list_recovery, recover};
pub use manifest::{FORMAT_VERSION, Manifest};
pub use store::{PcraftWriter, SaveStats};
+2 -6
View File
@@ -448,12 +448,8 @@ fn list_objects(dir: &Path) -> Result<HashSet<String>> {
Ok(out)
}
/// Crash-safe replace of one file (see [`crate::atomic`]).
pub(crate) fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!("{}tmp", path.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default()));
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)?;
crate::atomic::atomic_write(path, data)?;
Ok(())
}
+10 -1
View File
@@ -103,6 +103,11 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
scale: 1.5,
style: GradientStyle::default(),
reverse: true,
opacity_stops: vec![(0.0, 1.0), (1.0, 0.25)],
midpoints: vec![0.3],
offset: (0.1, -0.2),
dither: true,
align: false,
}),
);
@@ -118,7 +123,11 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
transform: Affine::translate(10.5, 20.25),
cache: Some(cache.clone()),
psd_raw: Some(blob(9, 100)),
runs: vec![TextRun { len: 6, style: CharStyle::default() }, TextRun { len: 9, style: CharStyle::default() }],
runs: vec![
TextRun { len: 1, style: CharStyle { kerning: text::Kerning::Off, kern: 120.0, ..CharStyle::default() } },
TextRun { len: 5, style: CharStyle::default() },
TextRun { len: 9, style: CharStyle { kerning: text::Kerning::Optical, ..CharStyle::default() } },
],
paragraphs: vec![ParagraphRun { len: 15, style: ParagraphStyle::default() }],
shape: TextShape::Box { x: 1.0, y: 2.0, width: 100.0, height: 50.0 },
orientation: text::Orientation::Vertical,
+39
View File
@@ -231,6 +231,45 @@ fn vector_fields_roundtrip_and_default_when_absent() {
assert!(old.layers.iter().all(|l| l.vector_mask.is_none()));
}
/// Type kerning (#206): the auto mode and manual kerning survive .pcraft; manifests written
/// before `kern` existed load with no manual kerning.
#[test]
fn text_kerning_roundtrips_and_defaults() {
use photocraft_doc::LayerContent;
use photocraft_doc::text::Kerning;
let doc = rich_doc(ColorMode::Rgb, SampleType::U8);
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
let back = load_from_bytes(&bytes).unwrap();
let runs = |d: &Document| {
d.walk()
.into_iter()
.find_map(|(_, _, l)| match &l.content {
LayerContent::Text(t) => Some(t.runs.iter().map(|r| (r.len, r.style.kerning, r.style.kern)).collect::<Vec<_>>()),
_ => None,
})
.unwrap()
};
assert_eq!(runs(&back), vec![(1, Kerning::Off, 120.0), (5, Kerning::Metrics, 0.0), (9, Kerning::Optical, 0.0)]);
assert_eq!(runs(&back), runs(&doc));
let m = read_manifest(&bytes).unwrap();
let mut v = serde_json::to_value(&m.document).unwrap();
fn strip(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(o) => {
o.remove("kern");
o.values_mut().for_each(strip);
}
serde_json::Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
strip(&mut v);
assert!(!v.to_string().contains("\"kern\""));
let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap();
let s = serde_json::to_string(&old).unwrap();
assert!(s.contains("\"kern\":0.0"), "kern defaults to 0");
}
#[test]
fn channel_restrictions_and_bevel_elements_roundtrip() {
use photocraft_doc::{Bevel, BevelContour, BevelTexture, Contour, Effect};
+59 -8
View File
@@ -39,6 +39,7 @@ const F_TEX: u32 = 4u; // layer pixels live in `layer_tex`
const F_GRADIENT: u32 = 8u; // gradient fill (stops in `lut_tex`)
const F_KNOCKOUT: u32 = 16u; // effect paint: the layer knocks out the coverage (drop shadow)
const F_NO_LAYER: u32 = 32u; // effect merge: A already holds the layer (only the opacity mix)
const F_OUTLINE: u32 = 131072u; // outside strokes: filled shape outline (effects::outline_share)
const F_VECTOR: u32 = 64u; // effect paint: shape layer (outside stroke never inside)
const F_ATOP: u32 = 128u; // effect merge: clipped layer over an opaque base
const F_GATE: u32 = 256u; // effect paint: coverage only inside the layer's shape
@@ -619,10 +620,24 @@ fn gradient_t(d: vec2<i32>) -> f32 {
return t;
}
// photocraft_color::dither_noise: the gradient dither's position hash, in [0, 1).
fn dither_noise(d: vec2<i32>) -> f32 {
var h = (bitcast<u32>(d.x) * 0x9E3779B1u) ^ (bitcast<u32>(d.y) * 0x85EBCA77u);
h = h ^ (h >> 15u);
h = h * 0x2C1B3C6Du;
h = h ^ (h >> 12u);
return f32(h & 0xFFFFu) / 65535.0;
}
fn layer_texel(d: vec2<i32>) -> vec4<f32> {
if ((op.flags & F_GRADIENT) != 0u) {
let t = gradient_t(d);
return vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t));
var c = vec4(lut(0, t), lut(1, t), lut(2, t), lut(3, t));
// p2.w: dither (gradient fills).
if (op.p2.w > 0.5) {
c = vec4(clamp(c.rgb + (dither_noise(d) - 0.5) / 255.0, vec3(0.0), vec3(1.0)), c.a);
}
return c;
}
if ((op.flags & F_TEX) != 0u) {
let q = d - op.tex_origin;
@@ -793,7 +808,10 @@ fn fx_color(d: vec2<i32>) -> vec4<f32> {
// Effect chain steps (`kind`): 0 A at `opacity` × alpha; 1 an opaque copy of A (a clipping base
// for clipped layers' effects); 2 A's colour with alpha `opacity` inside B's shape (the layer
// before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha).
// before its interior effects); 3 A with its alpha × B's alpha (the shape's own alpha); 4 the
// vector stroke C over A, relative to the shape B, at `opacity`; 5 A's colour with alpha
// `opacity` × A's alpha relative to B's (the layer within a filled shape's outline); 6 the
// outside-stroke coverage seed (0, A's alpha); 7 A with alpha joined with the effect shape (map).
@fragment
fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
@@ -802,10 +820,32 @@ fn fs_fxinit(in: VOut) -> @location(0) vec4<f32> {
case 1: { return vec4(c.rgb, 1.0); }
case 2: { return vec4(c.rgb, select(0.0, op.opacity, c.a > INSIDE_EPS)); }
case 3: { return vec4(c.rgb, c.a * min(textureLoad(tex_b, p, 0).a, 1.0)); }
case 4: {
let a = textureLoad(tex_b, p, 0).a;
let s = textureLoad(tex_c, p, 0);
if (s.a <= 0.0 || a <= INSIDE_EPS) { return c; }
return composite(op.mode, c, vec4(s.rgb, op.opacity * min(s.a / a, 1.0)), 1.0);
}
case 5: {
let a = textureLoad(tex_b, p, 0).a;
if (a <= INSIDE_EPS) { return vec4(c.rgb, 0.0); }
return vec4(c.rgb, op.opacity * min(c.a / a, 1.0));
}
case 6: { return vec4(0.0, c.a, 0.0, 0.0); }
case 7: { return vec4(c.rgb, max(c.a, map_value(doc_px(p), 0.0))); }
default: { return vec4(c.rgb, c.a * op.opacity); }
}
}
// effects::outline_share: an outside stroke's share of an edge pixel the outline covers `cov` of,
// beneath a layer of alpha `l` (disjoint areas).
fn outline_share(cov: f32, l: f32) -> f32 {
let outside = max(1.0 - clamp(cov, 0.0, 1.0), 0.0);
let rest = 1.0 - clamp(l, 0.0, 1.0);
if (rest <= 1e-6) { return 1.0; }
return min(outside / rest, 1.0);
}
// effects::paint: composite `colour × coverage × opacity` into A. B is the layer (its alpha `a`
// is the effect shape). Coverage `kind`: 0 the effect map, 1 full; then F_KNOCKOUT m × (1 − a k)
// (k = `p4.w`),
@@ -835,21 +875,32 @@ fn fs_fxpaint(in: VOut) -> @location(0) vec4<f32> {
}
// Outside strokes (A = the exterior result before them, B = the layer, C = coverage so far in
// `.r`, D (`layer_tex`) = accumulated premultiplied colour). Each stroke takes its band × opacity
// not yet covered above it; `kind` 0 accumulates that share of the stroke blended over A at full
// `.r` and, along a filled shape's outline (F_OUTLINE), the layer's alpha in `.g`, D
// (`layer_tex`) = accumulated premultiplied colour). Each stroke takes its band × opacity not yet
// covered above it; `kind` 0 accumulates that share of the stroke blended over A at full
// coverage, `kind` 1 the coverage.
@fragment
fn fs_fxstroke(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
let d = doc_px(p);
let a = textureLoad(tex_b, p, 0).a;
var k = map_value(d, 0.0);
if (a > INSIDE_EPS) { k = select(1.0, 0.0, (op.flags & F_VECTOR) != 0u); }
let outline = (op.flags & F_OUTLINE) != 0u;
let first = (op.flags & F_FIRST) != 0u;
var cover = 0.0;
if (!first) { cover = textureLoad(tex_c, p, 0).r; }
var lay_a = 0.0;
if (!first || outline) {
let cv = textureLoad(tex_c, p, 0);
cover = cv.r;
lay_a = cv.g;
}
var k = map_value(d, 0.0);
if (outline) {
k = k * outline_share(a, lay_a);
} else if (a > INSIDE_EPS) {
k = select(1.0, 0.0, (op.flags & F_VECTOR) != 0u);
}
let share = k * op.opacity * (1.0 - cover);
if (op.kind == 1) { return vec4(cover + share, 0.0, 0.0, 0.0); }
if (op.kind == 1) { return vec4(cover + share, lay_a, 0.0, 0.0); }
var acc = vec4(0.0);
if (!first) { acc = textureLoad(layer_tex, p, 0); }
if (share > 0.0) {
+7
View File
@@ -564,6 +564,13 @@ pub(crate) fn shape_key(layer: &Layer, canvas: Rect) -> u64 {
let fr = photocraft_compose::fill_frame(layer, canvas);
(fr.x0, fr.y0, fr.x1, fr.y1, content.is_some()).hash(&mut h);
}
// A filled shape's effect shape follows its outline too.
LayerContent::Shape(sh) => {
std::mem::discriminant(&layer.content).hash(&mut h);
if photocraft_compose::effect_outline(layer).is_some() {
format!("{:?}", sh.path).hash(&mut h);
}
}
other => std::mem::discriminant(other).hash(&mut h),
}
for s in [content, mask].into_iter().flatten() {
+202
View File
@@ -0,0 +1,202 @@
//! Device health (#243): notices a lost wgpu device or an uncaptured wgpu error so callers stop
//! issuing GPU work and fall back to the CPU compositor instead of panicking.
//!
//! wgpu's default uncaptured-error handler panics ("Buffer 'pc_compose_uniforms' is invalid"
//! after the OS killed a huge submission), and `Device::poll` panics on a lost device whatever
//! the handler. [`DeviceHealth::watch`] replaces the handler with one that records the fault,
//! registers the device-lost callback, and [`DeviceHealth::wait`] polls only while the device is
//! healthy (catching the poll's panic as a last resort). Every GPU entry point checks
//! [`DeviceHealth::fault`] first.
//!
//! Builds for the web too: the callbacks only touch atomics and a mutex.
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
/// Why the GPU can't be used any more.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Fault {
/// The device was lost (driver reset, the OS killed a submission, `Device::destroy`).
Lost(String),
/// An uncaptured wgpu error (out of memory, validation, internal): the GPU output can't be
/// trusted, and on most drivers the device is about to be lost anyway.
Error(String),
}
impl Fault {
/// Whether the device itself was lost (rather than an error reported).
pub fn is_lost(&self) -> bool {
matches!(self, Fault::Lost(_))
}
/// The driver's or wgpu's message.
pub fn detail(&self) -> &str {
match self {
Fault::Lost(s) | Fault::Error(s) => s,
}
}
}
impl fmt::Display for Fault {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Fault::Lost(s) if s.is_empty() => f.write_str("GPU device lost"),
Fault::Lost(s) => write!(f, "GPU device lost: {s}"),
Fault::Error(s) => write!(f, "GPU error: {s}"),
}
}
}
#[derive(Default)]
struct Inner {
faulted: AtomicBool,
fault: Mutex<Option<Fault>>,
errors: AtomicU64,
}
/// Shared health flag of one wgpu device. Cheap to clone; every clone sees the same state.
#[derive(Clone, Default)]
pub struct DeviceHealth(Arc<Inner>);
impl fmt::Debug for DeviceHealth {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DeviceHealth").field("fault", &self.fault()).field("errors", &self.errors()).finish()
}
}
impl DeviceHealth {
/// A healthy flag not attached to a device (tests, CPU-only callers).
pub fn new() -> Self {
Self::default()
}
/// Watch `device`: its device-lost callback and uncaptured errors mark the returned flag
/// instead of panicking. Replaces any handler set before (wgpu keeps one of each).
pub fn watch(device: &wgpu::Device) -> Self {
let health = Self::new();
let lost = health.clone();
// Called by wgpu with its own locks held: only record, never call back into wgpu.
device.set_device_lost_callback(move |reason, message| {
let detail = match (reason, message.is_empty()) {
(wgpu::DeviceLostReason::Destroyed, true) => "device destroyed".to_string(),
(_, true) => "lost by the driver".to_string(),
(_, false) => message,
};
log::error!("wgpu device lost ({reason:?}): {detail}");
lost.mark(Fault::Lost(detail));
});
let errors = health.clone();
device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| errors.uncaptured(&e)));
health
}
/// Record an uncaptured wgpu error.
fn uncaptured(&self, e: &wgpu::Error) {
self.0.errors.fetch_add(1, Ordering::Relaxed);
let text = e.to_string();
log::error!("uncaptured wgpu error: {text}");
// Errors about invalid or lost objects follow a device loss whose callback may not have
// run yet (it fires on the next poll).
let lower = text.to_ascii_lowercase();
let fault = if lower.contains("lost") || lower.contains("destroyed") { Fault::Lost(first_line(&text)) } else { Fault::Error(first_line(&text)) };
self.mark(fault);
}
/// Mark the device unusable. The first fault is kept; a later device loss upgrades an error.
pub fn mark(&self, fault: Fault) {
let mut slot = self.0.fault.lock().unwrap_or_else(PoisonError::into_inner);
let replace = match slot.as_ref() {
None => true,
Some(old) => !old.is_lost() && fault.is_lost(),
};
if replace {
*slot = Some(fault);
}
self.0.faulted.store(true, Ordering::Release);
}
/// Whether the device may still be used.
pub fn is_ok(&self) -> bool {
!self.0.faulted.load(Ordering::Acquire)
}
/// Why the device can't be used, if it can't.
pub fn fault(&self) -> Option<Fault> {
if self.is_ok() {
return None;
}
self.0.fault.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Uncaptured wgpu errors seen so far.
pub fn errors(&self) -> u64 {
self.0.errors.load(Ordering::Relaxed)
}
/// Block until `index` (or all submitted work) finished, unless the device is unusable.
/// Returns whether the device is still healthy afterwards. `Device::poll` panics on a lost
/// device, so a panic is caught and recorded as a loss (native only; the web has no blocking
/// wait).
#[cfg(not(target_arch = "wasm32"))]
pub fn wait(&self, device: &wgpu::Device, index: Option<wgpu::SubmissionIndex>) -> bool {
if !self.is_ok() {
return false;
}
let poll = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Wait { submission_index: index, timeout: None })));
match poll {
Ok(Ok(_)) => {}
Ok(Err(e)) => log::warn!("wgpu poll: {e}"),
Err(_) => self.mark(Fault::Lost("the device stopped responding".into())),
}
self.is_ok()
}
/// Non-blocking poll (reclaims staging memory), skipped once the device is unusable.
pub fn poll(&self, device: &wgpu::Device) {
if !self.is_ok() {
return;
}
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| device.poll(wgpu::PollType::Poll))).is_err() {
self.mark(Fault::Lost("the device stopped responding".into()));
}
}
}
fn first_line(s: &str) -> String {
let line = s.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("");
let mut out: String = line.chars().take(200).collect();
if line.chars().count() > 200 {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_fault_wins_but_loss_upgrades_an_error() {
let h = DeviceHealth::new();
assert!(h.is_ok());
assert_eq!(h.fault(), None);
h.mark(Fault::Error("out of memory".into()));
assert!(!h.is_ok());
let c = h.clone();
c.mark(Fault::Error("second".into()));
assert_eq!(h.fault(), Some(Fault::Error("out of memory".into())));
c.mark(Fault::Lost("reset".into()));
assert_eq!(h.fault(), Some(Fault::Lost("reset".into())));
c.mark(Fault::Lost("later".into()));
assert_eq!(h.fault().map(|f| f.to_string()).as_deref(), Some("GPU device lost: reset"));
}
#[test]
fn fault_text() {
assert_eq!(Fault::Lost(String::new()).to_string(), "GPU device lost");
assert_eq!(Fault::Error("x".into()).to_string(), "GPU error: x");
assert_eq!(first_line("\n Buffer 'pc' is invalid\nmore"), "Buffer 'pc' is invalid");
assert_eq!(first_line(&"a".repeat(300)).chars().count(), 201);
}
}
+45 -4
View File
@@ -33,6 +33,7 @@
pub mod bounds;
mod fx;
pub mod health;
pub mod plan;
use std::collections::HashMap;
@@ -45,6 +46,7 @@ use photocraft_doc::{DocId, Document, Layer, LayerContent, LayerId, Pattern};
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
pub use health::{DeviceHealth, Fault};
pub use plan::{Kernel, Plan, Role, Unsupported, plan};
/// Accumulator format for intermediate buffers. Full float: discontinuous operations
@@ -410,6 +412,8 @@ pub struct Compositor {
effect_maps: bool,
/// `CmykSpace::id` of the document being encoded (0: built-in coated CMYK).
cmyk: u64,
/// The device's health (see [`Compositor::set_health`]); `None` = assumed healthy.
health: Option<DeviceHealth>,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
@@ -598,6 +602,7 @@ impl Compositor {
acc_format,
effect_maps: map_error.is_none(),
cmyk: 0,
health: None,
}
.with_texture_limit(device.limits().max_texture_dimension_2d)
}
@@ -702,7 +707,7 @@ impl Compositor {
/// How far beyond a page cell the effect maps of `f` must be computed to be exact in it.
fn fx_apron(doc: &Document, f: &plan::FxLayer<'_>) -> i32 {
let vector_shape = matches!(f.layer.content, LayerContent::Shape(_));
let vector_shape = matches!(f.layer.content, LayerContent::Shape(_)) && photocraft_compose::effect_outline(f.layer).is_none();
let progs: Vec<fx::MapProgram> = f
.layer
.effects
@@ -758,6 +763,18 @@ impl Compositor {
self.fx.values().map(FxEntry::bytes).sum()
}
/// Watch `health` (see [`DeviceHealth::watch`]): once the device is lost or reported an
/// error, every render returns [`Unsupported`] without touching the GPU, so callers use the
/// CPU compositor instead.
pub fn set_health(&mut self, health: DeviceHealth) {
self.health = Some(health);
}
/// Why the device can't be used (`None` while healthy or unwatched).
pub fn fault(&self) -> Option<Fault> {
self.health.as_ref().and_then(DeviceHealth::fault)
}
/// Composite `region` of `doc`. Each finished chunk is passed to `sink` while its encoder is
/// still open; the chunk texture is reused afterwards, so the sink must record any copies
/// or passes that read it into the given encoder. Submits the work before returning; a
@@ -801,6 +818,9 @@ impl Compositor {
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
flush: bool,
) -> Result<Stats, Unsupported> {
if let Some(f) = self.fault() {
return Err(Unsupported(f.to_string()));
}
// CMYK layers convert through the document's own CMYK profile (uploads and plan colours).
let space = photocraft_compose::cmyk_space(doc);
self.cmyk = space.as_ref().map_or(0, |s| s.id);
@@ -892,7 +912,14 @@ impl Compositor {
let index = queue.submit([done.finish()]);
#[cfg(not(target_arch = "wasm32"))]
if let Some(prev) = last_submit.replace(index) {
let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None });
match &self.health {
Some(h) => {
h.wait(device, Some(prev));
}
None => {
let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None });
}
}
}
#[cfg(target_arch = "wasm32")]
let _ = index;
@@ -900,6 +927,10 @@ impl Compositor {
work = 0;
freed = 0;
stats.flushes += 1;
// The device was lost mid-refresh: issue no more work.
if let Some(f) = self.fault() {
return Err(Unsupported(f.to_string()));
}
}
}
@@ -1014,6 +1045,9 @@ impl Compositor {
let f = plan.fx.get(m.fx)?;
let key = (f.layer.id, paged.get(m.fx).copied().unwrap_or(false).then_some(cell));
let e = self.fx.get(&key)?;
if m.item == plan::SHAPE_MAP {
return Some((e.shape.view.clone(), e.region));
}
let t = e.progs.get(m.item)?.maps.get(m.map)?.as_ref()?;
Some((t.view.clone(), e.region))
}));
@@ -1445,7 +1479,8 @@ impl Compositor {
}
// Programs, and the distance fields they read (max reach per field).
let vector_shape = matches!(layer.content, LayerContent::Shape(_));
// Filled shapes stroke their outline (the effect shape); others estimate it.
let vector_shape = matches!(layer.content, LayerContent::Shape(_)) && photocraft_compose::effect_outline(layer).is_none();
let anchor = layer.effects.reference.unwrap_or((f64::from(f.bounds.x0), f64::from(f.bounds.y0)));
let progs: Vec<fx::MapProgram> =
layer.effects.items.iter().filter(|e| e.enabled()).map(|e| fx::program_with(e, &doc.global_light, vector_shape, &doc.patterns, anchor)).collect();
@@ -1820,7 +1855,13 @@ pub fn render_to_vec_stats(
for (_, b, _) in &staging {
b.slice(..).map_async(wgpu::MapMode::Read, |_| {});
}
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
match &comp.health {
Some(h) if !h.wait(device, None) => return Err(Unsupported(h.fault().map_or_else(|| "GPU device lost".into(), |f| f.to_string()))),
Some(_) => {}
None => {
let _ = device.poll(wgpu::PollType::Wait { submission_index: None, timeout: None });
}
}
let w = rect.width() as usize;
let mut out = vec![[0.0f32; 4]; w * rect.height() as usize];
for (r, b, row) in &staging {
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more