Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 46888b4bf5 Layout documents: fix GPU device loss, live Move tool, local damage, no per-frame work (#125, #128) (#226)
Merged with #241 (menu_bar returns the menus' right edge) and #240 (stroke trail field).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 10:53:03 -04:00
Brandon Thomas 0f955e6c82 Scorecard: cargo xtask perf + scorecard, budgets, checklists, nightly perf (#221) (#239) (#239) 2026-10-05 10:53:02 -04:00
Brandon Thomas 1d9d92341f PSD effects fidelity: spread metric, emboss, gradient geometry, text gamma (113 -> 118 oracle passes) (#224) (#224) 2026-10-05 10:52:47 -04:00
Brandon Thomas 109498a746 Fix live brush preview drawing a polygon (#189) (#240) (#240) 2026-10-05 10:52:33 -04:00
Brandon Thomas 7f3d9ea68a Packaging: Linux runtime deps + start-up library check (#201); wasm 26.3 -> 18.8 MiB (#198) (#237) (#237) 2026-10-05 10:52:20 -04:00
Brandon Thomas faf52b5657 Title bar: never draw the window title over the menus or controls (#241) (#241) 2026-10-05 10:52:06 -04:00
Brandon ThomasandClaude Opus 5.5 5e6db41fc2 FreeBSD: serialise canvas GPU tests, CI template, RAM detection, CJK font paths (#227) (#227)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 09:58:58 -04:00
John HassandJohn Hass a502a227a9 Fix automatic UI scaling on 4K monitors (#225) (#225)
Co-authored-by: John Hass <[email protected]>
2026-10-05 09:58:45 -04:00
Brandon Thomas a0823c5e41 FreeBSD: build and test the workspace in a FreeBSD VM (#223) (#223) 2026-10-05 09:02:29 -04:00
shou 349dfa0e57 fix(clipboard): use the wayland clipboard for images (#222) (#222) 2026-10-05 09:02:14 -04:00
Goran MekićandClaude Opus 5.5 2ad42d028a Fix tests on FreeBSD with RADV (#194) (#194)
Rebased onto the paged GPU compositor (#187): the first-frame empty submit moves into encode_inner, and the test GPU lock covers the paged compositor too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 09:01:58 -04:00
Brandon Thomas 4832e08aad PSD fidelity: 16/32-bit colour modes, Lab, Levels, Exposure, Dissolve oracle (corpus 133 → 140, psd-tools 212 → 222) (#202) (#202) 2026-10-05 08:42:05 -04:00
Brandon Thomas 4b53f620ef Tool feedback: selection-mode cursor badges, scrubby zoom, contrasting outlines, Shift lines (#188) (#188) 2026-10-05 08:01:52 -04:00
Brandon Thomas 574d0d3d53 PSD corpus: psd-tools set (pinned, verified), an oracle for every file, and the fixes it found (#195) (#195) 2026-10-05 08:01:39 -04:00
Brandon Thomas a4380b2953 Properties: uniform sections, per-kind Quick Actions, full-width controls (#155) (#192) (#192) 2026-10-05 08:01:25 -04:00
Brandon ThomasandClaude Opus 5.5 8877a812f9 GPU compositor: page layer surfaces beyond 16384 px with a memory budget (#187) (#187)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:49:38 -04:00
Brandon ThomasandClaude Opus 5.5 b22deaa944 Layers mask thumbnails + chains; Channels/Paths temporary rows and footers (#153, #154) (#191)
* Layers rows: vector-mask thumbnail, link chains, red X on disabled masks (#153)

Each mask gets a chain (click toggles layer.layerMask.linked /
layer.vectorMask.linked) and a thumbnail; disabled masks are crossed out
and Shift-click toggles them. Vector masks render white/grey, cached and
pruned like the other row thumbnails. Thumbnails shrink on narrow rows so
the name keeps its minimum width.

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

* Channels/Paths: temporary layer mask and layer path rows, aspect thumbnails, bottom footers (#154)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:45:12 -04:00
Brandon ThomasandClaude Opus 5.5 f5c6f4bb65 UI and Type tool CJK fallback: Japanese, Simplified/Traditional Chinese and Korean system fonts, lazily loaded in locale order (#149) (#185)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:23:03 -04:00
Brandon ThomasandClaude Opus 5.5 f857b4a31f Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91) (#183)
* Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91)

Move tool: Shift locks the drag to the dominant axis (re-evaluated with
hysteresis, so moving far enough the other way switches it); Alt at drag
start duplicates the selected layers once the pointer moves and moves the
copies, folded into one "Duplicate + Move" history step; arrow keys nudge
1/10 px (Alt duplicates first) and nudge the Free Transform box. All in a new
move_mods module with two one-line hooks in canvas::tool_event.

Free Transform: Shift on the body locks to 8 directions, Alt-click moves the
reference point, Cmd on an edge skews (Shift: along the edge, Alt: about the
centre), Cmd+Alt+Shift on a corner = perspective.

Preview (#91): the moving pixels were sampled down to 2048 px (nearest
decimation) and drawn bilinear, so a 24 MP layer previewed at 1/3
resolution. Now uploaded at full resolution (up to the device limit, box
filtered beyond), with a lazily built box-filtered mip chain for zoomed-out
views and nearest filtering at >= 200% (or Nearest Neighbor interpolation).

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

* test: clear texture deltas in transform gpu_ctx helper

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:50 -04:00
Brandon ThomasandClaude Opus 5.5 b9809ca63c Dock polish: Layers gets the height, Character/Paragraph group, eliding tab strips, reveal the active layer (#147, #150, #151, #152) (#181)
- #147: default-sized dock groups give way (down to compact heights) so the
  filler (Layers) keeps a preferred 500 pt: 10+ rows at 1440x900, ~5 at
  1280x720 (was ~2.5 and 0). Heights the user dragged to are untouched; the
  first splitter drag pins the other groups at the heights they show.
- #150: Window > Character / Paragraph (and Type > Panels) open a new
  Character | Paragraph dock group below Properties instead of switching
  Properties; choosing the visible one again closes it; workspace presets and
  Reset close it. Saved layouts missing a group slot it below its default
  predecessor, so it never lands under Layers.
- #151: dock tab strips (Pro and Studio) fit left of the panel menu button:
  padding shrinks, then labels elide with '...', then overflowing tabs move
  into a >> chevron menu; the selected tab always stays on the strip.
- #152: when the active layer changes by any means (canvas auto-select,
  Select commands, undo, automation) the Layers panel opens its closed parent
  groups (layer.setExpanded) and scrolls the row into view if it is hidden.
- Tests: rows visible at 900/720 pt; Character keeps Properties and toggles;
  tab rects never intersect the menu button in every theme at 1x/2x and
  180-420 pt; chevron menu switches tabs; reveal expands and scrolls (and
  doesn't scroll for visible rows); undo reveals. Adjustment-editor and
  shortcut tests no longer depend on default group heights.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:37 -04:00
Nigel Ihza Firdausy d94a8f617a fix(ui): avoid duplicate sliders for wide filter ranges (#179) 2026-10-05 07:22:22 -04:00
Nigel Ihza Firdausy 043e050237 fix(ui): pad Curves graph around endpoint handles (#177) 2026-10-05 07:22:07 -04:00
Nigel Ihza Firdausy d8cf255c47 fix(ui): sample eyedropper while dragging (#176) 2026-10-05 07:21:52 -04:00
Nigel Ihza Firdausy 107302f05c fix(ui): rerender Liquify proxy after undo (#175) 2026-10-05 07:21:37 -04:00
Brandon ThomasandClaude Opus 5.5 1c4cb8307c Brushes: Tilt Scale, Brush Projection, Spacing checkbox, section locks, Mixer Wetness/Mix; Brushes grid + drag and drop; options bar through tools.setBrush (#174)
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush

Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).

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

* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields

- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
  the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
  the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
  tool options map instead of warning.

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

* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush

- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
  Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
  and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
  drag group headers to reorder, context menus to rename or delete presets and groups; all via
  brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
  edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:23 -04:00
Brandon ThomasandClaude Opus 5.5 433b84fae7 Docs: where we stand and where we're going (roadmap, README, AGENTS.md) (#186)
roadmap.md: the honest parity assessment now breaks parity down by dimension with dated
measurements (PSD oracle 115/170, round trips 169/169, shortcut audit 0 failures, perf numbers,
RAW coverage, AI 0%, ~20 missing tools...), an overall estimate (feature surface ~60-70%,
pro-switchable ~25-35%), and a priority order for what's next.
README: the status note says plainly that PhotoCraft isn't yet a daily-driver Photoshop
replacement, what the biggest gaps are, and links the assessment.
AGENTS.md: agents read the assessment first when picking work and update it when numbers move.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:14:53 -04:00
Brandon ThomasandClaude Opus 5.5 3389ba558b Menus scroll and navigate by keyboard on small displays; Color Range dialog on the no-GPU path (#145, #138) (#168)
* Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)

Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

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

* Color Range: test the dialog in the full app without a GPU; show preview failures (#145)

The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:25 -04:00
163 changed files with 19310 additions and 1600 deletions

No files matched your search

+6
View File
@@ -60,6 +60,12 @@ jobs:
- name: Dependency layering
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
wasm:
name: wasm32 check
runs-on: ubuntu-latest
+48
View File
@@ -0,0 +1,48 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
freebsd:
name: FreeBSD 14 (build + test)
runs-on: ubuntu-latest
timeout-minutes: 300
steps:
- uses: actions/checkout@v4
- name: Build and test in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
copyback: false
prepare: |
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
rustc --version
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
+91
View File
@@ -0,0 +1,91 @@
name: Perf (nightly)
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
# more than 15 % against perf/baseline.json from the same machine class (other classes are
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
#
# To give this runner class its own baseline: run it by hand with "update_baseline", download
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
on:
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
inputs:
quick:
description: "Quick subset (small documents, no budgets)"
type: boolean
default: false
update_baseline:
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: perf-nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
perf:
name: perf (macOS, Apple GPU)
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
runs-on: macos-15
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: perf-release
- name: Run benchmarks
id: perf
env:
QUICK: ${{ inputs.quick }}
UPDATE: ${{ inputs.update_baseline }}
run: |
args=()
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
cargo xtask perf "${args[@]}"
- name: Job summary
if: always()
run: |
if [ -f target/perf/summary.md ]; then
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
else
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-results
path: |
target/perf/*.json
target/perf/summary.md
if-no-files-found: warn
retention-days: 30
- name: Upload baseline
if: always() && inputs.update_baseline
uses: actions/upload-artifact@v4
with:
name: perf-baseline
path: perf/baseline.json
if-no-files-found: warn
retention-days: 30
+18 -5
View File
@@ -11,8 +11,9 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
| `docs/roadmap.md` | Milestones and the **current focus** |
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
## 2. Workspace map
@@ -32,7 +33,7 @@ apps/
photocraft desktop app (eframe/wgpu), TCP control server
photocraft-cli headless CLI (convert/info/run/batch/commands/mcp)
photocraft-web the same app in the browser (trunk + wasm-bindgen)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity | perf | scorecard
```
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
@@ -67,9 +68,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
1. `docs/roadmap.md` → **Current focus**.
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
3. `log/devlog.md` → the "Still open" bullets of recent entries.
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
round trips, workflow tests, performance), update that section with the dated figure.
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
the performance scenarios that are over budget or not measurable yet, and the count of
settings that do nothing. Its numbers are measured; prefer them to estimates.
2. `docs/roadmap.md` → **Current focus**.
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
4. `log/devlog.md` → the "Still open" bullets of recent entries.
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
@@ -82,6 +90,10 @@ cargo xtask layers
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
```
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
@@ -101,6 +113,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
- `docs/parity.md`: generated Photoshop menu coverage.
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
+5
View File
@@ -51,3 +51,8 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
`cargo xtask corpus --psd-tools` fetches every PSD/PSB of the psd-tools test set
([`tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files), MIT,
Copyright (c) 2019 Kota Yamaguchi) at a pinned commit into `corpus/psd-tools/`, verified against
the sha256 list in `xtask/psd-tools-corpus.sha256`, with the upstream `LICENSE` next to them.
Generated
+73
View File
@@ -248,6 +248,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1556,6 +1557,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -2614,6 +2621,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "memory-stats"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3204,6 +3221,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3303,6 +3330,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3639,9 +3677,11 @@ dependencies = [
name = "photocraft-testkit"
version = "0.2.0"
dependencies = [
"memory-stats",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"serde_json",
]
[[package]]
@@ -3680,7 +3720,9 @@ dependencies = [
"photocraft-io",
"photocraft-plugins",
"photocraft-raster",
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"rayon",
"serde",
"serde_json",
@@ -5084,6 +5126,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "twox-hash"
version = "2.1.4"
@@ -6121,6 +6174,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.4"
@@ -6200,7 +6271,9 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
name = "xtask"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
"toml",
]
[[package]]
+30
View File
@@ -68,3 +68,33 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
[profile.wasm-release.package.photocraft-raster]
opt-level = 3
[profile.wasm-release.package.photocraft-color]
opt-level = 3
[profile.wasm-release.package.photocraft-cms]
opt-level = 3
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+1 -1
View File
@@ -267,7 +267,7 @@ flatpak run ai.storyteller.photocraft
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Documentation
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-wasm-opt="z" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
#photocraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
+1 -1
View File
@@ -29,7 +29,7 @@ log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = "3"
arboard = { version = "3", features = ["wayland-data-control"] }
open = "5"
[target.'cfg(target_os = "macos")'.dependencies]
+413
View File
@@ -0,0 +1,413 @@
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
//!
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
//! before eframe starts, we look for the libraries the current session type needs and, if one is
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
//! crash).
//!
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
//!
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
//! touches the system, and it is only called on Linux.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::{Path, PathBuf};
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplaySession {
Wayland,
X11,
/// Neither is set: winit reports that itself (an error, not a panic).
None,
}
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
DisplaySession::Wayland
} else if set("DISPLAY") {
DisplaySession::X11
} else {
DisplaySession::None
}
}
/// A runtime-loaded library and the package that provides it on each distro family.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lib {
pub soname: &'static str,
pub debian: &'static str,
pub fedora: &'static str,
pub arch: &'static str,
pub suse: &'static str,
}
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
Lib { soname, debian, fedora, arch, suse }
}
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
/// One requirement: at least one of these libraries must be present.
pub type AnyOf = &'static [Lib];
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
const GPU: AnyOf = &[VULKAN, EGL];
match session {
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
DisplaySession::None => Vec::new(),
}
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let line = line.trim_start();
let Some((name, rest)) = line.split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// A real `ldconfig -p` listing starts with "N libs found in cache".
fn ldconfig_output_is_valid(output: &str) -> bool {
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
}
/// Where we look for libraries, and what we found there.
pub struct Probe {
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
pub ldconfig: Option<String>,
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
pub dirs: Vec<PathBuf>,
}
impl Probe {
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
}
}
/// The outcome of the check.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Ok,
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
/// cache was read, so the libraries really are absent (and winit would fail or panic).
Missing {
missing: Vec<AnyOf>,
conclusive: bool,
},
}
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
}
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distro {
Debian,
Fedora,
Arch,
Suse,
Unknown,
}
pub fn distro_from_os_release(text: &str) -> Distro {
let mut ids = Vec::new();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else { continue };
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
}
}
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
if any(&["debian", "ubuntu"]) {
Distro::Debian
} else if any(&["fedora", "rhel", "centos"]) {
Distro::Fedora
} else if any(&["arch"]) {
Distro::Arch
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
Distro::Suse
} else {
Distro::Unknown
}
}
fn package(lib: &Lib, distro: Distro) -> &'static str {
match distro {
Distro::Debian | Distro::Unknown => lib.debian,
Distro::Fedora => lib.fedora,
Distro::Arch => lib.arch,
Distro::Suse => lib.suse,
}
}
fn install_command(packages: &[&str], distro: Distro) -> String {
let list = packages.join(" ");
match distro {
Distro::Debian => format!("sudo apt install {list}"),
Distro::Fedora => format!("sudo dnf install {list}"),
Distro::Arch => format!("sudo pacman -S {list}"),
Distro::Suse => format!("sudo zypper install {list}"),
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
}
}
/// The message for missing libraries: what's missing, and the command that installs it.
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
let session_name = match session {
DisplaySession::Wayland => "Wayland",
DisplaySession::X11 => "X11",
DisplaySession::None => "display",
};
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
// For an either/or requirement, suggest its first alternative.
let mut packages: Vec<&str> = Vec::new();
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
if !packages.contains(&p) {
packages.push(p);
}
}
let mut msg = if conclusive {
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
} else {
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
};
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
if distro == Distro::Unknown {
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
}
if conclusive {
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
}
msg
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
dirs.push(PathBuf::from(d));
}
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
fn read_ldconfig() -> Option<String> {
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
&& out.status.success()
{
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if ldconfig_output_is_valid(&text) {
return Some(text);
}
}
}
None
}
/// Run the check against this system. `Err` carries the message to print before exiting with a
/// non-zero status; a warning is printed here and the app starts.
pub fn preflight() -> Result<(), String> {
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
return Ok(());
}
let session = session_from_env(|k| std::env::var(k).ok());
if session == DisplaySession::None {
return Ok(());
}
let probe = Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) };
match check(session, &probe, |p| p.exists()) {
Verdict::Ok => Ok(()),
Verdict::Missing { missing, conclusive } => {
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
let msg = missing_message(session, &missing, distro, conclusive);
if conclusive {
Err(msg)
} else {
eprint!("{msg}");
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| map.get(k).cloned()
}
#[test]
fn session_follows_winit_order() {
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
}
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
assert!(ldconfig_output_is_valid(CACHE));
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
// Prefix of another name, or a missing name, is not a match.
assert!(!ldconfig_has(CACHE, "libX11.so", None));
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
assert!(!ldconfig_has("", "libX11.so.6", None));
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
}
fn cache_for_this_arch(names: &[&str]) -> String {
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
for n in names {
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
}
s
}
const ALL_X11: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
#[test]
fn x11_without_xkbcommon_x11_is_reported() {
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
let v = check(DisplaySession::X11, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
assert!(conclusive);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
}
#[test]
fn complete_systems_pass() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
// Wayland doesn't need the X11 libraries; no session needs nothing.
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
}
#[test]
fn gpu_needs_vulkan_or_egl() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
assert_eq!(missing.len(), 1);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
}
#[test]
fn library_dirs_count_and_unread_cache_only_warns() {
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
// Found by file in a directory (no ldconfig, as on NixOS).
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
let v = check(DisplaySession::Wayland, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
assert!(!conclusive);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
assert!(msg.contains("Starting anyway"), "{msg}");
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
}
#[test]
fn distro_detection() {
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
assert_eq!(distro_from_os_release(""), Distro::Unknown);
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
}
}
+11
View File
@@ -17,6 +17,9 @@
mod apple_events;
mod control_server;
mod crash_guard;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod monitor_profile;
mod services;
@@ -62,6 +65,14 @@ fn main() -> eframe::Result {
}
}
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
#[cfg(target_os = "linux")]
if let Err(message) = linux_libs::preflight() {
eprint!("{message}");
std::process::exit(1);
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
+24 -8
View File
@@ -80,6 +80,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@@ -132,14 +133,29 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
})),
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})),
clipboard_get_image: Some(Box::new(|| {
let img = arboard::Clipboard::new().ok()?.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?),
};
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})
}),
clipboard_get_image: Some({
let clip = clip.clone();
Box::new(move || {
let mut slot = clip.try_borrow_mut().ok()?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
};
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
// Crash recovery: background incremental .pcraft saves into the recovery directory.
+48 -8
View File
@@ -30,6 +30,15 @@ impl Transfer {
_ => Transfer::Srgb,
}
}
/// The tone curve Exposure linearises through: RGB documents use a pure 2.2 power, not the
/// sRGB curve (psd-tools adjustment_nested_composition_4: an offset of 0.1738 lifts 76 to 134,
/// not 136; ag-psd masks), grey documents their dot-gain gamma.
pub fn for_exposure(self) -> Self {
match self {
Transfer::Srgb => Transfer::Gamma(2.2),
t => t,
}
}
fn decode(self, v: f32) -> f32 {
match self {
Transfer::Srgb => photocraft_color::convert::srgb_to_linear(v.max(0.0)),
@@ -51,6 +60,13 @@ pub fn apply(adj: &Adjustment, buf: &mut Buffer) {
/// Applies an adjustment with the document's tone transfer.
pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
apply_depth(adj, buf, transfer, None);
}
/// Applies an adjustment with the document's tone transfer, on samples with `quantum` steps
/// per unit (the document's integer depth, see `adjustment_quantum`): Levels then works on
/// whole levels like Photoshop's (see [`levels_q`]).
pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, quantum: Option<f32>) {
match adj {
Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),
Adjustment::Threshold { level } => {
@@ -89,6 +105,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
// through the document tone curve (fitted on the corpus).
let m = 2f32.powf(*exposure);
let g = gamma.max(0.01);
let transfer = transfer.for_exposure();
map_rgb(buf, |c| {
c.map(|v| {
let lin = (transfer.decode(v) * m + offset).max(0.0).powf(1.0 / g);
@@ -97,7 +114,7 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
})
}
Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
let luts = tone_luts(adj);
let luts = tone_luts_q(adj, quantum);
match space {
ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
@@ -203,12 +220,18 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
/// `per_channel` channels then black (identity unless the space is CMYK). In Lab there is no
/// composite record, so the master is ignored.
pub fn tone_luts(adj: &Adjustment) -> [Vec<f32>; 4] {
tone_luts_q(adj, None)
}
/// [`tone_luts`] for samples with `quantum` steps per unit (Levels works on whole levels; see
/// [`levels_q`]).
pub fn tone_luts_q(adj: &Adjustment, quantum: Option<f32>) -> [Vec<f32>; 4] {
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
match adj {
Adjustment::Levels { master, per_channel, space, black } => {
let ident = LevelsChannel::default();
let m = if *space == ToneSpace::Lab { &ident } else { master };
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels(c, levels(m, x(k)))).collect();
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels_q(c, levels_q(m, x(k), quantum), quantum)).collect();
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &ident })]
}
Adjustment::Curves { master, per_channel, space, black } => {
@@ -458,20 +481,37 @@ pub fn posterize(v: f32, levels: u32) -> f32 {
}
pub fn levels(ch: &LevelsChannel, v: f32) -> f32 {
levels_q(ch, v, None)
}
/// [`levels`] on samples with `quantum` steps per unit (8-bit: 255). Photoshop works on whole
/// levels: the input is a level, and the input-range stretch is rounded (half up) to a whole
/// level before the midtone gamma, so a 44..214 range maps 45, 46, 47, 48 → 1.5, 3, 4.5, 6 →
/// 2, 3, 5, 6 (psd-tools levels_grayscale, where the gamma toe magnifies the steps). In a LUT
/// the result is a staircase that is exact at every level.
pub fn levels_q(ch: &LevelsChannel, v: f32, quantum: Option<f32>) -> f32 {
let range = (ch.in_white - ch.in_black).max(1e-6);
let u = ((v - ch.in_black) / range).clamp(0.0, 1.0);
let u = match quantum.filter(|q| q.is_finite() && *q >= 1.0) {
Some(q) => {
let v = (v * q).round() / q;
(((v - ch.in_black) / range).clamp(0.0, 1.0) * q + 0.5 + 1e-3).floor() / q
}
None => ((v - ch.in_black) / range).clamp(0.0, 1.0),
};
let g = ch.gamma.max(0.01);
let t = if g > 1.0 {
// A midtone gamma > 1 lifts shadows, and a pure `u^(1/g)` has infinite slope at black —
// which Photoshop bounds, giving a soft shadow toe (initial slope 2^gamma, verified against
// the real app). Soft-min (p-norm) of the power curve with the slope-2^gamma line; reduces
// to the exact power curve in the body, so highlights are unchanged. gamma <= 1 is untouched.
// which Photoshop bounds, giving a soft shadow toe (initial slope ≈ 2^gamma, verified against
// the real app). Soft-min (p-norm) of the power curve with that line; reduces to the exact
// power curve in the body, so highlights are unchanged. gamma <= 1 is untouched. Slope and
// sharpness fitted on whole-level input (`levels_q`): a 0..255 ramp at gamma 2.0 and
// psd-tools levels_grayscale's gamma 1.78 (within one level of Photoshop on both).
let power = u.powf(1.0 / g);
let line = 2.0f32.powf(g) * u;
let line = 0.93 * 2.0f32.powf(g) * u;
if power <= 1e-6 || line <= 1e-6 {
power.min(line)
} else {
let p = 1.8 + 8.8 / g; // p-norm sharpness, fit to Photoshop ground truth
let p = 10.0; // p-norm sharpness, fit to Photoshop ground truth
(power.powf(-p) + line.powf(-p)).powf(-1.0 / p)
}
} else {
+122 -35
View File
@@ -5,17 +5,19 @@
//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows,
//! inner shadows, strokes and the inner bevel on top. Exterior effects blend
//! straight into the backdrop with their own modes; the layer and its
//! interior effects blend with the layer's mode. Layer opacity applies to
//! the whole stack; fill opacity only to the layer's own pixels.
//! interior effects blend with the layer's mode. Emboss and pillow emboss then
//! shade the composited result (inside and outside halves). Layer opacity
//! applies to the whole stack; fill opacity only to the layer's own pixels.
//!
//! Interior effects are painted relative to the layer's shape and then take the shape's alpha
//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
//! lower ones. Drop shadows are knocked out only through see-through fill.
//!
//! Shapes come from the layer's alpha (after its mask). Strokes measure distances with a
//! 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does); other effects
//! use an exact Euclidean distance transform. Soft falloffs are Gaussian blurs.
//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure
//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does);
//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs
//! are two box blurs (a tent).
use photocraft_color::blend::BlendMode;
use photocraft_doc::Pattern;
@@ -242,9 +244,9 @@ fn dist_outside(s: &Map) -> Vec<f32> {
/// Distance metric for effect distance fields.
#[derive(Clone, Copy, PartialEq)]
enum Metric {
/// Exact Euclidean (spread/choke dilation, glows, bevels).
/// Exact Euclidean (precise glows, bevels).
Euclidean,
/// 5 × 5 chamfer (strokes; see [`chamfer_nearest`]).
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
Chamfer,
}
@@ -326,12 +328,16 @@ fn blur(m: &mut Map, size: f32) {
}
}
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge.
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge. Distances use
/// the 5 × 5 chamfer metric, as for strokes: Photoshop's spread of a group drop shadow around an
/// ellipse (ag-psd group-drop-shadows) is round at 0° and 45° and falls short in between, peaking
/// near 15° where the chamfer metric overestimates most (5/255 lighter with an exact Euclidean
/// dilation, within 2/255 with the chamfer).
fn dilate(s: &Map, r: f32) -> Map {
if r <= 0.0 {
return s.clone();
}
let d = dist_outside(s);
let d = dist_outside_by(s, Metric::Chamfer);
let mut out = s.clone();
for (o, d) in out.v.iter_mut().zip(d) {
// Partly covered pixels (distance sentinel < 0) grow by `r` from their own coverage, so a
@@ -432,9 +438,13 @@ pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, o
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
// span the width / height.
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0) * scale.max(1e-3);
// Linear / Reflected sample the pixel's top-left corner, half a pixel before its centre
// (with the whole-pixel end points of `fill_layout`: layer_effects' overlay 5.8 → 1.8/255,
// shape-fx2 2.8 → 1.1/255, gradient-fill.psd 1.9 → 0.5/255).
let corner = 0.5 * (c - s);
let mut t = match style {
GradientStyle::Linear => along / chord + 0.5,
GradientStyle::Reflected => (along / (chord / 2.0)).abs(),
GradientStyle::Linear => (along - corner) / chord + 0.5,
GradientStyle::Reflected => ((along - corner) / (chord / 2.0)).abs(),
GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0),
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
// Clockwise sweep starting at the gradient angle.
@@ -615,13 +625,14 @@ fn paint_fx(
FxPaint::Color(c) => paint_color(dst, m, rgb(c), blend, opacity),
FxPaint::Gradient(g) => {
let prepared = PreparedGradient::new(g);
let (angle, scale, offset) = crate::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, shape_bounds);
let w = big.width() as usize;
paint(
dst,
m,
|i| {
let (x, y) = ((big.x0 + (i % w) as i32) as f32 + 0.5, (big.y0 + (i / w) as i32) as f32 + 0.5);
prepared.sample(gradient_t(g.style, g.angle, g.scale, g.reverse, g.offset, shape_bounds, x, y))
prepared.sample(gradient_t(g.style, angle, scale, g.reverse, offset, shape_bounds, x, y))
},
blend,
opacity,
@@ -811,7 +822,7 @@ pub const BEVEL_H_EPS: f32 = 1e-5;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BevelGeom {
pub paint: BevelPaint,
/// Box width of the smooth height blur (size; half the size for emboss styles).
/// Box width of the smooth height blur (size; half the size, rounded up, for emboss styles).
pub width: f32,
/// Height-map gradient scale: depth × width (smooth) or depth × size (chisel), signed by
/// direction.
@@ -830,7 +841,10 @@ pub fn bevel_geom(b: &Bevel) -> BevelGeom {
BevelStyle::Emboss | BevelStyle::PillowEmboss => BevelPaint::Both,
BevelStyle::InnerBevel | BevelStyle::StrokeEmboss => BevelPaint::Inner,
};
let width = if emboss { size / 2.0 } else { size };
// Emboss styles straddle the edge with half the size, rounded up to whole pixels: a 41 px
// emboss blurs over 21 (psd-tools layer_effects: 20.5 left narrow letter parts up to
// 5.7/255 dark, 21 is within 2.2).
let width = if emboss { (size / 2.0).ceil() } else { size };
let smooth = b.technique == BevelTechnique::Smooth;
let sign = if b.up { 1.0 } else { -1.0 };
BevelGeom {
@@ -922,7 +936,16 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
let (sa, ca) = angle.to_radians().sin_cos();
let (se, ce) = altitude.to_radians().sin_cos();
let light_v = [ca * ce, -sa * ce, se];
let shade_into = |depth: f32, outer: bool| -> (Map, Map) {
// Region: 0 = inside (× the shape's alpha), 1 = under the layer's edge and outside.
// A pixel is on the bevel where its height is raised. Emboss styles also shade the last pixel
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
// bevel-outer-smooth).
let reach_past = g.paint == BevelPaint::Both;
let on_bevel = |x: i64, y: i64| {
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
};
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
for y in 0..shape.h as i64 {
for x in 0..shape.w as i64 {
@@ -932,7 +955,7 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
let region = if outer { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && hmap.v[i] > BEVEL_H_EPS) } else { shape.v[i] };
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
let k = shade - se;
if k > 0.0 {
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
@@ -945,13 +968,21 @@ fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, pat
};
let maps = match g.paint {
BevelPaint::Inner | BevelPaint::Outer => {
let (hi, sh) = shade_into(g.depth, g.paint == BevelPaint::Outer);
let (hi, sh) = shade_into(g.depth, u8::from(g.paint == BevelPaint::Outer));
vec![hi, sh]
}
BevelPaint::Both => {
let (hi, sh) = shade_into(g.depth, false);
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, true);
vec![hi, sh, ho, so]
// One map per colour over the whole bevel: the inside half where the shape is (× its
// alpha), the outside half (pillow: facing the other way) for the rest of each pixel.
// A 1 % edge pixel of a pillow emboss takes the outside shading (Photoshop oracle
// bevel-pillow-smooth), a 75 % one of an emboss mostly the inside (layer_effects).
let (hi, sh) = shade_into(g.depth, 0);
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
let mix = |a: Map, b: Map| -> Map {
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
Map { w: a.w, h: a.h, v }
};
vec![mix(hi, ho), mix(sh, so)]
}
};
(maps, g.paint)
@@ -1196,7 +1227,7 @@ pub(crate) fn composite_with_effects_prepared(
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] != BevelPaint::Outer
&& maps.bevel_paint[i] == BevelPaint::Inner
{
let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1)));
paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
@@ -1257,10 +1288,9 @@ pub(crate) fn composite_with_effects_prepared(
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] != BevelPaint::Inner
&& maps.bevel_paint[i] == BevelPaint::Outer
{
let k = if maps.bevel_paint[i] == BevelPaint::Both { 2 } else { 0 };
let (hi, sh) = (fx(i, k), fx(i, k + 1));
let (hi, sh) = (fx(i, 0), fx(i, 1));
paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
@@ -1274,6 +1304,17 @@ pub(crate) fn composite_with_effects_prepared(
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
}
}
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Both
{
paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
// Layer opacity applies to the whole stack.
let op = layer.opacity;
for y in rect.y0..rect.y1 {
@@ -1305,15 +1346,15 @@ fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
/// `glow_map`, `bevel_maps` and `build_maps`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FieldKind {
/// `dist_outside` of the shape: spread of drop shadows and softer outer glows, precise outer
/// and centre glows, bevels.
/// `dist_outside` of the shape: precise outer and centre glows, bevels.
Outside,
/// `dist_inside` of the shape: precise edge inner glows, bevels.
Inside,
/// `dist_outside` of `1 - alpha`: choke of inner shadows, softer inner glows.
OutsideInverse,
/// Stroke distance outside the shape.
/// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and
/// softer outer glows ([`dilate`]).
StrokeOutside,
/// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows.
ChokeInside,
/// Stroke distance inside the shape.
StrokeInside,
/// Stroke distance outside a shape layer's outline (from its local coverage).
@@ -1326,8 +1367,8 @@ pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize) -> V
match kind {
FieldKind::Outside => dist_outside(&s),
FieldKind::Inside => dist_inside(&s),
FieldKind::OutsideInverse => dist_outside(&s.map(|a| 1.0 - a)),
FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer),
FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer),
FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer),
FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer),
}
@@ -1374,9 +1415,10 @@ mod tests {
let b = Rect::new(0, 0, 100, 100);
let t = |s, x, y| gradient_t(s, 0.0, 1.0, false, (0.0, 0.0), b, x, y);
assert!(t(GradientStyle::Linear, 0.0, 50.0) < 0.01);
assert!((t(GradientStyle::Linear, 50.0, 50.0) - 0.5).abs() < 1e-6);
// Linear / reflected sample pixel corners: the centre is reached half a pixel later.
assert!((t(GradientStyle::Linear, 50.5, 50.0) - 0.5).abs() < 1e-6);
assert!(t(GradientStyle::Linear, 100.0, 50.0) > 0.99);
assert!(t(GradientStyle::Reflected, 50.0, 50.0) < 1e-6);
assert!(t(GradientStyle::Reflected, 50.5, 50.0) < 1e-6);
assert!((t(GradientStyle::Reflected, 0.0, 50.0) - 1.0).abs() < 1e-6);
assert!(t(GradientStyle::Radial, 50.0, 50.0) < 1e-6);
assert!((t(GradientStyle::Radial, 50.0, 0.0) - 1.0).abs() < 1e-6);
@@ -1387,6 +1429,18 @@ mod tests {
assert!(rev > 0.99);
}
#[test]
fn effect_gradients_snap_end_points_and_sample_corners() {
// 87° over 600 × 60 at (99, 422): end points (397, 482) and (400, 422), so the overlay runs
// along (3, -60) from its midpoint (398.5, 452), sampled at pixel corners.
let frame = Rect::new(99, 422, 699, 482);
let (a, sc, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 87.0, 1.0, (0.0, 0.0), frame);
assert!((a - (60f32).atan2(3.0).to_degrees()).abs() < 1e-3, "{a}");
let t = |x: f32, y: f32| gradient_t(GradientStyle::Linear, a, sc, false, o, frame, x, y);
assert!((t(399.0, 452.5) - 0.5).abs() < 1e-4, "{}", t(399.0, 452.5));
assert!(t(400.5, 422.5) > 0.99 && t(397.5, 482.5) < 0.01);
}
#[test]
fn chamfer_uses_euclidean_step_lengths() {
let (w, h) = (9, 9);
@@ -1402,6 +1456,19 @@ mod tests {
assert!(near.iter().all(|&n| n == 0));
}
#[test]
fn spread_dilates_with_the_chamfer_metric() {
// One opaque pixel grown by 3 px: at offset (1, 3) the chamfer distance is √5 + 1 = 3.236
// (Euclidean 3.162), so the anti-aliased rim keeps 3 + 0.5 − (3.236 − 0.5) = 0.764.
let mut m = Map::new(9, 9, 0.0);
m.v[4 * 9 + 4] = 1.0;
let d = dilate(&m, 3.0);
assert!((d.v[7 * 9 + 5] - 0.764).abs() < 1e-3, "{}", d.v[7 * 9 + 5]);
// (2, 3): √5 + √2 = 3.650 (Euclidean 3.606) leaves 0.350; axis steps are exact.
assert!((d.v[7 * 9 + 6] - (4.0 - 5f32.sqrt() - std::f32::consts::SQRT_2)).abs() < 1e-4, "{}", d.v[7 * 9 + 6]);
assert_eq!(d.v[4 * 9 + 7], 1.0);
}
#[test]
fn running_sum_tent_matches_the_kernel() {
let (w, h) = (37usize, 23usize);
@@ -1494,13 +1561,33 @@ mod tests {
let l = GlobalLight::default();
let (e, paint) =
bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
assert_eq!((paint, e.len()), (BevelPaint::Both, 4));
assert_eq!((paint, e.len()), (BevelPaint::Both, 2));
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
// Emboss: the slope across the top edge faces the light on both sides.
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[2], 20, 9) > 0.3);
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[0], 20, 9) > 0.3);
let (p, _) =
bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[3], 20, 9) > 0.3, "pillow: outside top edge in shadow");
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[1], 20, 9) > 0.3, "pillow: outside top edge in shadow");
}
#[test]
fn emboss_width_rounds_up_and_shades_past_the_blur() {
let mut b = bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth);
b.size = 41.0;
let g = bevel_geom(&b);
assert_eq!((g.width, g.depth), (21.0, 21.0));
b.size = 5.0;
assert_eq!(bevel_geom(&b).width, 3.0);
// The first row past the height blur's reach still slopes (its upper neighbour is raised),
// so it shades; the row after it does not.
let shape = square(60, 20, 40);
b.size = 9.0;
b.angle = 90.0;
let (m, _) = bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
let reach = tent_kernel(bevel_geom(&b).width).0 as usize;
let row = 39 + reach + 1; // last shape row + reach + 1: height 0, neighbour above raised
assert!(m[1].v[row * 60 + 30] > 0.0, "{}", m[1].v[row * 60 + 30]);
assert_eq!(m[1].v[(row + 1) * 60 + 30], 0.0);
}
#[test]
+137
View File
@@ -0,0 +1,137 @@
//! Gradient fill layer geometry.
//!
//! Photoshop lays a gradient fill layer's Linear and Reflected gradients out between whole-pixel
//! end points: the chord through the frame's centre along the angle (as [`effects::gradient_t`])
//! ends at points truncated to the pixel grid. On large frames that is invisible, on small ones it
//! changes the effective angle: a 30° Reflected fill on a 4 × 4 canvas ends at the corner
//! `(4, 0)` instead of `(4, 0.845)` and renders as a 45° one (fitted on the psd-tools `colormodes`
//! files, whose 16/32-bit versions store no fill pixels).
//!
//! The snapped end points are expressed as the angle / scale / centre offset that
//! [`effects::gradient_t`] (and the GPU's `gradient_t`) already take, so both compositors share
//! one layout.
use photocraft_doc::GradientStyle;
use photocraft_geom::Rect;
use crate::effects;
/// Effective `(angle, scale, centre offset)` for a gradient fill with `style`, `angle` (degrees)
/// and `scale` laid out in `frame`.
pub fn fill_gradient_layout(style: GradientStyle, angle: f32, scale: f32, frame: Rect) -> (f32, f32, (f32, f32)) {
gradient_layout(style, angle, scale, (0.0, 0.0), frame)
}
/// [`fill_gradient_layout`] for a gradient centred `offset` (a fraction of the frame) off the
/// frame's centre: layer-effect gradients (overlays, gradient glows and strokes) snap the same
/// way (psd-tools layer_effects: an 87° overlay on a 600 × 60 text line runs along (3, 60), i.e.
/// 87.14°).
pub fn gradient_layout(style: GradientStyle, angle: f32, scale: f32, offset: (f32, f32), frame: Rect) -> (f32, f32, (f32, f32)) {
let unchanged = (angle, scale, offset);
if !matches!(style, GradientStyle::Linear | GradientStyle::Reflected)
|| !angle.is_finite()
|| !scale.is_finite()
|| !offset.0.is_finite()
|| !offset.1.is_finite()
{
return unchanged;
}
let w = f64::from(frame.width().max(1));
let h = f64::from(frame.height().max(1));
let (cx, cy) = (f64::from(frame.x0) + w / 2.0 + f64::from(offset.0) * w, f64::from(frame.y0) + h / 2.0 + f64::from(offset.1) * h);
// Unscaled chord length along `a` (radians), as `effects::gradient_t`.
let chord_of = |a: f64| {
let (s, c) = a.sin_cos();
(w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0)
};
let a = f64::from(angle).to_radians();
let scale64 = f64::from(scale.max(1e-3));
let half = chord_of(a) * scale64 / 2.0;
let (s, c) = a.sin_cos();
let (dx, dy) = (c * half, -s * half);
// Truncate to the pixel grid (with a little slack for rounding error in exact cases).
let snap = |v: f64| (v + 1e-4).floor();
let end = (snap(cx + dx), snap(cy + dy));
let (start, mid) = match style {
GradientStyle::Reflected => ((cx, cy), (cx, cy)),
_ => {
let s0 = (snap(cx - dx), snap(cy - dy));
(s0, ((s0.0 + end.0) / 2.0, (s0.1 + end.1) / 2.0))
}
};
let v = (end.0 - start.0, end.1 - start.1);
let len = v.0.hypot(v.1);
if len < 0.5 {
return unchanged;
}
let a2 = (-v.1).atan2(v.0);
// Reflected spans half the chord from the centre; Linear the whole chord.
let span = if style == GradientStyle::Reflected { 2.0 * len } else { len };
let scale2 = span / chord_of(a2);
let shift = (((mid.0 - cx) / w) as f32, ((mid.1 - cy) / h) as f32);
(a2.to_degrees() as f32, scale2 as f32, (offset.0 + shift.0, offset.1 + shift.1))
}
/// Gradient parameter `t` of a gradient fill at pixel centre `(x, y)` (see [`fill_gradient_layout`]).
pub fn fill_gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, frame: Rect, x: f32, y: f32) -> f32 {
let (a, s, o) = fill_gradient_layout(style, angle, scale, frame);
effects::gradient_t(style, a, s, reverse, o, frame, x, y)
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(style: GradientStyle, angle: f32, frame: Rect) -> Vec<f32> {
let mut v = Vec::new();
for y in frame.y0..frame.y1 {
for x in frame.x0..frame.x1 {
v.push(fill_gradient_t(style, angle, 1.0, false, frame, x as f32 + 0.5, y as f32 + 0.5));
}
}
v
}
#[test]
fn small_reflected_30_degrees_snaps_to_the_corner() {
// Photoshop's 4 × 4 30° Reflected fill: t = |x − y| / 4.
let t = grid(GradientStyle::Reflected, 30.0, Rect::new(0, 0, 4, 4));
for y in 0..4 {
for x in 0..4 {
let want = (x as f32 - y as f32).abs() / 4.0;
assert!((t[y * 4 + x] - want).abs() < 1e-4, "({x},{y}) {} vs {want}", t[y * 4 + x]);
}
}
}
#[test]
fn exact_layouts_are_unchanged() {
for (style, angle, frame) in [
(GradientStyle::Linear, 90.0, Rect::new(0, 0, 33, 20)),
(GradientStyle::Linear, 0.0, Rect::new(-5, 3, 40, 17)),
(GradientStyle::Reflected, 45.0, Rect::new(0, 0, 32, 32)),
(GradientStyle::Linear, -90.0, Rect::new(10, 10, 210, 210)),
] {
for y in [frame.y0, (frame.y0 + frame.y1) / 2, frame.y1 - 1] {
for x in [frame.x0, (frame.x0 + frame.x1) / 2, frame.x1 - 1] {
let (px, py) = (x as f32 + 0.5, y as f32 + 0.5);
let a = fill_gradient_t(style, angle, 1.0, false, frame, px, py);
let b = effects::gradient_t(style, angle, 1.0, false, (0.0, 0.0), frame, px, py);
assert!((a - b).abs() < 1e-3, "{style:?} {angle} {frame:?} ({x},{y}): {a} vs {b}");
}
}
}
}
#[test]
fn large_frames_barely_move_and_bad_input_is_harmless() {
let f = Rect::new(0, 0, 3000, 2000);
let (a, s, o) = fill_gradient_layout(GradientStyle::Linear, 30.0, 1.0, f);
assert!((a - 30.0).abs() < 0.05 && (s - 1.0).abs() < 1e-3 && o.0.abs() < 1e-3 && o.1.abs() < 1e-3);
for (ang, sc) in [(f32::NAN, 1.0), (30.0, f32::INFINITY), (30.0, 0.0), (1e30, 1.0)] {
let t = fill_gradient_t(GradientStyle::Linear, ang, sc, false, Rect::new(0, 0, 4, 4), 1.5, 1.5);
assert!(t.is_nan() || (0.0..=1.0).contains(&t));
}
assert_eq!(fill_gradient_layout(GradientStyle::Radial, 30.0, 1.0, f), (30.0, 1.0, (0.0, 0.0)));
}
}
+70 -7
View File
@@ -19,6 +19,7 @@
pub mod adjust;
pub mod bounds;
pub mod effects;
pub mod fill_layout;
pub mod masks;
pub mod multichannel;
pub mod pattern;
@@ -515,19 +516,28 @@ pub fn layer_bounds(layer: &Layer, canvas: Rect) -> Rect {
}
/// The frame layer-effect gradients and linked patterns are laid out in, when it isn't the
/// layer's pixel bounds: a shape layer's path bounds (rounded out to whole pixels). Its rendered
/// layer's pixel bounds: a shape layer's path bounds, truncated to whole pixels. Its rendered
/// pixels can extend past the path (transparent anti-aliasing margin), which Photoshop ignores:
/// psd-tools shape-fx2's 45° overlay spans the 29 px path, not the 32 px of pixels.
/// psd-tools shape-fx2's 45° overlay spans the 0.92–29.61 path as 0–29 (29 px, centred at 14.5),
/// not the 32 px of pixels.
pub fn paint_bounds(layer: &Layer) -> Option<Rect> {
let LayerContent::Shape(sh) = &layer.content else { return None };
let (x0, y0, x1, y1) = sh.path.control_bounds()?;
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32);
let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.floor() as i32, y1.floor() as i32);
(!r.is_empty()).then_some(r)
}
/// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask
/// times the rasterized vector mask.
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
// Feathers blur across tiles: read the cached, canvas-wide combined mask.
if masks::has_feather(layer)
&& let Some(s) = masks::combined_mask(layer, cx.canvas)
{
let mut v = Vec::new();
s.read_region_into(rect, &mut v);
return Some(v);
}
let vector = layer.vector_mask.as_ref().map(|vm| cx.vector_masks.values(vm, rect));
let Some(m) = layer.mask.as_ref() else { return vector };
let mut v = Vec::new();
@@ -637,10 +647,11 @@ fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedP
})
.collect();
// Gradient geometry relative to the layer's frame, independent of the render rect.
let (angle, scale, offset) = fill_layout::fill_gradient_layout(*style, *angle, *scale, canvas);
let mut b = Buffer::transparent(rect);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let t = effects::gradient_t(*style, *angle, *scale, *reverse, (0.0, 0.0), canvas, x as f32 + 0.5, y as f32 + 0.5);
let t = effects::gradient_t(*style, angle, scale, *reverse, offset, canvas, x as f32 + 0.5, y as f32 + 0.5);
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
b.px[i] = sample_stops(&stops, t);
}
@@ -726,6 +737,55 @@ fn restore_channels(out: &mut Buffer, before: &Buffer, w: [f32; 3]) {
}
}
/// Where `layer`'s composite (its pixels and effects; a group's visible children with theirs) can
/// be non-transparent, or `None` when it may draw anywhere (a surface with an opaque default
/// pixel). Outside it, compositing the layer leaves the backdrop unchanged in every blend mode.
/// Adjustment layers count as empty: they only change pixels already there (which is what an
/// isolated group needs; see [`change_bounds`] for the document-level question).
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
if !transparent_outside(layer) {
return None;
}
let own = match &layer.content {
LayerContent::Group(g) if g.artboard.is_none() => {
let mut acc = Rect::EMPTY;
for c in g.children.iter().filter(|c| c.visible) {
let b = composite_bounds(c, canvas)?;
if !b.is_empty() {
acc = if acc.is_empty() { b } else { acc.union(&b) };
}
}
acc
}
LayerContent::Adjustment(_) => Rect::EMPTY,
_ => layer_bounds(layer, canvas),
};
if own.is_empty() {
return Some(Rect::EMPTY);
}
let m = if effects::has_effects(layer) { effects::margin(layer) } else { 0 };
Some(own.inflate(m).intersect(&canvas))
}
/// The document pixels that showing, hiding, moving or restyling `layer` can change, or `None`
/// for anywhere: an adjustment layer (and a pass-through group holding one) reaches everything
/// beneath it. Clipped layers above stay within their base's bounds, so they are covered too;
/// effects of groups around the layer can reach further (callers grow the rect by that reach).
pub fn change_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
fn reaches_below(l: &Layer) -> bool {
match &l.content {
// Clipped, it changes only its base's pixels.
LayerContent::Adjustment(_) => !l.clipped,
LayerContent::Group(g) if l.blend == BlendMode::PassThrough => g.children.iter().any(reaches_below),
_ => false,
}
}
if matches!(layer.content, LayerContent::Adjustment(_)) || reaches_below(layer) {
return None;
}
composite_bounds(layer, canvas)
}
/// Whether the layer's content is transparent outside its bounds (every surface it draws from
/// has a transparent default pixel), so its effects can't change pixels beyond its bounds grown
/// by their reach.
@@ -865,9 +925,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
}
let opacity = layer.opacity * layer.fill_opacity;
// Pass-through groups composite their children straight into the backdrop.
// Pass-through groups composite their children straight into the backdrop. Below 100% fill
// Photoshop renders the group isolated, like Normal (psd-tools passthrough_fill_*: an
// adjustment inside no longer reaches the layers beneath).
if let LayerContent::Group(g) = &layer.content
&& layer.blend == BlendMode::PassThrough
&& layer.fill_opacity >= 1.0
&& !effects::has_effects(layer)
{
let needs_mix = opacity < 1.0 || layer.mask.is_some() || layer.vector_mask.is_some();
@@ -924,7 +987,7 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
// Adjustment layers transform the backdrop, then blend the result back in.
if let LayerContent::Adjustment(adj) = &layer.content {
let mut adjusted = backdrop.clone();
adjust::apply_with(adj, &mut adjusted, cx.transfer);
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
// Clipped layers onto an adjustment are uncommon; they composite atop the adjusted result.
for c in clipped.iter().filter(|c| c.visible) {
composite_atop(c, &mut adjusted, cx);
@@ -1189,7 +1252,7 @@ fn composite_atop_any(layer: &Layer, base: &mut Buffer, cx: &Ctx) {
let rect = base.rect;
if let LayerContent::Adjustment(adj) = &layer.content {
let mut adjusted = base.clone();
adjust::apply_with(adj, &mut adjusted, cx.transfer);
adjust::apply_depth(adj, &mut adjusted, cx.transfer, adjustment_quantum(cx.depth));
let mv = mask_vals(layer, rect, cx);
for (i, p) in base.px.iter_mut().enumerate() {
let k = layer.opacity * layer.fill_opacity * mask_k(&mv, i);
+82 -17
View File
@@ -1,7 +1,8 @@
//! A layer's effective mask (pixel mask × vector mask) as one surface, for backends that sample
//! masks from textures (the GPU compositor). Rasterising a vector mask costs a path coverage
//! pass, so results are cached per mask state; unchanged masks return the same tiles (cheap to
//! clone, and the GPU sees no change).
//! A layer's effective mask (pixel mask × vector mask, each feathered) as one surface, for
//! backends that sample masks from textures (the GPU compositor) and for feathered masks on the
//! CPU. Rasterising a vector mask costs a path coverage pass and feathering a blur, so results
//! are cached per mask state; unchanged masks return the same tiles (cheap to clone, and the
//! GPU sees no change).
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
@@ -33,7 +34,7 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
(canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h);
format!("{:?}", layer.vector_mask).hash(&mut h);
if let Some(m) = &layer.mask {
(m.enabled, m.density.to_bits()).hash(&mut h);
(m.enabled, m.density.to_bits(), m.feather.to_bits()).hash(&mut h);
format!("{:?}", m.surface.default_pixel()).hash(&mut h);
for (c, t) in m.surface.tiles() {
(c.tx, c.ty, Arc::as_ptr(t) as usize).hash(&mut h);
@@ -42,12 +43,66 @@ fn key(layer: &Layer, canvas: Rect) -> u64 {
h.finish()
}
/// The layer's mask values (pixel mask with density × vector mask, exactly as the CPU
/// compositor applies them) as a single-channel f32 surface over `canvas`, or `None` when the
/// layer has no enabled vector mask (use the pixel mask directly). Values outside the computed
/// area are the surface's default.
/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather). Fitted on the
/// psd-tools corpus (layer_mask_data.psd, mask-parameters-no-real-channel.psd: sigma = feather
/// beats 0.33, 0.5, 0.7, 1.3 and 1.6 times it).
pub fn feather_sigma(px: f32) -> f32 {
if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 }
}
const FEATHER_SIGMA: f32 = 1.0;
/// Photoshop's feather tops out at 1000 px.
const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA;
/// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]).
pub fn has_feather(layer: &Layer) -> bool {
layer.mask.as_ref().is_some_and(|m| m.enabled && feather_sigma(m.feather) > 0.0)
|| layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0)
}
/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis (edges clamp, which
/// is exact here since the planes extend into constant mask regions).
fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) {
if sigma <= 0.0 || w == 0 || h == 0 {
return;
}
// Box widths whose three-pass variance matches sigma² (Wells 1986 / Kovesi).
let ideal = (12.0 * sigma * sigma / 3.0 + 1.0).sqrt();
let r = (((ideal.floor() as usize) | 1).max(1) - 1) / 2;
if r == 0 {
return;
}
let mut line = Vec::new();
let mut pass = |v: &mut [f32], len: usize, count: usize, at: &dyn Fn(usize, usize) -> usize| {
for k in 0..count {
for _ in 0..3 {
line.clear();
line.extend((0..len).map(|i| v[at(k, i)]));
let get = |i: isize| line[i.clamp(0, len as isize - 1) as usize];
let mut acc: f32 = (-(r as isize)..=r as isize).map(get).sum();
let n = (2 * r + 1) as f32;
for i in 0..len {
v[at(k, i)] = acc / n;
acc += get(i as isize + r as isize + 1) - get(i as isize - r as isize);
}
}
}
};
pass(v, w, h, &|row, i| row * w + i);
pass(v, h, w, &|col, i| i * w + col);
}
/// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather,
/// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or
/// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel
/// mask directly). Values outside the computed area are the surface's default.
pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled)?;
let vm = layer.vector_mask.as_ref().filter(|v| v.enabled);
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
let (sv, sp) = (vm.map_or(0.0, |v| feather_sigma(v.feather)), pixel.map_or(0.0, |m| feather_sigma(m.feather)));
if vm.is_none() && sp <= 0.0 {
return None;
}
let k = key(layer, canvas);
{
let mut c = cache().lock().unwrap_or_else(|e| e.into_inner());
@@ -60,22 +115,26 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
}
// Far outside the path the vector mask is constant.
let far = Rect::from_xywh(canvas.x0 - 1_000_000, canvas.y0 - 1_000_000, 1, 1);
let v_out = photocraft_vector::vector_mask_values(vm, far)[0];
let pixel = layer.mask.as_ref().filter(|m| m.enabled);
let v_out = vm.map_or(1.0, |vm| photocraft_vector::vector_mask_values(vm, far)[0]);
let p_def = pixel.map_or(1.0, |m| {
let d = m.surface.default_pixel().first().copied().unwrap_or(1.0);
1.0 - m.density * (1.0 - d)
});
let grow = |r: Rect, sigma: f32| {
let m = (sigma * 3.0).ceil() as i32 + 2;
if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) }
};
// Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel
// mask's painted tiles when the vector mask lets them through outside the path.
let mut area = match vm.path.control_bounds() {
Some((x0, y0, x1, y1)) => Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2),
// mask's painted tiles when the vector mask lets them through outside the path, each grown
// by its feather.
let mut area = match vm.and_then(|vm| vm.path.control_bounds()) {
Some((x0, y0, x1, y1)) => grow(Rect::new(x0.floor() as i32 - 2, y0.floor() as i32 - 2, x1.ceil() as i32 + 2, y1.ceil() as i32 + 2), sv),
None => Rect::EMPTY,
};
if let Some(m) = pixel
&& v_out > 0.0
{
let b = m.surface.content_bounds();
let b = grow(m.surface.content_bounds(), sp);
area = if area.is_empty() {
b
} else if b.is_empty() {
@@ -87,10 +146,16 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option<Surface> {
let area = area.intersect(&canvas);
let mut s = Surface::with_default(FORMAT, &[p_def * v_out]);
if !area.is_empty() {
let mut v = photocraft_vector::vector_mask_values(vm, area);
let (w, h) = (area.width() as usize, area.height() as usize);
let mut v = match vm {
Some(vm) => photocraft_vector::vector_mask_values(vm, area),
None => vec![1.0; w * h],
};
gaussian(&mut v, w, h, sv);
if let Some(m) = pixel {
let mut pm = Vec::new();
m.values_into(area, &mut pm);
gaussian(&mut pm, w, h, sp);
for (a, b) in v.iter_mut().zip(pm) {
*a *= b;
}
+31 -10
View File
@@ -131,17 +131,17 @@ pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity:
}
/// Photoshop's "Blend Text Colors Using Gamma" (Color Settings › Advanced, on at 1.45 by
/// default): type layers mix their colour with the backdrop in a gamma-1.45 space, so
/// anti-aliased glyph edges come out lighter over dark backdrops than a linear mix (fitted on
/// psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay over grey is 57/255 in red,
/// a linear mix gives 40).
/// default): type layers mix their colour with the backdrop in linear light raised to 1 / 1.45
/// ([`text_encode`]), so anti-aliased glyph edges come out lighter over dark backdrops than a
/// mix of the encoded values (psd-tools layer_effects.psd: a 69 % edge pixel of a blue overlay
/// over grey is 57/255 in red, an encoded mix gives 40).
pub const TEXT_GAMMA: f32 = 1.45;
static TEXT_GAMMA_SETTING: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x3fb9_999a); // 1.45f32
/// Sets Color Settings › "Blend Text Colors Using Gamma" (1 = off). It is an application
/// setting, not stored in documents: files saved with it off (ag-psd float-color) mix type
/// linearly. Values are clamped to Photoshop's 1.00–2.20.
/// setting, not stored in documents (1 here means switched off: a plain mix of the encoded
/// values). Values are clamped to Photoshop's 1.00–2.20.
///
/// The value is process-wide: a test that changes it races every concurrently running test
/// that composites a type layer, so such tests belong in their own test binary (see
@@ -156,7 +156,24 @@ pub fn text_gamma() -> f32 {
f32::from_bits(TEXT_GAMMA_SETTING.load(std::sync::atomic::Ordering::Relaxed))
}
/// [`composite`] with the coverage mix done in a `gamma` space (`gamma == 1` is [`composite`]).
/// Into the text blending space: linear light raised to `1 / gamma`. Gamma 2.2 is close to
/// mixing the encoded values, lower settings approach linear light. Fitted on psd-tools
/// layer_effects.psd (a blue colour overlay's glyph edges over grey, every coverage level within
/// 0.6/255; a plain `v^1.45` power mix is 6/255 too light at 94 % coverage) and ag-psd
/// float-color.
#[inline]
pub fn text_encode(v: f32, gamma: f32) -> f32 {
photocraft_color::convert::srgb_to_linear(v.max(0.0)).powf(1.0 / gamma)
}
/// Inverse of [`text_encode`].
#[inline]
pub fn text_decode(v: f32, gamma: f32) -> f32 {
photocraft_color::convert::linear_to_srgb(v.max(0.0).powf(gamma))
}
/// [`composite`] with the coverage mix done in the text blending space of `gamma`
/// ([`text_encode`]; `gamma == 1` is [`composite`], the setting switched off).
pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, gamma: f32) -> [f32; 4] {
if gamma == 1.0 {
return composite(mode, backdrop, source, opacity);
@@ -174,10 +191,11 @@ pub fn composite_gamma(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], op
let cs = [source[0], source[1], source[2]];
let b = blend_rgb(mode, cb, cs);
let (wb, ws, wbs) = ((1.0 - as_) * ab / ao, (1.0 - ab) * as_ / ao, as_ * ab / ao);
let g = |v: f32| v.max(0.0).powf(gamma);
let g = |v: f32| text_encode(v, gamma);
let ginv = |v: f32| text_decode(v, gamma);
let mut out = [0.0f32; 4];
for i in 0..3 {
out[i] = (wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i])).powf(1.0 / gamma);
out[i] = ginv(wb * g(cb[i]) + ws * g(cs[i]) + wbs * g(b[i]));
}
out[3] = ao;
out
@@ -189,7 +207,10 @@ mod tests {
fn text_gamma_lightens_dark_edges() {
// 69 % blue (0, 51, 153) over grey 129: Photoshop shows (57, 80, 146).
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.686], 1.0, TEXT_GAMMA);
assert!((r[0] * 255.0 - 57.0).abs() < 2.0 && (r[1] * 255.0 - 80.0).abs() < 2.0 && (r[2] * 255.0 - 146.0).abs() < 2.0, "{r:?}");
assert!((r[0] * 255.0 - 57.0).abs() < 1.0 && (r[1] * 255.0 - 80.0).abs() < 1.0 && (r[2] * 255.0 - 146.0).abs() < 1.0, "{r:?}");
// Near-opaque edges stay dark: 94 % coverage shows (13, 57, 152) (a v^1.45 mix gives 19 red).
let r = composite_gamma(BlendMode::Normal, [129.0 / 255.0, 129.0 / 255.0, 129.0 / 255.0, 1.0], [0.0, 0.2, 0.6, 0.937], 1.0, TEXT_GAMMA);
assert!((r[0] * 255.0 - 13.0).abs() < 1.0 && (r[1] * 255.0 - 57.0).abs() < 1.0 && (r[2] * 255.0 - 152.0).abs() < 1.0, "{r:?}");
assert_eq!(
composite_gamma(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8, 1.0),
composite(BlendMode::Multiply, [0.2, 0.4, 0.6, 0.7], [0.5, 0.1, 0.9, 0.5], 0.8)
+146
View File
@@ -153,6 +153,41 @@ fn isolated_group_differs_from_pass_through() {
assert!((iso[0] - 0.5).abs() <= E, "{iso:?}");
}
/// psd-tools passthrough_fill_adjustment.psd: below 100% fill a pass-through group is isolated,
/// so an adjustment inside it no longer changes the layers beneath.
#[test]
fn pass_through_group_below_full_fill_is_isolated() {
let make = |fill: f32| {
let mut d = doc_white(2, 2);
let mut g = Layer::group("g", vec![Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert))]);
g.blend = BlendMode::PassThrough;
g.fill_opacity = fill;
d.layers.push(g);
px(&d, 0, 0)
};
assert!(close4(make(1.0), [0.0, 0.0, 0.0, 1.0]), "full fill: the invert reaches the background");
assert!(close4(make(0.4), [1.0; 4]), "isolated: nothing beneath to invert");
}
/// psd-tools layer_mask_data.psd: a mask feather blurs the mask edge (CPU and the GPU's
/// combined mask share `masks::combined_mask`).
#[test]
fn mask_feather_blurs_the_edge() {
let mut d = doc_white(64, 8);
let mut l = solid_layer("k", Rect::new(0, 0, 64, 8), [0.0, 0.0, 0.0, 1.0]);
let mut m = LayerMask::hide_all();
m.surface.fill_rect(Rect::new(0, 0, 32, 8), &[1.0]);
l.mask = Some(m);
d.layers.push(l.clone());
assert!(px(&d, 31, 4)[0] < 0.01 && px(&d, 32, 4)[0] > 0.99, "sharp without feather");
if let Some(m) = &mut d.layers[1].mask {
m.feather = 8.0;
}
let (a, b) = (px(&d, 31, 4)[0], px(&d, 32, 4)[0]);
assert!(a > 0.3 && a < 0.5 && b > 0.5 && b < 0.7, "soft edge around 50%: {a} {b}");
assert!(px(&d, 4, 4)[0] < 0.01 && px(&d, 60, 4)[0] > 0.99, "far from the edge unchanged");
}
#[test]
fn group_opacity_applies_once() {
let mut d = doc_white(2, 2);
@@ -748,6 +783,53 @@ fn inside_stroke_respects_partial_edge_coverage() {
assert!((p[2] - 0.75).abs() < 0.02 && (p[0] - 0.25).abs() < 0.02, "{p:?}");
}
#[test]
fn emboss_shades_the_composited_layer() {
// Emboss styles paint over the layer as composited (for type: mixed at the text gamma), so a
// Normal white highlight `k` on an edge pixel keeps (1 − R) = (1 − k)(1 − C) whatever C is:
// the ratio of type to raster results equals that of their plain composites.
let emboss = Effect::BevelEmboss(photocraft_doc::Bevel {
enabled: true,
style: photocraft_doc::BevelStyle::Emboss,
technique: photocraft_doc::BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 6.0,
soften: 0.0,
angle: 90.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: photocraft_doc::Contour::Linear,
highlight: FxCommon::new(photocraft_color::BlendMode::Normal, 1.0),
highlight_color: Color::WHITE,
shadow: FxCommon::new(photocraft_color::BlendMode::Multiply, 0.0),
shadow_color: Color::BLACK,
contour: None,
texture: None,
});
let make = |text: bool, fx: bool| {
let mut d = doc_white(40, 40);
let mut l = solid_layer("sq", Rect::new(10, 10, 30, 30), [0.2, 0.1, 0.6, 1.0]);
l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 11), &[0.2, 0.1, 0.6, 0.5]);
if text {
let t = photocraft_doc::TextLayer { cache: l.surface().cloned(), ..Default::default() };
l = Layer::new("t", LayerContent::Text(t));
}
if fx {
l.effects.items = vec![emboss.clone()];
}
d.layers.push(l);
px(&d, 20, 10)
};
let (ct, cr, rt, rr) = (make(true, false), make(false, false), make(true, true), make(false, true));
assert!(rr[1] > cr[1] + 0.05, "the top edge is lit: {rr:?} vs {cr:?}");
assert!((ct[1] - cr[1]).abs() > 0.02, "text gamma changes the edge: {ct:?} {cr:?}");
for c in 0..3 {
let want = (1.0 - ct[c]) / (1.0 - cr[c]);
assert!(((1.0 - rt[c]) / (1.0 - rr[c]) - want).abs() < 2e-3, "channel {c}: {rt:?} {rr:?} {ct:?} {cr:?}");
}
}
#[test]
fn interior_effects_keep_the_layer_alpha() {
// A colour overlay replaces a half-transparent pixel's colour without adding coverage.
@@ -1042,6 +1124,70 @@ fn levels_matches_photoshop() {
assert!((adjust::levels(&ident(1.0), 0.37) - 0.37).abs() < 1e-6);
}
// Photoshop (psd-tools levels_grayscale, 8-bit): input range 44..214, gamma 1.78. The stretch
// is rounded to whole levels before the gamma (45 → 1.5 → 2, 47 → 4.5 → 5), so the shadows
// step unevenly: 45, 46, 47, 48, 52, 69 → 7, 10, 17, 20, 37, 88.
#[test]
fn levels_work_on_whole_levels() {
let ch = LevelsChannel { in_black: 44.0 / 255.0, in_white: 214.0 / 255.0, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
let adj = Adjustment::Levels {
master: LevelsChannel::default(),
per_channel: [ch.clone(), ch.clone(), ch.clone()],
space: Default::default(),
black: LevelsChannel::default(),
};
let luts = adjust::tone_luts_q(&adj, Some(255.0));
let at = |v: u8| {
let x = f32::from(v) / 255.0 * 4095.0;
let (i, f) = (x.floor() as usize, x.fract());
let y = luts[0][i] * (1.0 - f) + luts[0][(i + 1).min(4095)] * f;
(y * 255.0).round()
};
for (v, ps) in [(44u8, 0.0f32), (45, 7.0), (46, 10.0), (47, 17.0), (48, 20.0), (52, 37.0), (69, 88.0), (100, 137.0), (214, 255.0)] {
assert!((at(v) - ps).abs() <= 1.0, "levels {v}: got {} want {ps}", at(v));
}
// 16-bit and float stay smooth (no 8-bit staircase).
for q in [Some(32768.0), None] {
let a = adjust::levels_q(&ch, 0.2, q);
let b = adjust::levels_q(&ch, 0.2 + 1.0 / 2048.0, q);
assert!(b > a && b - a < 2.0 / 255.0, "{q:?}: {a} {b}");
}
// Hostile quantum values fall back to the continuous curve.
for q in [Some(0.0), Some(f32::NAN), Some(-3.0)] {
assert_eq!(adjust::levels_q(&ch, 0.4, q), adjust::levels(&ch, 0.4));
}
}
// Exposure linearises RGB documents through a 2.2 power, not the sRGB curve: Photoshop lifts 76
// to 134 with an offset of 0.1738 (psd-tools adjustment_nested_composition_4).
#[test]
fn exposure_offset_uses_gamma_2_2_in_rgb() {
let mut b = Buffer::filled(Rect::new(0, 0, 1, 1), [76.0 / 255.0, 76.0 / 255.0, 76.0 / 255.0, 1.0]);
adjust::apply_with(&Adjustment::Exposure { exposure: 0.0, offset: 0.1738, gamma: 1.0 }, &mut b, adjust::Transfer::Srgb);
assert_eq!((b.px[0][0] * 255.0).round(), 134.0);
assert_eq!(adjust::Transfer::Gamma(1.732).for_exposure(), adjust::Transfer::Gamma(1.732));
}
// A 30° Reflected gradient fill on a 4 × 4 canvas renders as Photoshop's (its end point snaps to
// the corner: t = |x − y| / 4) at every depth.
#[test]
fn small_gradient_fill_matches_photoshop_at_all_depths() {
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 1.0))];
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = Document::new("g", Size::new(4, 4), ColorMode::Rgb, depth);
let fill = Fill::Gradient { stops: stops.clone(), angle: 30.0, scale: 1.0, style: photocraft_doc::GradientStyle::Reflected, reverse: false };
d.layers.push(Layer::new("g", LayerContent::Fill(fill)));
let out = flatten(&d);
for y in 0..4usize {
for x in 0..4usize {
let want = (x as f32 - y as f32).abs() / 4.0;
let got = out.px[y * 4 + x][0];
assert!((got - want).abs() < 1.0 / 255.0, "{depth:?} ({x},{y}): {got} vs {want}");
}
}
}
}
/// A tall document with soft content, a translucent region and an adjustment.
fn tall_doc(w: u32, h: u32) -> Document {
let mut d = doc_white(w, h);
+3 -3
View File
@@ -5,7 +5,7 @@
//! "Scripts" are PhotoCraft action scripts: JSON (`[[id, params], …]`, `{"steps": …}` or a
//! droplet) or a plain-text list with one `command.id {json params}` per line (`#` comments).
//! Droplets are JSON files (`.pcdroplet`) holding an action plus batch options; `photocraft-cli
//! droplet <file> <inputs…>` runs one, and on macOS/Linux a `.command` shim makes it
//! droplet <file> <inputs…>` runs one, and on Unix a `.command` shim makes it
//! double-clickable / drop-target-able from the shell.
//!
//! Script events (Start Application, New / Open / Save / Close Document, Print, Export) are
@@ -291,7 +291,7 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result<Value> {
let droplet = json!({"photocraftDroplet": 1, "name": name, "action": {"name": name, "steps": steps_json}, "options": options});
let text = serde_json::to_string_pretty(&droplet).unwrap_or_default();
write_file(&path, text.as_bytes())?;
let shim = p.get("shim").and_then(Value::as_bool).unwrap_or(cfg!(any(target_os = "macos", target_os = "linux")));
let shim = p.get("shim").and_then(Value::as_bool).unwrap_or(cfg!(unix));
let shim_path = if shim { Some(write_shim(&path)?) } else { None };
let _ = s;
Ok(json!({"path": path, "shim": shim_path, "steps": steps.len()}))
@@ -526,7 +526,7 @@ pub fn specs() -> Vec<CommandSpec> {
"file.automate.createDroplet",
"Create Droplet…",
&["File", "Automate"],
r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on macOS/Linux (writes <name>.command calling `photocraft-cli droplet`)} → {path, shim}"##,
r##"{"path":str (.pcdroplet),"steps":[[id,params]…] (the action),"name":str?,"output":folder?,"format":"same|png|jpg|…"?,"quality":0..12?,"shim":bool=true on Unix (writes <name>.command calling `photocraft-cli droplet`)} → {path, shim}"##,
native,
create_droplet
),
+91 -4
View File
@@ -337,8 +337,16 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "paint.mixerBrush";
let pts = parse_points(p, cmd)?;
let brush = resolve_brush(s, p, cmd)?;
let m = MixerSettings { wet: pct(p, "wet", 50.0), load: pct(p, "load", 50.0), mix: pct(p, "mix", 50.0), flow: pct(p, "flow", 100.0) };
let sample_all = flag(p, "sampleAllLayers", false);
// The brush's Mixer Brush options, overridden by the scalar params.
let bm = &brush.mixer;
let m = MixerSettings {
wet: pct(p, "wet", bm.wet * 100.0),
load: pct(p, "load", bm.load * 100.0),
mix: pct(p, "mix", bm.mix * 100.0),
flow: pct(p, "flow", bm.flow * 100.0),
sample_all_layers: flag(p, "sampleAllLayers", bm.sample_all_layers),
};
let sample_all = m.sample_all_layers;
let (clean, load_after) = (flag(p, "cleanAfterStroke", true), flag(p, "loadAfterStroke", true));
let mut state = s.tools.mixer.clone();
if load_after || state.reservoir.is_none() {
@@ -543,10 +551,89 @@ fn set_brush(s: &mut Session, p: &Value) -> Result<Value> {
}
}
b = merge_brush(&b, &patch, cmd)?;
s.tools.brush = b;
let before = std::mem::replace(&mut s.tools.brush, b);
// A coalesced gesture (one slider drag) journals as one call: remember the brush it started from.
let key = p.get("coalesce").and_then(Value::as_str).filter(|_| p.get("preset").is_none() && p.get("reset").is_none());
let continuing = |s: &Session, k: &str| {
s.tools.brush_gesture.as_ref().is_some_and(|(g, _)| g == k)
&& s.journal.last().is_some_and(|(id, lp)| id == cmd && lp.get("coalesce").and_then(Value::as_str) == Some(k))
};
match key {
Some(k) if continuing(s, k) => {}
Some(k) => s.tools.brush_gesture = Some((k.to_string(), before)),
None => s.tools.brush_gesture = None,
}
Ok(brush_json(&s.tools.brush))
}
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
let mut c = b.clone();
if skip.0 {
c.tip = TipShape::Round;
}
if skip.1 {
c.dual_brush.tip = TipShape::Round;
}
if skip.2 {
c.texture.pattern = photocraft_paint::Pattern::default();
}
serde_json::to_value(&c).unwrap_or(Value::Null)
}
/// Fields of `new` that differ from `old`, nested objects diffed key by key: the minimal
/// `tools.setBrush` `brush` patch that turns `old` into `new` (`{}` when nothing changed).
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
fn diff(a: &Value, b: &Value) -> Option<Value> {
match (a, b) {
(Value::Object(ao), Value::Object(bo)) => {
let mut out = serde_json::Map::new();
for (k, bv) in bo {
match ao.get(k) {
Some(av) => {
if let Some(d) = diff(av, bv) {
out.insert(k.clone(), d);
}
}
None => {
out.insert(k.clone(), bv.clone());
}
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
}
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
}
/// Journal hook ([`Session::execute`]): consecutive `tools.setBrush` calls with the same
/// `coalesce` key (one drag in the options bar or the Brush Settings panel) are one journal
/// entry, the patch from the brush before the gesture to the brush now. Returns true when the
/// call was folded into the previous entry.
pub fn coalesce_journal(s: &mut Session, id: &str, params: &Value) -> bool {
if id != "tools.setBrush" {
return false;
}
let Some(key) = params.get("coalesce").and_then(Value::as_str) else { return false };
let Some((gk, start)) = s.tools.brush_gesture.as_ref() else { return false };
if gk != key {
return false;
}
let patch = brush_patch(start, &s.tools.brush);
match s.journal.last_mut() {
Some((last, lp)) if last == id && lp.get("coalesce").and_then(Value::as_str) == Some(key) => {
*lp = json!({ "brush": patch, "coalesce": key });
true
}
_ => false,
}
}
macro_rules! spec {
($id:literal, $label:literal, $params:literal, $en:expr, $run:expr, $journal:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal }
@@ -567,7 +654,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"paint.mixerBrush",
"Mixer Brush",
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=50,"load":0..100=50,"mix":0..100=50,"flow":0..100=100,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=false,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
r##"{"points":[…],"brush":{…}?,"preset":name?,"size":px?,"wet":0..100=brush.mixer.wet,"load":0..100=brush.mixer.load,"mix":0..100=brush.mixer.mix,"flow":0..100=brush.mixer.flow,"color":"#rrggbb"?=foreground,"sampleAllLayers":bool=brush.mixer.sampleAllLayers,"cleanAfterStroke":bool=true,"loadAfterStroke":bool=true,"seed":u64?}"##,
has_paintable,
mixer_brush,
true
+135
View File
@@ -311,3 +311,138 @@ fn live_stroke_matches_the_committed_stroke() {
assert!(LiveStroke::begin(&s, &json!({"points": [[1, 1]], "target": {"channel": 9}})).is_err());
assert!(LiveStroke::begin(&Session::new(), &json!({"points": [[1, 1]]})).is_err());
}
#[test]
fn live_stroke_equals_the_commit_at_every_zoom_with_smoothing() {
// #189: the preview while dragging must be the committed stroke (within 1/255), with a soft
// brush on an opaque Background with content, at any zoom (the smoothing string scales with
// it), with smoothing on, pressure, 8 and 16 bits, and points fed one pointer move at a time.
let pts: Vec<[f64; 3]> = (0..40)
.map(|i| {
let t = f64::from(i) / 39.0;
[40.0 + 120.0 * t, 60.0 + 30.0 * (t * 7.0).sin(), 0.3 + 0.7 * t]
})
.collect();
for depth in [8, 16] {
for zoom in [0.25, 1.0, 4.0, 16.0] {
for (amount, pulled) in [(0.1, false), (0.5, false), (0.3, true)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 120, "depth": depth, "background": "white"})).unwrap();
s.execute("edit.fill", json!({"color": "#1a2550"})).unwrap();
s.execute("paint.stroke", json!({"points": [[20, 20], [180, 100]], "size": 8, "hardness": 1.0, "color": "#ffffff"})).unwrap();
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
let brush = json!({"size": 40, "hardness": 0.0, "pressureSize": true, "smoothing": {"amount": amount, "pulledString": pulled}});
let p = json!({"points": [pts[0]], "brush": brush, "zoom": zoom, "target": "pixels"});
let mut live = LiveStroke::begin(&s, &p).unwrap();
for q in &pts[1..] {
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
}
let mut commit = p.clone();
commit["points"] = json!(pts);
commit["seed"] = json!(live.seed);
s.execute("paint.stroke", commit).unwrap();
let shown = live.doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().clone();
let done = surface(&s);
let worst = (0..120).flat_map(|y| (0..200).map(move |x| (x, y))).map(|(x, y)| {
let (a, b) = (shown.rgba(x, y), done.rgba(x, y));
(0..4).map(|i| (a[i] - b[i]).abs()).fold(0.0f32, f32::max)
});
let worst = worst.fold(0.0f32, f32::max);
let label = format!("depth {depth}, zoom {zoom}, smoothing {amount} (pulled string {pulled})");
assert!(worst <= 1.0 / 255.0, "{label}: preview differs from the commit by {worst}");
// Not two blank canvases: the soft stroke is there, edge included.
assert!(done.rgba(40, 60)[0] < 0.1 && (0.05..0.9).contains(&done.rgba(40, 60 - 14)[2]), "{label}: soft stroke");
}
}
}
}
#[test]
fn new_brush_controls_round_trip_through_set_brush() {
let mut s = session(40, 40);
let patch = json!({
"spacingEnabled": false,
"shapeDynamics": {"tiltScale": 1.5, "brushProjection": true},
"transfer": {"wetness": {"jitter": 0.3, "control": "penPressure"}, "mix": {"jitter": 0.6, "minimum": 0.2}},
"locks": {"shapeDynamics": true, "texture": true, "noise": true},
"mixer": {"wet": 0.8, "load": 0.25, "mix": 0.4, "flow": 0.9, "sampleAllLayers": true},
});
s.execute("tools.setBrush", json!({ "brush": patch })).unwrap();
let b = &s.tools.brush;
assert!(!b.spacing_enabled && b.shape_dynamics.brush_projection && b.shape_dynamics.tilt_scale == 1.5);
assert_eq!(b.transfer.wetness.control, photocraft_paint::Control::PenPressure);
assert_eq!((b.transfer.mix.jitter, b.transfer.mix.minimum), (0.6, 0.2));
assert!(b.locks.shape_dynamics && b.locks.texture && b.locks.noise && !b.locks.scattering);
assert_eq!((b.mixer.wet, b.mixer.load, b.mixer.mix, b.mixer.flow, b.mixer.sample_all_layers), (0.8, 0.25, 0.4, 0.9, true));
// brush.get reports them, and a fresh session gets the same brush from the reported JSON.
let got = s.execute("brush.get", json!({})).unwrap();
for (path, want) in [
("/spacingEnabled", json!(false)),
("/shapeDynamics/tiltScale", json!(1.5)),
("/shapeDynamics/brushProjection", json!(true)),
("/transfer/wetness/control", json!("penPressure")),
("/locks/texture", json!(true)),
("/mixer/sampleAllLayers", json!(true)),
("/mixer/load", json!(0.25)),
] {
assert_eq!(got.pointer(path), Some(&want), "{path}");
}
let mut t = session(40, 40);
t.execute("tools.setBrush", json!({ "brush": got })).unwrap();
assert_eq!(t.tools.brush, s.tools.brush);
// Locked sections survive picking a preset through the command.
s.execute("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
assert!(s.tools.brush.shape_dynamics.brush_projection && s.tools.brush.locks.texture);
// Wrong types are errors and leave the brush alone.
let before = s.tools.brush.clone();
for bad in
[json!({"spacingEnabled": 3}), json!({"locks": {"texture": "yes"}}), json!({"mixer": {"wet": "wet"}}), json!({"shapeDynamics": {"tiltScale": []}})]
{
assert!(s.execute("tools.setBrush", json!({ "brush": bad })).is_err());
}
assert_eq!(s.tools.brush, before);
}
#[test]
fn mixer_brush_uses_the_brush_mixer_settings() {
let mut s = session(200, 30);
s.execute("tools.setColors", json!({"foreground": "#ff0000"})).unwrap();
// No wet/load params: the brush's Mixer settings (a dry brush with 0 % Load) apply.
s.execute("tools.setBrush", json!({"brush": {"pressureSize": false, "spacing": 0.25, "mixer": {"wet": 0, "load": 0, "mix": 0}}})).unwrap();
s.execute("paint.mixerBrush", json!({"points": [[5, 15], [195, 15]], "size": 8})).unwrap();
assert!(rgba(&s, 10, 15)[3] > 0.95 && rgba(&s, 190, 15)[3] < 0.5, "the brush's 0 % Load dries out");
// A param still overrides the brush.
s.execute("paint.mixerBrush", json!({"points": [[5, 5], [195, 5]], "size": 8, "load": 100})).unwrap();
assert!(rgba(&s, 190, 5)[3] > 0.95);
}
#[test]
fn coalesced_set_brush_calls_journal_once_per_gesture() {
let mut s = session(20, 20);
let n = s.journal.len();
for k in 1..=10 {
s.execute("tools.setBrush", json!({"brush": {"opacity": k as f32 / 20.0}, "coalesce": "bar:1"})).unwrap();
s.execute("brush.get", json!({})).unwrap();
}
s.execute("tools.setBrush", json!({"brush": {"shapeDynamics": {"tiltScale": 0.5}}, "coalesce": "bar:1"})).unwrap();
assert_eq!(s.journal.len(), n + 1, "one gesture, one entry");
let (id, p) = s.journal.last().unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(p["brush"], json!({"opacity": 0.5, "shapeDynamics": {"tiltScale": 0.5}}));
// A new gesture is a new entry; so is an uncoalesced call.
s.execute("tools.setBrush", json!({"brush": {"flow": 0.5}, "coalesce": "bar:2"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"flow": 0.4}, "coalesce": "bar:2"})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 33}})).unwrap();
assert_eq!(s.journal.len(), n + 3);
// Another command in between ends the gesture.
s.execute("tools.setBrush", json!({"brush": {"size": 34}, "coalesce": "bar:3"})).unwrap();
s.execute("paint.stroke", json!({"points": [[2, 2], [10, 10]]})).unwrap();
s.execute("tools.setBrush", json!({"brush": {"size": 35}, "coalesce": "bar:3"})).unwrap();
assert_eq!(s.journal.len(), n + 6);
// Replay gives the same brush.
let mut t = session(20, 20);
for (id, p) in s.journal.iter().skip(1) {
t.execute(id, p.clone()).unwrap();
}
assert_eq!(t.tools.brush, s.tools.brush);
}
+336
View File
@@ -0,0 +1,336 @@
//! Organising brush presets (the Brushes panel's drag and drop and context menus): rename, move a
//! preset within or between groups, move, rename and delete whole groups.
//!
//! The library order is the panel order: groups appear in order of their first preset. Every
//! change syncs to the preset store ([`crate::preset_store`]), whose index keeps the group order
//! and the order of every preset, built-ins included. A built-in that is renamed or moved to
//! another group becomes the user's preset (built-ins are regenerated with their own group).
use photocraft_paint::BrushPreset;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
/// Longest preset or group name accepted.
pub const MAX_NAME: usize = 255;
fn text(p: &Value, k: &str, cmd: &str) -> Result<String> {
let v = p.get(k).and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?;
if v.chars().count() > MAX_NAME {
return Err(bad(cmd, format!("`{k}` is longer than {MAX_NAME} characters")));
}
Ok(v.to_string())
}
/// A group name param: may be empty (the ungrouped presets), never absurdly long.
fn group_param(p: &Value, k: &str, cmd: &str) -> Result<Option<String>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(g)) if g.chars().count() <= MAX_NAME => Ok(Some(g.trim().to_string())),
Some(_) => Err(bad(cmd, format!("`{k}` must be a group name (string, at most {MAX_NAME} characters)"))),
}
}
fn index_of(presets: &[BrushPreset], name: &str) -> Option<usize> {
presets.iter().position(|x| x.name.eq_ignore_ascii_case(name))
}
fn find(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<usize> {
let name = text(p, k, cmd)?;
index_of(&s.tools.presets, &name).ok_or_else(|| bad(cmd, format!("no brush preset named `{name}`")))
}
/// Groups in panel order (first appearance).
pub fn group_order(presets: &[BrushPreset]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for p in presets {
if !out.contains(&p.group) {
out.push(p.group.clone());
}
}
out
}
fn position_in_group(presets: &[BrushPreset], i: usize) -> usize {
presets.get(i).map_or(0, |pr| presets[..i].iter().filter(|x| x.group == pr.group).count())
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.rename";
let i = find(s, p, "name", cmd)?;
let new = text(p, "newName", cmd)?;
if index_of(&s.tools.presets, &new).is_some_and(|j| j != i) {
return Err(bad(cmd, format!("a brush preset named `{new}` already exists")));
}
let pr = s.tools.presets.get_mut(i).ok_or_else(|| bad(cmd, "no such preset"))?;
if pr.name != new {
pr.name = new.clone();
pr.builtin = false;
s.brush_presets_changed();
}
Ok(json!({ "name": new }))
}
fn move_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.move";
let i = find(s, p, "name", cmd)?;
let before = match p.get("before") {
None | Some(Value::Null) => None,
Some(_) => {
let b = find(s, p, "before", cmd)?;
if b == i {
return Err(bad(cmd, "`before` names the preset being moved"));
}
Some(b)
}
};
let index = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
};
let lib = &mut s.tools.presets;
let target = match (group_param(p, "group", cmd)?, before) {
(Some(g), _) => g,
(None, Some(b)) => lib[b].group.clone(),
(None, None) => lib[i].group.clone(),
};
if let Some(b) = before
&& lib[b].group != target
{
return Err(bad(cmd, format!("`before` is not in group `{target}`")));
}
let before_name = before.map(|b| lib[b].name.clone());
let mut pr = lib.remove(i);
if pr.group != target {
pr.group = target.clone();
// A built-in moved elsewhere is the user's now (built-ins regenerate in their own group).
pr.builtin = false;
}
let members: Vec<usize> = lib.iter().enumerate().filter(|(_, x)| x.group == target).map(|(k, _)| k).collect();
let pos = match (before_name, index) {
(Some(n), _) => index_of(lib, &n).unwrap_or(lib.len()),
(None, Some(n)) => match members.get(n.min(usize::MAX as u64) as usize) {
Some(&k) => k,
None => members.last().map_or(lib.len(), |k| k + 1),
},
(None, None) => members.last().map_or(lib.len(), |k| k + 1),
};
let pos = pos.min(lib.len());
let name = pr.name.clone();
lib.insert(pos, pr);
let at = position_in_group(lib, pos);
s.brush_presets_changed();
Ok(json!({ "name": name, "group": target, "index": at }))
}
fn group_block(s: &Session, g: &str, cmd: &str) -> Result<()> {
if s.tools.presets.iter().any(|x| x.group == g) { Ok(()) } else { Err(bad(cmd, format!("no brush preset group named `{g}`"))) }
}
fn move_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.moveGroup";
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
group_block(s, &g, cmd)?;
let before = group_param(p, "before", cmd)?;
if let Some(b) = &before {
group_block(s, b, cmd)?;
if *b == g {
return Err(bad(cmd, "`before` names the group being moved"));
}
}
let index = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_u64().ok_or_else(|| bad(cmd, "`index` must be a non-negative integer"))?),
};
let lib = std::mem::take(&mut s.tools.presets);
let (block, mut rest): (Vec<BrushPreset>, Vec<BrushPreset>) = lib.into_iter().partition(|x| x.group == g);
let order = group_order(&rest);
let first = |name: &str, rest: &[BrushPreset]| rest.iter().position(|x| x.group == name).unwrap_or(rest.len());
let pos = match (&before, index) {
(Some(b), _) => first(b, &rest),
(None, Some(n)) => order.get(n.min(usize::MAX as u64) as usize).map_or(rest.len(), |name| first(name, &rest)),
(None, None) => rest.len(),
};
let tail = rest.split_off(pos.min(rest.len()));
rest.extend(block);
rest.extend(tail);
s.tools.presets = rest;
s.brush_presets_changed();
let at = group_order(&s.tools.presets).iter().position(|x| *x == g).unwrap_or(0);
Ok(json!({ "group": g, "index": at }))
}
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.renameGroup";
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
group_block(s, &g, cmd)?;
let new = text(p, "newName", cmd)?;
if new != g && s.tools.presets.iter().any(|x| x.group == new) {
return Err(bad(cmd, format!("a group named `{new}` already exists")));
}
if new != g {
for x in s.tools.presets.iter_mut().filter(|x| x.group == g) {
x.group = new.clone();
x.builtin = false;
}
s.brush_presets_changed();
}
Ok(json!({ "group": new }))
}
fn delete_group(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "brush.presets.deleteGroup";
let g = group_param(p, "group", cmd)?.ok_or_else(|| bad(cmd, "missing `group`"))?;
group_block(s, &g, cmd)?;
let before = s.tools.presets.len();
s.tools.presets.retain(|x| x.group != g);
s.brush_presets_changed();
Ok(json!({ "deleted": before - s.tools.presets.len(), "count": s.tools.presets.len() }))
}
macro_rules! spec {
($id:literal, $label:literal, $params:literal, $run:expr) => {
CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: true }
};
}
/// Brush preset organisation specs.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("brush.presets.rename", "Rename Brush", r##"{"name":string,"newName":string} → {name}"##, rename),
spec!(
"brush.presets.move",
"Move Brush",
r##"{"name":string,"group":string?=its group ("" = ungrouped),"before":name? (preset to land before) | "index":n? (position in the group)=end} → {name, group, index}"##,
move_preset
),
spec!(
"brush.presets.moveGroup",
"Move Brush Group",
r##"{"group":string,"before":group? | "index":n? (group position)=end} → {group, index}"##,
move_group
),
spec!("brush.presets.renameGroup", "Rename Brush Group", r##"{"group":string,"newName":string} → {group}"##, rename_group),
spec!("brush.presets.deleteGroup", "Delete Brush Group", r##"{"group":string} → {deleted, count}"##, delete_group),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn names(s: &Session, g: &str) -> Vec<String> {
s.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect()
}
fn with_groups() -> Session {
let mut s = Session::new();
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta"), ("B2", "Beta")] {
s.tools.presets.push(BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
}
s
}
#[test]
fn move_within_and_between_groups() {
let mut s = with_groups();
let r = s.execute("brush.presets.move", json!({"name": "A3", "before": "A1"})).unwrap();
assert_eq!(r, json!({"name": "A3", "group": "Alpha", "index": 0}));
assert_eq!(names(&s, "Alpha"), ["A3", "A1", "A2"]);
s.execute("brush.presets.move", json!({"name": "A3", "index": 99})).unwrap();
assert_eq!(names(&s, "Alpha"), ["A1", "A2", "A3"]);
s.execute("brush.presets.move", json!({"name": "A1", "index": 1})).unwrap();
assert_eq!(names(&s, "Alpha"), ["A2", "A1", "A3"]);
// Into another group, before a member (the group comes from `before`).
s.execute("brush.presets.move", json!({"name": "A1", "before": "B2"})).unwrap();
assert_eq!((names(&s, "Alpha"), names(&s, "Beta")), (vec!["A2".to_string(), "A3".into()], vec!["B1".to_string(), "A1".into(), "B2".into()]));
// Into a group by name (appended), and into a new group.
s.execute("brush.presets.move", json!({"name": "A2", "group": "Beta"})).unwrap();
assert_eq!(names(&s, "Beta"), ["B1", "A1", "B2", "A2"]);
s.execute("brush.presets.move", json!({"name": "A3", "group": "Gamma"})).unwrap();
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Gamma"));
assert!(names(&s, "Alpha").is_empty());
// A built-in moved to another group becomes the user's.
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
s.execute("brush.presets.move", json!({"name": b, "group": "Beta", "index": 0})).unwrap();
let moved = photocraft_paint::presets::find(&s.tools.presets, &b).unwrap();
assert!(!moved.builtin && moved.group == "Beta");
assert_eq!(names(&s, "Beta")[0], b);
}
#[test]
fn groups_move_rename_and_delete() {
let mut s = with_groups();
let order = group_order(&s.tools.presets);
let n = order.len();
assert_eq!(&order[n - 2..], ["Alpha", "Beta"]);
let r = s.execute("brush.presets.moveGroup", json!({"group": "Beta", "index": 0})).unwrap();
assert_eq!(r["index"], 0);
assert_eq!(group_order(&s.tools.presets)[0], "Beta");
assert_eq!(names(&s, "Beta"), ["B1", "B2"], "a group moves as a block, in order");
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Beta"})).unwrap();
assert_eq!(&group_order(&s.tools.presets)[..2], ["Alpha", "Beta"]);
s.execute("brush.presets.moveGroup", json!({"group": "Alpha"})).unwrap();
assert_eq!(group_order(&s.tools.presets).last().map(String::as_str), Some("Alpha"));
s.execute("brush.presets.renameGroup", json!({"group": "Alpha", "newName": "First"})).unwrap();
assert_eq!(names(&s, "First"), ["A1", "A2", "A3"]);
assert!(s.execute("brush.presets.renameGroup", json!({"group": "First", "newName": "Beta"})).is_err());
let r = s.execute("brush.presets.deleteGroup", json!({"group": "First"})).unwrap();
assert_eq!(r["deleted"], 3);
assert!(names(&s, "First").is_empty());
}
#[test]
fn rename_checks_names() {
let mut s = with_groups();
s.execute("brush.presets.rename", json!({"name": "a1", "newName": "Alpha One"})).unwrap();
assert!(photocraft_paint::presets::find(&s.tools.presets, "Alpha One").is_some());
assert!(s.execute("brush.presets.rename", json!({"name": "A2", "newName": "alpha one"})).is_err());
// Case-only renames of itself are fine.
s.execute("brush.presets.rename", json!({"name": "Alpha One", "newName": "ALPHA ONE"})).unwrap();
let b = s.tools.presets.iter().find(|x| x.builtin).unwrap().name.clone();
s.execute("brush.presets.rename", json!({"name": b, "newName": "My Round"})).unwrap();
assert!(!photocraft_paint::presets::find(&s.tools.presets, "My Round").unwrap().builtin);
}
#[test]
fn bad_params_fail_without_changes() {
let mut s = with_groups();
let before = s.tools.presets.clone();
let long = "x".repeat(MAX_NAME + 1);
for (id, p) in [
("brush.presets.move", json!({})),
("brush.presets.move", json!({"name": "Nope"})),
("brush.presets.move", json!({"name": "A1", "before": "A1"})),
("brush.presets.move", json!({"name": "A1", "before": "Nope"})),
("brush.presets.move", json!({"name": "A1", "group": "Alpha", "before": "B1"})),
("brush.presets.move", json!({"name": "A1", "index": -1})),
("brush.presets.move", json!({"name": "A1", "index": "first"})),
("brush.presets.move", json!({"name": "A1", "group": 7})),
("brush.presets.move", json!({"name": "A1", "group": long})),
("brush.presets.rename", json!({"name": "A1"})),
("brush.presets.rename", json!({"name": "A1", "newName": " "})),
("brush.presets.rename", json!({"name": "A1", "newName": long})),
("brush.presets.moveGroup", json!({"group": "Nope"})),
("brush.presets.moveGroup", json!({"group": "Alpha", "before": "Alpha"})),
("brush.presets.moveGroup", json!({"group": "Alpha", "index": 1.5})),
("brush.presets.renameGroup", json!({"group": "Alpha"})),
("brush.presets.deleteGroup", json!({"group": null})),
("brush.presets.deleteGroup", json!({"group": "Nope"})),
] {
assert!(s.execute(id, p.clone()).is_err(), "{id} {p}");
}
assert_eq!(s.tools.presets, before);
// Huge indices clamp to the end.
s.execute("brush.presets.move", json!({"name": "A1", "index": u64::MAX})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group": "Alpha", "index": u64::MAX})).unwrap();
}
}
+8 -1
View File
@@ -468,6 +468,9 @@ fn build() -> Vec<CommandSpec> {
|s, p| {
let id = layer_param(s, p)?;
let label = if p.get("visible").is_some() && p.as_object().is_some_and(|o| o.len() <= 2) { "Layer Visibility" } else { "Layer Properties" };
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
// Clipping and channel changes reach the layers around it: recomposite everything.
let local = p.get("clipped").is_none() && p.get("channels").is_none();
s.edit(label, |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let Some(v) = p.get("name").and_then(Value::as_str) {
@@ -510,6 +513,9 @@ fn build() -> Vec<CommandSpec> {
}
Ok(())
})?;
if local {
crate::layer_multi_cmds::note_damage(s, &before, &[id]);
}
Ok(Value::Null)
}
),
@@ -940,6 +946,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::edit_cmds::specs());
v.extend(crate::color_cmds::specs());
v.extend(crate::brush_cmds::specs());
v.extend(crate::brush_preset_cmds::specs());
v.extend(crate::eraser_cmds::specs());
v.extend(crate::preset_import_cmds::specs());
v.extend(crate::retouch_cmds::specs());
@@ -1070,7 +1077,7 @@ fn combine(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) ->
/// Move a layer's pixels, linked mask and type by whole pixels (vectors move via
/// `vector_cmds::translate_vectors`).
pub(crate) fn translate_layer(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
use photocraft_algo::resample::translate_surface;
use crate::layer_multi_cmds::shift_surface as translate_surface;
// Linked patterns in the layer's effects move with it.
if let Some(r) = &mut l.effects.reference {
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
+160 -4
View File
@@ -246,17 +246,125 @@ pub fn translate(s: &mut Session, p: &Value) -> Result<Value> {
if roots.is_empty() {
return Err(EngineError::Other("no active layer".into()));
}
s.edit("Move", |doc, _| {
let ids = top_level(doc, &with_links(doc, &roots));
let before = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let ids = s.edit("Move", |doc, _| {
let ids = move_targets(doc, &roots);
if ids.is_empty() {
return Err(EngineError::NoLayer(roots[0]));
}
let moves: Vec<_> = ids.into_iter().map(|id| (id, dx, dy)).collect();
move_layers(doc, &moves)
let moves: Vec<_> = ids.iter().map(|&id| (id, dx, dy)).collect();
move_layers(doc, &moves)?;
Ok(ids)
})?;
note_damage(s, &before, &ids);
Ok(Value::Null)
}
/// The layers a move of `roots` takes along: their linked layers, without layers whose group
/// is also moving (the group carries them).
pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
top_level(doc, &with_links(doc, roots))
}
/// `doc` with the layers `ids` ([`move_targets`]) moved by whole pixels, as `layer.translate`
/// leaves them: the Move tool's live preview while it drags. Type, shape and smart-object pixels
/// shift as they are instead of re-rendering (the commit re-renders them), so a drag frame costs
/// copying the moving pixels only.
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
let mut out = doc.clone();
for &id in ids {
let l = out.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if l.locks.position || l.locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
shift_shown(doc, l, dx, dy);
}
Ok(out)
}
/// `photocraft_algo::resample::translate_surface` with the content scan cached per tile (a Move
/// drag shifts the same layers every frame; scanning them each time cost more than the copy).
pub(crate) fn shift_surface(s: &photocraft_raster::Surface, dx: i32, dy: i32) -> photocraft_raster::Surface {
let mut out = photocraft_raster::Surface::with_default(s.format(), &s.default_pixel());
let r = photocraft_compose::bounds::content_bounds(s);
if !r.is_empty() {
out.write_interleaved(r.translate(dx, dy), &s.to_interleaved(r));
}
out
}
/// [`crate::commands::translate_layer`] plus the vector side, without re-rendering anything.
fn shift_shown(doc: &Document, l: &mut Layer, dx: i32, dy: i32) {
use self::shift_surface as translate_surface;
let a = photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy));
if let Some(r) = &mut l.effects.reference {
*r = (r.0 + f64::from(dx), r.1 + f64::from(dy));
}
if let Some(m) = l.mask.as_mut().filter(|m| m.linked) {
m.surface = translate_surface(&m.surface, dx, dy);
}
if let Some(vm) = l.vector_mask.as_mut().filter(|v| v.linked) {
vm.path = vm.path.transform(&a);
}
match &mut l.content {
LayerContent::Raster(s) => *s = translate_surface(s, dx, dy),
LayerContent::Text(t) => {
t.transform = a.mul(&t.transform);
if let Some(c) = &mut t.cache {
*c = translate_surface(c, dx, dy);
}
}
LayerContent::Shape(sh) => {
// The rendered shape is cut at the canvas: one that reaches past it renders again.
let canvas = doc.bounds();
let inside = sh.cache.as_ref().is_some_and(|c| {
let b = photocraft_compose::bounds::content_bounds(c);
b.is_empty() || (b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1)
});
crate::vector_cmds::transform_shape(sh, &a);
match &mut sh.cache {
Some(c) if inside => *c = translate_surface(c, dx, dy),
_ => crate::vector_cmds::refresh_shape(doc, sh),
}
}
LayerContent::Smart(sm) => crate::smart_cmds::shift_smart(sm, dx, dy),
LayerContent::Group(g) => {
if let Some(ab) = &mut g.artboard {
ab.rect = ab.rect.translate(dx, dy);
}
g.children.iter_mut().for_each(|c| shift_shown(doc, c, dx, dy));
}
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
}
}
/// The document pixels an edit of the layers `ids` (moved, shown, hidden or restyled; contents
/// otherwise as they were) can have changed from `before` to `after`, or `None` for anywhere.
pub fn layers_damage(before: &Document, after: &Document, ids: &[LayerId]) -> Option<Rect> {
let mut out = Rect::EMPTY;
for d in [before, after] {
let canvas = d.bounds();
for id in ids {
// A layer gone from one side (never for a move) changes who knows what.
let b = photocraft_compose::change_bounds(d.layer(*id)?, canvas)?;
if !b.is_empty() {
out = if out.is_empty() { b } else { out.union(&b) };
}
}
}
Some(out)
}
/// Report [`layers_damage`] as the active document's last change, so the canvas recomposites
/// only that (grown by the effect reach around it) instead of everything.
pub(crate) fn note_damage(s: &mut Session, before: &Document, ids: &[LayerId]) {
let Some(st) = s.active_mut() else { return };
if before.size != st.doc.size {
return;
}
st.last_damage = layers_damage(before, &st.doc, ids);
}
/// Content bounds used by Align/Distribute: the layer's pixels (type and shape layers use their
/// rendered pixels), or the union of a group's children. Fill and adjustment layers have none.
pub fn layer_bounds(l: &Layer) -> Option<Rect> {
@@ -734,6 +842,54 @@ mod tests {
selected(s)
}
fn damage(s: &Session) -> Option<Rect> {
s.active().unwrap().last_damage
}
#[test]
fn moves_and_layer_props_report_only_their_area() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
s.execute("layer.select", json!({"layer": a.0})).unwrap();
s.execute("layer.translate", json!({"dx": 5, "dy": -3})).unwrap();
assert_eq!(damage(&s), Some(Rect::new(10, 7, 25, 20)), "where the layer was and is");
s.execute("layer.setProps", json!({"layer": a.0, "visible": false})).unwrap();
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
s.execute("layer.setProps", json!({"layer": a.0, "opacity": 0.5, "blend": "Multiply"})).unwrap();
assert_eq!(damage(&s), Some(Rect::new(15, 7, 25, 17)));
// Clipping changes reach the layers around: everything.
s.execute("layer.setProps", json!({"layer": a.0, "clipped": true})).unwrap();
assert_eq!(damage(&s), None);
// An adjustment layer changes everything beneath it.
let adj = s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": adj, "visible": false})).unwrap();
assert_eq!(damage(&s), None);
// The Background (opaque everywhere): everything.
let bg = doc(&s).layers[0].id;
s.execute("layer.setProps", json!({"layer": bg.0, "opacity": 0.5})).unwrap();
let all = doc(&s).bounds();
assert!(damage(&s).is_none_or(|r| r.contains_rect(&all)), "{:?}", damage(&s));
// Bad params still fail cleanly.
assert!(s.execute("layer.setProps", json!({"layer": 999_999, "visible": true})).is_err());
assert!(s.execute("layer.translate", json!({"layer": 999_999, "dx": 1})).is_err());
}
#[test]
fn the_move_preview_matches_the_move() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(10, 10, 20, 20));
let before = s.active().unwrap().doc.clone();
let ids = move_targets(&before, &[a]);
let shown = moved(&before, &ids, 7, 4).unwrap();
s.execute("layer.translate", json!({"layer": a.0, "dx": 7, "dy": 4})).unwrap();
assert_eq!(layer_bounds(shown.layer(a).unwrap()), Some(bounds(&s, a)));
// Locked layers don't move in the preview either.
let mut locked = (*before).clone();
locked.layer_mut(a).unwrap().locks.position = true;
assert!(moved(&locked, &ids, 1, 1).is_err());
assert!(moved(&before, &[LayerId(999_999)], 1, 1).is_err());
}
fn select_all(s: &mut Session, ids: &[LayerId]) {
s.execute("layer.select", json!({"layer": ids[0].0})).unwrap();
for id in &ids[1..] {
+6 -1
View File
@@ -14,6 +14,7 @@ pub mod analysis_cmds;
pub mod artboard_cmds;
pub mod automate_cmds;
pub mod brush_cmds;
pub mod brush_preset_cmds;
pub mod build_info;
mod canvas_geom;
pub mod channel_cmds;
@@ -198,6 +199,9 @@ pub struct ToolState {
pub presets_rev: u64,
/// Mixer Brush paint carried between strokes.
pub mixer: photocraft_paint::mixer::MixerState,
/// The coalescing key of the running `tools.setBrush` gesture and the brush before it, so the
/// gesture journals as one call ([`brush_cmds::coalesce_journal`]).
pub brush_gesture: Option<(String, photocraft_paint::BrushSettings)>,
}
impl Default for ToolState {
@@ -213,6 +217,7 @@ impl Default for ToolState {
presets: photocraft_paint::presets::builtin(),
presets_rev: 0,
mixer: Default::default(),
brush_gesture: None,
}
}
}
@@ -336,7 +341,7 @@ impl Session {
edit_menu_cmds::after_command(self, id);
automate_cmds::after_command(self, id);
self.sync_preset_store();
if spec.journal {
if spec.journal && !brush_cmds::coalesce_journal(self, id, &params) {
self.journal.push((id.to_string(), params));
}
Ok(r)
+31 -6
View File
@@ -9,7 +9,8 @@
//! - `tips/<hash>.pctip`: content-addressed bitmaps, deflated, stored at 8 bits per sample when
//! the tip came from 8-bit data and 16 bits otherwise. Renaming or re-grouping a preset only
//! rewrites a small JSON file; a tip no group references any more is deleted;
//! - `index.json`: group order and the built-in presets the user deleted.
//! - `index.json`: group order, the order of every preset (built-ins included, so drag and drop
//! in the Brushes panel persists) and the built-in presets the user deleted.
//!
//! Built-in presets are never written (they are regenerated on start). The store syncs after
//! every command that changes the brush presets ([`Session::brush_presets_changed`]); only groups
@@ -324,6 +325,8 @@ struct IndexFile {
groups: Vec<String>,
/// Built-in presets the user deleted (by name).
hidden_builtins: Vec<String>,
/// Every preset's name in library (panel) order; empty when it is the default order.
order: Vec<String>,
}
/// The group's file name: a readable, sanitised prefix plus a hash of the exact name, so
@@ -403,6 +406,8 @@ pub struct Opened {
pub presets: Vec<BrushPreset>,
/// Built-in presets the user deleted.
pub hidden_builtins: Vec<String>,
/// The saved library order (preset names), empty when none was saved.
pub order: Vec<String>,
/// Files that were skipped (corrupt, oversized, missing tips).
pub warnings: Vec<String>,
}
@@ -515,8 +520,9 @@ pub fn open(backend: Box<dyn PresetBackend>) -> Opened {
}
}
let hidden_builtins = index.as_ref().map(|i| i.hidden_builtins.clone()).unwrap_or_default();
let order = index.as_ref().map(|i| i.order.clone()).unwrap_or_default();
store.index = index;
Opened { store, presets, hidden_builtins, warnings }
Opened { store, presets, hidden_builtins, order, warnings }
}
/// Read and decode tips, in parallel on native targets.
@@ -615,10 +621,24 @@ impl PresetStore {
// Index: group order and deleted built-ins.
let hidden: Vec<String> = builtin_names().iter().filter(|n| !presets.iter().any(|p| p.name.eq_ignore_ascii_case(n))).map(|n| n.to_string()).collect();
let groups: Vec<String> = files.into_iter().filter(|f| self.groups.contains_key(f)).collect();
let want = IndexFile { version: 1, groups, hidden_builtins: hidden };
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins);
// The full order is only saved once it differs from the default (built-ins, then the rest).
let order: Vec<String> = presets.iter().map(|p| p.name.clone()).collect();
let default_order: Vec<String> = presets.iter().filter(|p| p.builtin).chain(presets.iter().filter(|p| !p.builtin)).map(|p| p.name.clone()).collect();
let builtins_in_order = {
let names = builtin_names();
let mut it = presets.iter().filter(|p| p.builtin).map(|p| names.iter().position(|n| *n == p.name));
let first = it.next().flatten();
it.try_fold(first, |prev, cur| match (prev, cur) {
(Some(a), Some(b)) if b > a => Some(Some(b)),
_ => None,
})
.is_some()
};
let order = if builtins_in_order && order == default_order { Vec::new() } else { order };
let want = IndexFile { version: 1, groups, hidden_builtins: hidden, order };
let same = self.index.as_ref().is_some_and(|i| i.groups == want.groups && i.hidden_builtins == want.hidden_builtins && i.order == want.order);
if !same {
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty();
let empty = want.groups.is_empty() && want.hidden_builtins.is_empty() && want.order.is_empty();
let r = if empty && self.index.is_none() {
Ok(())
} else {
@@ -693,7 +713,7 @@ impl Session {
/// built-in of the same name; presets created before the store finished loading win), hide
/// deleted built-ins, then sync. Returns the load warnings.
pub fn attach_preset_store(&mut self, opened: Opened) -> Vec<String> {
let Opened { store, presets, hidden_builtins, mut warnings } = opened;
let Opened { store, presets, hidden_builtins, order, mut warnings } = opened;
let lib = &mut self.tools.presets;
lib.retain(|p| !(p.builtin && hidden_builtins.iter().any(|h| h.eq_ignore_ascii_case(&p.name))));
for p in presets {
@@ -703,6 +723,11 @@ impl Session {
None => lib.push(p),
}
}
// The saved panel order (stable: presets it doesn't name keep their order, at the end).
if !order.is_empty() {
let pos: HashMap<String, usize> = order.iter().enumerate().map(|(i, n)| (n.to_lowercase(), i)).collect();
lib.sort_by_key(|p| pos.get(&p.name.to_lowercase()).copied().unwrap_or(usize::MAX));
}
self.preset_store = Some(store);
self.brush_presets_changed();
self.sync_preset_store();
+35
View File
@@ -239,6 +239,41 @@ fn startup_with_many_groups_loads_them_all() {
assert_eq!(groups, (0..25).map(|g| format!("Group {g}")).collect::<Vec<_>>(), "group order survives");
}
#[test]
fn reordering_presets_and_groups_persists() {
let names = |s: &Session| s.tools.presets.iter().map(|p| (p.name.clone(), p.group.clone())).collect::<Vec<_>>();
let dir = TempDir::new("reorder");
let (mut s, _) = session(&dir);
for (n, g) in [("Ink 1", "Inks"), ("Ink 2", "Inks"), ("Wash", "Washes")] {
s.execute("brush.presets.save", json!({"name": n})).unwrap();
s.tools.presets.iter_mut().find(|p| p.name == n).unwrap().group = g.into();
}
s.brush_presets_changed();
s.sync_preset_store();
// Untouched order: no order list is written (the default order is implied).
let (t, _) = session(&dir);
assert_eq!(names(&t), names(&s));
// Reorder a built-in within its group, a user preset across groups, and whole groups.
let builtin_group = s.tools.presets[0].group.clone();
let last_builtin = s.tools.presets.iter().rfind(|p| p.builtin && p.group == builtin_group).unwrap().name.clone();
let first = s.tools.presets[0].name.clone();
s.execute("brush.presets.move", json!({"name": last_builtin, "before": first})).unwrap();
s.execute("brush.presets.move", json!({"name": "Ink 2", "group": "Washes", "index": 0})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group": "Washes", "index": 0})).unwrap();
s.execute("brush.presets.moveGroup", json!({"group": "Inks", "before": builtin_group})).unwrap();
assert_eq!(s.tools.presets[0].name, "Ink 2");
let (t, w) = session(&dir);
assert!(w.is_empty(), "{w:?}");
assert_eq!(names(&t), names(&s), "the panel order survives a restart");
assert!(t.tools.presets.iter().find(|p| p.name == last_builtin).unwrap().builtin, "reordering keeps a built-in built-in");
// Renaming and deleting a group persist too.
s.execute("brush.presets.renameGroup", json!({"group": "Inks", "newName": "Pens"})).unwrap();
s.execute("brush.presets.deleteGroup", json!({"group": "Washes"})).unwrap();
let (t, _) = session(&dir);
assert_eq!(names(&t), names(&s));
assert!(t.tools.presets.iter().all(|p| p.group != "Washes" && p.group != "Inks"));
}
#[test]
fn corrupt_and_oversized_files_are_skipped_with_a_warning() {
let dir = TempDir::new("corrupt");
+2 -2
View File
@@ -403,10 +403,10 @@ pub(crate) fn snap_affine(a: Affine) -> Affine {
pub(crate) fn shift_smart(sm: &mut SmartObject, dx: i32, dy: i32) {
sm.transform = Affine::translate(dx as f64, dy as f64).mul(&sm.transform);
if let Some(c) = &mut sm.cache {
*c = translate_surface(c, dx, dy);
*c = crate::layer_multi_cmds::shift_surface(c, dx, dy);
}
if let Some(m) = &mut sm.filter_mask {
m.surface = translate_surface(&m.surface, dx, dy);
m.surface = crate::layer_multi_cmds::shift_surface(&m.surface, dx, dy);
}
}
+5 -2
View File
@@ -85,7 +85,10 @@ pub struct SnapTargets {
/// Bounds of a layer for snapping: what Free Transform would show (`None` for empty layers,
/// groups and maskless adjustment layers).
pub fn layer_rect(doc: &Document, id: LayerId) -> Option<[f64; 4]> {
let l = doc.layer(id)?;
rect_of(doc, doc.layer(id)?)
}
fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
if l.is_group() || (matches!(l.content, LayerContent::Adjustment(_)) && l.mask.is_none()) {
return None;
}
@@ -118,7 +121,7 @@ impl SnapTargets {
if !l.visible || exclude.contains(&l.id) {
continue;
}
if let Some(r) = layer_rect(doc, l.id) {
if let Some(r) = rect_of(doc, l) {
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
}
}
+4 -2
View File
@@ -19,12 +19,14 @@ fn bad(msg: impl Into<String>) -> EngineError {
}
/// Document-space bounds a transform of `layer` starts from (what Free Transform frames).
/// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag.
pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
let content = match &layer.content {
LayerContent::Group(g) => g.children.iter().map(|l| transform_bounds(doc, l)).fold(Rect::EMPTY, |a, b| a.union(&b)),
_ => layer.surface().map_or(Rect::EMPTY, Surface::content_bounds),
_ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds),
};
let content = if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| m.surface.content_bounds()) } else { content };
let content =
if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content };
match &doc.selection {
Some(sel) if !layer.is_group() => content.intersect(&sel.content_bounds()),
_ => content,
+5
View File
@@ -26,6 +26,11 @@ pub fn transparent_outside(layer: &Layer) -> bool {
photocraft_compose::transparent_outside(layer)
}
/// `photocraft_compose::composite_bounds`: where compositing the layer can change anything.
pub fn composite_bounds(layer: &Layer, canvas: Rect) -> Option<Rect> {
photocraft_compose::composite_bounds(layer, canvas)
}
#[cfg(test)]
mod tests {
use super::*;
+41 -14
View File
@@ -38,6 +38,7 @@ const F_MASK_TEX: u32 = 2u; // mask pixels live in `mask_tex`
const F_TEX: u32 = 4u; // layer pixels live in `layer_tex`
const F_GRADIENT: u32 = 8u; // gradient fill (stops in `lut_tex`)
const F_KNOCKOUT: u32 = 16u; // effect paint: the layer knocks out the coverage (drop shadow)
const F_NO_LAYER: u32 = 32u; // effect merge: A already holds the layer (only the opacity mix)
const F_VECTOR: u32 = 64u; // effect paint: shape layer (outside stroke never inside)
const F_ATOP: u32 = 128u; // effect merge: clipped layer over an opaque base
const F_GATE: u32 = 256u; // effect paint: coverage only inside the layer's shape
@@ -240,7 +241,17 @@ fn composite(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32) -> vec4<f32> {
return vec4(rgb, ao);
}
// `psblend::composite_gamma`: coverage mixed in a gamma space (type layers, gamma 1.45).
// `psblend::text_encode` / `text_decode`: linear light raised to 1 / gamma.
fn text_enc(c: vec3<f32>, g: f32) -> vec3<f32> {
let l = vec3(srgb_to_linear(max(c.r, 0.0)), srgb_to_linear(max(c.g, 0.0)), srgb_to_linear(max(c.b, 0.0)));
return pow(l, vec3(1.0 / g));
}
fn text_dec(v: vec3<f32>, g: f32) -> vec3<f32> {
let l = pow(max(v, vec3(0.0)), vec3(g));
return vec3(linear_to_srgb(l.r), linear_to_srgb(l.g), linear_to_srgb(l.b));
}
// `psblend::composite_gamma`: coverage mixed in the text blending space (type layers, 1.45).
fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bool) -> vec4<f32> {
if (!gamma_on) { return composite(mode, b, s, opacity); }
let ab = b.a;
@@ -250,8 +261,8 @@ fn composite_g(mode: i32, b: vec4<f32>, s: vec4<f32>, opacity: f32, gamma_on: bo
let ao = as_ + ab * (1.0 - as_);
if (ao <= 0.0) { return vec4(0.0); }
let g = op.p4.w; // psblend::text_gamma
let pw = (1.0 - as_) * ab * pow(max(b.rgb, vec3(0.0)), vec3(g)) + (1.0 - ab) * as_ * pow(max(s.rgb, vec3(0.0)), vec3(g)) + as_ * ab * pow(max(bl, vec3(0.0)), vec3(g));
return vec4(pow(pw / ao, vec3(1.0 / g)), ao);
let pw = (1.0 - as_) * ab * text_enc(b.rgb, g) + (1.0 - ab) * as_ * text_enc(s.rgb, g) + as_ * ab * text_enc(bl, g);
return vec4(text_dec(pw / ao, g), ao);
}
// photocraft_color::convert::{srgb_to_lab, lab_to_srgb} (D50, Bradford to sRGB).
@@ -593,13 +604,15 @@ fn gradient_t(d: vec2<i32>) -> f32 {
let len = max(sqrt((c * w) * (c * w) + (s * h) * (s * h)), 1.0) * max(op.p0.y, 1e-3);
// effects::gradient_t: Linear / Reflected span the bounds' chord along the angle.
let chord = max(min(w / max(abs(c), 1e-6), h / max(abs(s), 1e-6)), 1.0) * max(op.p0.y, 1e-3);
// Linear / Reflected sample the pixel's top-left corner (effects::gradient_t).
let corner = 0.5 * (c - s);
var t: f32;
switch i32(op.p0.w) {
case 1: { t = sqrt(dx * dx + dy * dy) / (len / 2.0); } // Radial
case 2: { t = rem_euclid((a - atan2(-dy, dx)) / 6.28318530718, 1.0); } // Angle
case 3: { t = abs(along / (chord / 2.0)); } // Reflected
case 3: { t = abs((along - corner) / (chord / 2.0)); } // Reflected
case 4: { t = (abs(along) + abs(across)) / (len / 2.0); } // Diamond
default: { t = along / chord + 0.5; } // Linear
default: { t = (along - corner) / chord + 0.5; } // Linear
}
t = clamp(t, 0.0, 1.0);
if (op.p0.z > 0.5) { t = 1.0 - t; }
@@ -880,7 +893,7 @@ fn fs_fxmerge(in: VOut) -> @location(0) vec4<f32> {
var w = textureLoad(tex_a, p, 0);
let l = textureLoad(tex_b, p, 0);
let c = textureLoad(tex_c, p, 0);
if (l.a > 0.0) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
if (l.a > 0.0 && (op.flags & F_NO_LAYER) == 0u) { w = composite_g(op.mode, w, l, 1.0, (op.flags & F_TEXT_GAMMA) != 0u); }
let atop = (op.flags & F_ATOP) != 0u;
var before = c;
if (atop) { before = vec4(c.rgb, 1.0); }
@@ -1000,19 +1013,33 @@ fn fs_mbevelh(in: VOut) -> @location(0) vec4<f32> {
@fragment
fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
let depth = op.p1.x;
let s = textureLoad(layer_tex, p, 0).r;
// effects::bevel_maps' on_bevel: the height above BEVEL_H_EPS (emboss styles: or a
// 4-neighbour's).
var hmax = ra(p);
if (op.p1.y > 1.5) {
hmax = max(max(hmax, max(ra(p + vec2(1, 0)), ra(p - vec2(1, 0)))), max(ra(p + vec2(0, 1)), ra(p - vec2(0, 1))));
}
let outside = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && hmax > 1e-5);
// p1.y: 0 inside (× alpha), 1 under the edge and outside (outer bevel), 2 / 3 emboss / pillow
// emboss (inside where the shape is, plus the outside half, flipped for pillow, × 1 − alpha).
if (op.p1.y < 0.5) { return mout(bevel_part(p, op.p1.x, s)); }
if (op.p1.y < 1.5) { return mout(bevel_part(p, op.p1.x, outside)); }
var inner = 0.0;
if (s > INSIDE_EPS) { inner = bevel_part(p, op.p1.x, s); }
let od = select(op.p1.x, -op.p1.x, op.p1.y > 2.5);
return mout(inner + bevel_part(p, od, outside) * (1.0 - min(s, 1.0)));
}
// One bevel shading value (`bevel_maps`' shade_into): highlight (p1.z = 0) or shadow amount for
// height-gradient scale `depth`, times `region`, through the gloss contour.
fn bevel_part(p: vec2<i32>, depth: f32, region: f32) -> f32 {
let gx = (ra(p + vec2(1, 0)) - ra(p - vec2(1, 0))) * 0.5 * depth;
let gy = (ra(p + vec2(0, 1)) - ra(p - vec2(0, 1))) * 0.5 * depth;
let n = vec3(-gx, -gy, 1.0);
let len = sqrt(n.x * n.x + n.y * n.y + 1.0);
let shade = (n.x * op.p0.x + n.y * op.p0.y + n.z * op.p0.z) / len;
let se = op.p0.w;
let s = textureLoad(layer_tex, p, 0).r;
var region = s;
if (op.p1.y > 0.5) {
// Outside parts paint at full strength wherever the shape isn't opaque (edge pixels too).
region = select(0.0, 1.0, s < 1.0 - INSIDE_EPS && textureLoad(tex_a, p, 0).r > 1e-5); // effects::BEVEL_H_EPS
}
let k = shade - se;
var v = 0.0;
if (op.p1.z > 0.5) {
@@ -1021,7 +1048,7 @@ fn fs_mbevelshade(in: VOut) -> @location(0) vec4<f32> {
v = clamp(k / max(1.0 - se, 1e-3), 0.0, 1.0) * region;
}
if (op.p1.w > 0.5) { v = lut(0, v); }
return mout(v);
return v;
}
// Bevel texture (`effects::bevel_height`): A + k × luminance of the pattern (× its alpha; 1 −
+35 -25
View File
@@ -92,15 +92,7 @@ impl MapProgram {
/// the reach each field was computed with.
pub fn windows(&self, d: Rect, region: Rect, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec<Rect> {
let n = self.stages.len();
let reach_of = |reach: &[i32], i: Option<In>| match i {
Some(In::Val(k)) => reach[k],
Some(In::Field(f)) => field_radius(f, field_reach(f)),
_ => 0,
};
let mut reach = vec![0i32; n];
for (i, s) in self.stages.iter().enumerate() {
reach[i] = reach_of(&reach, s.a).max(reach_of(&reach, s.b)).max(reach_of(&reach, s.s)) + s.radius;
}
let reach = self.stage_reach(field_reach);
let mut need = vec![Rect::EMPTY; n];
for (i, s) in self.stages.iter().enumerate() {
if s.out.is_some() {
@@ -123,6 +115,27 @@ impl MapProgram {
need
}
/// Per stage, how far (px) its output depends on the shape.
fn stage_reach(&self, field_reach: &dyn Fn(FieldKind) -> i32) -> Vec<i32> {
let mut reach: Vec<i32> = Vec::with_capacity(self.stages.len());
for s in &self.stages {
let of = |i: Option<In>| match i {
Some(In::Val(k)) => reach.get(k).copied().unwrap_or(0),
Some(In::Field(f)) => field_radius(f, field_reach(f)),
_ => 0,
};
let r = of(s.a).max(of(s.b)).max(of(s.s)) + s.radius;
reach.push(r);
}
reach
}
/// How far (px) any final map depends on the shape: maps computed over a window are exact
/// at least this far inside it.
pub fn reach(&self, field_reach: &dyn Fn(FieldKind) -> i32) -> i32 {
self.stage_reach(field_reach).into_iter().zip(&self.stages).filter(|(_, s)| s.out.is_some()).map(|(r, _)| r).max().unwrap_or(0)
}
/// Physical temporary index per stage (None for final maps), reusing temporaries after
/// their last read; returns (assignment, temporaries needed).
pub fn temps(&self) -> (Vec<Option<usize>>, usize) {
@@ -277,7 +290,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
if r > 0.0 {
// dist_outside of the shifted map is the shifted field (integer offsets; shifted-in
// pixels are far away for a drop shadow, inside for an inner shadow).
let f = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
let f = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r);
let d = if dx == 0.0 && dy == 0.0 {
f
} else {
@@ -308,7 +321,7 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
let (r, bw) = photocraft_compose::effects::spread_split(g.size, g.spread);
let mut m = src;
if r > 0.0 {
let d = b.field(if inner { FieldKind::OutsideInverse } else { FieldKind::Outside }, r);
let d = b.field(if inner { FieldKind::ChokeInside } else { FieldKind::StrokeOutside }, r);
m = b.dilate(src, d, r);
}
let m = b.blur(m, bw);
@@ -372,16 +385,18 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool,
let (sa, ca) = angle.to_radians().sin_cos();
let (se, ce) = altitude.to_radians().sin_cos();
let lut = contour_lut(&bv.gloss_contour).map(Arc::new);
let passes: Vec<(f32, bool)> = match g.paint {
BevelPaint::Inner => vec![(g.depth, false)],
BevelPaint::Outer => vec![(g.depth, true)],
BevelPaint::Both => vec![(g.depth, false), (if g.pillow { -g.depth } else { g.depth }, true)],
// Region (`fs_mbevelshade`): 0 inside, 1 under the edge and outside, 2 / 3 emboss /
// pillow emboss (both halves in one map).
let passes: Vec<(f32, f32)> = match g.paint {
BevelPaint::Inner => vec![(g.depth, 0.0)],
BevelPaint::Outer => vec![(g.depth, 1.0)],
BevelPaint::Both => vec![(g.depth, if g.pillow { 3.0 } else { 2.0 })],
};
let mut out = 0;
for (depth, outer) in &passes {
for (depth, region) in &passes {
for which in [0.0, 1.0] {
let mut s = stage(Kernel::MBevelShade, Some(h), None, [ca * ce, -sa * ce, se, se], 1);
s.p1 = [*depth, f32::from(u8::from(*outer)), which, f32::from(u8::from(lut.is_some()))];
s.p1 = [*depth, *region, which, f32::from(u8::from(lut.is_some()))];
s.lut = lut.clone();
s.s = Some(In::Shape);
s.out = Some(out);
@@ -668,14 +683,9 @@ mod tests {
shape[y * w + x] = (90.0 - r).clamp(0.0, 1.0) * if (x / 13 + y / 17) % 5 == 0 { 0.6 } else { 1.0 };
}
}
for kind in [
FieldKind::Outside,
FieldKind::Inside,
FieldKind::OutsideInverse,
FieldKind::StrokeOutside,
FieldKind::StrokeInside,
FieldKind::StrokeOutsideVector,
] {
for kind in
[FieldKind::Outside, FieldKind::Inside, FieldKind::ChokeInside, FieldKind::StrokeOutside, FieldKind::StrokeInside, FieldKind::StrokeOutsideVector]
{
let reach = 9;
let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h);
let banded = field(kind, reach, &shape, region, region);
+571 -155
View File
File diff suppressed because it is too large. Load diff
+152 -42
View File
@@ -338,6 +338,8 @@ enum Cov {
/// knockout `m × (1 − a × k)` (k in `p4.w`); gate `inside ? m : 0`; rel `inside ? min(m / a, 1) : 0`;
/// stroke-out `inside ? (vector ? 0 : 1) : m`.
pub const F_KNOCKOUT: u32 = 16;
/// Effect merge: A already holds the layer; only mix with the backdrop by the layer's opacity.
pub const F_NO_LAYER: u32 = 32;
/// Shape layer (outside strokes never show inside it).
pub const F_VECTOR: u32 = 64;
/// Merge onto an opaque clipping base, keeping its alpha.
@@ -539,8 +541,10 @@ impl<'a> Planner<'a> {
let opacity = layer.opacity * layer.fill_opacity;
let visible_clipped: Vec<&'a Layer> = clipped.iter().filter(|c| c.visible).collect();
// Below 100% fill a pass-through group renders isolated (as the CPU compositor does).
if let LayerContent::Group(g) = &layer.content
&& layer.blend == BlendMode::PassThrough
&& layer.fill_opacity >= 1.0
&& !has_effects(layer)
{
let before = self.retain(backdrop);
@@ -635,18 +639,72 @@ impl<'a> Planner<'a> {
return Ok(self.emit(p));
}
// A layer that covers little of the canvas composites only over its bounds and is
// copied back into the backdrop (#125): a layout with hundreds of small text and shape
// layers otherwise shades every layer over every chunk.
let clip = self.local_clip(layer);
if clip.is_some_and(|c| c.is_empty()) {
// Nothing to draw (an empty layer, or a group of them).
for c in &visible_clipped {
self.check_blend_if(c)?;
}
return Ok(backdrop);
}
let start = self.passes.len();
let mut content = self.content(layer)?;
for c in visible_clipped {
content = self.atop(c, content)?;
}
if let Some(clip) = clip {
// Everything that built the layer's content only matters inside its bounds.
for p in &mut self.passes[start..] {
if p.clip.is_none() && !matches!(p.kernel, Kernel::Clear | Kernel::CopyFull) {
p.clip = Some(clip);
}
}
}
let mut p = Pass::new(Kernel::Blend, 0);
p.a = Some(backdrop);
p.a = Some(if clip.is_some() { self.retain(backdrop) } else { backdrop });
p.b = Some(content);
p.mode = layer.blend;
p.opacity = opacity;
p.flags = gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
Ok(self.emit(p))
p.clip = clip;
let merged = self.emit(p);
match clip {
Some(clip) => Ok(self.write_back(backdrop, merged, clip)),
None => Ok(merged),
}
}
/// The rect a plain layer composites over: its composite bounds when they cover at most half
/// the canvas (beyond that, compositing everywhere costs less than the copy back).
fn local_clip(&self, layer: &Layer) -> Option<Rect> {
let canvas = self.cx.canvas;
let b = bounds::composite_bounds(layer, canvas)?;
(b.width() as u64 * b.height() as u64 * 2 <= canvas.width() as u64 * canvas.height() as u64).then_some(b)
}
/// Copy `merged` into `backdrop` over `clip` (in place when nothing else holds the backdrop);
/// returns the new backdrop. Consumes both.
fn write_back(&mut self, backdrop: Slot, merged: Slot, clip: Rect) -> Slot {
let dst = if self.refs[backdrop as usize] == 1 {
backdrop
} else {
let n = self.alloc();
let mut p = Pass::new(Kernel::CopyFull, n);
p.a = Some(backdrop);
self.passes.push(p);
self.release(backdrop);
n
};
let mut p = Pass::new(Kernel::CopyRect, dst);
p.a = Some(merged);
p.clip = Some(clip);
self.passes.push(p);
self.release(merged);
dst
}
/// render_content for non-adjustment layers.
@@ -724,9 +782,13 @@ impl<'a> Planner<'a> {
}
Fill::Gradient { stops, angle, scale, style, reverse } => {
p.gradient = true;
p.params[0] = [*angle, *scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
// compose::render_fill: whole-pixel end points (fill_layout).
let (angle, scale, offset) = photocraft_compose::fill_layout::fill_gradient_layout(*style, *angle, *scale, frame);
p.params[0] = [angle, scale, if *reverse { 1.0 } else { 0.0 }, style_index(*style)];
let c = frame;
p.params[1] = [c.x0 as f32, c.y0 as f32, c.width() as f32, c.height() as f32];
p.params[2][0] = offset.0;
p.params[2][1] = offset.1;
let conv: Vec<(f32, [f32; 4])> = stops
.iter()
.map(|(t, c)| {
@@ -796,7 +858,7 @@ impl<'a> Planner<'a> {
}
let mut p = Pass::new(Kernel::Adjust, 0);
p.a = Some(src);
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer);
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer, photocraft_compose::adjustment_quantum(self.cx.depth));
p.adjust_kind = kind;
p.params = params;
p.lut = lut;
@@ -920,11 +982,11 @@ impl<'a> Planner<'a> {
}
}
}
let bevel_paint = |b: &photocraft_doc::Bevel| photocraft_compose::effects::bevel_geom(b).paint;
for &(i, e) in &rev {
if let Effect::BevelEmboss(b) = e
&& b.style != photocraft_doc::BevelStyle::OuterBevel
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Inner
{
// Inner part (emboss styles: maps 0–1 inside, 2–3 outside).
l = self.paint(
l,
content,
@@ -984,10 +1046,9 @@ impl<'a> Planner<'a> {
}
for &(i, e) in &rev {
if let Effect::BevelEmboss(b) = e
&& let paint = photocraft_compose::effects::bevel_geom(b).paint
&& paint != photocraft_compose::effects::BevelPaint::Inner
&& bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer
{
let k = if paint == photocraft_compose::effects::BevelPaint::Both { 2 } else { 0 };
let k = 0;
w = self.paint(
w,
content,
@@ -1003,35 +1064,55 @@ impl<'a> Planner<'a> {
}
}
let mut p = Pass::new(Kernel::FxMerge, 0);
p.a = Some(w);
p.b = Some(l);
p.c = Some(self.retain(backdrop));
p.mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
p.opacity = layer.opacity;
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
p.clip = Some(clip);
let merged = self.emit(p);
let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
let late: Vec<(usize, &'a photocraft_doc::Bevel)> = rev
.iter()
.filter_map(|&(i, e)| if let Effect::BevelEmboss(b) = e { Some((i, b)) } else { None })
.filter(|(_, b)| bevel_paint(b) == photocraft_compose::effects::BevelPaint::Both)
.collect();
let merged = if late.is_empty() {
let mut p = Pass::new(Kernel::FxMerge, 0);
p.a = Some(w);
p.b = Some(l);
p.c = Some(self.retain(backdrop));
p.mode = mode;
p.opacity = layer.opacity;
p.flags = if atop { F_ATOP } else { 0 } | gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
p.clip = Some(clip);
self.emit(p)
} else {
// Emboss styles shade the composited layer (`composite_with_effects`): merge at full
// opacity, paint their maps (inside and outside halves), then mix.
let mut p = Pass::new(Kernel::FxMerge, 0);
p.a = Some(w);
p.b = Some(l);
p.c = Some(self.retain(w));
p.mode = mode;
p.opacity = 1.0;
p.flags = gamma_flag(layer);
p.extra[3] = photocraft_compose::text_gamma(layer);
p.clip = Some(clip);
let mut m = self.emit(p);
for (i, b) in late {
for (k, color, fxc) in [(0, &b.highlight_color, &b.highlight), (1, &b.shadow_color, &b.shadow)] {
m = self.paint(m, content, Cov::Map(map(i, k), 0.0), &Paint::Color(color.to_rgb()), fxc.blend, fxc.opacity, 0, clip, sb);
}
}
let mut p = Pass::new(Kernel::FxMerge, 0);
p.b = Some(self.retain(m));
p.a = Some(m);
p.c = Some(self.retain(backdrop));
p.mode = mode;
p.opacity = layer.opacity;
p.flags = if atop { F_ATOP } else { 0 } | F_NO_LAYER;
p.clip = Some(clip);
self.emit(p)
};
self.release(content);
// Write the clipped result back into the backdrop (in place when nothing else holds it).
let dst = if self.refs[backdrop as usize] == 1 {
backdrop
} else {
let n = self.alloc();
let mut p = Pass::new(Kernel::CopyFull, n);
p.a = Some(backdrop);
self.passes.push(p);
self.release(backdrop);
n
};
let mut p = Pass::new(Kernel::CopyRect, dst);
p.a = Some(merged);
p.clip = Some(clip);
self.passes.push(p);
self.release(merged);
Ok(dst)
Ok(self.write_back(backdrop, merged, clip))
}
fn fx_paint(&self, p: &'a FxPaint, anchor: (f64, f64)) -> Paint<'a> {
@@ -1076,10 +1157,12 @@ impl<'a> Planner<'a> {
Paint::None => {}
Paint::Color(c) => p.color = [c[0], c[1], c[2], 1.0],
Paint::Gradient(g) => {
p.params[0] = [g.angle, g.scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
// effects::paint_fx: whole-pixel end points (fill_layout::gradient_layout).
let (angle, scale, offset) = photocraft_compose::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, sb);
p.params[0] = [angle, scale, if g.reverse { 1.0 } else { 0.0 }, style_index(g.style)];
p.params[1] = [sb.x0 as f32, sb.y0 as f32, sb.width() as f32, sb.height() as f32];
p.params[2][0] = g.offset.0;
p.params[2][1] = g.offset.1;
p.params[2][0] = offset.0;
p.params[2][1] = offset.1;
p.params[2][2] = 1.0;
let mut rows = vec![[0.0f32; 4096]; 4];
for k in 0..4096 {
@@ -1235,7 +1318,7 @@ pub fn adjustment_on_gpu(adj: &Adjustment) -> bool {
}
/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, quantum: Option<f32>) -> Program {
let mut p = [[0.0f32; 4]; 4];
match adj {
Adjustment::Invert => (1, p, None),
@@ -1260,7 +1343,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
(5, p, None)
}
Adjustment::Exposure { exposure, offset, gamma } => {
let g = match transfer {
let g = match transfer.for_exposure() {
Transfer::Srgb => 0.0,
Transfer::Gamma(g) => g,
};
@@ -1268,7 +1351,7 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
(6, p, None)
}
// RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts(adj).iter().take(3).map(|t| to_row(t)).collect())),
Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts_q(adj, quantum).iter().take(3).map(|t| to_row(t)).collect())),
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }];
if !*colorize && ranges.iter().any(|r| !r.is_neutral()) {
@@ -1393,8 +1476,35 @@ mod tests {
d.layers.push(Layer::raster(format!("l{i}"), d.pixel_format()));
}
let p = plan(&d).unwrap();
// Empty layers draw nothing.
assert_eq!(p.passes.len(), 1 + 2);
for (i, l) in d.layers.iter_mut().skip(1).enumerate() {
l.surface_mut().unwrap().fill_rect(Rect::new(i as i32 % 4, 0, 4, 8), &[0.0, 0.0, 1.0, 1.0]);
}
let p = plan(&d).unwrap();
assert!(p.slots <= 3, "{} slots", p.slots);
assert_eq!(p.passes.len(), 1 + 11 * 2);
// The background, then each small layer over its bounds and copied back.
assert_eq!(p.passes.len(), 1 + 2 + 10 * 3);
}
#[test]
fn small_layers_composite_only_over_their_bounds() {
let mut d = Document::with_background("t", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut l = Layer::raster("small", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(10, 12, 20, 30), &[1.0, 0.0, 0.0, 1.0]);
let mut group = Layer::group("g", vec![l]);
group.blend = BlendMode::Normal;
d.layers.push(group);
let p = plan(&d).unwrap();
let r = Rect::new(10, 12, 20, 30);
// The group's content, its blend and the copy back are all limited to the layer.
assert!(p.passes.iter().filter(|p| matches!(p.kernel, Kernel::Content | Kernel::Blend)).skip(2).all(|p| p.clip == Some(r)), "{:?}", p.passes);
let last = p.passes.last().unwrap();
assert_eq!((last.kernel, last.dst, last.clip), (Kernel::CopyRect, p.root, Some(r)));
// A layer that covers most of the canvas composites everywhere.
d.layers[1].children_mut().unwrap()[0].surface_mut().unwrap().fill_rect(Rect::new(0, 0, 60, 60), &[1.0, 0.0, 0.0, 1.0]);
let p = plan(&d).unwrap();
assert!(p.passes.iter().all(|p| p.clip.is_none()));
}
#[test]
+261 -31
View File
@@ -9,13 +9,25 @@ use photocraft_gpu::{Compositor, render_to_vec};
const TOL: f32 = 1.0 / 255.0;
/// Concurrent wgpu instances in one process segfault on some drivers (RADV), so the GPU tests
/// take this lock and hold it until their device is dropped.
static GPU_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
comp: Compositor,
/// The same device with a simulated texture limit of [`PAGED_LIMIT`]: every check also runs
/// through layer pages and per-cell effect maps.
paged: Compositor,
_lock: std::sync::MutexGuard<'static, ()>,
}
/// Simulated texture limit (pages and chunks of 256 px).
const PAGED_LIMIT: u32 = 256;
fn gpu() -> Option<Gpu> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let instance = wgpu::Instance::default();
let adapter = match pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) {
Ok(a) => a,
@@ -38,14 +50,18 @@ fn gpu() -> Option<Gpu> {
return None;
}
};
Some(Gpu { device, queue, comp })
let mut paged = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba32Float).ok()?;
paged.set_texture_limit(PAGED_LIMIT);
Some(Gpu { device, queue, comp, paged, _lock: lock })
}
/// Any adapter and device, for the fallback-path tests (which don't need 32-bit float targets).
fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
/// The returned guard holds [`GPU_LOCK`] until the device is dropped.
fn any_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue, std::sync::MutexGuard<'static, ()>)> {
let lock = GPU_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let adapter = pollster::block_on(wgpu::Instance::default().request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?;
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?;
Some((adapter, device, queue))
Some((adapter, device, queue, lock))
}
/// Deterministic pseudo-random values in 0..1.
@@ -88,11 +104,10 @@ fn base_doc(w: u32, h: u32) -> Document {
d
}
fn check(g: &mut Gpu, doc: &Document, what: &str) {
let cpu = photocraft_compose::flatten(doc);
let out = render_to_vec(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).unwrap_or_else(|e| panic!("{what}: {e}"));
/// Largest premultiplied difference between `cpu` and `gpu` pixels, and its index.
fn worst_diff(cpu: &[[f32; 4]], gpu: &[[f32; 4]]) -> (f32, usize) {
let mut worst = (0.0f32, 0usize);
for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() {
for (i, (c, o)) in cpu.iter().zip(gpu).enumerate() {
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
for k in 0..4 {
@@ -102,9 +117,37 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) {
}
}
}
let w = doc.size.width as usize;
let (x, y) = (worst.1 % w, worst.1 / w);
assert!(worst.0 <= TOL, "{what}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]);
worst
}
/// GPU (whole textures, then pages) vs CPU over `rect`: Err with the worst pixel if over the
/// tolerance, else the stats of the unpaged render.
fn diff_rect(g: &mut Gpu, doc: &Document, rect: Rect, what: &str) -> Result<photocraft_gpu::Stats, String> {
let cpu = photocraft_compose::render(doc, rect);
let mut first = None;
for (label, comp) in [("", &mut g.comp), (" (paged)", &mut g.paged)] {
let (out, stats) = photocraft_gpu::render_to_vec_stats(comp, &g.device, &g.queue, doc, rect).map_err(|e| format!("{what}{label}: {e}"))?;
let worst = worst_diff(&cpu.px, &out);
let w = rect.width() as usize;
let (x, y) = (rect.x0 + (worst.1 % w) as i32, rect.y0 + (worst.1 / w) as i32);
if worst.0 > TOL {
return Err(format!("{what}{label}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
}
first.get_or_insert(stats);
}
first.ok_or_else(|| format!("{what}: no render"))
}
fn check(g: &mut Gpu, doc: &Document, what: &str) {
diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}"));
}
#[test]
fn first_frame_renders() {
// Regression: the first submission after device creation is dropped on some drivers
// (RADV), which used to make the very first render come back all zeroes.
let Some(mut g) = gpu() else { return };
check(&mut g, &base_doc(64, 48), "first frame");
}
#[test]
@@ -203,6 +246,11 @@ fn adjustment_layers() {
d.layers.push(noise_layer("bg", fmt, Rect::from_xywh(0, 0, 40, 30), 71, 0.5));
d.layers.push(Layer::new("ex", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.4, offset: 0.01, gamma: 1.1 })));
check(&mut g, &d, &format!("adjustment {depth:?}"));
// Levels on whole levels of the document's depth (compose::adjust::levels_q).
let lc = LevelsChannel { in_black: 0.17, in_white: 0.84, gamma: 1.78, out_black: 0.0, out_white: 1.0 };
let lv = Adjustment::Levels { master: lc, per_channel: Default::default(), space: Default::default(), black: LevelsChannel::default() };
d.layers.push(Layer::new("lv", LayerContent::Adjustment(lv)));
check(&mut g, &d, &format!("levels {depth:?}"));
}
for adj in adjustments() {
for (mode, opacity, masked) in [(BlendMode::Normal, 1.0, false), (BlendMode::Multiply, 0.7, true)] {
@@ -307,6 +355,14 @@ fn fills_and_dissolve() {
d.layers.push(l);
check(&mut g, &d, &format!("gradient {style:?}"));
}
// Tiny frames, where the whole-pixel end points (compose::fill_layout) change the angle
// and centre.
for (w, h, style, angle) in [(4, 4, GradientStyle::Reflected, 30.0), (7, 5, GradientStyle::Linear, 30.0), (9, 4, GradientStyle::Linear, -60.0)] {
let mut d = base_doc(w, h);
let stops = vec![(0.0, Color::rgb(0.0, 0.0, 0.7)), (0.5, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(1.0, 1.0, 0.0))];
d.layers.push(Layer::new("grad", LayerContent::Fill(Fill::Gradient { stops, angle, scale: 1.0, style, reverse: false })));
check(&mut g, &d, &format!("small gradient {w}x{h} {style:?} {angle}"));
}
let mut d = base_doc(64, 48);
let mut l = noise_layer("dis", PixelFormat::RGBA8, Rect::new(0, 0, 64, 48), 41, 0.2);
l.blend = BlendMode::Dissolve;
@@ -373,6 +429,60 @@ use photocraft_doc::{
/// An anti-aliased blob (disc plus a soft-edged bar and a hole) with partially transparent parts,
/// so effects see real edge coverage, concavities and interior alpha.
/// Small layers composite only over their bounds and are copied back into the backdrop (#125):
/// every blend mode, clipped layers, isolated and pass-through groups (with adjustments, masks,
/// opacity), layers partly off the canvas and over several chunks must still match the CPU.
#[test]
fn small_layers_composite_over_their_bounds() {
let Some(mut g) = gpu() else { return };
let small = |seed: u32, x: i32, y: i32, mode: BlendMode| {
let mut l = noise_layer("s", PixelFormat::RGBA8, Rect::from_xywh(x, y, 23, 17), seed, 0.0);
l.blend = mode;
l.opacity = 0.85;
l
};
let mut d = base_doc(200, 150);
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
let i = i as i32;
d.layers.push(small(100 + i as u32, (i * 29) % 190 - 8, (i * 37) % 140 - 5, mode));
}
// A clipping base with a raster and an adjustment clipped to it.
let base = small(200, 120, 90, BlendMode::Normal);
let mut c1 = noise_layer("c1", PixelFormat::RGBA8, Rect::new(100, 80, 190, 140), 201, 0.0);
c1.clipped = true;
c1.blend = BlendMode::Multiply;
let mut c2 = Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert));
c2.clipped = true;
d.layers.extend([base, c1, c2]);
// An isolated group with a mask, an adjustment and a nested pass-through group.
let inner = Layer::group("inner", vec![small(300, 40, 100, BlendMode::Screen), small(301, 55, 110, BlendMode::Normal)]);
let mut iso = Layer::group(
"iso",
vec![
small(302, 30, 95, BlendMode::Normal),
Layer::new("inv", LayerContent::Adjustment(Adjustment::Invert)),
inner,
small(303, 70, 120, BlendMode::Overlay),
],
);
iso.blend = BlendMode::Normal;
iso.opacity = 0.75;
iso.mask = Some(mask(Rect::new(25, 90, 100, 145), 304, 1.0));
d.layers.push(iso);
// A pass-through group with opacity around small layers.
let mut pt = Layer::group("pt", vec![small(305, 150, 10, BlendMode::Normal), small(306, 160, 20, BlendMode::Difference)]);
pt.opacity = 0.6;
d.layers.push(pt);
check(&mut g, &d, "small layers");
// The same over several compositor chunks.
let mut big = base_doc(1500, 1200);
for (i, mode) in BlendMode::LAYER_MODES.into_iter().enumerate() {
let i = i as i32;
big.layers.push(small(400 + i as u32, 1010 + (i % 4) * 5, 1010 + (i / 4) * 5, mode));
}
check(&mut g, &big, "small layers across chunks");
}
fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer {
let mut l = Layer::raster(name, fmt);
let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32);
@@ -673,25 +783,7 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
/// GPU vs CPU over the whole document: Err with the worst pixel if over the tolerance.
fn fx_diff(g: &mut Gpu, doc: &Document, what: &str) -> Result<photocraft_gpu::Stats, String> {
let cpu = photocraft_compose::flatten(doc);
let (out, stats) = photocraft_gpu::render_to_vec_stats(&mut g.comp, &g.device, &g.queue, doc, doc.bounds()).map_err(|e| format!("{what}: {e}"))?;
let mut worst = (0.0f32, 0usize);
for (i, (c, o)) in cpu.px.iter().zip(&out).enumerate() {
let pc = [c[0] * c[3], c[1] * c[3], c[2] * c[3], c[3]];
let po = [o[0] * o[3], o[1] * o[3], o[2] * o[3], o[3]];
for k in 0..4 {
let d = (pc[k] - po[k]).abs();
if d.is_nan() || d > worst.0 {
worst = (if d.is_nan() { 9.0 } else { d }, i);
}
}
}
let w = doc.size.width as usize;
let (x, y) = (worst.1 % w, worst.1 / w);
if worst.0 > TOL {
return Err(format!("{what}: max diff {:.2}/255 at ({x},{y}): cpu {:?} gpu {:?}", worst.0 * 255.0, cpu.px[worst.1], out[worst.1]));
}
Ok(stats)
diff_rect(g, doc, doc.bounds(), what)
}
fn fx_check(g: &mut Gpu, doc: &Document, what: &str) -> photocraft_gpu::Stats {
@@ -927,8 +1019,13 @@ fn as_text(src: Layer) -> Layer {
l
}
/// Held by tests that change the process-wide text gamma, and by tests with type layers that
/// run long enough to see such a change.
static TEXT_GAMMA: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn type_layers_blend_with_text_gamma() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut g) = gpu() else { return };
for mode in [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Color] {
let mut d = base_doc(64, 48);
@@ -958,6 +1055,24 @@ fn type_layers_blend_with_text_gamma() {
];
d.layers.push(l);
fx_check(&mut g, &d, "text fx");
// Emboss styles shade the composited layer (merge, shade, then the opacity mix), also as a
// clipped layer over an opaque base.
let mut e = fx_doc(96, 80, SampleType::U8);
let mut l = as_text(blob("emboss", e.pixel_format(), 46.0, 40.0, 20.0, [0.4, 0.1, 0.3]));
l.effects.items = vec![
Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 9.0, 0.0)),
Effect::BevelEmboss(bevel(BevelStyle::PillowEmboss, false, 5.0, 1.0)),
Effect::DropShadow(shadow(BlendMode::Multiply, 0.6, 90.0, 5.0, 6.0, 0.0)),
];
l.opacity = 0.7;
l.fill_opacity = 0.6;
e.layers.push(l.clone());
fx_check(&mut g, &e, "text emboss");
let mut c = l;
c.clipped = true;
c.blend = BlendMode::Multiply;
e.layers.push(c);
fx_check(&mut g, &e, "clipped text emboss");
// The gamma changes edge pixels against a linear mix.
let flat = photocraft_compose::flatten(&d);
let mut lin = d.clone();
@@ -1129,7 +1244,7 @@ fn rgba16f_fallback_path_renders() {
// Rgba16Float. Force that path here (even on a 32f-capable GPU) to prove it works end to end —
// pipeline creation, the accumulation texture, and the half-float readback — within display
// tolerance of the CPU reference. This is the path Intel-Vulkan / limited GPUs take.
let Some((adapter, device, queue)) = any_device() else { return };
let Some((adapter, device, queue, _lock)) = any_device() else { return };
let mut comp = match Compositor::try_new_with_format(&device, wgpu::TextureFormat::Rgba16Float) {
Ok(c) => c,
Err(e) => return eprintln!("skipping: {e}"),
@@ -1159,7 +1274,7 @@ fn rgba16f_fallback_path_renders() {
fn unbuildable_pipelines_are_an_error_not_a_panic() {
// The app falls back to the CPU compositor on Err; a panic here would crash it (as FXC once
// did on D3D12). A depth format can't be a colour target, so its pipelines fail to build.
let Some((adapter, device, _)) = any_device() else { return };
let Some((adapter, device, _, _lock)) = any_device() else { return };
let e = Compositor::try_new_with_format(&device, wgpu::TextureFormat::Depth32Float).err().expect("depth target must fail");
assert!(e.0.contains("pipelines"), "{e}");
// The format the app picks builds (effect maps the adapter can't render are left out, not fatal).
@@ -1168,3 +1283,118 @@ fn unbuildable_pipelines_are_an_error_not_a_panic() {
assert!(Compositor::try_new_with_format(&device, f).is_ok());
}
}
// ---- documents larger than the texture limit ------------------------------------------------
/// A document several pages wide under [`PAGED_LIMIT`]: pixel layers crossing page borders (one
/// masked, one off-canvas), a big effect layer whose region exceeds the limit (maps built per
/// page cell), a type layer with a shadow, an adjustment, a clipped group and a gradient fill.
fn big_doc() -> Document {
let (w, h) = (1100, 900);
let mut d = fx_doc(w, h, SampleType::U8);
// Right over the background: adjustment results round to 8 bits, which would turn sub-LSB
// float differences of blends beneath into whole steps.
let mut adj = Layer::new("invert", LayerContent::Adjustment(Adjustment::Invert));
adj.opacity = 0.6;
adj.mask = Some(mask(Rect::new(240, 0, 800, 520), 83, 0.0));
d.layers.push(adj);
let mut top = noise_layer("top", PixelFormat::RGBA8, Rect::new(-30, 200, 700, 820), 81, 0.0);
top.blend = BlendMode::Multiply;
top.mask = Some(mask(Rect::new(100, 240, 600, 700), 82, 1.0));
d.layers.push(top);
let stack = effect_cases().into_iter().find(|(n, _)| *n == "full stack").unwrap().1;
let mut big = blob("big", d.pixel_format(), 520.0, 450.0, 330.0, [0.9, 0.4, 0.2]);
big.effects.items = stack;
big.effects.items.push(Effect::OuterGlow(glow(FxPaint::Color(Color::rgb(0.2, 1.0, 0.6)), GlowTechnique::Softer, 40.0, 0.2, GlowSource::Edge)));
d.layers.push(big);
let mut text = as_text(blob("type", d.pixel_format(), 900.0, 150.0, 90.0, [0.1, 0.2, 0.9]));
text.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.8, 135.0, 12.0, 20.0, 0.0))];
d.layers.push(text);
let a = noise_layer("ga", PixelFormat::RGBA8, Rect::new(200, 500, 1000, 880), 84, 0.6);
let mut c = noise_layer("gc", PixelFormat::RGBA8, Rect::new(0, 600, 1100, 700), 85, 0.0);
c.clipped = true;
c.blend = BlendMode::Screen;
let mut grp = Layer::group("g", vec![a, c]);
grp.opacity = 0.9;
d.layers.push(grp);
let stops = vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (0.6, Color::rgb(0.0, 1.0, 0.2)), (1.0, Color::rgb(0.1, 0.1, 0.9))];
let mut grad = Layer::new("grad", LayerContent::Fill(Fill::Gradient { stops, angle: 30.0, scale: 0.8, style: GradientStyle::Radial, reverse: false }));
grad.opacity = 0.3;
grad.blend = BlendMode::Overlay;
d.layers.push(grad);
d
}
#[test]
fn documents_larger_than_the_texture_limit() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut g) = gpu() else { return };
let mut d = big_doc();
assert!(g.paged.supports(&d).is_ok());
// Every pass sees page borders, and effect maps and blurs crossing them.
check(&mut g, &d, "big document");
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
assert!(s.cells >= 20 && s.chunks == s.cells, "{s:?}");
// A viewport off the page grid covers only its cells.
let view = Rect::new(300, 330, 790, 610);
diff_rect(&mut g, &d, view, "viewport").unwrap_or_else(|e| panic!("{e}"));
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, view).unwrap().1;
assert_eq!(s.cells, 6, "{s:?}");
// A dab across a page corner on the effect layer, and an edit elsewhere.
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(500, 490, 530, 530), &[0.1, 0.1, 0.9, 1.0]);
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(250, 250, 270, 270), &[0.0, 0.0, 0.0, 0.0]);
check(&mut g, &d, "after dabs");
let s = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
assert_eq!((s.tiles_uploaded, s.fx_shapes), (0, 0), "nothing changed since the last render: {s:?}");
}
#[test]
fn pages_are_evicted_under_the_budget() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut g) = gpu() else { return };
let mut d = big_doc();
check(&mut g, &d, "warm");
let all = g.paged.resident_bytes();
// A budget of one page: everything but the cell being drawn is evicted as the render goes.
let page = u64::from(g.paged.page_size()).pow(2) * 4;
g.paged.set_resident_budget(page);
for round in 0..3 {
let cpu = photocraft_compose::flatten(&d);
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut g.paged, &g.device, &g.queue, &d, d.bounds()).unwrap();
let worst = worst_diff(&cpu.px, &out);
assert!(worst.0 <= TOL, "round {round}: max diff {:.2}/255 at {}", worst.0 * 255.0, worst.1);
assert!(s.evicted > 0, "round {round}: {s:?}");
assert!(g.paged.resident_bytes() < all / 4, "{} of {all}", g.paged.resident_bytes());
// Evicted pages come back with the edits made meanwhile.
d.layers[2].surface_mut().unwrap().fill_rect(Rect::new(10 + round * 300, 300, 60 + round * 300, 340), &[0.9, 0.9, 0.1, 1.0]);
d.layers[3].surface_mut().unwrap().fill_rect(Rect::new(200 + round * 200, 420, 240 + round * 200, 470), &[0.0, 0.0, 0.0, 0.0]);
}
check(&mut g, &d, "after eviction");
}
#[test]
fn the_focused_view_stays_resident_after_a_full_refresh() {
let _gamma = TEXT_GAMMA.lock().unwrap_or_else(|e| e.into_inner());
let Some(g) = gpu() else { return };
let d = big_doc();
let mut comp = Compositor::try_new_with_format(&g.device, wgpu::TextureFormat::Rgba32Float).unwrap();
comp.set_texture_limit(PAGED_LIMIT);
let view = Rect::new(520, 300, 760, 500);
photocraft_gpu::render_to_vec(&mut comp, &g.device, &g.queue, &d, view).unwrap();
// A budget holding just the view's pages: a full refresh evicts as it goes, and draws the
// view's cells last, so the next edit in the view uploads nothing.
// The working set of a region is what rendering it makes resident.
assert_eq!(comp.working_set(&d, view), comp.resident_bytes());
assert!(comp.working_set(&d, d.bounds()) > 4 * comp.resident_bytes());
comp.set_resident_budget(comp.resident_bytes());
assert!(comp.fits_budget(&d, view) && !comp.fits_budget(&d, d.bounds()));
comp.set_focus(Some(view));
for _ in 0..2 {
let s = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, d.bounds()).unwrap().1;
assert!(s.evicted > 0, "{s:?}");
let (out, s) = photocraft_gpu::render_to_vec_stats(&mut comp, &g.device, &g.queue, &d, view).unwrap();
assert_eq!(s.tiles_uploaded, 0, "{s:?}");
let cpu = photocraft_compose::render(&d, view);
assert!(worst_diff(&cpu.px, &out).0 <= TOL);
}
}
+1 -1
View File
@@ -21,6 +21,7 @@ photocraft-codecs = { workspace = true }
photocraft-raw = { workspace = true }
photocraft-text = { workspace = true }
photocraft-paint = { workspace = true }
photocraft-vector = { workspace = true }
photocraft-format = { path = "../format" }
thiserror = { workspace = true }
@@ -28,4 +29,3 @@ thiserror = { workspace = true }
photocraft-psd = { workspace = true, features = ["testgen"] }
photocraft-raw = { workspace = true, features = ["testgen"] }
proptest = { workspace = true }
photocraft-vector = { workspace = true }
+51 -1
View File
@@ -10,7 +10,9 @@
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 layerpx x y # each layer's pixel
//! cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd # every file: max err, bad %, PASS/DIFF
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 dump prefix # raw f32 planes for offline fitting
//! cargo run --release -p photocraft-io --example oracle_diff -- file.psd 0 bylayer # max error around each layer
//! HIDE_ADJ=1 … # adjustment layers hidden
//! ONLY="name,name" … # only these top-level layers (+ the bottom one)
//! DUMP_FX=1 … # raw effects descriptors
//! ```
fn main() {
@@ -36,6 +38,14 @@ fn main() {
}
hide(&mut imp.document.layers);
}
if let Some(keep) = std::env::var_os("ONLY") {
// Hide every top-level layer but the bottom one and those named in ONLY (comma-separated).
let keep = keep.to_string_lossy().to_string();
let names: Vec<&str> = keep.split(',').collect();
for l in imp.document.layers.iter_mut().skip(1) {
l.visible = names.contains(&l.name.as_str());
}
}
let doc = &imp.document;
println!("mode {:?} depth {:?} layers {} warnings {:?} light {:?}", doc.mode, doc.depth, doc.layer_count(), imp.warnings, doc.global_light);
for l in doc.walk() {
@@ -173,7 +183,7 @@ fn main() {
}
}
for rec in file.layers() {
println!("-- layer {:?}", String::from_utf8_lossy(&rec.name));
println!("-- layer {:?} rect {:?}", String::from_utf8_lossy(&rec.name), rec.rect);
if let Some(b) = rec.block(b"lmfx").or(rec.block(b"lfx2")).or(rec.block(b"lfxs"))
&& let Ok((vd, _)) = photocraft_psd::descriptor::VersionedDescriptor::parse_prefix(&b.data[4..])
{
@@ -264,6 +274,46 @@ fn main() {
}
return;
}
if std::env::args().nth(3).as_deref() == Some("bylayer") {
// Max error and bad-pixel count inside each layer's bounds grown by its effects' reach
// (regions overlap; a quick way to tell which layer or effect is off).
let (w, h) = (doc.size.width as i32, doc.size.height as i32);
for (_, _, l) in doc.walk() {
let Some(sf) = l.surface() else { continue };
let cb = sf.content_bounds();
let mut b = photocraft_geom::Rect::new(i32::MAX, i32::MAX, i32::MIN, i32::MIN);
for y in cb.y0..cb.y1 {
for x in cb.x0..cb.x1 {
let p = sf.pixel(x, y);
if p[p.len() - 1] > 0.0 {
b = photocraft_geom::Rect::new(b.x0.min(x), b.y0.min(y), b.x1.max(x + 1), b.y1.max(y + 1));
}
}
}
if b.x0 > b.x1 {
continue;
}
let m = photocraft_compose::effects::margin(l);
let r = photocraft_geom::Rect::new((b.x0 - m).max(0), (b.y0 - m).max(0), (b.x1 + m).min(w), (b.y1 + m).min(h));
let (mut worst, mut at, mut bad) = (0.0f32, (0, 0), 0usize);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let i = (y * w + x) as usize;
let (a, b) = (ours[i], merged[i]);
let d = (0..3).map(|c| (a[c] * a[3] - b[c] * b[3]).abs()).fold((a[3] - b[3]).abs(), f32::max);
if d > 2.0 / 255.0 {
bad += 1;
}
if d > worst {
(worst, at) = (d, (x, y));
}
}
}
let fx: Vec<&str> = l.effects.items.iter().filter(|e| e.enabled()).map(|e| e.label()).collect();
println!("{:<32} {:>6.1} at {:?} bad {:>6} {:?} {:?}", l.name, worst * 255.0, at, bad, l.blend, fx);
}
return;
}
if std::env::args().nth(3).as_deref() == Some("worst") {
// The N worst pixels (premultiplied max channel error), with coordinates.
let w = doc.size.width as usize;
+33 -15
View File
@@ -10,7 +10,7 @@
use std::collections::BTreeSet;
use photocraft_paint::{
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
BrushPreset, BrushSettings, Control, DualBrush, Dynamic, GrayTile, MaskMode, MixerSettings, Pattern, PatternStyle, Pose, ShapeDynamics, TipShape, Transfer,
};
use photocraft_psd::abr::{self, AbrFile, AbrSample, LegacyTip};
use photocraft_psd::descriptor::{Descriptor, Value};
@@ -281,6 +281,25 @@ impl Mapper<'_> {
}
}
/// Mixer Brush tool options captured with a preset (Wet, Load, Mix, Flow, Sample All Layers).
/// Key names are best effort (Photoshop doesn't document them); several spellings are read.
fn mixer_options(&mut self, to: &Descriptor, m: &mut MixerSettings) {
let first = |keys: &[&str]| keys.iter().find_map(|k| pct(to, k));
let wet = first(&["wetness", "Wtns", "wet"]);
let load = first(&["dryness", "load", "Ld "]);
let mix = first(&["mix", "mixRatio", "Mx "]);
if wet.is_none() && load.is_none() && mix.is_none() {
return;
}
m.wet = wet.unwrap_or(m.wet).clamp(0.0, 1.0);
m.load = load.unwrap_or(m.load).clamp(0.0, 1.0);
m.mix = mix.unwrap_or(m.mix).clamp(0.0, 1.0);
m.flow = pct(to, "flow").unwrap_or(m.flow).clamp(0.0, 1.0);
if let Some(v) = ["sampleMerged", "useAllLayers", "sampleAllLayers", "Mrgd"].iter().find_map(|k| boolean(to, k)) {
m.sample_all_layers = v;
}
}
fn preset(&mut self, d: &Descriptor) -> Option<BrushSettings> {
for (k, _) in &d.items {
let k = String::from_utf8_lossy(k.as_bytes()).to_string();
@@ -298,8 +317,9 @@ impl Mapper<'_> {
}
b.angle = num(t, "Angl").unwrap_or(0.0) as f32;
b.roundness = pct(t, "Rndn").unwrap_or(1.0).clamp(0.01, 1.0);
// `Intr` off = spacing unchecked (Photoshop spaces by pointer speed): keep it tight.
b.spacing = if boolean(t, "Intr") == Some(false) { 0.05 } else { pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0) };
// `Intr` off = the Spacing checkbox is off: the pointer's speed sets the spacing.
b.spacing = pct(t, "Spcn").unwrap_or(0.25).clamp(0.01, 10.0);
b.spacing_enabled = boolean(t, "Intr") != Some(false);
b.flip_x = boolean(t, "flipX").unwrap_or(false);
b.flip_y = boolean(t, "flipY").unwrap_or(false);
}
@@ -316,13 +336,9 @@ impl Mapper<'_> {
roundness,
flip_x_jitter: boolean(d, "flipX").unwrap_or(false),
flip_y_jitter: boolean(d, "flipY").unwrap_or(false),
tilt_scale: pct(d, "tiltScale").unwrap_or(0.0).clamp(0.0, 2.0),
brush_projection: boolean(d, "brushProjection").unwrap_or(false),
};
if pct(d, "tiltScale").is_some_and(|v| v > 0.0) {
self.warnings.insert("Tilt Scale (Shape Dynamics) is not supported".into());
}
if boolean(d, "brushProjection") == Some(true) {
self.warnings.insert("Brush Projection is not supported".into());
}
}
// Scattering.
if boolean(d, "useScatter") == Some(true) {
@@ -393,12 +409,13 @@ impl Mapper<'_> {
}
// Transfer.
if boolean(d, "usePaintDynamics") == Some(true) {
b.transfer = Transfer { enabled: true, opacity: self.dynamic(obj(d, "opVr")), flow: self.dynamic(obj(d, "prVr")) };
for (k, what) in [("wtVr", "Wetness"), ("mxVr", "Mix")] {
if self.dynamic(obj(d, k)).is_active() {
self.warnings.insert(format!("Transfer › {what} jitter (Mixer Brush) is not supported"));
}
}
b.transfer = Transfer {
enabled: true,
opacity: self.dynamic(obj(d, "opVr")),
flow: self.dynamic(obj(d, "prVr")),
wetness: self.dynamic(obj(d, "wtVr")),
mix: self.dynamic(obj(d, "mxVr")),
};
}
// Brush Pose (Photoshop's tilt is ±100 %; ours is ±90°).
if boolean(d, "useBrushPose") == Some(true) {
@@ -443,6 +460,7 @@ impl Mapper<'_> {
if let Some(v) = boolean(to, "smoothingZoomCompensation") {
b.smoothing.adjust_for_zoom = v;
}
self.mixer_options(to, &mut b.mixer);
if mask_blend_is_set(to) {
self.warnings.insert("tool blend modes stored with presets are not applied".into());
}
+45
View File
@@ -226,6 +226,51 @@ fn big_tips_are_downsampled() {
assert!(imp.warnings.iter().any(|w| w.contains("downsampled")));
}
#[test]
fn tilt_scale_projection_spacing_and_mixer_fields_map() {
let tip = Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 30.0)).with("Spcn", prc(35.0)).with("Intr", Value::Boolean(false));
let preset = Descriptor::new("brushPreset")
.with("Nm ", t("Wet Pen"))
.with("Brsh", Value::Descriptor(tip))
.with("useTipDynamics", Value::Boolean(true))
.with("szVr", var(3, 0.0, 0.0))
.with("tiltScale", prc(150.0))
.with("brushProjection", Value::Boolean(true))
.with("usePaintDynamics", Value::Boolean(true))
.with("wtVr", var(2, 30.0, 10.0))
.with("mxVr", var(1, 45.0, 0.0))
.with(
"toolOptions",
Value::Descriptor(
Descriptor::new("currentToolOptions")
.with("wetness", prc(80.0))
.with("dryness", prc(20.0))
.with("mix", prc(65.0))
.with("flow", prc(70.0))
.with("sampleMerged", Value::Boolean(true)),
),
);
let plain =
Descriptor::new("brushPreset").with("Nm ", t("Plain")).with("Brsh", Value::Descriptor(Descriptor::new("computedBrush").with("Dmtr", u(b"#Pxl", 9.0))));
let bytes = write_v6(2, &[], &[], &[preset, plain], true).unwrap();
let imp = read_abr(&bytes, "Mixer").unwrap();
let b = &imp.presets[0].brush;
assert!(!b.spacing_enabled && (b.spacing - 0.35).abs() < 1e-6, "Intr off = Spacing unchecked, the value kept");
let sd = &b.shape_dynamics;
assert!(sd.brush_projection && (sd.tilt_scale - 1.5).abs() < 1e-6 && sd.size.control == Control::PenTilt);
let tr = &b.transfer;
assert_eq!((tr.wetness.control, tr.wetness.jitter, tr.wetness.minimum), (Control::PenPressure, 0.3, 0.1));
assert_eq!((tr.mix.control, tr.mix.jitter), (Control::Fade, 0.45));
let m = &b.mixer;
assert_eq!((m.wet, m.load, m.mix, m.flow, m.sample_all_layers), (0.8, 0.2, 0.65, 0.7, true));
assert!(!imp.warnings.iter().any(|w| w.contains("Tilt Scale") || w.contains("Projection") || w.contains("Mixer")), "{:?}", imp.warnings);
// Defaults when the file says nothing.
let p = &imp.presets[1].brush;
assert!(p.spacing_enabled && !p.shape_dynamics.brush_projection && p.shape_dynamics.tilt_scale == 0.0);
assert_eq!(p.mixer, photocraft_paint::MixerSettings::default());
assert_eq!((p.transfer.wetness, p.transfer.mix), (Dynamic::default(), Dynamic::default()));
}
#[test]
fn garbage_is_an_error() {
assert!(read_abr(b"", "g").is_err());
+113
View File
@@ -104,6 +104,8 @@ pub fn color_from_desc(d: &Descriptor) -> Option<Color> {
b"RGBC" => Color::rgb(g("Rd ")? / 255.0, g("Grn ")? / 255.0, g("Bl ")? / 255.0),
b"CMYC" => Color { mode: ColorMode::Cmyk, c: [g("Cyn ")? / 100.0, g("Mgnt")? / 100.0, g("Ylw ")? / 100.0, g("Blck")? / 100.0], alpha: 1.0 },
b"Grsc" => Color::gray(1.0 - g("Gry ")? / 100.0),
// CIE L*a*b*: L 0..100, a / b −128..127 (stored like Lab pixels: offset by 128).
b"LbCl" => Color { mode: ColorMode::Lab, c: [g("Lmnc")? / 100.0, (g("A ")? + 128.0) / 255.0, (g("B ")? + 128.0) / 255.0, 0.0], alpha: 1.0 },
// Newer Photoshop writes 0..1 floats.
_ if d.get("redFloat").is_some() => Color::rgb(g("redFloat")?, g("greenFloat")?, g("blueFloat")?),
_ => {
@@ -123,6 +125,9 @@ pub fn color_to_desc(c: &Color) -> Descriptor {
.with("Ylw ", d(c.c[2] * 100.0))
.with("Blck", d(c.c[3] * 100.0)),
ColorMode::Grayscale => Descriptor::new("Grsc").with("Gry ", d((1.0 - c.c[0]) * 100.0)),
ColorMode::Lab => {
Descriptor::new("LbCl").with("Lmnc", d(c.c[0] * 100.0)).with("A ", d(c.c[1] * 255.0 - 128.0)).with("B ", d(c.c[2] * 255.0 - 128.0))
}
_ => {
let rgb = c.to_rgb();
Descriptor::new("RGBC").with("Rd ", d(rgb[0] * 255.0)).with("Grn ", d(rgb[1] * 255.0)).with("Bl ", d(rgb[2] * 255.0))
@@ -244,6 +249,70 @@ pub(crate) fn gradient_desc(stops: &[(f32, Color)], opacity: &[(f32, f32)]) -> D
.with("Trns", Value::List(trns))
}
/// Opacity stops of a `Grdn` descriptor with Photoshop's smoothness and midpoints applied (the
/// same curve as the colour stops, Classic interpolation), densely sampled.
fn smoothed_opacity_stops(grad: &Descriptor) -> Vec<(f32, f32)> {
let mut stops = Vec::new();
let mut mids = Vec::new();
if let Some(Value::List(items)) = grad.get("Trns") {
for it in items {
if let Value::Descriptor(s) = it {
let a = num(s.get("Opct")).unwrap_or(100.0) as f32 / 100.0;
stops.push((num(s.get("Lctn")).unwrap_or(0.0) as f32 / 4096.0, Color::rgba(a, a, a, 1.0)));
mids.push(num(s.get("Mdpn")).unwrap_or(50.0) as f32 / 100.0);
}
}
}
let mut order: Vec<usize> = (0..stops.len()).collect();
order.sort_by(|a, b| stops[*a].0.total_cmp(&stops[*b].0));
let mids: Vec<f32> = order.iter().skip(1).filter_map(|i| mids.get(*i).copied()).collect();
let smooth = num(grad.get("Intr")).map_or(0.0, |v| (v / 4096.0) as f32);
let baked = if stops.len() >= 2 { crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::Classic) } else { stops };
baked.into_iter().map(|(t, c)| (t, c.to_rgb()[0])).collect()
}
/// Piecewise-linear value of sorted `(location, v)` stops at `t` (clamped at the ends).
fn lerp_stops<T: Copy>(stops: &[(f32, T)], t: f32, mix: impl Fn(T, T, f32) -> T) -> Option<T> {
let first = stops.first()?;
if t <= first.0 {
return Some(first.1);
}
for w in stops.windows(2) {
let (a, b) = (w[0], w[1]);
if t <= b.0 {
let k = if b.0 > a.0 { (t - a.0) / (b.0 - a.0) } else { 1.0 };
return Some(mix(a.1, b.1, k));
}
}
stops.last().map(|s| s.1)
}
/// Folds a gradient's opacity stops into its colour stops' alpha (the model keeps one stop
/// list): stops at every colour and opacity location, colours and opacities each interpolated
/// linearly. Without opacity stops (or all opaque) the colour stops are returned unchanged.
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels).
pub(crate) fn with_opacity_stops(stops: Vec<(f32, Color)>, opacity: &[(f32, f32)]) -> Vec<(f32, Color)> {
let mut op: Vec<(f32, f32)> = opacity.iter().copied().filter(|(t, a)| t.is_finite() && a.is_finite()).collect();
if op.iter().all(|(_, a)| *a >= 1.0) || stops.is_empty() {
return stops;
}
op.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut sorted = stops;
sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut locs: Vec<f32> = sorted.iter().map(|s| s.0).chain(op.iter().map(|s| s.0)).collect();
locs.sort_by(f32::total_cmp);
locs.dedup();
let mix_color =
|a: Color, b: Color, k: f32| Color { c: std::array::from_fn(|i| a.c[i] + (b.c[i] - a.c[i]) * k), alpha: a.alpha + (b.alpha - a.alpha) * k, ..a };
locs.into_iter()
.filter_map(|t| {
let c = lerp_stops(&sorted, t, mix_color)?;
let a = lerp_stops(&op, t, |a, b, k| a + (b - a) * k)?;
Some((t, Color { alpha: (c.alpha * a).clamp(0.0, 1.0), ..c }))
})
.collect()
}
/// Parses a fill block (`SoCo`, `GdFl`, `PtFl`).
pub fn parse_fill(key: &[u8; 4], data: &[u8]) -> Option<Fill> {
// Block data may carry trailing padding after the descriptor.
@@ -260,9 +329,11 @@ pub fn fill_from_desc(key: &[u8; 4], d: &Descriptor) -> Option<Fill> {
let angle = num(d.get("Angl")).unwrap_or(90.0) as f32;
let scale = num(d.get("Scl ")).map_or(1.0, |v| v as f32 / 100.0);
let (mut stops, _) = get_desc(d, "Grad").map(|g| gradient_stops_with(g, enum_of(d, "gs99"))).unwrap_or_default();
let opacity = get_desc(d, "Grad").map(smoothed_opacity_stops).unwrap_or_default();
if stops.is_empty() {
stops = vec![(0.0, Color::BLACK), (1.0, Color::WHITE)];
}
let stops = with_opacity_stops(stops, &opacity);
Some(Fill::Gradient { stops, angle, scale, style: gradient_style(d), reverse: bool_of(d, "Rvrs") })
}
b"PtFl" => {
@@ -431,6 +502,48 @@ pub fn effects_enabled(lfx2: &[u8]) -> bool {
#[cfg(test)]
mod tests {
/// psd-tools layers-minimal/gradient-fill.psd ("Color to Transparent", no stored pixels): a
/// gradient fill's opacity stops reach the model (as stop alpha) and survive a write.
#[test]
fn gradient_fill_opacity_stops_become_alpha() {
use photocraft_color::Color;
use photocraft_doc::{Fill, GradientStyle};
let red = Color::rgba(1.0, 0.0, 0.0, 1.0);
let f = Fill::Gradient {
stops: vec![(0.0, red), (1.0, Color { alpha: 0.0, ..red })],
angle: 90.0,
scale: 1.0,
style: GradientStyle::Linear,
reverse: false,
};
let (k, data) = super::write_fill(&f);
let Some(Fill::Gradient { stops, .. }) = super::parse_fill(&k, &data) else { panic!("gradient") };
let alpha_at = |t: f32| stops.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map(|s| s.1.alpha).unwrap();
assert_eq!(alpha_at(0.0), 1.0);
assert!(alpha_at(1.0) < 0.01, "{stops:?}");
assert!(stops.iter().all(|s| s.1.c[0] == 1.0 && s.1.c[1] == 0.0));
// Opaque gradients keep their colour stops untouched.
let opaque = super::with_opacity_stops(vec![(0.0, red), (1.0, Color::WHITE)], &[(0.0, 1.0), (1.0, 1.0)]);
assert_eq!(opaque, vec![(0.0, red), (1.0, Color::WHITE)]);
}
#[test]
fn lab_descriptor_colours_round_trip() {
use photocraft_color::ColorMode;
use photocraft_psd::descriptor::{Descriptor, Value};
let d = Descriptor::new("LbCl").with("Lmnc", Value::Double(19.07)).with("A ", Value::Double(52.29)).with("B ", Value::Double(-85.08));
let c = super::color_from_desc(&d).unwrap();
assert_eq!(c.mode, ColorMode::Lab);
assert!((c.c[0] - 0.1907).abs() < 1e-5 && (c.c[1] * 255.0 - 128.0 - 52.29).abs() < 1e-3 && (c.c[2] * 255.0 - 128.0 + 85.08).abs() < 1e-3);
let back = super::color_from_desc(&super::color_to_desc(&c)).unwrap();
assert_eq!(back.mode, ColorMode::Lab);
for i in 0..3 {
assert!((back.c[i] - c.c[i]).abs() < 1e-6);
}
// Missing components are rejected, not defaulted.
assert!(super::color_from_desc(&Descriptor::new("LbCl").with("Lmnc", Value::Double(50.0))).is_none());
}
#[test]
fn blending_ranges_map_to_blend_if() {
use photocraft_doc::{BlendIf, BlendRange};
+56 -1
View File
@@ -47,8 +47,35 @@ const BLEND_NAMES: [(BlendMode, &str); 28] = [
(BlendMode::PassThrough, "passThrough"),
];
/// Long (string-ID) names some writers use instead of the four-character codes, for the modes
/// whose code is a short ID (`BlnM` `multiply` = `Mltp`). The others already use their long name.
const BLEND_LONG_NAMES: [(BlendMode, &str); 17] = [
(BlendMode::Normal, "normal"),
(BlendMode::Dissolve, "dissolve"),
(BlendMode::Darken, "darken"),
(BlendMode::Multiply, "multiply"),
(BlendMode::ColorBurn, "colorBurn"),
(BlendMode::Lighten, "lighten"),
(BlendMode::Screen, "screen"),
(BlendMode::ColorDodge, "colorDodge"),
(BlendMode::Overlay, "overlay"),
(BlendMode::SoftLight, "softLight"),
(BlendMode::HardLight, "hardLight"),
(BlendMode::Difference, "difference"),
(BlendMode::Exclusion, "exclusion"),
(BlendMode::Hue, "hue"),
(BlendMode::Saturation, "saturation"),
(BlendMode::Color, "color"),
(BlendMode::Luminosity, "luminosity"),
];
/// A blend-mode enum from either its four-character code or its long string ID.
fn blend_from_id(v: &[u8]) -> Option<BlendMode> {
BLEND_NAMES.iter().chain(&BLEND_LONG_NAMES).find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)
}
fn blend_of(d: &Descriptor, key: &str, default: BlendMode) -> BlendMode {
enum_of(d, key).and_then(|v| BLEND_NAMES.iter().find(|(_, n)| n.as_bytes() == v).map(|(m, _)| *m)).unwrap_or(default)
enum_of(d, key).and_then(blend_from_id).unwrap_or(default)
}
fn blend_value(m: BlendMode) -> Value {
@@ -721,6 +748,34 @@ mod tests {
}
}
#[test]
fn long_string_blend_ids_parse() {
// Some writers store effect modes as long string IDs (`BlnM` `multiply`, `colorDodge`)
// instead of the four-character codes (psd-tools effects/blend-modes.psd).
let long = |v: &str| Value::Enumerated { type_id: Id::new("BlnM"), value: Id::new(v) };
let d = Descriptor::new("null")
.with("masterFXSwitch", Value::Boolean(true))
.with("DrSh", Value::Descriptor(Descriptor::new("DrSh").with("enab", Value::Boolean(true)).with("Md ", long("linearBurn"))))
.with("ebbl", Value::Descriptor(Descriptor::new("ebbl").with("hglM", long("colorDodge")).with("sdwM", long("colorBurn"))))
.with("SoFi", Value::Descriptor(Descriptor::new("SoFi").with("Md ", long("hue"))));
let mut data = 0u32.to_be_bytes().to_vec();
data.extend(VersionedDescriptor { version: 16, descriptor: d }.to_bytes());
let (_, fx) = parse_lfx2(&data).unwrap();
let modes: Vec<BlendMode> = fx
.iter()
.flat_map(|e| match e {
Effect::DropShadow(s) => vec![s.common.blend],
Effect::BevelEmboss(b) => vec![b.highlight.blend, b.shadow.blend],
Effect::ColorOverlay { common, .. } => vec![common.blend],
_ => vec![],
})
.collect();
assert_eq!(modes, vec![BlendMode::LinearBurn, BlendMode::ColorDodge, BlendMode::ColorBurn, BlendMode::Hue]);
for (m, n) in BLEND_LONG_NAMES {
assert_eq!(blend_from_id(n.as_bytes()), Some(m));
}
}
#[test]
fn garbage_is_none() {
assert!(parse_lfx2(&[0, 0]).is_none());
+25 -4
View File
@@ -8,7 +8,7 @@
//! colours (end tangents at half the chord slope); Perceptual interpolates in Oklab, Linear in
//! linear light, Classic in sRGB. Stop midpoints remap each segment piecewise-linearly.
use photocraft_color::Color;
use photocraft_color::{Color, ColorMode};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Method {
@@ -117,7 +117,10 @@ fn eval(stops: &[(f32, [f32; 3])], mids: &[f32], smooth: f32, t: f32) -> [f32; 3
/// already interpolates linearly in sRGB.
pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32, method: Method) -> Vec<(f32, Color)> {
let plain_mids = midpoints.iter().all(|m| (m - 0.5).abs() < 1e-3);
if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids) {
// Classic interpolates in the stops' own model: Lab stops (Lab documents) blend in L*a*b*, which the
// compositors' sRGB interpolation can't reproduce (psd-tools 4x4_16bit_lab).
let lab = method == Method::Classic && stops.iter().all(|(_, c)| c.mode == ColorMode::Lab);
if stops.len() < 2 || (smoothness <= 0.0 && method == Method::Classic && plain_mids && !lab) {
return stops;
}
let mut s = stops;
@@ -126,12 +129,16 @@ pub(crate) fn bake(stops: Vec<(f32, Color)>, midpoints: &[f32], smoothness: f32,
// Stop colours carry no alpha in Photoshop (opacity has its own stops); keep the nearest.
s.iter().min_by(|a, b| (a.0 - t).abs().total_cmp(&(b.0 - t).abs())).map_or(1.0, |x| x.1.alpha)
};
let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, into_space(method, c.to_rgb()))).collect();
let pts: Vec<(f32, [f32; 3])> = s.iter().map(|(t, c)| (*t, if lab { [c.c[0], c.c[1], c.c[2]] } else { into_space(method, c.to_rgb()) })).collect();
const N: usize = 96;
let mut out: Vec<(f32, Color)> = (0..=N)
.map(|k| {
let t = k as f32 / N as f32;
let rgb = out_of_space(method, eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t));
let v = eval(&pts, midpoints, smoothness.clamp(0.0, 1.0), t);
if lab {
return (t, Color { mode: ColorMode::Lab, c: [v[0].clamp(0.0, 1.0), v[1].clamp(0.0, 1.0), v[2].clamp(0.0, 1.0), 0.0], alpha: alpha(t) });
}
let rgb = out_of_space(method, v);
(t, Color::rgba(rgb[0], rgb[1], rgb[2], alpha(t)))
})
.collect();
@@ -184,6 +191,20 @@ mod tests {
}
}
#[test]
fn classic_lab_stops_interpolate_in_lab() {
// Lab documents blend Classic gradients in L*a*b* (psd-tools 4x4_16bit_lab): the half-way
// colour is the Lab average, even without smoothness.
let lab = |l: f32, a: f32, b: f32| Color { mode: ColorMode::Lab, c: [l / 100.0, (a + 128.0) / 255.0, (b + 128.0) / 255.0, 0.0], alpha: 1.0 };
let s = bake(vec![(0.0, lab(19.07, 52.29, -85.08)), (1.0, lab(54.29, 80.81, 69.91))], &[0.5], 0.0, Method::Classic);
assert!(s.len() > 2 && s.iter().all(|(_, c)| c.mode == ColorMode::Lab));
let mid = s.iter().find(|(t, _)| (t - 0.5).abs() < 1e-6).map(|(_, c)| *c).unwrap();
let want = lab(36.68, 66.55, -7.585);
for i in 0..3 {
assert!((mid.c[i] - want.c[i]).abs() < 1e-4, "{mid:?}");
}
}
#[test]
fn midpoint_shifts_the_half_way_colour() {
let s = bake(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], &[0.25], 0.0, Method::Classic);
+4
View File
@@ -181,6 +181,10 @@ pub fn merged_composite(file: &PsdFile) -> Result<Vec<[f32; 4]>, IoError> {
}
// Generic path (Lab, CMYK and others) via the raster model conversion.
let (doc, _) = psd_to_document(&PsdFile { layer_info: None, ..file.clone() });
// Multichannel documents keep their channels apart (no layer): composite them.
if doc.layers.is_empty() && doc.mode == photocraft_color::ColorMode::Multichannel {
return Ok(photocraft_compose::flatten(&doc).px);
}
let l = doc.layers.first().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
let s = l.surface().ok_or_else(|| IoError::Unsupported("no merged image".into()))?;
Ok(photocraft_compose::surface_to_buffer(s, doc.bounds()).px)
+9 -3
View File
@@ -6,7 +6,7 @@
use std::sync::Arc;
use photocraft_color::{ColorMode, PixelFormat};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Pattern};
use photocraft_geom::Rect;
use photocraft_psd::patterns::{PsdPattern, block_key, mode_channels, parse_pattern_block, write_pattern_block};
@@ -69,7 +69,10 @@ pub fn from_psd(p: &PsdPattern) -> Option<Pattern> {
fill.push(max_sample(s));
invert.push(false);
}
let bytes = interleave(&refs, &fill, w * h, s, &invert);
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
if mode == ColorMode::Lab && s == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, refs.len(), true);
}
let surface = Surface::from_interleaved(fmt, Rect::new(0, 0, w as i32, h as i32), &bytes);
Some(Pattern { id: p.id.clone(), name: p.name.clone(), width: p.width, height: p.height, surface })
}
@@ -83,8 +86,11 @@ pub fn to_psd(p: &Pattern) -> PsdPattern {
};
let fmt = PixelFormat::new(mode, f.sample, f.alpha);
let surf = if fmt == f { p.surface.clone() } else { p.surface.convert(fmt) };
let bytes = surf.to_interleaved(p.rect());
let mut bytes = surf.to_interleaved(p.rect());
let ch = fmt.channels();
if mode == ColorMode::Lab && fmt.sample == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, ch, false);
}
let mut invert = vec![mode == ColorMode::Cmyk; ch];
if fmt.alpha {
invert[ch - 1] = false;
+49
View File
@@ -104,6 +104,27 @@ pub fn max_sample(s: SampleType) -> Vec<u8> {
}
}
/// Top of Photoshop's 16-bit Lab a*/b* scale: it stores `32768 + 256·a` (0..65280 spans −128..127,
/// fitted on psd-tools `4x4_16bit_lab`: the merged image holds a stop's `LbCl` a* = 52.29 as 46154),
/// while documents keep a*/b* on the 8-bit scale `(a + 128) / 255` at every depth.
pub const LAB16_CHROMA_MAX: f32 = 65280.0;
/// Rescales the a*/b* channels (1 and 2) of interleaved native-endian 16-bit Lab pixels with
/// `ch` channels: PSD → document when `to_doc`, else document → PSD. Round-trips exactly.
pub fn lab16_chroma(bytes: &mut [u8], ch: usize, to_doc: bool) {
if ch < 3 {
return;
}
let k = if to_doc { 65535.0 / f64::from(LAB16_CHROMA_MAX) } else { f64::from(LAB16_CHROMA_MAX) / 65535.0 };
for px in bytes.chunks_exact_mut(ch * 2) {
for c in 1..3 {
let v = u16::from_ne_bytes([px[2 * c], px[2 * c + 1]]);
let w = (f64::from(v) * k).round().min(65535.0) as u16;
px[2 * c..2 * c + 2].copy_from_slice(&w.to_ne_bytes());
}
}
}
/// Native-endian encoding of zero.
pub fn zero_sample(s: SampleType) -> Vec<u8> {
vec![0; s.bytes()]
@@ -198,6 +219,34 @@ mod tests {
assert_eq!(il, vec![9, 5, 9, 6]);
}
#[test]
fn lab16_chroma_scale_round_trips() {
// Two pixels of three channels: L is untouched, a*/b* rescale, and every PSD value
// comes back exactly.
let mut all = Vec::new();
for v in 0..=u16::MAX {
all.extend_from_slice(&v.to_ne_bytes());
all.extend_from_slice(&v.to_ne_bytes());
all.extend_from_slice(&v.to_ne_bytes());
}
let orig = all.clone();
lab16_chroma(&mut all, 3, true);
let px = |b: &[u8], i: usize, c: usize| u16::from_ne_bytes([b[(i * 3 + c) * 2], b[(i * 3 + c) * 2 + 1]]);
// Neutral (32768 in PSD) is 128 / 255 of the document scale; 65280 (a* = 127) is the top.
assert_eq!(px(&all, 32768, 0), 32768);
assert_eq!(px(&all, 32768, 1), (32768.0f64 * 65535.0 / 65280.0).round() as u16);
assert_eq!(px(&all, 65280, 2), 65535);
lab16_chroma(&mut all, 3, false);
for i in 0..=65280usize {
assert_eq!(px(&all, i, 1), px(&orig, i, 1), "{i}");
}
// Short or odd buffers are left alone.
let mut short = vec![1u8, 2, 3];
lab16_chroma(&mut short, 3, true);
lab16_chroma(&mut short, 1, true);
assert_eq!(short, [1, 2, 3]);
}
#[test]
fn be_codec() {
let mut v = Vec::new();
+40 -19
View File
@@ -88,7 +88,10 @@ impl Ex {
if r.is_empty() {
return (PsdRect::default(), self.empty_channels());
}
let bytes = s.to_interleaved(r);
let mut bytes = s.to_interleaved(r);
if self.fmt.mode == ColorMode::Lab && self.fmt.sample == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, false);
}
let mut invert = vec![self.cmyk; self.cc];
invert.push(false);
let planes = deinterleave(&bytes, self.cc + 1, self.fmt.sample, &invert);
@@ -101,7 +104,7 @@ impl Ex {
(to_psd_rect(r), ch)
}
fn mask(&self, m: &LayerMask) -> (MaskData, Option<ChannelData>) {
fn mask(&self, m: &LayerMask, vector: Option<(f32, f32)>) -> (MaskData, Option<ChannelData>) {
let s = if m.surface.format() != self.mask_fmt { m.surface.convert(self.mask_fmt) } else { m.surface.clone() };
let default = s.default_pixel().first().copied().unwrap_or(0.0);
let r = s.content_bounds();
@@ -114,18 +117,10 @@ impl Ex {
if !m.enabled {
flags |= PsdMask::FLAG_DISABLED;
}
let density = (m.density < 1.0).then(|| q255(m.density));
let feather = (m.feather != 0.0).then_some(f64::from(m.feather));
let parameters = (density.is_some() || feather.is_some()).then(|| {
let parameters = mask_parameters(Some((m.density, m.feather)), vector);
if parameters.is_some() {
flags |= PsdMask::FLAG_PARAMETERS;
MaskParameters {
flags: u8::from(density.is_some()) | u8::from(feather.is_some()) << 1,
user_density: density,
user_feather: feather,
vector_density: None,
vector_feather: None,
}
});
}
let pm = PsdMask {
rect: if r.is_empty() { PsdRect::default() } else { to_psd_rect(r) },
default_color: q255(default),
@@ -133,6 +128,7 @@ impl Ex {
trailing: if parameters.is_none() { vec![0, 0] } else { Vec::new() },
parameters,
real: None,
real_first: true,
};
(MaskData::Mask(pm), Some(self.encode(-2, &plane, w, h)))
}
@@ -157,10 +153,23 @@ impl Ex {
None => (PsdRect::default(), self.empty_channels()),
};
let mut mask = MaskData::None;
// Vector mask density/feather live in the mask parameters, with or without a user mask.
let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather));
if let Some(m) = &l.mask {
let (md, ch) = self.mask(m);
let (md, ch) = self.mask(m, vector);
mask = md;
channels.extend(ch);
} else if let Some(parameters) = mask_parameters(None, vector) {
// Parameters only: no user mask channel, so nothing hides on re-import.
mask = MaskData::Mask(PsdMask {
rect: PsdRect::default(),
default_color: 255,
flags: PsdMask::FLAG_PARAMETERS,
parameters: Some(parameters),
real: None,
trailing: Vec::new(),
real_first: true,
});
}
let mut flags = LayerFlags(0);
flags.set_hidden(!l.visible);
@@ -321,11 +330,6 @@ impl Ex {
}
}
}
if let Some(vm) = &l.vector_mask
&& (vm.density < 1.0 || vm.feather != 0.0)
{
self.warnings.push(format!("layer \"{}\": vector mask density/feather are not written to PSD", l.name));
}
}
/// Pixels for a fill layer: Photoshop's cached rendering while valid,
@@ -501,6 +505,7 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) ->
let canvas = doc.bounds();
let w = canvas.width() as usize;
let space = photocraft_compose::cmyk_space(doc);
let lab16 = fmt.mode == ColorMode::Lab && sample == SampleType::U16;
let _ = photocraft_compose::render_bands(doc, canvas, 0, |band| -> Result<(), ()> {
// Retain alpha whenever it differs from opaque at the stored precision.
has_alpha |= band.px.iter().any(|p| match sample {
@@ -522,6 +527,8 @@ fn merged_planes(doc: &Document, fmt: &PixelFormat, cmyk: bool, matte: bool) ->
// Matte against white like Photoshop (see `pixels::matte`).
let m = if c < cc && matte { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
let x = if cmyk && c < cc { 1.0 - m } else { m };
// 16-bit Lab a*/b* use Photoshop's 0..65280 scale (`pixels::lab16_chroma`).
let x = if lab16 && (c == 1 || c == 2) { x * (crate::pixels::LAB16_CHROMA_MAX / 65535.0) } else { x };
encode_be(x, sample, &mut out[c]);
}
}
@@ -779,3 +786,17 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
};
(file, ex.warnings)
}
/// PSD mask parameters for a user mask's and a vector mask's (density, feather); `None` when
/// every value is the default (density 1, feather 0).
fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option<MaskParameters> {
let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d)));
let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f)));
let p =
MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) };
let flags = u8::from(p.user_density.is_some())
| u8::from(p.user_feather.is_some()) << 1
| u8::from(p.vector_density.is_some()) << 2
| u8::from(p.vector_feather.is_some()) << 3;
(flags != 0).then_some(MaskParameters { flags, ..p })
}
+36 -2
View File
@@ -73,6 +73,23 @@ fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
rec.layer_mask()?.real
}
/// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer
/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a
/// feather, or when it stores no pixels and fills with a pattern.
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<photocraft_doc::Fill> {
if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() {
return None;
}
let k = fill_key?;
let fill = rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))?;
let p = rec.layer_mask().and_then(|m| m.parameters);
let soft = p.is_some_and(|p| p.vector_density.is_some_and(|d| d < 255) || p.vector_feather.is_some_and(|f| f > 0.0));
// Without stored pixels a pattern-filled shape cannot be rasterized on its own (the
// compositor resolves the document's patterns for fill layers).
let unrendered_pattern = (rec.rect.is_empty() || rec.rect.size().is_err()) && matches!(fill, photocraft_doc::Fill::Pattern { .. });
(soft || unrendered_pattern).then_some(fill)
}
impl Ctx<'_> {
fn warn(&mut self, s: impl Into<String>) {
self.warnings.push(s.into());
@@ -106,7 +123,10 @@ impl Ctx<'_> {
fill.push(max_sample(s));
let mut invert = vec![self.cmyk; self.cc];
invert.push(false);
let bytes = interleave(&refs, &fill, w * h, s, &invert);
let mut bytes = interleave(&refs, &fill, w * h, s, &invert);
if self.fmt.mode == ColorMode::Lab && s == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, self.cc + 1, true);
}
let mut surf = Surface::from_interleaved(self.fmt, Rect::new(r.left, r.top, r.right, r.bottom), &bytes);
surf.prune();
surf
@@ -247,11 +267,22 @@ impl Ctx<'_> {
warp: rec.block(k).and_then(|b| blocks::parse_placed_warp(k, &b.data)),
stack_mode: None,
})
} else if let Some(f) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
// A shape whose vector mask has a density or feather is a fill layer seen through a
// soft vector mask: the fill shows beyond the path, which the stored pixels (the
// shape alone) lack. Import it as exactly that.
LayerContent::Fill(f)
} else if (vector_key.is_some() && (fill_key.is_some() || rec.block(b"vstk").is_some())) || rec.block(b"vscg").is_some() {
let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)));
let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() };
let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone());
crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi);
// No stored pixels (32-bit documents, files saved without layer pixels): render the
// shape from its path and fill, as Photoshop does when it opens the file.
if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) {
let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32);
sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas));
}
LayerContent::Shape(sh)
} else if let Some(k) = fill_key {
match rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)) {
@@ -499,7 +530,10 @@ pub fn psd_to_document(file: &PsdFile) -> (Document, Vec<String>) {
fill.push(max_sample(depth));
let mut inv = vec![cx.cmyk; cc];
inv.push(false);
let bytes = interleave(&planes, &fill, n, depth, &inv);
let mut bytes = interleave(&planes, &fill, n, depth, &inv);
if fmt.mode == ColorMode::Lab && depth == SampleType::U16 {
crate::pixels::lab16_chroma(&mut bytes, cc + 1, true);
}
let mut s = Surface::from_interleaved(fmt, canvas, &bytes);
if alpha_idx.is_some() {
// Undo Photoshop's white matting of the merged image.
+107 -7
View File
@@ -75,30 +75,84 @@ pub fn path_from_resource(data: &[u8], w: u32, h: u32) -> Option<Path> {
PathData::from_bytes(data).ok().map(|p| path_from_records(&p, w, h))
}
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags).
/// Layer vector data (`vmsk`/`vsms`) → model path, with Photoshop's reading of the records
/// (matching its rendering of the psd-tools corpus):
/// - without subpaths, the initial-fill record alone decides coverage (1 = everything);
/// - with subpaths the initial-fill record is ignored; a first component that subtracts starts
/// from a full canvas (the shape is inverted);
/// - a subpath with operation -1 continues the previous component (same operation).
///
/// The model applies the first subpath as "combine" and inverts the final result, so a leading
/// subtract becomes `inverted` with every later operation replaced by its complement dual
/// (combine ↔ subtract; exclude is self-dual; intersect has no dual and is kept as is).
fn layer_path_from_records(p: &PathData, w: u32, h: u32) -> Path {
let mut path = path_from_records(p, w, h);
if path.subpaths.is_empty() {
return path;
}
let ops: Vec<i16> = p.subpaths().iter().map(|s| s.operation).collect();
let mut prev = PathOp::Combine;
for (s, &raw) in path.subpaths.iter_mut().zip(&ops) {
if raw == -1 {
s.op = prev;
}
prev = s.op;
}
path.inverted = path.subpaths.first().is_some_and(|s| s.op == PathOp::Subtract);
if path.inverted {
for s in path.subpaths.iter_mut().skip(1) {
s.op = match s.op {
PathOp::Combine => PathOp::Subtract,
PathOp::Subtract => PathOp::Combine,
op => op,
};
}
if let Some(first) = path.subpaths.first_mut() {
first.op = PathOp::Combine;
}
}
path
}
/// A `vmsk`/`vsms` block → (path with the invert flag applied, flags). Coverage semantics: an
/// empty path covers everything when `inverted` (shape layers; vector masks flip this, see
/// [`vector_mask_from_block`]).
pub fn path_from_vmsk(data: &[u8], w: u32, h: u32) -> Option<(Path, u32)> {
let b = VectorMaskBlock::from_bytes(data).ok()?;
let mut p = path_from_records(&b.path, w, h);
let mut p = layer_path_from_records(&b.path, w, h);
if b.flags & VectorMaskBlock::FLAG_INVERT != 0 {
p.inverted = !p.inverted;
}
Some((p, b.flags))
}
/// A `vmsk` block for `path` with extra flags (not linked / disabled).
/// A `vmsk` block for `path` with extra flags (not linked / disabled). An empty path is written
/// as the initial-fill record (Photoshop's form); otherwise inversion uses the invert flag.
pub fn vmsk_bytes(path: &Path, flags: u32, w: u32, h: u32) -> Vec<u8> {
let mut p = path.clone();
let mut flags = flags & !VectorMaskBlock::FLAG_INVERT;
if p.inverted {
if p.inverted && !p.subpaths.is_empty() {
flags |= VectorMaskBlock::FLAG_INVERT;
p.inverted = false;
}
// The model draws the first subpath as "combine"; a stored leading subtract would read back
// as an inverted shape.
if let Some(first) = p.subpaths.first_mut()
&& first.op == PathOp::Subtract
{
first.op = PathOp::Combine;
}
VectorMaskBlock { version: 3, flags, path: path_to_records(&p, w, h) }.to_bytes()
}
/// A layer's vector mask from its `vmsk`/`vsms` block.
/// A layer's vector mask from its `vmsk`/`vsms` block. A vector mask without subpaths reveals
/// everything unless inverted (see `photocraft_vector::vector_mask_values`), the opposite of a
/// shape's coverage, hence the flip.
pub fn vector_mask_from_block(data: &[u8], w: u32, h: u32) -> Option<VectorMask> {
let (path, flags) = path_from_vmsk(data, w, h)?;
let (mut path, flags) = path_from_vmsk(data, w, h)?;
if path.subpaths.is_empty() {
path.inverted = !path.inverted;
}
Some(VectorMask {
path,
enabled: flags & VectorMaskBlock::FLAG_DISABLED == 0,
@@ -117,7 +171,11 @@ pub fn vector_mask_bytes(m: &VectorMask, w: u32, h: u32) -> Vec<u8> {
if !m.linked {
flags |= VectorMaskBlock::FLAG_NOT_LINKED;
}
vmsk_bytes(&m.path, flags, w, h)
let mut path = m.path.clone();
if path.subpaths.is_empty() {
path.inverted = !path.inverted;
}
vmsk_bytes(&path, flags, w, h)
}
/// Compares the parts of a vector mask stored in the block (density/feather live elsewhere).
@@ -515,6 +573,48 @@ mod tests {
assert_eq!(op_from_psd(-1), PathOp::Combine);
}
fn vmsk_with(initial_fill: bool, ops: &[i16]) -> Vec<u8> {
let square = |x: f64| PsdSubpath {
closed: true,
operation: 0,
knots: [(x, 0.25), (x + 0.25, 0.25), (x + 0.25, 0.5), (x, 0.5)].iter().map(|&p| PsdKnot { linked: false, pre: p, anchor: p, post: p }).collect(),
};
let subs: Vec<PsdSubpath> = ops.iter().enumerate().map(|(i, &op)| PsdSubpath { operation: op, ..square(0.1 * i as f64) }).collect();
VectorMaskBlock { version: 3, flags: 0, path: PathData::from_subpaths(&subs, initial_fill) }.to_bytes()
}
/// psd-tools vector-mask.psd / vector-mask2.psd / vector-mask3.psd: Photoshop's initial-fill
/// record only matters without subpaths; a leading subtract inverts; -1 continues the
/// previous component.
#[test]
fn layer_path_semantics_follow_photoshop() {
// Initial fill with a combine subpath: a plain (not inverted) shape.
let (p, _) = path_from_vmsk(&vmsk_with(true, &[1]), 100, 100).unwrap();
assert!(!p.inverted);
// Leading subtract: everything but the shape; later ops become their complements.
let (p, _) = path_from_vmsk(&vmsk_with(true, &[2, 1, 2, 0]), 100, 100).unwrap();
assert!(p.inverted);
assert_eq!(p.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), [PathOp::Combine, PathOp::Subtract, PathOp::Combine, PathOp::Exclude]);
// -1 continues the previous component's operation.
let (p, _) = path_from_vmsk(&vmsk_with(false, &[1, 2, -1]), 100, 100).unwrap();
assert_eq!(p.subpaths[2].op, PathOp::Subtract);
// No subpaths: a vector mask with initial fill reveals all, without hides all; a shape
// with initial fill covers everything.
let reveal = vector_mask_from_block(&vmsk_with(true, &[]), 100, 100).unwrap();
assert_eq!(photocraft_vector::vector_mask_values(&reveal, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![1.0; 4]);
let hide = vector_mask_from_block(&vmsk_with(false, &[]), 100, 100).unwrap();
assert_eq!(photocraft_vector::vector_mask_values(&hide, photocraft_geom::Rect::new(0, 0, 2, 2)), vec![0.0; 4]);
assert!(path_from_vmsk(&vmsk_with(true, &[]), 100, 100).unwrap().0.inverted);
// Our own writer round-trips each case.
for m in [reveal, hide] {
assert_eq!(vector_mask_from_block(&vector_mask_bytes(&m, 100, 100), 100, 100), Some(m));
}
let mut lead = sample_path();
lead.subpaths[0].op = PathOp::Subtract;
let back = path_from_vmsk(&vmsk_bytes(&lead, 0, 64, 64), 64, 64).unwrap().0;
assert!(!back.inverted, "a stored leading subtract is drawn as combine by the model");
}
#[test]
fn vector_mask_roundtrip_with_flags() {
let mut m = VectorMask::new(sample_path());
+437 -93
View File
@@ -1,22 +1,49 @@
//! Optional real-file corpus: `corpus/psd/**/*.{psd,psb}` at the workspace
//! root (gitignored; see `corpus/psd/SOURCES.md`). Skips silently if absent.
//! Optional real-file corpora (gitignored, never committed; each test skips
//! silently when its corpus is absent):
//!
//! - `corpus/psd/**/*.{psd,psb}`: a hand-picked mix of small ag-psd and
//! psd-tools samples (see `corpus/psd/SOURCES.md`). Enforced with
//! `PHOTOCRAFT_CORPUS` (a directory, or `1` for the default location).
//! - `corpus/psd-tools/**/*.{psd,psb}`: the complete psd-tools test set
//! (MIT) at a pinned commit, fetched and sha256-verified by
//! `cargo xtask corpus --psd-tools`. Enforced with
//! `PHOTOCRAFT_PSDTOOLS_CORPUS` (a directory, or `1`).
//!
//! For each file: parse → document → flatten, compared with the file's own
//! merged composite (Photoshop's rendering) as the oracle; then document →
//! PSD → document → flatten, compared with the first flatten (our own export
//! must not change what the document looks like). Prints a per-file table.
//! A panic anywhere is caught and reported as CRASH (Rule 9).
//!
//! Without `PHOTOCRAFT_CORPUS` the comparisons are only reported: differences
//! are expected where features are not yet rendered (effects, text engine,
//! smart filters). With `PHOTOCRAFT_CORPUS` set (to the corpus directory, or
//! to `1` for the default location) the run asserts that the oracle pass
//! count and the export round-trip count do not fall below their floors.
//! Raise the floors when they improve; never lower them.
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to fail on import/export errors.
//! Oracles: files without layers (Bitmap, Indexed, plain flat files) compare
//! our import with the merged image as decoded by the psd crate. When
//! Photoshop wrote no real merged image (Maximize Compatibility off: blank
//! image data) or the merged image would decode through our own model code
//! (Multichannel), the embedded thumbnail (resource 1036, Photoshop's own
//! render, JPEG, ≤ 160 px) is the oracle: our composite is box-downsampled to
//! its size and both are lightly blurred; PASS (thumbnail) needs a mean error
//! ≤ 3/255 with ≤ 2 % of pixels off by more than 24/255. That bar is strict
//! (calibrated on files that match their merged image, many thumbnails being
//! stale or resampled differently), so a thumbnail PASS is real evidence while
//! a thumbnail DIFF may be the thumbnail's fault. A file SKIPs only when no
//! oracle exists (no real composite and no usable thumbnail).
//!
//! Without the env var the comparisons are only reported: differences are
//! expected where features are not yet rendered (effects, text engine, smart
//! filters, knockout). With it set the run asserts no crashes, and that the
//! oracle pass count and the export round-trip count do not fall below the
//! source's floors. Raise the floors when they improve; never lower them.
//! Set `PHOTOCRAFT_CORPUS_STRICT=1` to also fail on import/export errors
//! (files listed in `KNOWN_BAD` excepted).
//!
//! `*_mutations_never_panic` truncates and corrupts every corpus file and
//! asserts import + flatten return (Ok or Err) without panicking.
mod common;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use std::time::Instant;
use photocraft_io::*;
use photocraft_psd::PsdFile;
@@ -36,111 +63,428 @@ fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
const PASS_TOL: f32 = 2.0 / 255.0;
/// Export → re-import must render within one 8-bit step of the imported document.
const ROUNDTRIP_TOL: f32 = 1.0 / 255.0 + 1e-5;
/// Files whose flatten matches Photoshop's merged image (`corpus/psd`, 170 files).
const PASS_FLOOR: usize = 113;
/// Files whose export → re-import renders the same as the import.
const ROUNDTRIP_FLOOR: usize = 169;
#[test]
fn corpus_import_flatten_oracle() {
let env = std::env::var_os("PHOTOCRAFT_CORPUS").filter(|v| !v.is_empty());
/// Dissolve block size and tolerance (see [`dissolve_matches`]).
const DISSOLVE_BLOCK: i32 = 16;
const DISSOLVE_TOL: f32 = 0.1;
/// Oracle for documents with Dissolve layers. Photoshop's dissolve decides each pixel with a
/// position-only pseudo-random threshold (observed on psd-tools dissolve.psd: where dissolved
/// layers of equal opacity overlap, a pixel shows the top layer or nothing, never a lower one),
/// but its generator is not public and can't be recovered from one binary pattern; ours uses
/// another hash with the same rule. So outside the dissolve layers' bounds pixels must match
/// as usual (≤ 2/255), and inside them the premultiplied colour averaged over 16 × 16 blocks
/// (density and colour) must agree within 0.1 (binomial noise of a 50 % dissolve is about
/// 0.044 per block difference).
fn dissolve_matches(doc: &photocraft_doc::Document, ours: &[[f32; 4]], ps: &[[f32; 4]]) -> bool {
let canvas = doc.bounds();
let regions: Vec<_> = doc
.walk()
.into_iter()
.filter(|(_, _, l)| l.visible && l.blend == photocraft_color::BlendMode::Dissolve)
.map(|(_, _, l)| photocraft_compose::layer_bounds(l, canvas).intersect(&canvas))
.filter(|r| !r.is_empty())
.collect();
let (w, h) = (canvas.width() as i32, canvas.height() as i32);
if regions.is_empty() || ours.len() != ps.len() || ours.len() != (w as usize) * (h as usize) {
return false;
}
let pm = |p: &[f32; 4], c: usize| if c < 3 { p[c] * p[3] } else { p[3] };
let inside = |x: i32, y: i32| regions.iter().any(|r| r.contains(x + canvas.x0, y + canvas.y0));
for y in 0..h {
for x in 0..w {
let i = (y * w + x) as usize;
if !inside(x, y) && (0..4).any(|c| (pm(&ours[i], c) - pm(&ps[i], c)).abs() > PASS_TOL) {
return false;
}
}
}
for by in (0..h).step_by(DISSOLVE_BLOCK as usize) {
for bx in (0..w).step_by(DISSOLVE_BLOCK as usize) {
let (x1, y1) = ((bx + DISSOLVE_BLOCK).min(w), (by + DISSOLVE_BLOCK).min(h));
let n = ((x1 - bx) * (y1 - by)) as f32;
for c in 0..4 {
let (mut a, mut b) = (0.0, 0.0);
for y in by..y1 {
for x in bx..x1 {
let i = (y * w + x) as usize;
a += pm(&ours[i], c);
b += pm(&ps[i], c);
}
}
if ((a - b) / n).abs() > DISSOLVE_TOL {
return false;
}
}
}
}
true
}
/// Files known to be invalid upstream: their parse error is expected.
const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools fixture stripped to 1906 bytes: merged image data missing")];
/// One corpus directory with its own floors.
struct Source {
label: &'static str,
/// Enforcing env var: a directory, or `1` for `default_dir`.
env: &'static str,
/// Default location, relative to the workspace root.
default_dir: &'static str,
/// Files whose flatten matches their oracle (merged image, or thumbnail when there is none).
pass_floor: usize,
/// Files whose export → re-import renders the same as the import.
roundtrip_floor: usize,
}
/// Hand-picked mix in `corpus/psd` (170 files: 133 vs the merged image + 12 vs the thumbnail).
const MIXED: Source = Source { label: "io corpus", env: "PHOTOCRAFT_CORPUS", default_dir: "corpus/psd", pass_floor: 145, roundtrip_floor: 169 };
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
/// 218 vs the merged image + 10 vs the thumbnail.
const PSD_TOOLS: Source =
Source { label: "psd-tools corpus", env: "PHOTOCRAFT_PSDTOOLS_CORPUS", default_dir: "corpus/psd-tools", pass_floor: 228, roundtrip_floor: 307 };
/// The corpus directory and whether floors are enforced; `None` when absent.
fn locate(src: &Source) -> Option<(PathBuf, bool)> {
let env = std::env::var_os(src.env).filter(|v| !v.is_empty());
let root = match &env {
Some(v) if v != "1" => PathBuf::from(v),
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd"),
_ => Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(src.default_dir),
};
if !root.is_dir() {
assert!(env.is_none(), "PHOTOCRAFT_CORPUS is set but {} is not a directory", root.display());
return;
assert!(env.is_none(), "{} is set but {} is not a directory", src.env, root.display());
return None;
}
Some((root, env.is_some()))
}
fn files_in(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
collect(&root, &mut files);
collect(root, &mut files);
files.sort();
let (mut pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0);
let (mut rt_same, mut rt_diff) = (0, Vec::new());
files
}
fn panic_message(e: &(dyn std::any::Any + Send)) -> String {
e.downcast_ref::<&str>().map(|s| (*s).to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
/// Thumbnail oracle: mean absolute error and the share of pixels off by more than
/// `THUMB_BAD`, after box-downsampling our composite (over white) to the JPEG thumbnail's size.
const THUMB_MEAN_TOL: f32 = 3.0 / 255.0;
const THUMB_BAD: f32 = 24.0 / 255.0;
const THUMB_BAD_FRAC: f32 = 0.02;
/// Photoshop's embedded thumbnail (resource 1036: a 28-byte header, then JFIF) as RGB floats.
fn thumbnail(file: &PsdFile) -> Option<(u32, u32, Vec<[f32; 3]>)> {
let r = file.resource(photocraft_psd::resources::ids::THUMBNAIL)?;
let format = u32::from_be_bytes(r.data.get(..4)?.try_into().ok()?);
if format != 1 {
return None;
}
let img = photocraft_codecs::decode(r.data.get(28..)?).ok()?;
let rgba = img.to_rgba8();
Some((img.width(), img.height(), rgba.as_chunks::<4>().0.iter().map(|p| [0, 1, 2].map(|c| f32::from(p[c]) / 255.0)).collect()))
}
/// 3×3 box blur of an RGB plane (edges clamp): absorbs the sub-pixel offsets between our box
/// downsampling and Photoshop's thumbnail resampling, and JPEG ringing.
fn blur3(v: &[[f32; 3]], w: usize, h: usize) -> Vec<[f32; 3]> {
let mut out = vec![[0.0; 3]; v.len()];
for y in 0..h {
for x in 0..w {
let mut acc = [0.0f32; 3];
for dy in [-1i64, 0, 1] {
for dx in [-1i64, 0, 1] {
let (sx, sy) = ((x as i64 + dx).clamp(0, w as i64 - 1) as usize, (y as i64 + dy).clamp(0, h as i64 - 1) as usize);
for c in 0..3 {
acc[c] += v[sy * w + sx][c];
}
}
}
out[y * w + x] = acc.map(|a| a / 9.0);
}
}
out
}
/// Compares `ours` (canvas-size straight RGBA) with the thumbnail: our composite over white is
/// box-downsampled to the thumbnail's size, both are lightly blurred, and the result is the mean
/// absolute error and the share of pixels off by more than `THUMB_BAD`. `None` without a
/// thumbnail, or when it is not smaller than the canvas (tiny documents: not a real reduction).
fn thumbnail_oracle(file: &PsdFile, ours: &[[f32; 4]]) -> Option<(f32, f32)> {
let (tw, th, thumb) = thumbnail(file)?;
let (w, h) = (file.header.width as usize, file.header.height as usize);
let (tw, th) = (tw as usize, th as usize);
if tw < 8 || th < 8 || tw > w || th > h || ours.len() != w * h || thumb.len() != tw * th {
return None;
}
let mut small = Vec::with_capacity(tw * th);
for ty in 0..th {
for tx in 0..tw {
let (x0, x1) = (tx * w / tw, ((tx + 1) * w / tw).max(tx * w / tw + 1).min(w));
let (y0, y1) = (ty * h / th, ((ty + 1) * h / th).max(ty * h / th + 1).min(h));
let mut acc = [0.0f32; 3];
for y in y0..y1 {
for p in &ours[y * w + x0..y * w + x1] {
for c in 0..3 {
acc[c] += p[c] * p[3] + 1.0 - p[3];
}
}
}
let n = ((x1 - x0) * (y1 - y0)) as f32;
small.push(acc.map(|a| a / n));
}
}
let (a, b) = (blur3(&small, tw, th), blur3(&thumb, tw, th));
let (mut sum, mut bad) = (0.0f32, 0usize);
for (p, t) in a.iter().zip(&b) {
let d: [f32; 3] = std::array::from_fn(|c| (p[c] - t[c]).abs());
sum += (d[0] + d[1] + d[2]) / 3.0;
bad += usize::from(d.iter().any(|v| *v > THUMB_BAD));
}
let n = (tw * th) as f32;
Some((sum / n, bad as f32 / n))
}
enum Outcome {
Pass,
/// Matched the embedded thumbnail (no real merged image to compare with).
ThumbPass,
Diff,
Skip,
Error,
}
/// Oracle + round trip for one file. Returns the oracle outcome and whether the export round trip
/// rendered the same (`None` when it could not run).
fn check_file(name: &str, bytes: &[u8]) -> (Outcome, Option<f32>) {
let known_bad = KNOWN_BAD.iter().any(|(f, _)| name.ends_with(f));
let file = match PsdFile::from_bytes(bytes) {
Ok(f) => f,
Err(e) => {
let tag = if known_bad { "KNOWN-BAD" } else { "PARSE-ERROR" };
eprintln!("{name:<60} {:>6} {:>9} {:>8} {tag} {e}", "-", "-", "-");
return (if known_bad { Outcome::Skip } else { Outcome::Error }, None);
}
};
let imp = match import(name, bytes) {
Ok(i) => i,
Err(e) => {
eprintln!("{name:<60} IMPORT-ERROR {e}");
return (Outcome::Error, None);
}
};
let doc = &imp.document;
// Export must always succeed, re-parse and re-import.
let reimported = match export(doc, "x.psd", &ExportOptions::default()) {
Ok(r) => {
if let Err(e) = PsdFile::from_bytes(&r.bytes) {
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
return (Outcome::Error, None);
}
match import(name, &r.bytes) {
Ok(i) => i.document,
Err(e) => {
eprintln!("{name:<60} REIMPORT-ERROR {e}");
return (Outcome::Error, None);
}
}
}
Err(e) => {
eprintln!("{name:<60} EXPORT-ERROR {e}");
return (Outcome::Error, None);
}
};
let ours = photocraft_compose::flatten(doc).px;
// Our own export must not change how the document renders.
let again = photocraft_compose::flatten(&reimported).px;
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
let layers = doc.layer_count();
// Oracle choice: the merged image (Photoshop's composite; for files without layers it is the
// whole image, decoded independently by the psd crate). When Photoshop wrote no real merged
// image (Maximize Compatibility off) or it decodes through our own model code (Multichannel),
// its embedded thumbnail is the oracle at thumbnail size.
let use_thumb = file.has_real_merged_data() == Some(false) || file.header.color_mode == photocraft_psd::ColorMode::Multichannel;
let merged = if use_thumb { None } else { merged_composite(&file).ok() };
let Some(merged) = merged else {
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
return match thumbnail_oracle(&file, &ours) {
Some((mean, frac)) => {
let ok = mean <= THUMB_MEAN_TOL && frac <= THUMB_BAD_FRAC;
let status = if ok { "PASS (thumbnail)" } else { "DIFF (thumbnail)" };
eprintln!("{name:<60} {layers:>6} {mean:>9.4} {:>7.2}% {status} {}", 100.0 * frac, notes.join(" | "));
(if ok { Outcome::ThumbPass } else { Outcome::Diff }, Some(rt))
}
None => {
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no real composite and no thumbnail)", "-", "-");
(Outcome::Skip, Some(rt))
}
};
};
// Without an alpha channel in the merged image, Photoshop flattened the document over white.
let ours = if file.merged_has_alpha() {
ours
} else {
ours.iter()
.map(|p| {
let w = 1.0 - p[3];
[p[0] * p[3] + w, p[1] * p[3] + w, p[2] * p[3] + w, 1.0]
})
.collect()
};
let m = common::max_diff(&ours, &merged);
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
let (outcome, status) = if m <= PASS_TOL {
(Outcome::Pass, "PASS")
} else if dissolve_matches(doc, &ours, &merged) {
(Outcome::Pass, "PASS (dissolve metric)")
} else {
(Outcome::Diff, "DIFF")
};
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
if std::env::var_os("CORPUS_THUMB_CALIBRATE").is_some()
&& let Some((mean, frac)) = thumbnail_oracle(&file, &ours)
{
eprintln!("THUMBCAL {status} {mean:.4} {frac:.4} {name}");
}
(outcome, Some(rt))
}
fn run_oracle(src: &Source) {
let Some((root, enforce)) = locate(src) else { return };
let files = files_in(&root);
let (mut pass, mut thumb_pass, mut diff, mut skipped, mut errors) = (0, 0, 0, 0, 0);
let (mut rt_same, mut rt_diff, mut crashes) = (0, Vec::new(), Vec::new());
eprintln!("{:<60} {:>6} {:>9} {:>8} status", "file", "layers", "max_err", "bad_px%");
for p in &files {
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
let bytes = std::fs::read(p).unwrap_or_default();
let file = match PsdFile::from_bytes(&bytes) {
Ok(f) => f,
let (outcome, rt) = match catch_unwind(AssertUnwindSafe(|| check_file(&name, &bytes))) {
Ok(r) => r,
Err(e) => {
eprintln!("{name:<60} {:>6} {:>9} {:>8} PARSE-ERROR {e}", "-", "-", "-");
errors += 1;
let msg = panic_message(e.as_ref());
eprintln!("{name:<60} CRASH {msg}");
crashes.push(format!("{name}: {msg}"));
continue;
}
};
let imp = match import(&name, &bytes) {
Ok(i) => i,
Err(e) => {
eprintln!("{name:<60} IMPORT-ERROR {e}");
errors += 1;
continue;
}
};
let doc = &imp.document;
// Export must always succeed, re-parse and re-import.
let reimported = match export(doc, "x.psd", &ExportOptions::default()) {
Ok(r) => {
if let Err(e) = PsdFile::from_bytes(&r.bytes) {
eprintln!("{name:<60} REEXPORT-PARSE-ERROR {e}");
errors += 1;
continue;
}
match import(&name, &r.bytes) {
Ok(i) => i.document,
Err(e) => {
eprintln!("{name:<60} REIMPORT-ERROR {e}");
errors += 1;
continue;
}
}
}
Err(e) => {
eprintln!("{name:<60} EXPORT-ERROR {e}");
errors += 1;
continue;
}
};
let ours = photocraft_compose::flatten(doc).px;
// Our own export must not change how the document renders.
let again = photocraft_compose::flatten(&reimported).px;
let rt = if again.len() == ours.len() { common::max_diff(&ours, &again) } else { f32::INFINITY };
if rt <= ROUNDTRIP_TOL {
rt_same += 1;
} else {
rt_diff.push(format!("{name} ({rt:.4})"));
match outcome {
Outcome::Pass => pass += 1,
Outcome::ThumbPass => thumb_pass += 1,
Outcome::Diff => diff += 1,
Outcome::Skip => skipped += 1,
Outcome::Error => errors += 1,
}
let layers = doc.layer_count();
if file.has_real_merged_data() == Some(false) || file.layers().is_empty() {
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (no layers or no real composite)", "-", "-");
skipped += 1;
continue;
match rt {
Some(rt) if rt <= ROUNDTRIP_TOL => rt_same += 1,
Some(rt) => rt_diff.push(format!("{name} ({rt:.4})")),
None => {}
}
let Ok(merged) = merged_composite(&file) else {
eprintln!("{name:<60} {layers:>6} {:>9} {:>8} SKIP (merged not decodable)", "-", "-");
skipped += 1;
continue;
};
let m = common::max_diff(&ours, &merged);
let bad = ours.iter().zip(&merged).filter(|(a, b)| (0..4).any(|c| (a[c] * a[3] - b[c] * b[3]).abs() > PASS_TOL)).count();
let pct = 100.0 * bad as f32 / ours.len().max(1) as f32;
let status = if m <= PASS_TOL {
pass += 1;
"PASS"
} else {
diff += 1;
"DIFF"
};
let notes: Vec<&str> = imp.warnings.iter().map(String::as_str).take(2).collect();
eprintln!("{name:<60} {layers:>6} {:>9.4} {:>7.2}% {status} {}", m, pct, notes.join(" | "));
}
eprintln!("io corpus: {} files: {pass} pass (<= 2/255), {diff} differ, {skipped} skipped, {errors} errors", files.len());
eprintln!("io corpus: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
let label = src.label;
eprintln!(
"{label}: {} files: {} pass ({pass} vs the merged image <= 2/255, {thumb_pass} vs the thumbnail), {diff} differ, {skipped} skipped, {errors} errors, {} crashes",
files.len(),
pass + thumb_pass,
crashes.len()
);
let pass = pass + thumb_pass;
eprintln!("{label}: export -> re-import renders the same for {rt_same} files; differs for {}: {}", rt_diff.len(), rt_diff.join(", "));
if std::env::var_os("PHOTOCRAFT_CORPUS_STRICT").is_some() {
assert_eq!(errors, 0);
}
if env.is_some() {
assert!(pass >= PASS_FLOOR, "oracle pass count {pass} fell below the floor {PASS_FLOOR}");
assert!(rt_same >= ROUNDTRIP_FLOOR, "export round trip {rt_same} fell below the floor {ROUNDTRIP_FLOOR}: {rt_diff:?}");
if enforce {
assert!(crashes.is_empty(), "{label}: panics (Rule 9): {crashes:?}");
assert!(pass >= src.pass_floor, "{label}: oracle pass count {pass} fell below the floor {}", src.pass_floor);
assert!(rt_same >= src.roundtrip_floor, "{label}: export round trip {rt_same} fell below the floor {}: {rt_diff:?}", src.roundtrip_floor);
}
}
/// Deterministic xorshift for the mutation sweep.
fn next(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
/// Truncations and byte corruptions of every corpus file: import (and flatten when the document
/// is small) must return without panicking. Files are mutated in their first 64 KB, where the
/// header, resources and layer records live.
fn run_mutations(src: &Source) {
// Minutes of work: only when the corpus is enforced.
let Some((root, true)) = locate(src) else { return };
let enforce = true;
let files = files_in(&root);
let (mut runs, mut panics, mut slow) = (0usize, Vec::new(), Vec::new());
for p in &files {
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
let Ok(bytes) = std::fs::read(p) else { continue };
let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ bytes.len() as u64;
let span = bytes.len().clamp(1, 64 * 1024);
let mut variants: Vec<(String, Vec<u8>)> = Vec::new();
for k in 1..8 {
let cut = bytes.len() * k / 8;
variants.push((format!("truncate@{cut}"), bytes[..cut].to_vec()));
}
for _ in 0..24 {
let mut m = bytes.clone();
let flips = 1 + (next(&mut seed) % 4) as usize;
for _ in 0..flips {
let at = (next(&mut seed) as usize) % span;
if let Some(b) = m.get_mut(at) {
*b = match next(&mut seed) % 3 {
0 => 0xFF,
1 => 0x00,
_ => *b ^ (1 << (next(&mut seed) % 8)),
};
}
}
variants.push(("mutate".into(), m));
}
for (what, data) in variants {
runs += 1;
let t = Instant::now();
let r = catch_unwind(AssertUnwindSafe(|| {
if let Ok(imp) = import(&name, &data) {
let b = imp.document.bounds();
if i64::from(b.width()) * i64::from(b.height()) <= 4_000_000 {
let _ = photocraft_compose::flatten(&imp.document);
}
}
}));
let secs = t.elapsed().as_secs_f32();
if secs > 20.0 {
slow.push(format!("{name} {what}: {secs:.1}s"));
}
if let Err(e) = r {
let msg = panic_message(e.as_ref());
eprintln!("CRASH {name} {what}: {msg}");
panics.push(format!("{name} {what}: {msg}"));
}
}
}
eprintln!("{}: {runs} mutated imports, {} panics, {} slow (> 20 s): {slow:?}", src.label, panics.len(), slow.len());
if enforce {
assert!(panics.is_empty(), "{}: mutated files panicked (Rule 9): {panics:?}", src.label);
}
}
#[test]
fn corpus_import_flatten_oracle() {
run_oracle(&MIXED);
}
#[test]
fn psd_tools_import_flatten_oracle() {
run_oracle(&PSD_TOOLS);
}
#[test]
fn psd_tools_mutations_never_panic() {
run_mutations(&PSD_TOOLS);
}
+167
View File
@@ -0,0 +1,167 @@
//! Synthetic reproductions of failures found by the psd-tools corpus
//! (`cargo xtask corpus --psd-tools`; see `tests/corpus.rs`). The corpus
//! files themselves are never committed: each test rebuilds the relevant
//! structure from our own documents.
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_doc::{Document, Fill, Layer, LayerContent, LayerMask, Path, ShapeLayer, Size, Subpath, VectorMask};
use photocraft_geom::Rect;
use photocraft_io::*;
use photocraft_psd::layer::CHANNEL_REAL_USER_MASK;
use photocraft_psd::{ChannelData, Compression, MaskData, PsdFile, RealMask, Version};
fn doc(depth: SampleType) -> Document {
Document::with_background("t", Size::new(32, 32), ColorMode::Rgb, depth, Color::WHITE)
}
fn square(x0: f64, y0: f64, x1: f64, y1: f64) -> Path {
Path::new(vec![Subpath::polygon(&[(x0, y0), (x1, y0), (x1, y1), (x0, y1)])])
}
fn reimport(file: &PsdFile) -> ImportResult {
import("x.psd", &file.to_bytes().unwrap()).unwrap()
}
/// psd-tools 32bit.psd, 300dpi.psd, transparentbg.psd, vector-mask2.psd: shape layers whose
/// record stores no pixels (empty rect; every 32-bit shape layer) imported as invisible. They
/// are now rendered from their path and fill.
#[test]
fn shape_layer_without_stored_pixels_renders_from_its_path() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let mut d = doc(depth);
let sh = ShapeLayer { path: square(8.0, 8.0, 24.0, 24.0), fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
let mut sh2 = sh.clone();
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
d.layers.push(Layer::new("Shape 1", LayerContent::Shape(sh2)));
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
// Drop the shape record's pixels, as Photoshop does for 32-bit documents.
let bits = file.header.depth;
let rec = file.layers_mut().last_mut().unwrap();
assert!(rec.block(b"vmsk").is_some() || rec.block(b"vsms").is_some());
rec.rect = Default::default();
for ch in &mut rec.channels {
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, bits, Version::Psd).unwrap();
}
let imp = reimport(&file);
let f = photocraft_compose::flatten(&imp.document);
assert!(f.get(16, 16)[0] < 0.01, "{depth:?}: shape interior renders black: {:?}", f.get(16, 16));
assert!(f.get(2, 2)[0] > 0.99, "{depth:?}: outside stays white");
}
}
/// psd-tools mask-density-vectormask.psd, mask_parameters.psd: vector mask density and feather
/// were dropped on export (only the user mask's parameters were written).
#[test]
fn vector_mask_density_and_feather_survive_export() {
for with_user_mask in [false, true] {
let mut d = doc(SampleType::U8);
let mut l = Layer::raster("pix", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
let mut vm = VectorMask::new(square(0.0, 0.0, 16.0, 32.0));
vm.density = 128.0 / 255.0;
vm.feather = 1.5;
l.vector_mask = Some(vm);
if with_user_mask {
let mut m = LayerMask::reveal_all();
m.density = 204.0 / 255.0;
m.feather = 2.5;
l.mask = Some(m);
}
d.layers.push(l);
let r = export(&d, "x.psd", &ExportOptions::default()).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("density")), "{:?}", r.warnings);
let b = import("x.psd", &r.bytes).unwrap().document;
let vm = b.layers[1].vector_mask.as_ref().unwrap();
assert_eq!((vm.density, vm.feather), (128.0 / 255.0, 1.5), "user mask: {with_user_mask}");
assert_eq!(b.layers[1].mask.is_some(), with_user_mask);
if let Some(m) = &b.layers[1].mask {
assert_eq!((m.density, m.feather), (204.0 / 255.0, 2.5));
}
}
}
/// psd-tools layer_mask_data.psd, mask-density-layervectormask.psd: a layer with both a user
/// mask and a vector mask stores the "real" user mask (channel -3) and mask parameters; the
/// real-mask fields precede the parameters, so the -3 channel failed to decode.
#[test]
fn real_user_mask_with_parameters_is_decoded() {
let mut d = doc(SampleType::U8);
let mut l = Layer::raster("pix", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 32, 32), &[0.0, 0.0, 0.0, 1.0]);
let mut m = LayerMask::reveal_all();
m.density = 0.5;
l.mask = Some(m);
let mut vm = VectorMask::new(square(0.0, 0.0, 32.0, 32.0));
vm.density = 0.5;
l.vector_mask = Some(vm);
d.layers.push(l);
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
// Add a real user mask hiding the left half (0) inside its rect, revealing elsewhere.
let rec = file.layers_mut().last_mut().unwrap();
let MaskData::Mask(pm) = &mut rec.mask else { panic!("mask record") };
assert!(pm.parameters.is_some());
let real_rect = photocraft_psd::Rect { top: 0, left: 0, bottom: 32, right: 16 };
pm.real = Some(RealMask { flags: 0, background: 255, rect: real_rect });
pm.trailing.clear();
rec.channels.push(ChannelData::encode(CHANNEL_REAL_USER_MASK, Compression::Rle, &[0u8; 16 * 32], 16, 32, 8, Version::Psd).unwrap());
let imp = reimport(&file);
assert!(imp.warnings.iter().all(|w| !w.contains("could not be decoded")), "{:?}", imp.warnings);
let mask = imp.document.layers[1].mask.as_ref().unwrap();
// Hidden at density 0.5: 1 - 0.5 · (1 - 0).
assert!((mask.value(4, 4) - 0.5).abs() < 0.01, "real mask hides the left half: {}", mask.value(4, 4));
assert_eq!(mask.value(24, 4), 1.0, "and reveals outside its rect");
assert_eq!(mask.density, 128.0 / 255.0);
assert_eq!(imp.document.layers[1].vector_mask.as_ref().map(|v| v.density), Some(128.0 / 255.0));
}
/// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose
/// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the
/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask.
#[test]
fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() {
let mut d = doc(SampleType::U8);
let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK)));
let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0));
vm.density = 0.8;
l.vector_mask = Some(vm);
d.layers.push(l);
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
// Photoshop stores the shape alone: replace the record's pixels with the path's coverage.
let rec = file.layers_mut().last_mut().unwrap();
assert!(rec.block(b"SoCo").is_some() && rec.block(b"vmsk").is_some());
rec.rect = photocraft_psd::Rect { top: 8, left: 8, bottom: 24, right: 24 };
for ch in &mut rec.channels {
if ch.id >= -1 {
let v = if ch.id == -1 { 255 } else { 0 };
*ch = ChannelData::encode(ch.id, Compression::Raw, &[v; 16 * 16], 16, 16, 8, Version::Psd).unwrap();
}
}
let imp = reimport(&file);
let l = &imp.document.layers[1];
assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name());
assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0));
let f = photocraft_compose::flatten(&imp.document);
assert!(f.get(16, 16)[0] < 0.01, "inside the path: black");
assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2));
}
/// psd-tools adjustment-fillers.psd: a solid-colour shape whose vector path is empty with the
/// initial-fill record set (it covers the canvas) and no stored pixels rendered nothing.
#[test]
fn shape_without_pixels_and_full_initial_fill_covers_the_canvas() {
let mut d = doc(SampleType::U8);
let mut path = Path::new(Vec::new());
path.inverted = true;
let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::BLACK)), ..Default::default() };
let mut sh2 = sh.clone();
sh2.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds()));
d.layers.push(Layer::new("Color Fill 1", LayerContent::Shape(sh2)));
let mut file = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
let rec = file.layers_mut().last_mut().unwrap();
rec.rect = Default::default();
for ch in &mut rec.channels {
*ch = ChannelData::encode(ch.id, Compression::Raw, &[], 0, 0, 8, Version::Psd).unwrap();
}
let f = photocraft_compose::flatten(&reimport(&file).document);
assert!(f.get(0, 0)[0] < 0.01 && f.get(31, 31)[0] < 0.01, "covers everything: {:?}", f.get(0, 0));
}
+71
View File
@@ -0,0 +1,71 @@
//! 16-bit Lab PSDs: Photoshop stores a*/b* as `32768 + 256·a` (0..65280 spans −128..127), not
//! on the full 0..65535 range. Layer pixels, the merged image and patterns use that scale, and
//! the documents' a*/b* stay on the 8-bit scale `(a + 128) / 255` at every depth.
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, Layer, LayerContent};
use photocraft_geom::{Rect, Size};
use photocraft_io::*;
use photocraft_psd::PsdFile;
use photocraft_raster::Surface;
/// a* = 52.29, b* = −85.08 (a gradient stop of psd-tools 4x4_16bit_lab) on the document scale.
const A: f32 = (52.29 + 128.0) / 255.0;
const B: f32 = (-85.08 + 128.0) / 255.0;
fn lab_doc(depth: SampleType) -> Document {
let mut d = Document::new("lab", Size::new(3, 2), ColorMode::Lab, depth);
let fmt = PixelFormat::new(ColorMode::Lab, depth, true);
let mut s = Surface::new(fmt);
let px: Vec<f32> = (0..6).flat_map(|_| [0.1907, A, B, 1.0]).collect();
s.write_region(Rect::new(0, 0, 3, 2), &px);
d.layers.push(Layer::new("px", LayerContent::Raster(s)));
d
}
fn be16(b: &[u8], i: usize) -> u16 {
u16::from_be_bytes([b[2 * i], b[2 * i + 1]])
}
#[test]
fn lab16_chroma_uses_photoshops_scale() {
let doc = lab_doc(SampleType::U16);
let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
let file = PsdFile::from_bytes(&out.bytes).unwrap();
assert_eq!(file.header.depth, 16);
// Merged image: planes L, a, b.
let m = file.decode_merged().unwrap();
let n = 6;
let a = be16(&m, n);
let b = be16(&m, 2 * n);
assert!((i32::from(a) - (32768 + (256.0f32 * 52.29).round() as i32)).abs() <= 1, "a = {a}");
assert!((i32::from(b) - (32768 - (256.0f32 * 85.08).round() as i32)).abs() <= 1, "b = {b}");
// Layer channel 1 (a*) the same.
let rec = &file.layers()[0];
let plane = rec.decode_channel(1, 16, file.header.version).unwrap();
assert!((i32::from(be16(&plane, 0)) - i32::from(a)).abs() <= 1);
// And back: the document values survive.
let back = import("x.psd", &out.bytes).unwrap().document;
let s = back.layers[0].surface().unwrap();
let p = s.pixel(1, 1);
assert!((p[1] - A).abs() < 2e-5 && (p[2] - B).abs() < 2e-5, "{p:?}");
}
#[test]
fn lab_round_trips_at_every_depth() {
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let doc = lab_doc(depth);
let out = export(&doc, "x.psd", &ExportOptions::default()).unwrap();
let back = import("x.psd", &out.bytes).unwrap().document;
let p = back.layers[0].surface().unwrap().pixel(2, 0);
let tol = if depth == SampleType::U8 { 0.5 / 255.0 + 1e-6 } else { 2e-5 };
assert!((p[1] - A).abs() <= tol && (p[2] - B).abs() <= tol, "{depth:?}: {p:?}");
let a = photocraft_compose::flatten(&doc).px;
let b = photocraft_compose::flatten(&back).px;
for (x, y) in a.iter().zip(&b) {
for c in 0..4 {
assert!((x[c] - y[c]).abs() <= 1.0 / 255.0, "{depth:?}: {x:?} vs {y:?}");
}
}
}
}
+14
View File
@@ -99,6 +99,11 @@ impl History {
self.undo.len()
}
/// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).
pub fn set_current_label(&mut self, label: impl Into<String>) {
self.current_label = label.into();
}
/// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the
/// last step can no longer be undone and its pixels are released.
pub fn purge_last(&mut self) -> bool {
@@ -118,6 +123,14 @@ impl History {
self.undo.iter().chain(self.redo.iter()).map(|s| tile_bytes(&s.doc, &mut seen)).sum()
}
/// Pixel bytes of `current` plus the tiles only history holds (what [`History::trim`]
/// bounds by [`History::max_bytes`]).
pub fn pixel_bytes(&self, current: &Document) -> usize {
let mut seen = HashSet::new();
let own = tile_bytes(current, &mut seen);
self.undo.iter().chain(self.redo.iter()).fold(own, |n, s| n.saturating_add(tile_bytes(&s.doc, &mut seen)))
}
/// Keep pixel memory within [`History::max_bytes`]: the current document's tiles plus the
/// tiles only history holds (newest states first). The oldest undo states that don't fit are
/// dropped; the most recent one is always kept so the last step can be undone. Returns how
@@ -257,6 +270,7 @@ mod tests {
assert_eq!(h.trim(&cur), 3);
assert_eq!(h.past_len(), 3);
assert_eq!(h.unique_bytes(&cur), 3 * tile);
assert_eq!(h.pixel_bytes(&cur), 4 * tile, "the current tile plus history's");
assert_eq!(h.entries()[0], "paint 2");
// A budget smaller than one state still keeps the last step undoable.
h.max_bytes = 1;
+80 -2
View File
@@ -10,6 +10,7 @@
use photocraft_color::BlendMode;
use serde::{Deserialize, Serialize};
use crate::mixer::MixerSettings;
use crate::tile::GrayTile;
/// What drives a dynamic parameter (Photoshop's "Control" pop-ups).
@@ -80,12 +81,19 @@ pub struct ShapeDynamics {
pub enabled: bool,
/// Size jitter / control; `minimum` is Photoshop's Minimum Diameter.
pub size: Dynamic,
/// Tilt Scale, 0..2 (0–200 %): with the size control on Pen Tilt, how much a tilted pen
/// squashes the tip's height (before the tip angle is applied). At full tilt the height is
/// scaled by `1 − tiltScale / 2`, so 0 leaves the tip alone and 200 % flattens it.
pub tilt_scale: f32,
/// Angle jitter (fraction of ±180°) / control.
pub angle: Dynamic,
/// Roundness jitter / control; `minimum` is Minimum Roundness.
pub roundness: Dynamic,
pub flip_x_jitter: bool,
pub flip_y_jitter: bool,
/// Brush Projection: the pen's tilt and barrel rotation are applied to the tip as a
/// projection (the tip is foreshortened along the tilt direction and turns with the pen).
pub brush_projection: bool,
}
/// Scattering.
@@ -254,13 +262,36 @@ impl Default for ColorDynamics {
}
}
/// Transfer: per-dab opacity ceiling and flow.
/// Transfer: per-dab opacity ceiling and flow, plus the Mixer Brush's wetness and mix.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Transfer {
pub enabled: bool,
pub opacity: Dynamic,
pub flow: Dynamic,
/// Wetness Jitter / control (Mixer Brush only): scales the Wet setting per dab.
pub wetness: Dynamic,
/// Mix Jitter / control (Mixer Brush only): scales the Mix setting per dab.
pub mix: Dynamic,
}
/// The sections a lock can hold (the lock icons in Photoshop's Brush Settings list): a locked
/// section keeps its settings when another brush preset is picked.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SectionLocks {
pub shape_dynamics: bool,
pub scattering: bool,
pub texture: bool,
pub dual_brush: bool,
pub color_dynamics: bool,
pub transfer: bool,
pub pose: bool,
pub noise: bool,
pub wet_edges: bool,
pub build_up: bool,
pub smoothing: bool,
pub protect_texture: bool,
}
/// Brush Pose: stylus values used instead of (override) or for missing device data.
@@ -327,6 +358,9 @@ pub struct BrushSettings {
pub hardness: f32,
/// Spacing between dabs as a fraction of the diameter.
pub spacing: f32,
/// Photoshop's Spacing checkbox. Off: the pointer's speed sets the spacing (one dab every
/// [`crate::dynamics::SPEED_SPACING_MS`] of stroke time; without timestamps, one per input point).
pub spacing_enabled: bool,
/// Maximum coverage for the whole stroke.
pub opacity: f32,
/// Per-dab coverage.
@@ -374,6 +408,10 @@ pub struct BrushSettings {
pub smoothing: Smoothing,
/// Keep the current texture (pattern + scale) when switching to another textured preset.
pub protect_texture: bool,
/// Sections whose settings stay when another preset is picked (tool state, like Photoshop's locks).
pub locks: SectionLocks,
/// Mixer Brush options (Wet, Load, Mix, Flow, Sample All Layers) used by `paint.mixerBrush`.
pub mixer: MixerSettings,
/// Random seed for jitters (commands derive it from the stroke when not given), so strokes replay.
pub seed: u64,
}
@@ -384,6 +422,7 @@ impl Default for BrushSettings {
size: 20.0,
hardness: 1.0,
spacing: 0.1,
spacing_enabled: true,
opacity: 1.0,
flow: 1.0,
pressure_size: true,
@@ -411,6 +450,8 @@ impl Default for BrushSettings {
build_up_rate: 20.0,
smoothing: Smoothing::default(),
protect_texture: false,
locks: SectionLocks::default(),
mixer: MixerSettings::default(),
seed: 0,
}
}
@@ -418,10 +459,47 @@ impl Default for BrushSettings {
impl BrushSettings {
/// This brush (a preset) picked while `current` is the tool's brush: Smoothing is a tool
/// option, so it stays the tool's (Photoshop), and a protected texture stays too.
/// option, so it stays the tool's (Photoshop), a protected texture stays too, and so does every
/// section `current` has locked (the locks themselves are tool state and carry over).
pub fn picked_over(self, current: &BrushSettings) -> Self {
let mut b = self.with_protected_texture(current);
b.smoothing = current.smoothing.clone();
let l = &current.locks;
if l.shape_dynamics {
b.shape_dynamics = current.shape_dynamics.clone();
}
if l.scattering {
b.scattering = current.scattering.clone();
}
if l.texture {
b.texture = current.texture.clone();
}
if l.dual_brush {
b.dual_brush = current.dual_brush.clone();
}
if l.color_dynamics {
b.color_dynamics = current.color_dynamics.clone();
}
if l.transfer {
b.transfer = current.transfer.clone();
}
if l.pose {
b.pose = current.pose.clone();
}
if l.noise {
b.noise = current.noise;
}
if l.wet_edges {
b.wet_edges = current.wet_edges;
}
if l.build_up {
b.build_up = current.build_up;
b.build_up_rate = current.build_up_rate;
}
if l.protect_texture {
b.protect_texture = current.protect_texture;
}
b.locks = current.locks.clone();
b
}
+175
View File
@@ -232,6 +232,7 @@ fn jitter_is_deterministic_per_seed() {
roundness: Dynamic { jitter: 1.0, minimum: 0.2, ..Default::default() },
flip_x_jitter: true,
flip_y_jitter: true,
..Default::default()
},
scattering: Scattering { enabled: true, scatter: Dynamic::jitter(2.0), both_axes: true, count: 3, count_jitter: Dynamic::jitter(1.0) },
..brush()
@@ -638,3 +639,177 @@ fn tail_preview_shows_the_stroke_as_finishing_it_would() {
r.push(&pts);
assert!(r.tail_preview().is_none());
}
// ---------- Tilt Scale, Brush Projection, speed spacing, locks, mixer transfer ----------
/// Coverage extent (pixels above half coverage) of a rasterised dab along x and y through its centre.
fn extents(b: &BrushSettings, d: &Dab) -> (usize, usize) {
let r = raster(b, d);
let (cx, cy) = (d.center.x.floor() as i32, d.center.y.floor() as i32);
let w = (r.0.x0..r.0.x1).filter(|&x| at(&r, x, cy) > 0.5).count();
let h = (r.0.y0..r.0.y1).filter(|&y| at(&r, cx, y) > 0.5).count();
(w, h)
}
fn pen(x: f64, y: f64, tilt_x: f32, tilt_y: f32, rotation: f32) -> StrokePoint {
StrokePoint { tilt_x, tilt_y, rotation, ..StrokePoint::new(x, y, 1.0) }
}
#[test]
fn tilt_scale_squashes_the_tip_height_with_pen_tilt() {
// Size control on Pen Tilt with a 100 % minimum, so only Tilt Scale changes the shape.
let mk = |scale: f32| BrushSettings {
size: 40.0,
shape_dynamics: ShapeDynamics {
enabled: true,
size: Dynamic { control: Control::PenTilt, minimum: 1.0, ..Default::default() },
tilt_scale: scale,
..Default::default()
},
..brush()
};
let upright = [pen(50.0, 50.0, 0.0, 0.0, 0.0)];
let tilted = [pen(50.0, 50.0, 45.0, 0.0, 0.0)];
// 0 % (the default) and an upright pen leave the tip round.
let d = dabs_of(&mk(0.0), &tilted)[0];
assert_eq!(d.roundness, 1.0);
let d = dabs_of(&mk(2.0), &upright)[0];
assert_eq!(d.roundness, 1.0);
// 100 % at 45° tilt: height × (1 − 0.5 × 0.5) = 0.75; 200 %: 0.5.
let d = dabs_of(&mk(1.0), &tilted)[0];
assert!((d.roundness - 0.75).abs() < 1e-4, "{}", d.roundness);
let d2 = dabs_of(&mk(2.0), &tilted)[0];
assert!((d2.roundness - 0.5).abs() < 1e-4, "{}", d2.roundness);
let (w, h) = extents(&mk(2.0), &d2);
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
// Only with the size control on Pen Tilt (Photoshop greys the slider otherwise).
let mut b = mk(2.0);
b.shape_dynamics.size.control = Control::PenPressure;
assert_eq!(dabs_of(&b, &tilted)[0].roundness, 1.0);
// Rendered strokes differ.
let a = paint(&mk(0.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
let c = paint(&mk(2.0), &[pen(20.0, 40.0, 60.0, 0.0, 0.0), pen(120.0, 40.0, 60.0, 0.0, 0.0)], 0, 0);
let area = Rect::new(0, 0, 160, 80);
assert!(alpha_sum(&c, area) < alpha_sum(&a, area) * 0.75, "{} vs {}", alpha_sum(&c, area), alpha_sum(&a, area));
}
#[test]
fn brush_projection_foreshortens_along_tilt_and_turns_with_rotation() {
let mk = |on: bool| BrushSettings { size: 40.0, shape_dynamics: ShapeDynamics { enabled: true, brush_projection: on, ..Default::default() }, ..brush() };
// Tilted 60° towards +x: the tip is foreshortened to cos 60° = ½ along x.
let pts = [pen(50.0, 50.0, 60.0, 0.0, 0.0)];
let d = dabs_of(&mk(true), &pts)[0];
assert!((d.proj_scale - 0.5).abs() < 1e-4 && d.proj_angle.abs() < 1e-5, "{d:?}");
let (w, h) = extents(&mk(true), &d);
assert!((w as i32 - 20).abs() <= 2 && h >= 38, "{w}×{h}");
// Tilted along y instead: foreshortened vertically.
let d = dabs_of(&mk(true), &[pen(50.0, 50.0, 0.0, 60.0, 0.0)])[0];
let (w, h) = extents(&mk(true), &d);
assert!(w >= 38 && (h as i32 - 20).abs() <= 2, "{w}×{h}");
// Off: the tilt doesn't touch the tip.
let d = dabs_of(&mk(false), &pts)[0];
assert_eq!((d.proj_scale, d.angle), (1.0, 0.0));
assert_eq!(extents(&mk(false), &d), (40, 40));
// Barrel rotation turns the tip (an elliptical one shows it).
let mut b = mk(true);
b.roundness = 0.5;
let d = dabs_of(&b, &[pen(50.0, 50.0, 0.0, 0.0, 90.0)])[0];
assert!((d.angle.to_degrees() - 90.0).abs() < 1e-3);
let (w, h) = extents(&b, &d);
assert!(w < h, "{w}×{h}");
// Dual-brush dabs are never projected.
let ctx = BrushContext::new(&mk(true));
let dd = Dab { proj_scale: 0.3, ..Dab::round(Point::new(50.0, 50.0), 20.0, 1.0) };
let (r, mut a, mut c) = (ctx.dab_rect(&dd, true), Vec::new(), Vec::new());
ctx.rasterize(&dd, true, r, &mut a);
ctx.rasterize(&Dab { proj_scale: 1.0, ..dd }, true, r, &mut c);
assert_eq!(a, c);
}
#[test]
fn spacing_off_spaces_dabs_by_pointer_speed() {
let mut b = BrushSettings { size: 20.0, spacing_enabled: false, ..brush() };
let timed = |ms: f64| vec![StrokePoint { time: 0.0, ..StrokePoint::new(0.0, 10.0, 1.0) }, StrokePoint { time: ms, ..StrokePoint::new(100.0, 10.0, 1.0) }];
// 100 px in 400 ms: a dab every 8 ms = every 2 px; in 40 ms: every 20 px.
let slow = dabs_of(&b, &timed(400.0)).len();
let fast = dabs_of(&b, &timed(40.0)).len();
assert_eq!((slow, fast), (51, 6));
// Never denser than half a pixel, however slow.
assert!(dabs_of(&b, &timed(1.0e9)).len() <= 201);
// No timestamps: one dab per input point.
let pts: Vec<StrokePoint> = (0..7).map(|i| StrokePoint::new(f64::from(i) * 15.0, 10.0, 1.0)).collect();
assert_eq!(dabs_of(&b, &pts).len(), 7);
// Checked: fixed spacing (25 % of 20 px = 5 px) whatever the speed.
b.spacing_enabled = true;
assert_eq!(dabs_of(&b, &timed(400.0)).len(), dabs_of(&b, &timed(40.0)).len());
// Chunked input gives the same dabs.
b.spacing_enabled = false;
let pts: Vec<StrokePoint> = (0..20).map(|i| StrokePoint { time: f64::from(i) * 13.0, ..StrokePoint::new(f64::from(i) * 7.0, 10.0, 1.0) }).collect();
let one = dabs_of(&b, &pts);
let mut g = crate::dynamics::DabGenerator::new(&b, 1.0);
let (mut out, mut dual) = (Vec::new(), Vec::new());
for c in pts.chunks(3) {
g.push(c, &mut out, &mut dual);
}
g.finish(&mut out, &mut dual);
assert_eq!(one, out);
}
#[test]
fn locked_sections_survive_picking_a_preset() {
let presets = crate::presets::builtin();
let chalk = crate::presets::find(&presets, "chalk").unwrap().brush.clone();
let mut cur = BrushSettings {
scattering: Scattering { enabled: true, count: 5, ..Default::default() },
transfer: Transfer { enabled: true, wetness: Dynamic::jitter(0.4), ..Default::default() },
noise: true,
..brush()
};
// Unlocked: the preset's sections win.
let next = chalk.clone().picked_over(&cur);
assert_eq!(next.scattering, chalk.scattering);
assert_eq!(next.noise, chalk.noise);
// Locked: the current sections stay, and so do the locks.
cur.locks = SectionLocks { scattering: true, transfer: true, noise: true, ..Default::default() };
let next = chalk.clone().picked_over(&cur);
assert_eq!(next.scattering, cur.scattering);
assert_eq!(next.transfer, cur.transfer);
assert!(next.noise);
assert_eq!(next.locks, cur.locks);
// Unlocked sections still come from the preset.
assert_eq!(next.texture, chalk.texture);
assert_eq!(next.tip, chalk.tip);
}
#[test]
fn transfer_wetness_and_mix_vary_per_dab() {
let b = BrushSettings {
transfer: Transfer {
enabled: true,
wetness: Dynamic::controlled(Control::PenPressure),
mix: Dynamic { jitter: 1.0, minimum: 0.25, ..Default::default() },
..Default::default()
},
..brush()
};
let pts = [StrokePoint::new(0.0, 0.0, 0.3), StrokePoint::new(60.0, 0.0, 0.3)];
let d = dabs_of(&b, &pts);
assert!(d.iter().all(|x| (x.wet - 0.3).abs() < 1e-5));
assert!(d.iter().all(|x| (0.25..=1.0).contains(&x.mix)) && d.iter().any(|x| x.mix < 0.9));
// Transfer off: as set.
let d = dabs_of(&brush(), &pts);
assert!(d.iter().all(|x| x.wet == 1.0 && x.mix == 1.0));
// New fields round-trip and old JSON loads with neutral values.
let full = BrushSettings {
spacing_enabled: false,
shape_dynamics: ShapeDynamics { tilt_scale: 1.5, brush_projection: true, ..Default::default() },
locks: SectionLocks { texture: true, ..Default::default() },
mixer: crate::mixer::MixerSettings { wet: 0.9, sample_all_layers: true, ..Default::default() },
..b
};
let back: BrushSettings = serde_json::from_value(serde_json::to_value(&full).unwrap()).unwrap();
assert_eq!(back, full);
let old: BrushSettings = serde_json::from_str(r#"{"size": 12, "shapeDynamics": {"enabled": true}, "transfer": {"enabled": true}}"#).unwrap();
assert!(old.spacing_enabled && !old.shape_dynamics.brush_projection && old.shape_dynamics.tilt_scale == 0.0 && old.locks == SectionLocks::default());
assert_eq!(old.mixer, crate::mixer::MixerSettings::default());
}
+84 -7
View File
@@ -13,6 +13,13 @@ use crate::{Dab, StrokePoint};
/// Maximum pulled-string length at 100 % smoothing, in screen pixels.
pub const MAX_STRING_PX: f64 = 100.0;
/// With Spacing unchecked, one dab per this many milliseconds of stroke time, so faster strokes
/// space their dabs further apart (Photoshop: "the speed of the cursor determines the spacing").
pub const SPEED_SPACING_MS: f64 = 8.0;
/// Densest speed spacing, in pixels between dabs (a slow, long stroke never floods the buffer).
const MIN_SPEED_STEP: f64 = 0.5;
#[inline]
fn lerp_pt(a: &StrokePoint, b: &StrokePoint, f: f64) -> StrokePoint {
let ff = f as f32;
@@ -147,11 +154,32 @@ pub struct PathWalker {
/// Airbrush interval in ms (None = off).
interval: Option<f64>,
time_acc: f64,
/// Speed spacing (Spacing unchecked): one step per this many ms of stroke time.
speed: Option<f64>,
speed_acc: f64,
}
impl PathWalker {
pub fn new(build_up_interval_ms: Option<f64>) -> Self {
Self { last: None, next_at: 0.0, pending_first: None, initial_dir: None, dir: 0.0, step: 0, interval: build_up_interval_ms, time_acc: 0.0 }
Self {
last: None,
next_at: 0.0,
pending_first: None,
initial_dir: None,
dir: 0.0,
step: 0,
interval: build_up_interval_ms,
time_acc: 0.0,
speed: None,
speed_acc: 0.0,
}
}
/// Space steps by stroke time instead of distance ([`SPEED_SPACING_MS`]): faster movement
/// spreads them out. Segments without timestamps get one step at their end point.
pub fn speed_spacing(mut self, interval_ms: f64) -> Self {
self.speed = Some(interval_ms.max(0.1));
self
}
fn emit(&mut self, point: StrokePoint, out: &mut impl FnMut(StepInput)) {
@@ -179,12 +207,36 @@ impl PathWalker {
self.emit(f, out);
}
if len > 1e-9 {
while self.next_at <= len + 1e-9 {
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
self.emit(q, out);
self.next_at += step_len(&q).max(0.25);
match self.speed {
None => {
while self.next_at <= len + 1e-9 {
let q = lerp_pt(&a, &p, (self.next_at / len).min(1.0));
self.emit(q, out);
self.next_at += step_len(&q).max(0.25);
}
self.next_at -= len;
}
Some(iv) => {
let dt = p.time - a.time;
if dt > 0.0 && dt.is_finite() {
self.speed_acc += dt;
let max_n = (len / MIN_SPEED_STEP).ceil().max(1.0) as usize;
let mut n = 0;
while self.speed_acc >= iv && n < max_n {
self.speed_acc -= iv;
let f = ((dt - self.speed_acc) / dt).clamp(0.0, 1.0);
self.emit(lerp_pt(&a, &p, f), out);
n += 1;
}
if n == max_n {
self.speed_acc = self.speed_acc.rem_euclid(iv);
}
} else {
// No timestamps: one dab per input point.
self.emit(p, out);
}
}
}
self.next_at -= len;
}
if let Some(iv) = self.interval {
let dt = p.time - a.time;
@@ -379,6 +431,22 @@ impl DabBuilder {
fy = !fy;
}
}
// Tilt Scale and Brush Projection (Shape Dynamics).
let (mut proj_angle, mut proj_scale) = (0.0, 1.0);
if sd.enabled {
let tilt = s.point.tilt_x.hypot(s.point.tilt_y).clamp(0.0, 90.0);
if sd.size.control == Control::PenTilt && sd.tilt_scale > 0.0 {
let k = 1.0 - sd.tilt_scale.clamp(0.0, 2.0) * 0.5 * tilt / 90.0;
roundness = (roundness * k).clamp(0.01, 1.0);
}
if sd.brush_projection {
angle += s.point.rotation;
if tilt > 0.0 {
proj_angle = (-s.point.tilt_y).atan2(s.point.tilt_x);
proj_scale = tilt.to_radians().cos().max(0.05);
}
}
}
let radius = (diameter / 2.0).max(0.5);
// Scatter.
let mut c = Point::new(s.point.x, s.point.y);
@@ -403,6 +471,7 @@ impl DabBuilder {
opacity = scalar(&tr.opacity, s, r(stream::OPACITY));
flow *= scalar(&tr.flow, s, r(stream::FLOW));
}
let (wet, mix) = if tr.enabled { (scalar(&tr.wetness, s, r(stream::WET)), scalar(&tr.mix, s, r(stream::MIX))) } else { (1.0, 1.0) };
if b.pressure_opacity && !(tr.enabled && tr.opacity.control != Control::Off) {
opacity *= s.point.pressure.clamp(0.0, 1.0);
}
@@ -428,6 +497,10 @@ impl DabBuilder {
color,
depth,
index: i,
proj_angle,
proj_scale,
wet,
mix,
});
}
}
@@ -479,6 +552,10 @@ impl DualBuilder {
color: [0.0, 0.0, 0.0, 1.0],
depth: 1.0,
index: i,
proj_angle: 0.0,
proj_scale: 1.0,
wet: 1.0,
mix: 1.0,
});
}
}
@@ -519,7 +596,7 @@ impl DabGenerator {
let dual_on = brush.dual_brush.enabled;
Self {
smoother: Smoother::new(&brush.smoothing, zoom),
walker: PathWalker::new(interval),
walker: if brush.spacing_enabled { PathWalker::new(interval) } else { PathWalker::new(interval).speed_spacing(SPEED_SPACING_MS) },
dual_walker: dual_on.then(|| PathWalker::new(None)),
builder: DabBuilder::new(brush),
dual: dual_on.then(|| DualBuilder::new(brush)),
+14 -2
View File
@@ -31,9 +31,10 @@ pub mod rng;
pub mod tile;
pub use brush::{
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, ShapeDynamics, Smoothing,
Texture, TipShape, Transfer,
BrushPreset, BrushSettings, ColorDynamics, Control, DualBrush, Dynamic, MaskMode, Pattern, PatternStyle, Pose, Scattering, SectionLocks, ShapeDynamics,
Smoothing, Texture, TipShape, Transfer,
};
pub use mixer::MixerSettings;
pub use render::{BrushContext, StrokeRenderer, render_stroke};
pub use tile::GrayTile;
@@ -94,6 +95,13 @@ pub struct Dab {
pub depth: f32,
/// Position in the stroke (0 = first).
pub index: u64,
/// Brush Projection: direction (radians, counter-clockwise, y up) along which the tip is
/// foreshortened, and the factor (1 = no projection).
pub proj_angle: f32,
pub proj_scale: f32,
/// Mixer Brush multipliers of Wet and Mix (Transfer › Wetness/Mix Jitter), 1 = as set.
pub wet: f32,
pub mix: f32,
}
impl Dab {
@@ -111,6 +119,10 @@ impl Dab {
color: [0.0, 0.0, 0.0, 1.0],
depth: 1.0,
index: 0,
proj_angle: 0.0,
proj_scale: 1.0,
wet: 1.0,
mix: 1.0,
}
}
}
+35 -3
View File
@@ -24,11 +24,13 @@ pub struct MixerSettings {
pub load: f32,
pub mix: f32,
pub flow: f32,
/// Pick up colour from all visible layers instead of the target layer only.
pub sample_all_layers: bool,
}
impl Default for MixerSettings {
fn default() -> Self {
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0 }
Self { wet: 0.5, load: 0.5, mix: 0.5, flow: 1.0, sample_all_layers: false }
}
}
@@ -87,6 +89,8 @@ pub fn apply_mixer_stroke(
let mut acc = vec![0.0f32; n];
let mut paint = vec![0.0f32; n];
let dmg = apply_dab_stroke(target, &st, selection, lock_transparency, 0, |work, fp| {
// Transfer › Wetness / Mix Jitter scale the settings per dab.
let (wet, mix) = (wet * fp.dab.wet.clamp(0.0, 1.0), mix * fp.dab.mix.clamp(0.0, 1.0));
// Sample the canvas under the dab.
acc.iter_mut().for_each(|v| *v = 0.0);
let (mut wsum, mut asum) = (0.0f32, 0.0f32);
@@ -165,7 +169,7 @@ mod tests {
let mut s = Surface::new(PixelFormat::RGBA8);
let mut st = MixerState::default();
st.load([1.0, 0.0, 0.0, 1.0]);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0 }, &mut st, None, false);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 0.0, load, mix: 0.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
let (start, end) = (s.rgba(10, 10)[3], s.rgba(190, 10)[3]);
assert!(start > 0.95, "{start}");
if expect_dry {
@@ -186,7 +190,15 @@ mod tests {
s.fill_rect(Rect::new(100, 0, 200, 20), &[1.0, 1.0, 1.0, 1.0]);
let mut st = MixerState::default();
st.load([1.0, 0.0, 0.0, 1.0]);
apply_mixer_stroke(&mut s, None, &stroke(10.0), &MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0 }, &mut st, None, false);
apply_mixer_stroke(
&mut s,
None,
&stroke(10.0),
&MixerSettings { wet: 1.0, load: 0.3, mix: 0.9, flow: 1.0, ..Default::default() },
&mut st,
None,
false,
);
// Blue is dragged into the white half.
let c = s.rgba(115, 10);
assert!(c[2] > c[1] + 0.05 && c[1] < 0.95, "{c:?}");
@@ -205,4 +217,24 @@ mod tests {
assert!(!dmg.is_empty());
assert_eq!(s.rgba(50, 10)[3], 0.0);
}
#[test]
fn transfer_wetness_control_scales_pickup_per_dab() {
// Wetness on Pen Pressure: no pressure means a dry brush (no pickup), full pressure picks up.
let run = |pressure: f32| {
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 200, 20), &[0.0, 0.0, 1.0, 1.0]);
let mut st = MixerState::default();
st.load([1.0, 0.0, 0.0, 1.0]);
let mut k = stroke(10.0);
k.brush.transfer = crate::Transfer { enabled: true, wetness: crate::Dynamic::controlled(crate::Control::PenPressure), ..Default::default() };
for p in &mut k.points {
p.pressure = pressure;
}
apply_mixer_stroke(&mut s, None, &k, &MixerSettings { wet: 1.0, load: 1.0, mix: 1.0, flow: 1.0, ..Default::default() }, &mut st, None, false);
st.pickup
};
assert!(run(0.0).is_none());
assert!(run(1.0).is_some_and(|p| p[2] > 0.2));
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ fn size_dyn(size: Dynamic) -> ShapeDynamics {
}
fn transfer(opacity: Dynamic, flow: Dynamic) -> Transfer {
Transfer { enabled: true, opacity, flow }
Transfer { enabled: true, opacity, flow, ..Default::default() }
}
fn paper(seed: u32, depth: f32) -> Texture {
+13 -2
View File
@@ -144,6 +144,12 @@ impl BrushContext {
let (cx, cy) = (d.center.x as f32, d.center.y as f32);
let (sn, cs) = d.angle.sin_cos();
let (fx, fy) = (if d.flip_x { -1.0 } else { 1.0 }, if d.flip_y { -1.0 } else { 1.0 });
// Brush Projection: stretch the sampling coordinates along the tilt direction, which
// foreshortens the tip there by `proj_scale`.
let proj = (!dual && d.proj_scale < 0.999).then(|| {
let (ps, pc) = d.proj_angle.sin_cos();
(pc, ps, 1.0 / d.proj_scale.max(0.05) - 1.0)
});
let r = d.radius;
let ro = d.roundness.max(0.5 / r).min(1.0);
let rm = (r * ro).max(0.5);
@@ -163,8 +169,13 @@ impl BrushContext {
for xx in 0..w {
let x = rect.x0 + xx as i32;
let dx = x as f32 + 0.5 - cx;
// To y-up, rotate by -angle, flip.
let (ux, uy) = (dx, -dy);
// To y-up, project, rotate by -angle, flip.
let (mut ux, mut uy) = (dx, -dy);
if let Some((ax, ay, k)) = proj {
let t = (ux * ax + uy * ay) * k;
ux += t * ax;
uy += t * ay;
}
let u = (ux * cs + uy * sn) * fx;
let v = (-ux * sn + uy * cs) * fy;
let (mut val, rn) = match mips {
+2
View File
@@ -53,6 +53,8 @@ pub mod stream {
pub const DUAL_X: u64 = 16;
pub const DUAL_Y: u64 = 17;
pub const DUAL_FLIP: u64 = 18;
pub const WET: u64 = 19;
pub const MIX: u64 = 20;
}
/// Seed derived from arbitrary bytes (FNV-1a then mixed), for commands without an explicit seed.
+1 -1
View File
@@ -48,7 +48,7 @@ ChannelData { id, compression: Option<Compression>, data } // encoded bytes ke
ChannelData::encode(id, compression, decoded, w, h, depth, version)
ChannelData::decode(w, h, depth, version); set_decoded(...)
MaskData::{None, Mask(LayerMask), Raw(Vec<u8>)}
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing }
LayerMask { rect, default_color, flags, parameters: Option<MaskParameters>, real: Option<RealMask>, trailing, real_first }
BlendMode (27 modes + PassThrough + Unknown([u8;4])); BlendMode::key()/from_key()
Layer<'_>::rgba8() -> Result<RgbaImage>; user_mask() -> Option<Result<GrayImage>>; channel_bytes(id)
+101 -8
View File
@@ -176,6 +176,21 @@ pub struct LayerMask {
pub real: Option<RealMask>,
/// Remaining bytes (the 2 padding bytes of the 20-byte form, etc.).
pub trailing: Vec<u8>,
/// When both are present, the real-mask fields come before the mask parameters. Photoshop
/// writes this order (the published spec lists the parameters first); `false` keeps a file
/// that uses the spec's order byte-stable.
#[cfg_attr(feature = "serde", serde(default = "default_true"))]
pub real_first: bool,
}
#[cfg(feature = "serde")]
fn default_true() -> bool {
true
}
/// Byte length of the mask parameter values announced by the parameter flags byte `pf`.
fn mask_parameter_bytes(pf: u8) -> usize {
usize::from(pf & 1 != 0) + 8 * usize::from(pf & 2 != 0) + usize::from(pf & 4 != 0) + 8 * usize::from(pf & 8 != 0)
}
impl LayerMask {
@@ -190,19 +205,34 @@ impl LayerMask {
/// The simple 20-byte form.
pub fn new(rect: Rect, default_color: u8, flags: u8) -> Self {
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0] }
LayerMask { rect, default_color, flags: flags & !Self::FLAG_PARAMETERS, parameters: None, real: None, trailing: vec![0, 0], real_first: true }
}
/// `true` if the disabled flag is set.
pub fn disabled(&self) -> bool {
self.flags & Self::FLAG_DISABLED != 0
}
/// Whether `d` (a whole mask record with mask parameters) stores the real-mask fields before
/// the parameters, as Photoshop does. The spec's order wins when it reads as parameters
/// followed by padding alone (no real mask), the one layout the two orders can share.
fn real_before_parameters(d: &[u8]) -> bool {
let fits =
|pf_at: usize| d.get(pf_at).is_some_and(|&pf| (pf_at + 1 + mask_parameter_bytes(pf)..=pf_at + 4 + mask_parameter_bytes(pf)).contains(&d.len()));
!fits(18) && fits(36)
}
fn parse(d: &[u8]) -> Result<Self> {
let mut r = Reader::new(d);
let rect = Rect::read(&mut r)?;
let default_color = r.u8()?;
let flags = r.u8()?;
let parameters = if flags & Self::FLAG_PARAMETERS != 0 {
let read_real = |r: &mut Reader<'_>| -> Result<Option<RealMask>> {
Ok(if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(r)? }) } else { None })
};
let has_parameters = flags & Self::FLAG_PARAMETERS != 0;
let real_first = has_parameters && Self::real_before_parameters(d);
let mut real = if real_first { read_real(&mut r)? } else { None };
let parameters = if has_parameters {
let pf = r.u8()?;
Some(MaskParameters {
flags: pf,
@@ -214,14 +244,28 @@ impl LayerMask {
} else {
None
};
let real = if r.remaining() >= 18 { Some(RealMask { flags: r.u8()?, background: r.u8()?, rect: Rect::read(&mut r)? }) } else { None };
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec() })
if !real_first {
real = read_real(&mut r)?;
}
// The order only matters when both are present; otherwise default to Photoshop's.
let real_first = real_first || real.is_none() || parameters.is_none();
Ok(LayerMask { rect, default_color, flags, parameters, real, trailing: r.peek_rest().to_vec(), real_first })
}
fn write(&self, out: &mut Vec<u8>) {
self.rect.write(out);
out.put_u8(self.default_color);
out.put_u8(self.flags);
let write_real = |out: &mut Vec<u8>| {
if let Some(real) = &self.real {
out.put_u8(real.flags);
out.put_u8(real.background);
real.rect.write(out);
}
};
if self.real_first {
write_real(out);
}
if let Some(p) = &self.parameters {
out.put_u8(p.flags);
if let Some(v) = p.user_density {
@@ -237,10 +281,8 @@ impl LayerMask {
out.put_f64(v);
}
}
if let Some(real) = &self.real {
out.put_u8(real.flags);
out.put_u8(real.background);
real.rect.write(out);
if !self.real_first {
write_real(out);
}
out.put(&self.trailing);
}
@@ -755,6 +797,57 @@ mod tests {
}
}
/// Photoshop stores the real-mask fields before the mask parameters (psd-tools
/// mask-density-layervectormask.psd): user + vector mask with both densities set.
#[test]
fn real_mask_before_parameters_photoshop_layout() {
let mut d = Vec::new();
Rect::from_xywh(15, -1, 18, 10).write(&mut d);
d.extend([0, LayerMask::FLAG_PARAMETERS | 8]);
d.extend([0, 255]);
Rect::from_xywh(0, 0, 32, 8).write(&mut d);
d.extend([0b0101, 64, 64, 0]);
let m = LayerMask::parse(&d).unwrap();
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 32, 8)));
assert_eq!(m.real.map(|r| r.background), Some(255));
let p = m.parameters.unwrap();
assert_eq!((p.user_density, p.vector_density), (Some(64), Some(64)));
assert_eq!(m.trailing, vec![0]);
assert!(m.real_first);
let mut out = Vec::new();
m.write(&mut out);
assert_eq!(out, d);
}
/// The spec's order (parameters, then real fields) still parses and stays byte-stable.
#[test]
fn real_mask_after_parameters_spec_layout() {
let mut d = Vec::new();
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
d.extend([255, LayerMask::FLAG_PARAMETERS]);
d.extend([0b0011, 200]);
d.extend(1.5f64.to_be_bytes());
d.extend([0, 255]);
Rect::from_xywh(0, 0, 3, 3).write(&mut d);
let m = LayerMask::parse(&d).unwrap();
assert!(!m.real_first);
assert_eq!(m.real.map(|r| r.rect), Some(Rect::from_xywh(0, 0, 3, 3)));
assert_eq!(m.parameters.unwrap().user_feather, Some(1.5));
let mut out = Vec::new();
m.write(&mut out);
assert_eq!(out, d);
// Parameters alone (no real mask) keep reading in the spec's order.
let mut d = Vec::new();
Rect::from_xywh(1, 2, 3, 4).write(&mut d);
d.extend([255, LayerMask::FLAG_PARAMETERS, 0b1010]);
d.extend(1.0f64.to_be_bytes());
d.extend(2.0f64.to_be_bytes());
d.extend([0, 0]);
let m = LayerMask::parse(&d).unwrap();
assert_eq!(m.real, None);
assert_eq!(m.parameters.map(|p| (p.user_feather, p.vector_feather)), Some((Some(1.0), Some(2.0))));
}
#[test]
fn mask_sizes() {
let mut out = Vec::new();
+1
View File
@@ -281,6 +281,7 @@ pub fn layered(version: Version, mode: ColorMode, depth: u16, compression: Compr
parameters: Some(MaskParameters { flags: 0b0011, user_density: Some(200), user_feather: Some(1.5), vector_density: None, vector_feather: None }),
real: Some(RealMask { flags: 0, background: 255, rect: Rect::from_xywh(0, 0, 3, 3) }),
trailing: Vec::new(),
real_first: true,
};
next(&mut layers, Rect::from_xywh(3, 3, 6, 4), "Grüße", BlendMode::ColorDodge, Some(real_mask));
layers.push(group_record(&header, "Inner", SectionType::ClosedFolder, BlendMode::Normal, id0 + 2));
+13 -7
View File
@@ -1,6 +1,7 @@
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` at the
//! workspace root when present (fetched by `xtask corpus`; never committed).
//! Skips silently when the directory does not exist.
//! Optional corpus test: iterates `corpus/psd/**/*.{psd,psb}` and
//! `corpus/psd-tools/**/*.{psd,psb}` (the full psd-tools set, fetched by
//! `cargo xtask corpus --psd-tools`) at the workspace root when present
//! (never committed). Skips silently when neither directory exists.
use std::path::{Path, PathBuf};
@@ -24,16 +25,21 @@ const KNOWN_BAD: &[(&str, &str)] = &[("group-divider-blend-mode.psd", "psd-tools
#[test]
fn corpus_parse_and_byte_stable() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd");
for dir in ["corpus/psd", "corpus/psd-tools"] {
parse_and_byte_stable(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").join(dir));
}
}
fn parse_and_byte_stable(root: &Path) {
if !root.is_dir() {
return;
}
let mut files = Vec::new();
collect(&root, &mut files);
collect(root, &mut files);
files.sort();
let mut failures = Vec::new();
for p in &files {
let name = p.strip_prefix(&root).unwrap_or(p).display().to_string();
let name = p.strip_prefix(root).unwrap_or(p).display().to_string();
let bytes = match std::fs::read(p) {
Ok(b) => b,
Err(e) => {
@@ -61,6 +67,6 @@ fn corpus_parse_and_byte_stable() {
},
}
}
eprintln!("corpus: {} files, {} failures", files.len(), failures.len());
eprintln!("corpus {}: {} files, {} failures", root.display(), files.len(), failures.len());
assert!(failures.is_empty(), "corpus failures:\n{}", failures.join("\n"));
}
+5
View File
@@ -11,3 +11,8 @@ workspace = true
photocraft-geom = { workspace = true }
photocraft-color = { workspace = true }
photocraft-raster = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# In-process resident memory for the benchmarks (`perf`; MIT OR Apache-2.0).
memory-stats = "1.2"
+4
View File
@@ -1 +1,5 @@
//! photocraft-testkit
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(not(target_arch = "wasm32"))]
pub mod perf;
+194
View File
@@ -0,0 +1,194 @@
//! Benchmark helpers shared by the `--json` benches that `cargo xtask perf` runs (#221).
//!
//! - [`RssSampler`]: peak resident memory measured in-process. A background thread polls the
//! process's resident set size every few milliseconds; [`RssSampler::reset`] starts a new
//! window, so each scenario gets its own peak. Polling can miss a spike shorter than the
//! interval; on Linux [`process_peak_rss_bytes`] also reads the kernel's exact high-water mark.
//! - [`Summary`]: p50 / p95 / max of a scenario's samples (nearest-rank percentiles).
//! - [`row`] / [`report`]: the JSON shape `xtask perf` reads.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;
use serde_json::{Value, json};
/// Current resident set size of this process, in bytes.
pub fn current_rss_bytes() -> Option<u64> {
memory_stats::memory_stats().map(|m| m.physical_mem as u64)
}
/// The process's peak resident set size so far: the kernel's high-water mark on Linux
/// (`VmHWM`), `None` elsewhere (use an [`RssSampler`]).
pub fn process_peak_rss_bytes() -> Option<u64> {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
parse_vm_hwm(&status)
}
fn parse_vm_hwm(status: &str) -> Option<u64> {
let line = status.lines().find(|l| l.starts_with("VmHWM:"))?;
let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
kb.checked_mul(1024)
}
/// Polls the resident set size on a background thread and keeps the maximum.
pub struct RssSampler {
peak: Arc<AtomicU64>,
stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl RssSampler {
/// Starts sampling every `interval` (2–5 ms is cheap: one syscall per sample).
pub fn start(interval: Duration) -> Self {
let peak = Arc::new(AtomicU64::new(current_rss_bytes().unwrap_or(0)));
let stop = Arc::new(AtomicBool::new(false));
let (p, s) = (Arc::clone(&peak), Arc::clone(&stop));
let thread = std::thread::Builder::new()
.name("rss-sampler".into())
.spawn(move || {
while !s.load(Ordering::Relaxed) {
if let Some(v) = current_rss_bytes() {
p.fetch_max(v, Ordering::Relaxed);
}
std::thread::sleep(interval);
}
})
.ok();
Self { peak, stop, thread }
}
/// Starts a new window: the peak becomes the current resident size.
pub fn reset(&self) {
self.peak.store(current_rss_bytes().unwrap_or(0), Ordering::Relaxed);
}
/// The peak resident size since the last [`reset`](Self::reset) (or start), including now.
pub fn peak(&self) -> Option<u64> {
if let Some(v) = current_rss_bytes() {
self.peak.fetch_max(v, Ordering::Relaxed);
}
Some(self.peak.load(Ordering::Relaxed)).filter(|v| *v > 0)
}
}
impl Drop for RssSampler {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
// A sampler thread that panicked has nothing to report; don't cascade.
let _ = t.join();
}
}
}
/// p50 / p95 / max of a set of timings (ms).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Summary {
pub n: usize,
pub p50: f64,
pub p95: f64,
pub max: f64,
}
/// Nearest-rank percentile `q` (0..=1) of sorted, non-empty `v`.
fn rank(v: &[f64], q: f64) -> Option<f64> {
let n = v.len();
let k = ((q * n as f64).ceil() as usize).clamp(1, n.max(1));
v.get(k - 1).copied()
}
impl Summary {
/// `None` when there are no finite samples.
pub fn of(samples: &[f64]) -> Option<Self> {
let mut v: Vec<f64> = samples.iter().copied().filter(|x| x.is_finite()).collect();
v.sort_by(f64::total_cmp);
Some(Self { n: v.len(), p50: rank(&v, 0.5)?, p95: rank(&v, 0.95)?, max: *v.last()? })
}
}
/// One scenario row: `name`, sample count, p50/p95/max, the raw samples, peak RSS and GPU bytes.
pub fn row(name: &str, samples: &[f64], peak_rss: Option<u64>, gpu_bytes: Option<u64>) -> Value {
let s = Summary::of(samples);
json!({
"name": name,
"n": s.map_or(0, |s| s.n),
"p50_ms": s.map(|s| s.p50),
"p95_ms": s.map(|s| s.p95),
"max_ms": s.map(|s| s.max),
"samples_ms": samples,
"peak_rss_bytes": peak_rss,
"gpu_bytes": gpu_bytes,
})
}
/// A bench's whole report: its name, free-form context (document size, adapter…), the rows and
/// the process's peak RSS (the kernel's high-water mark where there is one, else the highest
/// sampled peak).
pub fn report(bench: &str, context: Value, rows: Vec<Value>, sampler: Option<&RssSampler>) -> Value {
let sampled = rows.iter().filter_map(|r| r["peak_rss_bytes"].as_u64()).chain(sampler.and_then(RssSampler::peak)).max();
let process_peak = process_peak_rss_bytes().or(sampled);
json!({
"bench": bench,
"schema": 1,
"context": context,
"rows": rows,
"process_peak_rss_bytes": process_peak,
})
}
/// Writes `report` to `path` (pretty JSON). Errors are returned, not panicked.
pub fn write_report(path: &str, report: &Value) -> Result<(), String> {
let text = serde_json::to_string_pretty(report).map_err(|e| format!("encode {path}: {e}"))?;
std::fs::write(path, text).map_err(|e| format!("write {path}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_nearest_rank() {
let v: Vec<f64> = (1..=20).map(f64::from).collect();
let s = Summary::of(&v).unwrap();
assert_eq!((s.n, s.p50, s.p95, s.max), (20, 10.0, 19.0, 20.0));
let s = Summary::of(&[3.0]).unwrap();
assert_eq!((s.p50, s.p95, s.max), (3.0, 3.0, 3.0));
assert!(Summary::of(&[]).is_none());
assert!(Summary::of(&[f64::NAN]).is_none());
}
#[test]
fn vm_hwm_parses() {
assert_eq!(parse_vm_hwm("Name:\tx\nVmHWM:\t 2048 kB\nVmRSS: 1 kB\n"), Some(2048 * 1024));
assert_eq!(parse_vm_hwm("VmHWM: lots\n"), None);
assert_eq!(parse_vm_hwm(""), None);
}
#[test]
fn sampler_reports_a_peak() {
let s = RssSampler::start(Duration::from_millis(1));
s.reset();
let big = vec![1u8; 32 << 20];
std::hint::black_box(&big);
let p = s.peak();
drop(big);
// Platforms without a memory API report nothing rather than a made-up number.
if current_rss_bytes().is_some() {
assert!(p.unwrap() > 0);
}
}
#[test]
fn row_shape() {
let r = row("x", &[2.0, 1.0, 3.0], Some(10), None);
assert_eq!(r["p50_ms"], 2.0);
assert_eq!(r["max_ms"], 3.0);
assert_eq!(r["n"], 3);
assert!(r["gpu_bytes"].is_null());
let empty = row("y", &[], None, None);
assert!(empty["p95_ms"].is_null());
}
}
+480
View File
@@ -0,0 +1,480 @@
//! CJK font fallback: which installed system fonts cover Japanese, Simplified Chinese,
//! Traditional Chinese and Korean, and in which order to try them for the user's locale.
//!
//! Han characters are shared between the four, but their preferred glyph forms differ, so the
//! font for the locale's script goes first: Japanese forms only for a Japanese locale, Chinese
//! forms for zh-Hans / zh-Hant, Korean (Hanja-capable) fonts for ko. Kana always prefers a
//! Japanese font, Hangul a Korean one and Bopomofo a Traditional Chinese one.
//!
//! Used by the egui shell (UI fallback fonts, loaded lazily) and by [`crate::fonts::FontDb`]
//! (Type tool fallback families).
/// A CJK writing system with its own preferred fonts.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CjkScript {
Japanese,
SimplifiedChinese,
TraditionalChinese,
Korean,
}
/// The kind of CJK character that needs a fallback font.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum CjkChar {
/// Ideographs, CJK punctuation and full-width forms: font choice follows the locale.
Han,
Kana,
Hangul,
Bopomofo,
}
impl CjkChar {
/// The script whose fonts should be tried first for this character.
pub fn preferred(self, order: &[CjkScript; 4]) -> CjkScript {
match self {
CjkChar::Kana => CjkScript::Japanese,
CjkChar::Hangul => CjkScript::Korean,
CjkChar::Bopomofo => CjkScript::TraditionalChinese,
CjkChar::Han => order[0],
}
}
}
/// Classifies a character that needs a CJK font, or `None` for everything else.
pub fn classify(c: char) -> Option<CjkChar> {
let u = c as u32;
Some(match u {
0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7AF | 0xD7B0..=0xD7FF | 0xFFA0..=0xFFDC => CjkChar::Hangul,
0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF65..=0xFF9F | 0x1B000..=0x1B16F => CjkChar::Kana,
0x3100..=0x312F | 0x31A0..=0x31BF => CjkChar::Bopomofo,
0x2E80..=0x2FDF | 0x3000..=0x303F | 0x3190..=0x319F | 0x31C0..=0x31EF | 0x3200..=0x33FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF => CjkChar::Han,
0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFF64 | 0xFFE0..=0xFFEF | 0x20000..=0x3FFFF => CjkChar::Han,
_ => return None,
})
}
/// Script order for a locale tag such as `ja_JP.UTF-8`, `zh-Hant-TW`, `zh_CN` or `ko-KR`.
/// Non-CJK (or unknown) locales get Simplified Chinese first, so Japanese forms only win
/// for shared Han when the locale is Japanese.
pub fn script_order(locale: Option<&str>) -> [CjkScript; 4] {
use CjkScript::*;
let tag = locale.unwrap_or("").split(['.', '@']).next().unwrap_or("").to_ascii_lowercase().replace('_', "-");
let mut parts = tag.split('-');
let lang = parts.next().unwrap_or("");
let rest: Vec<&str> = parts.collect();
match lang {
"ja" => [Japanese, SimplifiedChinese, TraditionalChinese, Korean],
"ko" => [Korean, TraditionalChinese, SimplifiedChinese, Japanese],
"zh" | "yue" => {
let hant = rest.iter().any(|p| matches!(*p, "hant" | "tw" | "hk" | "mo"));
let hans = rest.iter().any(|p| matches!(*p, "hans" | "cn" | "sg"));
if hant && !hans || lang == "yue" && !hans {
[TraditionalChinese, SimplifiedChinese, Japanese, Korean]
} else {
[SimplifiedChinese, TraditionalChinese, Japanese, Korean]
}
}
_ => [SimplifiedChinese, TraditionalChinese, Japanese, Korean],
}
}
/// The user's UI locale (cached). `PHOTOCRAFT_LOCALE` overrides; then macOS's preferred
/// language list, the Windows user locale, and finally `LC_ALL` / `LC_MESSAGES` / `LANG` /
/// `LANGUAGE`. `None` on the web or when nothing is set.
pub fn ui_locale() -> Option<&'static str> {
static LOCALE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
LOCALE.get_or_init(detect_locale).as_deref()
}
/// [`script_order`] for [`ui_locale`].
pub fn ui_script_order() -> [CjkScript; 4] {
script_order(ui_locale())
}
fn detect_locale() -> Option<String> {
let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty() && v != "C" && v != "POSIX" && !v.starts_with("C."));
if let Some(v) = env("PHOTOCRAFT_LOCALE") {
return Some(v);
}
if let Some(v) = os_locale() {
return Some(v);
}
["LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"].iter().find_map(|k| env(k)).map(|v| v.split(':').next().unwrap_or("").to_string())
}
#[cfg(target_os = "macos")]
fn os_locale() -> Option<String> {
let home = std::env::var_os("HOME")?;
let path = std::path::Path::new(&home).join("Library/Preferences/.GlobalPreferences.plist");
let meta = std::fs::metadata(&path).ok()?;
if meta.len() > 4 << 20 {
return None;
}
let bytes = std::fs::read(path).ok()?;
bplist_first_language(&bytes)
}
#[cfg(target_os = "windows")]
fn os_locale() -> Option<String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let out = std::process::Command::new("reg")
.args(["query", "HKCU\\Control Panel\\International", "/v", "LocaleName"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
// " LocaleName REG_SZ ja-JP"
text.lines().find(|l| l.contains("LocaleName")).and_then(|l| l.split_whitespace().last()).map(str::to_string)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn os_locale() -> Option<String> {
None
}
/// First entry of `AppleLanguages` in a binary property list (macOS `.GlobalPreferences.plist`).
/// A minimal, bounds-checked reader of the documented `bplist00` format; `None` on anything else.
pub fn bplist_first_language(b: &[u8]) -> Option<String> {
if b.len() < 40 || !b.starts_with(b"bplist00") {
return None;
}
let t = b.get(b.len() - 32..)?;
let offset_size = *t.get(6)? as usize;
let ref_size = *t.get(7)? as usize;
let be = |s: &[u8]| s.iter().fold(0u64, |a, &x| (a << 8) | x as u64);
let num_objects = usize::try_from(be(t.get(8..16)?)).ok()?;
let top = usize::try_from(be(t.get(16..24)?)).ok()?;
let table = usize::try_from(be(t.get(24..32)?)).ok()?;
if !(1..=8).contains(&offset_size) || !(1..=8).contains(&ref_size) || num_objects > b.len() {
return None;
}
let offset = |obj: usize| -> Option<usize> {
if obj >= num_objects {
return None;
}
let at = table.checked_add(obj.checked_mul(offset_size)?)?;
usize::try_from(be(b.get(at..at.checked_add(offset_size)?)?)).ok()
};
// (marker high nibble, count, start of payload)
let header = |obj: usize| -> Option<(u8, usize, usize)> {
let at = offset(obj)?;
let m = *b.get(at)?;
let (kind, low) = (m >> 4, (m & 0x0F) as usize);
if low != 0x0F || !matches!(kind, 0x5 | 0x6 | 0xA | 0xD) {
return Some((kind, low, at + 1));
}
let im = *b.get(at + 1)?;
if im >> 4 != 0x1 {
return None;
}
let n = 1usize.checked_shl((im & 0x0F) as u32)?;
let count = usize::try_from(be(b.get(at + 2..at.checked_add(2 + n)?)?)).ok()?;
Some((kind, count, at + 2 + n))
};
let string = |obj: usize| -> Option<String> {
let (kind, n, start) = header(obj)?;
match kind {
0x5 => b.get(start..start.checked_add(n)?).map(|s| String::from_utf8_lossy(s).into_owned()),
0x6 => {
let s = b.get(start..start.checked_add(n.checked_mul(2)?)?)?;
Some(String::from_utf16_lossy(&s.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect::<Vec<_>>()))
}
_ => None,
}
};
let obj_ref = |at: usize| -> Option<usize> { usize::try_from(be(b.get(at..at.checked_add(ref_size)?)?)).ok() };
let (kind, n, start) = header(top)?;
if kind != 0xD {
return None;
}
for i in 0..n {
let key = obj_ref(start.checked_add(i.checked_mul(ref_size)?)?)?;
if string(key).as_deref() != Some("AppleLanguages") {
continue;
}
let val = obj_ref(start.checked_add(n.checked_add(i)?.checked_mul(ref_size)?)?)?;
let (akind, an, astart) = header(val)?;
if akind != 0xA || an == 0 {
return None;
}
return string(obj_ref(astart)?).filter(|s| !s.is_empty());
}
None
}
/// Family names (as the Type tool's font database knows them) per script, most preferred first.
pub fn families(script: CjkScript) -> &'static [&'static str] {
match script {
CjkScript::Japanese => &["Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP", "IPAexGothic"],
CjkScript::SimplifiedChinese => {
&["PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", "Noto Sans SC", "WenQuanYi Micro Hei", "WenQuanYi Zen Hei", "Heiti SC"]
}
CjkScript::TraditionalChinese => &["PingFang TC", "Microsoft JhengHei", "Noto Sans CJK TC", "Noto Sans TC", "Hiragino Sans CNS", "Heiti TC"],
CjkScript::Korean => &["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR", "NanumGothic", "UnDotum"],
}
}
/// An installed font file to try: its path and the family of the wanted face in a collection
/// (empty: face 0). Matching by family name keeps working when a `.ttc` reorders its faces.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontFile {
pub path: std::path::PathBuf,
pub family: &'static str,
}
/// System font files for `script`, most preferred first. Paths that don't exist are included;
/// callers skip them.
#[cfg(not(target_arch = "wasm32"))]
pub fn font_files(script: CjkScript) -> Vec<FontFile> {
use CjkScript::*;
let f = |p: &str, family: &'static str| FontFile { path: p.into(), family };
if cfg!(target_os = "macos") {
let pingfang = |family| mac_pingfang().map(|path| FontFile { path, family });
match script {
Japanese => vec![f("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc", ""), f("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", "")],
SimplifiedChinese => pingfang("PingFang SC")
.into_iter()
.chain([f("/System/Library/Fonts/Hiragino Sans GB.ttc", ""), f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti SC")])
.collect(),
TraditionalChinese => pingfang("PingFang TC").into_iter().chain([f("/System/Library/Fonts/STHeiti Light.ttc", "Heiti TC")]).collect(),
Korean => vec![f("/System/Library/Fonts/AppleSDGothicNeo.ttc", "Apple SD Gothic Neo")],
}
} else if cfg!(target_os = "windows") {
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
let w = |file: &str, family: &'static str| FontFile { path: std::path::Path::new(&dir).join("Fonts").join(file), family };
match script {
Japanese => vec![w("YuGothR.ttc", "Yu Gothic"), w("YuGothM.ttc", "Yu Gothic"), w("meiryo.ttc", "Meiryo"), w("msgothic.ttc", "")],
SimplifiedChinese => vec![w("msyh.ttc", "Microsoft YaHei"), w("msyh.ttf", ""), w("simsun.ttc", "")],
TraditionalChinese => vec![w("msjh.ttc", "Microsoft JhengHei"), w("msjh.ttf", ""), w("mingliu.ttc", "")],
Korean => vec![w("malgun.ttf", ""), w("gulim.ttc", "")],
}
} else {
// One Noto Sans CJK collection covers all four scripts; the face picks the glyph forms.
let noto = match script {
Japanese => "Noto Sans CJK JP",
SimplifiedChinese => "Noto Sans CJK SC",
TraditionalChinese => "Noto Sans CJK TC",
Korean => "Noto Sans CJK KR",
};
let mut v: Vec<FontFile> = [
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
// FreeBSD ports (x11-fonts/noto-sans-cjk and friends) install under /usr/local.
"/usr/local/share/fonts/noto/NotoSansCJK-Regular.ttc",
"/usr/local/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/local/share/fonts/noto/NotoSansCJK-VF.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts/NotoSansCJK-VF.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-VF.ttc",
]
.iter()
.map(|p| f(p, noto))
.collect();
v.extend(match script {
Japanese => vec![
f("/usr/share/fonts/opentype/ipafont-gothic/ipag.ttf", ""),
f("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf", ""),
f("/usr/share/fonts/truetype/takao-gothic/TakaoPGothic.ttf", ""),
],
SimplifiedChinese | TraditionalChinese => vec![
f("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", ""),
f("/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc", ""),
f("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", ""),
f("/usr/share/fonts/wenquanyi/wqy-zenhei/wqy-zenhei.ttc", ""),
],
Korean => vec![
f("/usr/share/fonts/truetype/nanum/NanumGothic.ttf", ""),
f("/usr/share/fonts/nanum/NanumGothic.ttf", ""),
f("/usr/share/fonts/truetype/unfonts-core/UnDotum.ttf", ""),
f("/usr/share/fonts/un-core/UnDotum.ttf", ""),
],
});
v
}
}
/// Broad-coverage fonts tried after every script's own fonts (they cover Han, kana and Hangul
/// but look worse than the script fonts).
#[cfg(not(target_arch = "wasm32"))]
pub fn last_resort_files() -> Vec<FontFile> {
let f = |p: &str| FontFile { path: p.into(), family: "" };
if cfg!(target_os = "macos") {
vec![f("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"), f("/Library/Fonts/Arial Unicode.ttf")]
} else if cfg!(target_os = "windows") {
let dir = std::env::var("WINDIR").unwrap_or_else(|_| "C:\\Windows".into());
vec![FontFile { path: std::path::Path::new(&dir).join("Fonts").join("ARIALUNI.TTF"), family: "" }]
} else {
vec![f("/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf"), f("/usr/share/fonts/google-droid-sans-fonts/DroidSansFallbackFull.ttf")]
}
}
/// macOS ships PingFang as a pre-installed "mobile asset" outside `/System/Library/Fonts`
/// (`/System/Library/AssetsV2/com_apple_MobileAsset_Font*/<hash>.asset/AssetData/PingFang.ttc`).
/// Looks there (a few small directory listings) and in the pre-Catalina location.
#[cfg(not(target_arch = "wasm32"))]
pub fn mac_pingfang() -> Option<std::path::PathBuf> {
use std::path::{Path, PathBuf};
let old = PathBuf::from("/System/Library/Fonts/PingFang.ttc");
if old.is_file() {
return Some(old);
}
let roots = [Path::new("/System/Library/AssetsV2"), Path::new("/System/Library/AssetsV2/PreinstalledAssetsV2/InstallWithOs")];
for root in roots {
let Ok(rd) = std::fs::read_dir(root) else { continue };
for e in rd.flatten().take(512) {
if !e.file_name().to_string_lossy().starts_with("com_apple_MobileAsset_Font") {
continue;
}
let Ok(assets) = std::fs::read_dir(e.path()) else { continue };
for a in assets.flatten().take(4096) {
let p = a.path().join("AssetData/PingFang.ttc");
if p.is_file() {
return Some(p);
}
}
}
}
None
}
/// Face index in a font file (or collection) whose family name is `family`; 0 when `family` is
/// empty or no face matches. Never panics on malformed data.
pub fn face_index_for_family(bytes: &[u8], family: &str) -> u32 {
use skrifa::{MetadataProvider, raw::FileRef, string::StringId};
if family.is_empty() {
return 0;
}
let Ok(FileRef::Collection(c)) = FileRef::new(bytes) else { return 0 };
for (i, f) in c.iter().enumerate() {
let Ok(f) = f else { continue };
let named = [StringId::TYPOGRAPHIC_FAMILY_NAME, StringId::FAMILY_NAME].iter().any(|id| f.localized_strings(*id).any(|s| s.chars().eq(family.chars())));
if named {
return i as u32;
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use CjkScript::*;
#[test]
fn locale_orders() {
assert_eq!(script_order(Some("ja_JP.UTF-8"))[0], Japanese);
assert_eq!(script_order(Some("ja"))[0], Japanese);
assert_eq!(script_order(Some("zh_CN.UTF-8"))[0], SimplifiedChinese);
assert_eq!(script_order(Some("zh-Hans-CN"))[0], SimplifiedChinese);
assert_eq!(script_order(Some("zh-Hant-TW"))[0], TraditionalChinese);
assert_eq!(script_order(Some("zh_TW"))[0], TraditionalChinese);
assert_eq!(script_order(Some("zh-HK"))[0], TraditionalChinese);
assert_eq!(script_order(Some("zh-Hans-HK"))[0], SimplifiedChinese);
assert_eq!(script_order(Some("ko-KR"))[0], Korean);
// Non-CJK and unknown: Japanese forms are never first.
for l in [None, Some(""), Some("en_US.UTF-8"), Some("C"), Some("de"), Some("!!@@")] {
let o = script_order(l);
assert_eq!(o[0], SimplifiedChinese);
assert_ne!(o[0], Japanese);
}
// Every order is a permutation of all four scripts.
for l in ["ja", "ko", "zh-TW", "zh", "en"] {
let o = script_order(Some(l));
for s in [Japanese, SimplifiedChinese, TraditionalChinese, Korean] {
assert!(o.contains(&s), "{l}: {o:?}");
}
}
}
#[test]
fn type_tool_fallback_follows_locale() {
let pos = |v: &[&str], f: &str| v.iter().position(|x| *x == f).unwrap();
let ja = crate::fonts::fallback_candidates(&script_order(Some("ja_JP")));
assert!(pos(&ja, "Hiragino Sans") < pos(&ja, "PingFang SC"));
assert!(pos(&ja, "Noto Sans CJK JP") < pos(&ja, "Noto Sans CJK SC"));
let sc = crate::fonts::fallback_candidates(&script_order(Some("zh_CN")));
assert!(pos(&sc, "PingFang SC") < pos(&sc, "Hiragino Sans"));
assert!(pos(&sc, "Microsoft YaHei") < pos(&sc, "Microsoft JhengHei"));
let tc = crate::fonts::fallback_candidates(&script_order(Some("zh-Hant")));
assert!(pos(&tc, "PingFang TC") < pos(&tc, "PingFang SC"));
let ko = crate::fonts::fallback_candidates(&script_order(Some("ko")));
assert!(pos(&ko, "Apple SD Gothic Neo") < pos(&ko, "PingFang SC"));
assert!(pos(&ko, "Malgun Gothic") < pos(&ko, "Yu Gothic"));
// Arial Unicode covers Han too: it must not pre-empt the script fonts.
for v in [&ja, &sc, &tc, &ko] {
assert!(pos(v, "Arial Unicode MS") > pos(v, "Noto Sans CJK KR"));
assert!(pos(v, "Noto Sans") < pos(v, "Hiragino Sans"));
}
}
#[test]
fn classify_chars() {
let order = script_order(Some("en"));
assert_eq!(classify('a'), None);
assert_eq!(classify('é'), None);
assert_eq!(classify('图'), Some(CjkChar::Han));
assert_eq!(classify('圖'), Some(CjkChar::Han));
assert_eq!(classify('レ'), Some(CjkChar::Kana));
assert_eq!(classify('ー'), Some(CjkChar::Kana));
assert_eq!(classify('카'), Some(CjkChar::Hangul));
assert_eq!(classify('ㄅ'), Some(CjkChar::Bopomofo));
assert_eq!(CjkChar::Kana.preferred(&order), Japanese);
assert_eq!(CjkChar::Hangul.preferred(&order), Korean);
assert_eq!(CjkChar::Han.preferred(&order), SimplifiedChinese);
assert_eq!(CjkChar::Han.preferred(&script_order(Some("ja"))), Japanese);
}
/// `plutil -convert binary1` of a dict with AKLastLocale, AppleLanguages
/// [zh-Hant-TW, en-US] and AppleLocale.
const SAMPLE: &[u8] = &[
0x62, 0x70, 0x6c, 0x69, 0x73, 0x74, 0x30, 0x30, 0xd3, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x5b, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x6f, 0x63, 0x61,
0x6c, 0x65, 0x5c, 0x41, 0x4b, 0x4c, 0x61, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x65, 0x5e, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x61, 0x6e, 0x67,
0x75, 0x61, 0x67, 0x65, 0x73, 0x55, 0x7a, 0x68, 0x5f, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x5f, 0x55, 0x53, 0xa2, 0x07, 0x08, 0x5a, 0x7a, 0x68, 0x2d, 0x48,
0x61, 0x6e, 0x74, 0x2d, 0x54, 0x57, 0x55, 0x65, 0x6e, 0x2d, 0x55, 0x53, 0x08, 0x0f, 0x1b, 0x28, 0x37, 0x3d, 0x43, 0x46, 0x51, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x57,
];
#[test]
fn reads_apple_languages_and_rejects_garbage() {
assert_eq!(bplist_first_language(SAMPLE).as_deref(), Some("zh-Hant-TW"));
assert_eq!(script_order(bplist_first_language(SAMPLE).as_deref())[0], TraditionalChinese);
assert_eq!(bplist_first_language(b""), None);
assert_eq!(bplist_first_language(b"bplist00"), None);
assert_eq!(bplist_first_language(&[0xFF; 64]), None);
// Every truncation and every single-byte corruption must fail gracefully, never panic.
for n in 0..SAMPLE.len() {
let _ = bplist_first_language(&SAMPLE[..n]);
}
for i in 0..SAMPLE.len() {
for v in [0x00, 0x0F, 0x5F, 0xAF, 0xDF, 0xFF] {
let mut b = SAMPLE.to_vec();
b[i] = v;
let _ = bplist_first_language(&b);
}
}
}
#[test]
fn face_index_handles_bad_data() {
assert_eq!(face_index_for_family(b"", "PingFang SC"), 0);
assert_eq!(face_index_for_family(b"ttcf\0\0\0\0garbage", "PingFang SC"), 0);
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
assert_eq!(face_index_for_family(inter, "Inter"), 0);
assert_eq!(face_index_for_family(inter, ""), 0);
}
/// On a Mac with PingFang, the SC and TC faces are found by name (not a hard-coded index).
#[cfg(target_os = "macos")]
#[test]
fn pingfang_faces_by_name() {
let Some(p) = mac_pingfang() else { return };
let Ok(bytes) = std::fs::read(p) else { return };
let sc = face_index_for_family(&bytes, "PingFang SC");
let tc = face_index_for_family(&bytes, "PingFang TC");
assert_ne!(sc, tc);
}
}
+34 -30
View File
@@ -15,38 +15,37 @@ pub const DEFAULT_FAMILY: &str = "Inter";
/// Bundled monospace family.
pub const MONO_FAMILY: &str = "JetBrains Mono";
// The font files are embedded once, here. The UI (`ui-egui` theme) uses these same statics:
// a second `include_bytes!` of the same file elsewhere put a second 1.5 MB copy into the wasm.
pub static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
pub static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
pub static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
/// Fonts shipped with Photocraft (OFL; licences in `assets/fonts`).
pub const BUNDLED: &[(&str, &[u8])] = &[
("Inter-Regular.ttf", include_bytes!("../../../assets/fonts/Inter-Regular.ttf")),
("Inter-Medium.ttf", include_bytes!("../../../assets/fonts/Inter-Medium.ttf")),
("Inter-SemiBold.ttf", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf")),
("JetBrainsMono-Regular.ttf", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")),
pub static BUNDLED: &[(&str, &[u8])] = &[
("Inter-Regular.ttf", INTER_REGULAR),
("Inter-Medium.ttf", INTER_MEDIUM),
("Inter-SemiBold.ttf", INTER_SEMIBOLD),
("JetBrainsMono-Regular.ttf", JETBRAINS_MONO_REGULAR),
];
/// Families tried (if installed) after the requested one, for missing glyphs.
const FALLBACK_CANDIDATES: &[&str] = &[
"Noto Sans",
"Arial Unicode MS",
"Segoe UI",
"DejaVu Sans",
"Geeza Pro",
"Arial Hebrew",
"Noto Sans Arabic",
"Noto Sans Hebrew",
// Japanese first, so shared Han characters get Japanese forms before the SC fonts are tried.
"Hiragino Sans",
"Hiragino Kaku Gothic ProN",
"Yu Gothic",
"Meiryo",
"Noto Sans CJK JP",
"Noto Sans JP",
"IPAexGothic",
"Noto Sans CJK SC",
"PingFang SC",
"Apple Color Emoji",
"Segoe UI Emoji",
"Noto Color Emoji",
];
/// Families tried (if installed) after the requested one, for missing glyphs. The CJK families
/// ([`crate::cjk::families`]) go between these two lists, in the UI locale's script order.
const FALLBACK_CANDIDATES: &[&str] = &["Noto Sans", "Segoe UI", "DejaVu Sans", "Geeza Pro", "Arial Hebrew", "Noto Sans Arabic", "Noto Sans Hebrew"];
/// Broad-coverage and emoji fonts, tried after the CJK script fonts so shared Han characters get
/// the locale's forms instead of Arial Unicode's.
const FALLBACK_LAST: &[&str] = &["Arial Unicode MS", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"];
/// Every fallback family candidate, CJK ordered for `order` (see [`crate::cjk::script_order`]).
pub fn fallback_candidates(order: &[crate::cjk::CjkScript; 4]) -> Vec<&'static str> {
let mut v = FALLBACK_CANDIDATES.to_vec();
for s in order {
v.extend_from_slice(crate::cjk::families(*s));
}
v.extend_from_slice(FALLBACK_LAST);
v
}
/// One face in the database (for font menus).
#[derive(Clone, Debug, PartialEq)]
@@ -121,6 +120,10 @@ impl FontDb {
for d in system_font_dirs() {
collect_font_files(&d, 0, &mut files);
}
// PingFang (the macOS Chinese UI font) lives outside the font folders.
if cfg!(target_os = "macos") {
files.extend(crate::cjk::mac_pingfang());
}
for f in files {
self.fcx.collection.load_fonts_from_paths([f]);
}
@@ -161,7 +164,8 @@ impl FontDb {
}
}
}
self.fallbacks = FALLBACK_CANDIDATES.iter().filter(|f| c.family_id(f).is_some()).map(|s| s.to_string()).collect();
let order = crate::cjk::ui_script_order();
self.fallbacks = fallback_candidates(&order).into_iter().filter(|f| c.family_id(f).is_some()).map(str::to_string).collect();
}
/// Families available after the requested one (bundled default + installed coverage fonts).
+1
View File
@@ -14,6 +14,7 @@
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod cjk;
pub mod engine_data;
pub mod fonts;
pub mod glyphs;
+3
View File
@@ -18,6 +18,7 @@ photocraft-engine = { workspace = true }
photocraft-plugins = { workspace = true }
photocraft-algo = { workspace = true }
photocraft-text = { workspace = true }
photocraft-vector = { workspace = true }
egui = { workspace = true }
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
@@ -36,3 +37,5 @@ photocraft-io = { workspace = true }
# Only for examples/interactive_bench.rs (JPEG encode, .pcraft saves).
photocraft-codecs = { workspace = true }
photocraft-format = { workspace = true }
# Benchmark helpers for the `--json` benches `cargo xtask perf` runs (in-process RSS, percentiles).
photocraft-testkit = { workspace = true }
+23 -1
View File
@@ -130,6 +130,7 @@ struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
comp: photocraft_gpu::Compositor,
adapter: String,
}
fn gpu() -> Option<Gpu> {
@@ -142,7 +143,7 @@ fn gpu() -> Option<Gpu> {
let limits = photocraft_ui_egui::gpu_canvas::device_limits(&adapter);
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { required_limits: limits, ..Default::default() })).ok()?;
let comp = photocraft_gpu::Compositor::new(&device);
Some(Gpu { device, queue, comp })
Some(Gpu { device, queue, comp, adapter: adapter.get_info().name })
}
fn gpu_time(g: &mut Gpu, doc: &Document, region: Rect) -> Result<f64, String> {
@@ -227,6 +228,7 @@ fn main() {
let full = doc.bounds();
println!("document {}x{} ({} layers)", doc.size.width, doc.size.height, doc.walk().len());
let mut g = gpu();
let adapter = g.as_ref().map(|g| g.adapter.clone());
let mut gt = |doc: &Document, r: Rect| match g.as_mut() {
Some(g) => gpu_time(g, doc, r),
None => Err("no adapter".into()),
@@ -246,8 +248,13 @@ fn main() {
};
println!("{what:<42} {mp:6.2} MP cpu {c:>22} gpu {g} (min / median)");
};
// `--json out.json` (`cargo xtask perf`): one row per case and compositor, with samples and
// the peak RSS while the case ran.
let rss = photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2));
let mut json_rows: Vec<serde_json::Value> = Vec::new();
// Each case: `edit` mutates the document, then both compositors refresh `region`.
let mut case = |doc: &mut Document, what: &str, n: usize, edit: &mut dyn FnMut(&mut Document, usize) -> Rect, cpu_too: bool| {
rss.reset();
let (mut cs, mut gs, mut gerr) = (Vec::new(), Vec::new(), None);
let mut region = full;
for i in 0..n {
@@ -260,6 +267,13 @@ fn main() {
Err(e) => gerr = Some(e),
}
}
let peak = rss.peak();
if !gs.is_empty() {
json_rows.push(photocraft_testkit::perf::row(&format!("{what} (GPU)"), &gs, peak, None));
}
if !cs.is_empty() {
json_rows.push(photocraft_testkit::perf::row(&format!("{what} (CPU)"), &cs, peak, None));
}
show(
what,
region,
@@ -350,4 +364,12 @@ fn main() {
},
true,
);
if let Some(out) = arg(&args, "--json") {
let context = json!({"width": w, "height": h, "gpu_adapter": adapter});
let report = photocraft_testkit::perf::report("fx_bench", context, json_rows, Some(&rss));
if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) {
eprintln!("{e}");
std::process::exit(1);
}
}
}
+254
View File
@@ -0,0 +1,254 @@
//! Huge-document benchmark (issue #49): canvas refreshes of a 20000×20000 (or `--size`) 8-bit
//! RGB document with eleven layers (full-canvas pixel layers, soft blobs, an adjustment, type
//! layers with effects, a full-width band whose effect region is wider than the GPU texture
//! limit, a group with a clipped layer), on a device created the way the app creates it.
//!
//! One operation per run, so the process's peak memory is that operation's (plus building the
//! document):
//!
//! ```sh
//! cargo build --release -p photocraft-ui-egui --example huge_doc_bench
//! for op in full view; do
//! /usr/bin/time -l target/release/examples/huge_doc_bench --size 20000x20000 --op $op 2>&1 | grep -E 'ms|MB|maximum resident|peak memory'
//! done
//! # The CPU fallback, for comparison:
//! PHOTOCRAFT_CPU_COMPOSE=1 /usr/bin/time -l target/release/examples/huge_doc_bench --op full
//! ```
//!
//! `full`: the first refresh (every page uploaded, every effect map built) and three repeats.
//! `view`: refreshes of a 2560×1440 viewport (zoomed in) at three places, after the first
//! refresh, then the same after a brush dab in each.
use std::time::Instant;
use eframe::egui_wgpu::RenderState;
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Adjustment, Document, Effect, FxCommon, FxPaint, Layer, LayerContent, Shadow, StrokeFx, StrokePosition, TextLayer};
use photocraft_geom::{Rect, Size};
use photocraft_ui_egui::gpu_canvas::GpuCanvas;
use rayon::prelude::*;
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
/// A layer over `r` whose RGBA8 pixels are `f(x, y)`, written in parallel-generated bands.
fn layer_from(name: &str, r: Rect, f: impl Fn(i32, i32) -> [u8; 4] + Sync) -> Layer {
let mut l = Layer::raster(name, PixelFormat::RGBA8);
let s = l.surface_mut().expect("raster");
let mut y = r.y0;
while y < r.y1 {
let band = Rect::new(r.x0, y, r.x1, (y + 256).min(r.y1));
let w = band.width() as usize;
let mut bytes = vec![0u8; w * band.height() as usize * 4];
bytes.par_chunks_mut(w * 4).enumerate().for_each(|(row, px)| {
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
p.copy_from_slice(&f(band.x0 + i as i32, band.y0 + row as i32));
}
});
s.write_interleaved(band, &bytes);
y = band.y1;
}
l
}
fn hash(x: i32, y: i32) -> u8 {
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) as u32 % 251) as u8
}
/// A soft-edged disc of colour `c` centred in `r`.
fn blob(name: &str, r: Rect, c: [u8; 3]) -> Layer {
let (cx, cy, rad) = ((r.x0 + r.x1) as f32 / 2.0, (r.y0 + r.y1) as f32 / 2.0, r.width().min(r.height()) as f32 / 2.0 - 8.0);
layer_from(name, r, |x, y| {
let d = ((x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2)).sqrt();
let a = ((rad - d) / 6.0).clamp(0.0, 1.0);
[c[0], c[1].wrapping_add(hash(x, y) / 16), c[2], (a * 255.0) as u8]
})
}
/// A type layer: rows of glyph-like blocks rasterised into its cache.
fn text(name: &str, r: Rect) -> Layer {
let glyphs = layer_from(name, r, |x, y| {
let (gx, gy) = ((x - r.x0) % 90, (y - r.y0) % 160);
let on = gx < 70 && gy < 120 && !(gx > 20 && gx < 50 && gy > 30 && gy < 90);
[20, 30, 200, if on { 255 } else { 0 }]
});
let mut l = Layer::new(name, LayerContent::Text(TextLayer { cache: glyphs.surface().cloned(), ..Default::default() }));
l.effects.items = vec![
Effect::DropShadow(Shadow {
common: FxCommon::new(BlendMode::Multiply, 0.75),
color: Color::rgb(0.0, 0.0, 0.0),
angle: 120.0,
use_global_light: false,
distance: 12.0,
spread: 0.0,
size: 16.0,
contour: photocraft_doc::Contour::Linear,
anti_alias: false,
noise: 0.0,
knocks_out: true,
}),
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 4.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)),
}),
];
l
}
fn build(w: u32, h: u32) -> Document {
let (wi, hi) = (w as i32, h as i32);
let mut d = Document::new("huge", Size::new(w, h), ColorMode::Rgb, SampleType::U8);
d.layers.push(layer_from("background", Rect::new(0, 0, wi, hi), |x, y| {
let (fx, fy) = (x as f32 / wi as f32, y as f32 / hi as f32);
let n = hash(x, y) / 8;
[(80.0 + 120.0 * fx) as u8 + n, (60.0 + 140.0 * fy) as u8 + n, (150.0 - 60.0 * fx * fy) as u8 + n, 255]
}));
let mut over = layer_from("texture", Rect::new(0, 0, wi * 3 / 5, hi), |x, y| [200, 180u8.wrapping_add(hash(x, y) / 4), 120, 90 + hash(y, x) / 2]);
over.blend = BlendMode::Multiply;
over.opacity = 0.7;
d.layers.push(over);
let mut adj = Layer::new("exposure", LayerContent::Adjustment(Adjustment::Exposure { exposure: 0.3, offset: 0.0, gamma: 1.05 }));
adj.opacity = 0.8;
d.layers.push(adj);
for (i, (x, y)) in [(0.15, 0.2), (0.55, 0.35), (0.3, 0.7)].into_iter().enumerate() {
let (x, y) = ((wi as f32 * x) as i32, (hi as f32 * y) as i32);
let mut b = blob(&format!("blob {i}"), Rect::new(x, y, x + wi / 6, y + wi / 6), [220, 60 + 60 * i as u8, 40]);
b.blend = if i == 1 { BlendMode::Screen } else { BlendMode::Normal };
d.layers.push(b);
}
// A full-width band with effects: its effect region is wider than a 16384 px texture.
let mut band = layer_from("band", Rect::new(40, hi / 2, wi - 40, hi / 2 + 1200), |x, y| [240, 240, 230, if (x / 64 + y / 64) % 5 == 0 { 0 } else { 230 }]);
band.effects.items = vec![
Effect::DropShadow(Shadow {
common: FxCommon::new(BlendMode::Multiply, 0.6),
color: Color::rgb(0.0, 0.0, 0.1),
angle: 90.0,
use_global_light: false,
distance: 20.0,
spread: 0.0,
size: 24.0,
contour: photocraft_doc::Contour::Linear,
anti_alias: false,
noise: 0.0,
knocks_out: true,
}),
Effect::Stroke(StrokeFx {
common: FxCommon::new(BlendMode::Normal, 1.0),
size: 3.0,
position: StrokePosition::Outside,
paint: FxPaint::Color(Color::rgb(0.1, 0.1, 0.1)),
}),
];
d.layers.push(band);
d.layers.push(text("title", Rect::new(wi / 10, hi / 12, wi / 10 + 6000, hi / 12 + 900)));
d.layers.push(text("caption", Rect::new(wi / 2, hi * 5 / 6, wi / 2 + 4000, hi * 5 / 6 + 500)));
let a = blob("group base", Rect::new(wi * 2 / 3, hi / 10, wi * 2 / 3 + wi / 4, hi / 10 + wi / 4), [40, 160, 90]);
let mut c = layer_from("clipped", Rect::new(wi * 2 / 3, hi / 10, wi, hi / 10 + wi / 8), |x, y| [250, 230, 40, 120 + hash(x, y) / 3]);
c.clipped = true;
c.blend = BlendMode::Overlay;
let mut g = Layer::group("group", vec![a, c]);
g.opacity = 0.9;
d.layers.push(g);
d
}
/// A headless GPU canvas on a device created like the app's.
fn canvas() -> Option<(GpuCanvas, RenderState)> {
let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup();
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?;
let l = rs.device.limits();
eprintln!("adapter: {} (max texture {})", rs.adapter.get_info().name, l.max_texture_dimension_2d);
Some((GpuCanvas::new(&rs), rs))
}
/// One canvas refresh (everything, or `damage`), waiting for the GPU. Returns the path taken.
fn refresh(g: &GpuCanvas, rs: &RenderState, doc: &Document, damage: Option<Rect>) -> (String, Option<String>) {
let r = g.refresh(doc.id.0, doc, damage, None);
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
let kind = if r.kind.starts_with("gpu") { format!("{}, {} tiles up", r.kind, r.uploads) } else { r.kind.to_string() };
(kind, r.fallback)
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let (w, h) =
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(20000), b.parse().unwrap_or(20000)))).unwrap_or((20000, 20000));
let op = arg(&args, "--op").unwrap_or_else(|| "full".into());
let Some((g, rs)) = canvas() else {
eprintln!("no GPU adapter");
return;
};
let t = Instant::now();
let mut doc = build(w, h);
println!("document {w}×{h} ({:.0} MP), {} layers, built in {:.0} ms", (w as f64 * h as f64) / 1e6, doc.walk().len(), ms(t));
// The compositor budget the app would use: Memory Usage (default 8 GB, `--memory-mb`) less
// the document's pixels, within a quarter of RAM (`PHOTOCRAFT_RAM_MB` simulates a smaller
// machine). The view is the first viewport (`view`) or the whole document (`full`).
let allowance = arg(&args, "--memory-mb").and_then(|v| v.parse::<u64>().ok()).unwrap_or(8192) << 20;
let pixels: u64 = doc
.walk()
.iter()
.flat_map(|(_, _, l)| l.surface().into_iter().chain(l.mask.as_ref().map(|m| &m.surface)))
.map(|s| s.tiles().map(|(_, t)| t.bytes().len() as u64).sum::<u64>())
.sum();
let ram = photocraft_ui_egui::gpu_canvas::physical_memory();
let budget = photocraft_ui_egui::gpu_canvas::memory_budget(allowance, pixels, ram);
g.set_memory_budget(budget);
println!("RAM {} MB, document pixels {} MB, Memory Usage {} MB -> GPU budget {} MB", ram.unwrap_or(0) >> 20, pixels >> 20, allowance >> 20, budget >> 20);
let first_view = Rect::from_xywh((w as f32 * 0.1) as i32, (h as f32 * 0.1) as i32, 2560, 1440);
g.set_focus(Some(if op == "view" { first_view } else { doc.bounds() }));
let t = Instant::now();
let (kind, fallback) = refresh(&g, &rs, &doc, None);
println!("first refresh ({kind:<8}) {:>9.0} ms{}", ms(t), fallback.map_or(String::new(), |f| format!(" [fallback: {f}]")));
let report = |g: &GpuCanvas| {
if let Some((format, bytes)) = g.texture_info(doc.id.0) {
println!("canvas texture {format:?}: {} MB with mips", bytes >> 20);
}
if let Some((pages, fx)) = g.compositor_bytes() {
println!("compositor: {} MB resident pages, {} MB effect maps", pages >> 20, fx >> 20);
}
};
report(&g);
match op.as_str() {
"full" => {
for _ in 0..3 {
let t = Instant::now();
let (kind, _) = refresh(&g, &rs, &doc, None);
println!("full refresh ({kind:<8}) {:>9.0} ms", ms(t));
}
}
"view" => {
let views: Vec<Rect> = [(0.1, 0.1), (0.45, 0.48), (0.7, 0.8)]
.into_iter()
.map(|(x, y)| Rect::from_xywh((w as f32 * x) as i32, (h as f32 * y) as i32, 2560, 1440).intersect(&doc.bounds()))
.collect();
for v in &views {
let t = Instant::now();
let (kind, _) = refresh(&g, &rs, &doc, Some(*v));
println!("viewport refresh ({kind:<8}) {:>9.0} ms at {v:?}", ms(t));
}
// A dab on the band (an effect layer) inside each viewport, then its refresh.
let band = doc.layers.iter().position(|l| l.name == "band").unwrap_or(0);
for v in &views {
let c = Rect::from_xywh(v.x0 + 600, h as i32 / 2 + 300, 120, 120);
if let Some(s) = doc.layers.get_mut(band).and_then(Layer::surface_mut) {
s.fill_rect(c, &[0.1, 0.3, 0.9, 1.0]);
}
let t = Instant::now();
let (kind, _) = refresh(&g, &rs, &doc, Some(*v));
println!("dab + viewport refresh ({kind:<8}) {:>6.0} ms", ms(t));
}
}
other => eprintln!("unknown --op {other}"),
}
report(&g);
}
+26 -3
View File
@@ -83,6 +83,10 @@ struct Bench {
/// state later rows need).
only: Option<String>,
rows: Vec<(String, f64, f64, f64, f64)>,
/// Per row (same order as `rows`): the timed samples and the peak RSS while they ran, for
/// `--json` (`cargo xtask perf`).
extra: Vec<(Vec<f64>, Option<u64>)>,
rss: photocraft_testkit::perf::RssSampler,
}
impl Bench {
@@ -116,8 +120,10 @@ impl Bench {
f(self);
return;
}
self.rss.reset();
let cold = f(self);
let mut v: Vec<f64> = (0..self.reps).map(|_| f(self)).collect();
self.extra.push((v.clone(), self.rss.peak()));
v.sort_by(f64::total_cmp);
let med = v[v.len() / 2];
println!("{name:<44} {med:>9.1} ms (min {:>8.1}, max {:>8.1}, cold {:>8.1})", v[0], v[v.len() - 1], cold);
@@ -161,7 +167,14 @@ fn main() {
let (w, h) =
arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912));
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1);
let mut b = Bench { gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() }, reps, only: arg(&args, "--only"), rows: Vec::new() };
let mut b = Bench {
gpu: if args.iter().any(|a| a == "--cpu") { None } else { gpu() },
reps,
only: arg(&args, "--only"),
rows: Vec::new(),
extra: Vec::new(),
rss: photocraft_testkit::perf::RssSampler::start(std::time::Duration::from_millis(2)),
};
println!("document {w}×{h} ({:.1} MP), 8-bit RGB, {} reps, {}", (w * h) as f64 / 1e6, reps, if b.gpu.is_some() { "GPU canvas" } else { "CPU canvas" });
// ---- open / save / export ----------------------------------------------------------------
@@ -399,8 +412,18 @@ fn main() {
});
if let Some(out) = arg(&args, "--json") {
let rows: Vec<Value> =
b.rows.iter().map(|(n, med, min, max, cold)| json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold})).collect();
let rows: Vec<Value> = b
.rows
.iter()
.zip(&b.extra)
.map(|((n, med, min, max, cold), (samples, rss))| {
let mut r = json!({"name": n, "median_ms": med, "min_ms": min, "max_ms": max, "cold_ms": cold});
// For `cargo xtask perf`: raw samples (it computes p50 / p95) and peak RSS.
r["samples_ms"] = json!(samples);
r["peak_rss_bytes"] = json!(rss);
r
})
.collect();
std::fs::write(&out, serde_json::to_string_pretty(&json!({"width": w, "height": h, "gpu": b.gpu.is_some(), "rows": rows})).unwrap())
.expect("write json");
}
+28 -2
View File
@@ -99,7 +99,10 @@ fn main() {
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).expect("jpeg");
std::fs::write(&src, jpeg).expect("write cache");
eprintln!("generated {} in {:.0} ms (run again to measure)", src.display(), ms(t));
return;
// `--json` (`cargo xtask perf`) measures in the same run.
if arg(&args, "--json").is_none() {
return;
}
}
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
let gpu = gpu.as_ref();
@@ -119,23 +122,37 @@ fn main() {
let t_open = ms(t);
let t = Instant::now();
let path = refresh(gpu, &s, true);
let t_first = ms(t);
println!("open (decode + document) {t_open:>9.0} ms");
println!("first refresh ({path:<8}) {:>9.0} ms", ms(t));
println!("first refresh ({path:<8}) {t_first:>9.0} ms");
// `--json out.json` (`cargo xtask perf`): open and first refresh (every op includes them),
// the canvas texture size and the process's peak RSS.
let json_out = arg(&args, "--json");
let mut json_rows = vec![
photocraft_testkit::perf::row("open (decode + document)", &[t_open], None, None),
photocraft_testkit::perf::row("first refresh", &[t_first], None, None),
];
if let Some((g, _)) = gpu
&& let Some((format, bytes)) = g.texture_info(s.active().expect("doc").doc.id.0)
{
println!("canvas texture {format:?}, {} MB with mips", bytes >> 20);
if let Some(r) = json_rows.get_mut(1) {
r["gpu_bytes"] = json!(bytes);
}
}
let doc = || s.active().expect("doc").doc.clone();
match op.as_str() {
"open" => {}
"refresh" => {
let mut v = Vec::new();
for _ in 0..3 {
let t = Instant::now();
let path = refresh(gpu, &s, true);
v.push(ms(t));
println!("full refresh ({path:<8}) {:>9.0} ms", ms(t));
}
json_rows.push(photocraft_testkit::perf::row("full refresh", &v, None, None));
}
"thumbs" => {
let d = doc();
@@ -181,4 +198,13 @@ fn main() {
}
other => eprintln!("unknown --op {other}"),
}
if let Some(out) = json_out {
let peak = photocraft_testkit::perf::process_peak_rss_bytes().or_else(photocraft_testkit::perf::current_rss_bytes);
let context = json!({"width": w, "height": h, "op": op, "gpu_adapter": gpu.map(|(_, rs)| rs.adapter.get_info().name)});
let mut report = photocraft_testkit::perf::report("large_image_bench", context, json_rows, None);
report["process_peak_rss_bytes"] = json!(peak);
if let Err(e) = photocraft_testkit::perf::write_report(&out, &report) {
eprintln!("{e}");
}
}
}
+472
View File
@@ -0,0 +1,472 @@
//! Frame times of the whole app on a designer's layout document (#125, #128): ~175 layers in
//! nested groups (pass-through and isolated), 60 text layers in several fonts and sizes, stroked
//! shapes, smart objects, drop shadows / strokes / gradient overlays, adjustment layers and masks,
//! 4000×3000 8-bit RGB (`tests/support/layout_doc.rs`), saved as PSD and reopened through the real
//! import path.
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example layout_bench -- [--reps 9] [--json out.json] [--direct] [--navigator] [--size 4000x3000]
//! ```
//!
//! The real `PhotocraftApp` runs in an offscreen egui_kittest harness on wgpu (the GPU canvas
//! path, with the device the app requests). Interactions go through real input where the harness
//! can reach it (key shortcuts, pointer drags and clicks on the canvas) and otherwise through the
//! command a panel runs (`layer.select`, `layer.setProps`), then the frames that follow. Each row
//! is the median / p90 / max of a frame's time over the repetitions; `PHOTOCRAFT_GPU_SYNC` makes
//! the canvas wait for the GPU, so a frame's time includes its composite. Move drags report the
//! press, the pointer-move frames and the release (the commit) separately. `--direct` skips the
//! PSD round trip; `--navigator` also shows the Navigator panel; `--spin idle|select|tool|move-
//! pixel|move-text|move-shape|move-group|move-smart|move-headline` repeats one interaction for 20 s
//! for a sampling profiler.
#[path = "../tests/support/layout_doc.rs"]
mod layout_doc;
use std::time::Instant;
use egui::{Event, Key, Modifiers, PointerButton, Pos2};
use egui_kittest::kittest::Queryable;
use photocraft_doc::{LayerContent, LayerId};
use photocraft_ui_egui::canvas::ViewXform;
use photocraft_ui_egui::state::Tool;
use photocraft_ui_egui::{PhotocraftApp, Services};
use serde_json::{Value, json};
type H = egui_kittest::Harness<'static, PhotocraftApp>;
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
/// One frame, timed.
fn frame(h: &mut H) -> f64 {
let t = Instant::now();
h.step();
ms(t)
}
struct Rows {
reps: usize,
rows: Vec<(String, f64, f64, f64)>,
}
impl Rows {
/// A row from frame times already taken.
fn add(&mut self, name: &str, v: Vec<f64>) {
let mut v: Vec<f64> = v.into_iter().filter(|t| t.is_finite()).collect();
if v.is_empty() {
return;
}
v.sort_by(f64::total_cmp);
let (med, p90, max) = (v[v.len() / 2], v[(v.len() * 9 / 10).min(v.len() - 1)], v[v.len() - 1]);
let flag = if med > 16.0 { " <-- over 16 ms" } else { "" };
println!("{name:<46} median {med:>8.2} ms p90 {p90:>8.2} max {max:>8.2}{flag}");
self.rows.push((name.to_string(), med, p90, max));
}
/// `f` performs one interaction and returns the worst frame it took (ms).
fn time(&mut self, name: &str, mut f: impl FnMut() -> f64) {
let v = (0..self.reps).map(|_| f()).collect();
self.add(name, v);
}
}
/// `n` frames (returns the worst).
fn settle(h: &mut H, n: usize) -> f64 {
(0..n).map(|_| frame(h)).fold(0.0, f64::max)
}
/// Screen position of document point (x, y) in the main canvas.
fn screen(h: &H, x: f64, y: f64) -> Pos2 {
let app = h.state();
let idx = app.session.active_index().unwrap_or(0);
let v = &app.ui.views[idx];
let rect = photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect);
ViewXform { rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal }.to_screen(x as f32, y as f32)
}
fn select(h: &mut H, id: LayerId) {
let _ = h.state_mut().run("layer.select", json!({"layer": id.0}));
}
/// A drag on the canvas from document point `from` by `d`, `steps` pointer moves one frame apart.
/// Returns the frame times: hover, press, the moves, release, one more.
fn drag(h: &mut H, from: [f64; 2], d: [f64; 2], steps: usize) -> Vec<f64> {
let p0 = screen(h, from[0], from[1]);
let mut times = Vec::new();
h.event(Event::PointerMoved(p0));
times.push(frame(h));
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
times.push(frame(h));
for i in 1..=steps {
let f = i as f64 / steps as f64;
let p = screen(h, from[0] + d[0] * f, from[1] + d[1] * f);
h.event(Event::PointerMoved(p));
times.push(frame(h));
}
let p1 = screen(h, from[0] + d[0], from[1] + d[1]);
h.event(Event::PointerButton { pos: p1, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
times.push(frame(h));
times.push(frame(h));
times
}
/// Rows for Move drags (frame times from [`drag`]): the press, the pointer moves, the release.
fn move_rows(rows: &mut Rows, label: &str, drags: &[Vec<f64>]) {
let pick = |f: &dyn Fn(&[f64]) -> Vec<f64>| drags.iter().flat_map(|d| f(d)).collect::<Vec<f64>>();
rows.add(&format!("Move {label}: press"), pick(&|d| d.get(1).copied().into_iter().collect()));
rows.add(&format!("Move {label}: drag frames"), pick(&|d| d.get(2..d.len().saturating_sub(2)).unwrap_or_default().to_vec()));
rows.add(&format!("Move {label}: release (commit)"), pick(&|d| d.len().checked_sub(2).and_then(|i| d.get(i)).copied().into_iter().collect()));
}
fn contains(l: &photocraft_doc::Layer, t: LayerId) -> bool {
l.id == t || l.children().is_some_and(|c| c.iter().any(|k| contains(k, t)))
}
/// A point where `id` (or one of its layers, for a group) is the topmost layer with pixels.
fn point_on(h: &H, id: LayerId) -> Option<[f64; 2]> {
let st = h.state().session.active()?;
let l = st.doc.layer(id)?;
let b = photocraft_engine::layer_multi_cmds::layer_bounds(l)?;
let (cx, cy) = ((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2);
for r in 0..60 {
for (dx, dy) in [(0, 0), (r, 0), (-r, 0), (0, r), (0, -r)] {
let (x, y) = (cx + dx * 3, cy + dy * 3);
if photocraft_engine::pick_cmds::layers_at(&st.doc, x, y).first().is_some_and(|&t| contains(l, t)) {
return Some([x as f64 + 0.5, y as f64 + 0.5]);
}
}
}
None
}
fn main() {
reexec_with_gpu_sync();
let args: Vec<String> = std::env::args().collect();
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(9).max(3);
let mut spec = layout_doc::Spec::full();
if let Some((w, hh)) = arg(&args, "--size").and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.parse().ok()?, b.parse().ok()?)))) {
(spec.width, spec.height) = (w, hh);
}
let t = Instant::now();
let (doc, _) = layout_doc::build(spec);
let (n, texts) = layout_doc::count(&doc);
println!("built {}×{} layout: {n} layers, {texts} text layers in {:.0} ms", spec.width, spec.height, ms(t));
let doc = if args.iter().any(|a| a == "--direct") {
doc
} else {
let t = Instant::now();
let psd = photocraft_io::export(&doc, "layout.psd", &Default::default()).expect("psd export").bytes;
let t_save = ms(t);
let t = Instant::now();
let r = photocraft_io::import("layout.psd", &psd).expect("psd import");
println!("PSD: {:.1} MB, save {t_save:.0} ms, open {:.0} ms ({} warnings)", psd.len() as f64 / 1e6, ms(t), r.warnings.len());
// PSD export writes engine-made smart objects as pixels (no SoLd yet): convert them back,
// as a Photoshop-made file would have them.
let mut s = photocraft_engine::Session::new();
s.open_document(r.document, None);
let smart = |l: &photocraft_doc::Layer| l.name == "Logo" || (l.name.starts_with("Product ") && !l.name.contains('—') && !l.is_group());
let ids: Vec<u64> = s.active().map(|st| st.doc.walk().iter().filter(|(_, _, l)| smart(l)).map(|(_, _, l)| l.id.0).collect()).unwrap_or_default();
for id in ids {
s.execute("layer.smartObjects.convertToSmartObject", json!({"layer": id})).expect("convert");
}
(*s.active().expect("doc").doc).clone()
};
// Layer ids change through the PSD round trip: find the layers by name and kind.
let find = |name: &str, pred: &dyn Fn(&LayerContent) -> bool| -> LayerId {
doc.walk().iter().find(|(_, _, l)| l.name == name && pred(&l.content)).map(|(_, _, l)| l.id).unwrap_or_else(|| panic!("no layer {name}"))
};
let pixel = find("Tile 1", &|c| matches!(c, LayerContent::Raster(_)));
let text = find("Product 1 — Oak", &|c| matches!(c, LayerContent::Text(_)));
let shape = find("Badge", &|c| matches!(c, LayerContent::Shape(_)));
let group = find("Card 1", &|c| matches!(c, LayerContent::Group(_)));
let smart = find("Product 1", &|c| matches!(c, LayerContent::Smart(_)));
let hero = find("Hero", &|c| matches!(c, LayerContent::Group(_)));
let headline = find("Make things that last", &|c| matches!(c, LayerContent::Text(_)));
let products = find("Products", &|c| matches!(c, LayerContent::Group(_)));
let navigator = args.iter().any(|a| a == "--navigator");
let ppp: f32 = arg(&args, "--ppp").and_then(|v| v.parse().ok()).unwrap_or(1.0);
let t = Instant::now();
let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp);
let mut h: H = builder.wgpu_setup(photocraft_ui_egui::gpu_canvas::wgpu_setup()).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
rs.device.set_device_lost_callback(|r, m| eprintln!("GPU device lost ({r:?}): {m}"));
app.set_wgpu(rs.clone());
}
app.session.open_document(doc, Some("layout.psd".into()));
app.sync_views();
app.ui.panels.navigator = navigator;
app
});
settle(&mut h, 4);
{
let p = &h.state().perf;
println!(
"open + first frames: {:.0} ms (canvas: gpu {}, fallback {:?}, first composite {} {:.0} ms)",
ms(t),
p.gpu,
p.gpu_fallback,
p.last_refresh,
p.composite_ms
);
}
settle(&mut h, 4);
if let Some(dir) = arg(&args, "--shots") {
shots(&mut h, &dir, ppp, [group, headline]);
return;
}
if let Some(what) = arg(&args, "--spin") {
spin(&mut h, &what, [pixel, text, shape, group, smart, headline]);
return;
}
let mut rows = Rows { reps, rows: Vec::new() };
rows.time("idle frame", || frame(&mut h));
rows.time("hover over the canvas", || {
let p = screen(&h, 1000.0, 1000.0);
h.event(Event::PointerMoved(p));
let a = frame(&mut h);
h.event(Event::PointerMoved(p + egui::vec2(7.0, 3.0)));
a.max(frame(&mut h))
});
// Selecting layers: the command a Layers panel row click runs, then the frames after (the
// Layers and Properties panels follow the new layer).
let picks = [pixel, text, shape, group, smart, hero, headline];
let mut k = 0;
rows.time("select a layer (Layers panel)", || {
k += 1;
let t = Instant::now();
select(&mut h, picks[k % picks.len()]);
(ms(t) + frame(&mut h)).max(frame(&mut h))
});
// A real click on a Layers panel row (rows carry their layer's name for accessibility).
let names = ["Gallery", "Products", "Hero", "Header", "Footer"];
rows.time("click a Layers panel row", || {
k += 1;
// The row is the widest node with the name (its disclosure triangle has it too).
let row = h.query_all_by_label(names[k % names.len()]).map(|n| n.rect()).max_by(|a, b| a.width().total_cmp(&b.width()));
let Some(p) = row.map(|r| r.center()) else { return f64::NAN };
h.event(Event::PointerMoved(p));
let a = frame(&mut h);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
let b = frame(&mut h);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
a.max(b).max(frame(&mut h)).max(frame(&mut h))
});
rows.time("open / close a group (Layers panel)", || {
let t = Instant::now();
let _ = h.state_mut().run("layer.setExpanded", json!({"layer": products.0}));
(ms(t) + frame(&mut h)).max(frame(&mut h))
});
let tools = [Key::V, Key::B, Key::M, Key::T, Key::U, Key::E];
rows.time("switch tool (shortcut)", || {
k += 1;
h.key_press(tools[k % tools.len()]);
frame(&mut h).max(frame(&mut h))
});
let mut vis = false;
for (label, id) in [("text layer", text), ("group", group)] {
rows.time(&format!("toggle visibility ({label})"), || {
vis = !vis;
let t = Instant::now();
let _ = h.state_mut().run("layer.setProps", json!({"layer": id.0, "visible": vis}));
(ms(t) + frame(&mut h)).max(frame(&mut h))
});
let _ = h.state_mut().run("layer.setProps", json!({"layer": id.0, "visible": true}));
settle(&mut h, 3);
}
let mut op = 1.0;
select(&mut h, shape);
settle(&mut h, 2);
rows.time("opacity change (Layers / Properties)", || {
op = if op > 0.6 { 0.5 } else { 0.9 };
let t = Instant::now();
let _ = h.state_mut().run("layer.setProps", json!({"layer": shape.0, "opacity": op}));
(ms(t) + frame(&mut h)).max(frame(&mut h))
});
// Move tool drags. Auto-Select off (Photoshop's default): the selected layer moves.
h.state_mut().ui.tool = Tool::Move;
h.state_mut().ui.tool_options.move_auto_select = false;
settle(&mut h, 3);
for (label, id) in [("pixel", pixel), ("text", text), ("shape", shape), ("group", group), ("smart object", smart)] {
select(&mut h, id);
settle(&mut h, 3);
let Some(from) = point_on(&h, id) else {
println!("move {label}: no visible pixel found");
continue;
};
// Back and forth, so every other drag returns the layer where it started.
let mut commit = Vec::new();
let drags: Vec<Vec<f64>> = (0..reps)
.map(|i| {
let d = drag(&mut h, from, if i.is_multiple_of(2) { [37.0, 23.0] } else { [-37.0, -23.0] }, 8);
commit.push(h.state().perf.command_ms);
d
})
.collect();
move_rows(&mut rows, label, &drags);
rows.add(&format!(" of which layer.translate ({label})"), commit);
}
// Auto-Select on: a click on the canvas selects the layer under the pointer; a press on a
// type layer that isn't selected drags it.
h.state_mut().ui.tool_options.move_auto_select = true;
if let (Some(a), Some(b)) = (point_on(&h, text), point_on(&h, pixel)) {
let mut i = 0u32;
rows.time("click the canvas (auto-select a layer)", || {
i += 1;
let p = screen(&h, if i.is_multiple_of(2) { a[0] } else { b[0] }, if i.is_multiple_of(2) { a[1] } else { b[1] });
h.event(Event::PointerMoved(p));
let f0 = frame(&mut h);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
let f1 = frame(&mut h);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
let f2 = frame(&mut h);
let f3 = frame(&mut h);
f0.max(f1).max(f2).max(f3)
});
}
if let Some(from) = point_on(&h, headline) {
let drags: Vec<Vec<f64>> = (0..reps)
.map(|i| {
select(&mut h, pixel);
settle(&mut h, 2);
drag(&mut h, from, if i.is_multiple_of(2) { [25.0, 0.0] } else { [-25.0, 0.0] }, 8)
})
.collect();
move_rows(&mut rows, "auto-select type", &drags);
}
println!("perf spans: {:?}", h.state().perf.spans);
if let Some(out) = arg(&args, "--json") {
let r: Vec<Value> = rows.rows.iter().map(|(n, med, p90, max)| json!({"name": n, "median_ms": med, "p90_ms": p90, "max_ms": max})).collect();
let report = json!({"width": spec.width, "height": spec.height, "layers": n, "text_layers": texts, "rows": r});
std::fs::write(&out, serde_json::to_string_pretty(&report).expect("json")).expect("write json");
}
}
/// `--shots DIR`: screenshots of the whole app while a Move drag is under way and after the
/// release, fitted on screen and at 100 %, for a card group and the hero headline (`{DIR}/
/// move-{what}-{view}-{phase}@{ppp}x.png`).
fn shots(h: &mut H, dir: &str, ppp: f32, [group, headline]: [LayerId; 2]) {
let _ = std::fs::create_dir_all(dir);
let save = |h: &mut H, name: &str| match h.render() {
Ok(img) => {
let p = format!("{dir}/{name}@{ppp}x.png");
match img.save(&p) {
Ok(()) => println!("wrote {p}"),
Err(e) => eprintln!("{p}: {e}"),
}
}
Err(e) => eprintln!("{name}: render failed: {e}"),
};
h.state_mut().ui.tool = Tool::Move;
h.state_mut().ui.tool_options.move_auto_select = false;
for (what, id) in [("card", group), ("headline", headline)] {
for view in ["fit", "100"] {
select(h, id);
if let Some(b) = h.state().session.active().and_then(|s| s.doc.layer(id)).and_then(photocraft_engine::layer_multi_cmds::layer_bounds) {
let app = h.state_mut();
let v = &mut app.ui.views[0];
if view == "100" {
v.zoom = 1.0;
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
v.fit_pending = false;
} else {
v.fit_pending = true;
}
}
settle(h, 6);
let Some(from) = point_on(h, id) else { continue };
// Press and drag most of the way, render mid-drag, then release and render again.
let d = [140.0, 60.0];
let p0 = screen(h, from[0], from[1]);
h.event(Event::PointerMoved(p0));
frame(h);
h.event(Event::PointerButton { pos: p0, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
frame(h);
for i in 1..=10 {
let f = f64::from(i) / 10.0;
h.event(Event::PointerMoved(screen(h, from[0] + d[0] * f, from[1] + d[1] * f)));
frame(h);
}
save(h, &format!("move-{what}-{view}-dragging"));
let p1 = screen(h, from[0] + d[0], from[1] + d[1]);
h.event(Event::PointerButton { pos: p1, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
settle(h, 4);
save(h, &format!("move-{what}-{view}-released"));
let _ = h.state_mut().run("edit.undo", json!({}));
settle(h, 3);
}
}
}
/// `--spin`: one interaction over and over for 20 s, for a sampling profiler.
fn spin(h: &mut H, what: &str, [pixel, text, shape, group, smart, headline]: [LayerId; 6]) {
let t = Instant::now();
let mut k = 0usize;
if let Some(target) = what.strip_prefix("move-") {
let id = match target {
"group" => group,
"text" => text,
"shape" => shape,
"smart" => smart,
"headline" => headline,
_ => pixel,
};
h.state_mut().ui.tool = Tool::Move;
select(h, id);
settle(h, 3);
let from = point_on(h, id).unwrap_or([1000.0, 1000.0]);
while t.elapsed().as_secs() < 20 {
k += 1;
let s = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
drag(h, from, [30.0 * s, 20.0 * s], 30);
}
return;
}
if what == "click" {
h.state_mut().ui.tool = Tool::Move;
h.state_mut().ui.tool_options.move_auto_select = true;
let pts: Vec<[f64; 2]> = [text, pixel].iter().filter_map(|&id| point_on(h, id)).collect();
while t.elapsed().as_secs() < 20 && !pts.is_empty() {
k += 1;
let p = screen(h, pts[k % pts.len()][0], pts[k % pts.len()][1]);
h.event(Event::PointerMoved(p));
frame(h);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
frame(h);
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
settle(h, 2);
}
return;
}
while t.elapsed().as_secs() < 20 {
k += 1;
match what {
"select" => select(h, [pixel, text, shape, group][k % 4]),
"tool" => h.key_press([Key::V, Key::B, Key::M, Key::T][k % 4]),
_ => {}
}
frame(h);
}
}
/// Run again with `PHOTOCRAFT_GPU_SYNC=1` unless it is set (the workspace forbids the `unsafe`
/// that setting it in-process needs).
fn reexec_with_gpu_sync() {
if std::env::var_os("PHOTOCRAFT_GPU_SYNC").is_some() {
return;
}
let exe = std::env::current_exe().expect("exe");
let status = std::process::Command::new(exe).args(std::env::args().skip(1)).env("PHOTOCRAFT_GPU_SYNC", "1").status().expect("re-exec");
std::process::exit(status.code().unwrap_or(1));
}
+638
View File
@@ -0,0 +1,638 @@
//! Scorecard performance scenarios (#221): the measurable rows of the targets in #209, #210 and
//! #211, each timed through the command path the UI, CLI and MCP share (`Session::execute`) plus
//! the canvas refresh the app does after it (the app's own `GpuCanvas`, on a device created the
//! way the app creates it; the CPU compositor without an adapter).
//!
//! ```sh
//! cargo run --release -p photocraft-ui-egui --example perf_scenarios -- [--quick] [--json out.json] [--only text] [--reps N] [--cpu]
//! ```
//!
//! `cargo xtask perf` runs this and maps the rows to scenario ids in `perf/budgets.toml`, so
//! keep row names stable. `--quick` uses small synthetic documents (seconds, for smoke runs);
//! its numbers are only comparable with other `--quick` runs. Every row records p50 / p95 / max
//! over its samples, the peak resident memory measured in-process while it ran, and the GPU
//! bytes the canvas holds for the document (texture + compositor) when there is a GPU.
use std::time::Instant;
use eframe::egui_wgpu::RenderState;
use photocraft_engine::Session;
use photocraft_testkit::perf::{RssSampler, report, row, write_report};
use photocraft_ui_egui::gpu_canvas::GpuCanvas;
use serde_json::{Value, json};
type Res<T> = Result<T, String>;
fn arg(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
fn exec(s: &mut Session, id: &str, p: Value) -> Res<Value> {
s.execute(id, p).map_err(|e| format!("{id}: {e}"))
}
fn active_layer(s: &Session) -> Res<u64> {
s.active().and_then(|d| d.active_layer).map(|l| l.0).ok_or_else(|| "no active layer".to_string())
}
/// A photo-like RGB image (gradients, soft blobs, fine noise) with a high-contrast disc as the
/// subject, encoded as JPEG so it opens like a real photo.
fn photo_jpeg(w: u32, h: u32) -> Res<Vec<u8>> {
use rayon::prelude::*;
let mut px = vec![0u8; w as usize * h as usize * 3];
let (cx, cy, r) = (w as f32 * 0.55, h as f32 * 0.5, w.min(h) as f32 / 3.0);
px.par_chunks_mut(w as usize * 3).enumerate().for_each(|(y, row)| {
for x in 0..w as usize {
let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32);
let n = ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 997) as f32 / 997.0 - 0.5;
let blob = (-(((fx - 0.4) * 3.0).powi(2) + ((fy - 0.55) * 4.0).powi(2))).exp();
let inside = ((x as f32 - cx).powi(2) + (y as f32 - cy).powi(2)).sqrt() < r;
let (r0, g0, b0) = if inside { (0.85, 0.3 + 0.2 * (fx * 40.0).sin(), 0.15) } else { (0.35, 0.45, 0.55) };
let rgb = [r0 + 0.1 * blob + n * 0.08, g0 + 0.1 * blob + n * 0.08, b0 + 0.2 * fy + n * 0.08];
for (c, v) in rgb.into_iter().enumerate() {
row[x * 3 + c] = (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
}
});
let img =
photocraft_codecs::Image::from_raw(w, h, photocraft_codecs::ChannelLayout::Rgb, photocraft_codecs::SampleType::U8, px).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).map_err(|e| e.to_string())
}
fn open_photo(w: u32, h: u32) -> Res<Session> {
let jpeg = photo_jpeg(w, h)?;
let doc = photocraft_io::import("photo.jpg", &jpeg).map_err(|e| e.to_string())?.document;
let mut s = Session::new();
s.open_document(doc, Some("photo.jpg".into()));
Ok(s)
}
/// How far an edit's composite change reaches beyond its damage (layer effects); mirrors
/// `canvas::effect_reach`.
fn reach(layers: &[photocraft_doc::Layer]) -> i32 {
layers
.iter()
.map(|l| {
let own = if photocraft_compose::effects::has_effects(l) { photocraft_compose::effects::margin(l) } else { 0 };
let kids = match &l.content {
photocraft_doc::LayerContent::Group(g) => reach(&g.children),
_ => 0,
};
own + kids
})
.max()
.unwrap_or(0)
}
struct Bench {
gpu: Option<(GpuCanvas, RenderState)>,
sampler: RssSampler,
reps: usize,
only: Option<String>,
rows: Vec<Value>,
/// Rows that couldn't run: (name, error).
errors: Vec<(String, String)>,
/// The row being timed, so a panic inside it is reported against that row.
current: Option<String>,
}
impl Bench {
fn wanted(&self, name: &str) -> bool {
self.only.as_ref().is_none_or(|o| name.contains(o.as_str()))
}
/// The canvas refresh after an edit (`canvas::ensure_gpu`): the damage rect grown by the
/// effect reach when the session reports one, else everything; waits for the GPU.
fn refresh(&self, s: &Session, full: bool) -> Res<f64> {
let st = s.active().ok_or("no document")?;
let doc = &st.doc;
let damage = if full { None } else { st.last_damage.map(|r| r.inflate(reach(&doc.layers)).intersect(&doc.bounds())) };
let t = Instant::now();
match &self.gpu {
Some((g, rs)) => {
g.refresh(doc.id.0, doc, damage, None);
let _ = rs.device.poll(eframe::wgpu::PollType::Wait { submission_index: None, timeout: None });
}
None => {
let r = damage.unwrap_or(doc.bounds()).intersect(&doc.bounds());
if !r.is_empty() {
std::hint::black_box(photocraft_compose::render(doc, r));
}
}
}
Ok(ms(t))
}
fn gpu_bytes(&self, s: &Session) -> Option<u64> {
let (g, _) = self.gpu.as_ref()?;
let tex = s.active().and_then(|d| g.texture_info(d.doc.id.0)).map_or(0, |(_, b)| b);
let (resident, fx) = g.compositor_bytes().unwrap_or((0, 0));
Some(tex + resident + fx as u64)
}
/// Times `f` `reps` times after one untimed warm-up run; `f` returns its own time in ms.
fn time(&mut self, name: &str, s: &mut Session, reps: usize, warm: bool, mut f: impl FnMut(&Self, &mut Session, usize) -> Res<f64>) {
if !self.wanted(name) {
return;
}
self.current = Some(name.to_string());
self.sampler.reset();
let mut samples = Vec::new();
let mut run = || -> Res<()> {
if warm {
f(self, s, 0)?;
}
for i in 0..reps.max(1) {
samples.push(f(self, s, i + 1)?);
}
Ok(())
};
let result = run();
self.record(name, s, &samples, result);
}
fn record(&mut self, name: &str, s: &Session, samples: &[f64], result: Res<()>) {
match result {
Ok(()) => {
let r = row(name, samples, self.sampler.peak(), self.gpu_bytes(s));
println!(
"{name:<52} p50 {:>9.2} ms p95 {:>9.2} ms max {:>9.2} ms (n {}, peak RSS {} MB)",
r["p50_ms"].as_f64().unwrap_or(f64::NAN),
r["p95_ms"].as_f64().unwrap_or(f64::NAN),
r["max_ms"].as_f64().unwrap_or(f64::NAN),
samples.len(),
r["peak_rss_bytes"].as_u64().unwrap_or(0) >> 20,
);
self.rows.push(r);
}
Err(e) => {
println!("{name:<52} ERROR {e}");
self.errors.push((name.to_string(), e));
}
}
}
}
/// A headless GPU canvas on a device created like the app's.
fn canvas() -> Option<(GpuCanvas, RenderState)> {
let setup = photocraft_ui_egui::gpu_canvas::wgpu_setup();
let rs = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| egui_kittest::wgpu::create_render_state(setup, Default::default()))).ok()?;
eprintln!("adapter: {}", rs.adapter.get_info().name);
Some((GpuCanvas::new(&rs), rs))
}
/// Sizes per mode.
struct Sizes {
/// The layered document of #209 (20 MP).
layered: (u32, u32),
/// A layer of 12 MP to duplicate and paste.
big_layer: (u32, u32),
/// Layers in the "150-layer document".
many_layers: usize,
/// The 16-bit document (15000 × 10000).
huge16: (u32, u32),
/// The 32-bit document with 20 layers.
float32: (u32, u32),
/// 10 MP for Content-Aware Fill and Spot Healing.
ten_mp: (u32, u32),
/// 24 MP for the selection algorithms and Content-Aware Scale.
big_photo: (u32, u32),
/// A4 at 300 ppi.
a4: (u32, u32),
}
const FULL: Sizes = Sizes {
layered: (5472, 3648),
big_layer: (4243, 2828),
many_layers: 150,
huge16: (15000, 10000),
float32: (4000, 3000),
ten_mp: (3872, 2592),
big_photo: (6000, 4000),
a4: (2480, 3508),
};
const QUICK: Sizes = Sizes {
layered: (1600, 1066),
big_layer: (1200, 800),
many_layers: 40,
huge16: (2400, 1600),
float32: (1200, 900),
ten_mp: (1200, 800),
big_photo: (1600, 1066),
a4: (1240, 1754),
};
/// The layered document of #209: a background, six pixel layers with a drop shadow and a stroke,
/// four type layers with a drop shadow, and a Levels adjustment on top. Returns the session and
/// the id of the styled layer the edits target.
fn layered_doc(w: u32, h: u32) -> Res<(Session, u64)> {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
let mut target = 0;
for i in 0..6u32 {
exec(&mut s, "layer.new.layer", json!({"name": format!("panel {i}")}))?;
let (x, y) = ((i % 3) * w / 3 + w / 24, (i / 3) * h / 2 + h / 16);
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 4, "height": h / 3, "ellipse": i % 2 == 1}))?;
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}6040", 40 + i * 30)}))?;
exec(&mut s, "select.deselect", json!({}))?;
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 12, "size": 16}))?;
exec(&mut s, "layer.layerStyle.stroke", json!({"size": 3, "color": "#202020", "add": true}))?;
if i == 1 {
target = active_layer(&s)?;
}
}
for i in 0..4u32 {
exec(&mut s, "type.create", json!({"x": w / 10 + i * w / 5, "y": h * 9 / 10, "text": format!("Headline {i}"), "size": (h / 30).max(12)}))?;
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
}
exec(&mut s, "layer.newAdjustmentLayer.levels", json!({"inBlack": 8, "inWhite": 245, "gamma": 1.05}))?;
exec(&mut s, "layer.select", json!({"layer": target}))?;
Ok((s, target))
}
/// A document with `n` layers: small painted rects, every fifth a type layer, every seventh
/// styled.
fn many_layers_doc(w: u32, h: u32, n: usize) -> Res<(Session, u64)> {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h, "background": "white"}))?;
let mut mid = 0;
for i in 0..n as u32 {
let (x, y) = ((i * 397) % (w - w / 8), (i * 211) % (h - h / 8));
if i % 5 == 4 {
exec(&mut s, "type.create", json!({"x": x, "y": y + 40, "text": format!("Label {i}"), "size": 28}))?;
} else {
exec(&mut s, "layer.new.layer", json!({"name": format!("item {i}")}))?;
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 10, "height": h / 10}))?;
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}{:02x}80", (i * 37) % 256, (i * 91) % 256)}))?;
exec(&mut s, "select.deselect", json!({}))?;
}
if i % 7 == 3 {
exec(&mut s, "layer.layerStyle.dropShadow", json!({"distance": 6, "size": 8}))?;
}
if i as usize == n / 2 {
mid = active_layer(&s)?;
}
}
exec(&mut s, "layer.select", json!({"layer": mid}))?;
Ok((s, mid))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let quick = args.iter().any(|a| a == "--quick");
let sz = if quick { &QUICK } else { &FULL };
let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(if quick { 7 } else { 15 }).max(1);
let gpu = if args.iter().any(|a| a == "--cpu") { None } else { canvas() };
let adapter = gpu.as_ref().map(|(_, rs)| rs.adapter.get_info().name);
let mut b = Bench {
gpu,
sampler: RssSampler::start(std::time::Duration::from_millis(2)),
reps,
only: arg(&args, "--only"),
rows: Vec::new(),
errors: Vec::new(),
current: None,
};
println!("perf_scenarios: {} mode, {} reps, {}", if quick { "quick" } else { "full" }, reps, adapter.as_deref().unwrap_or("CPU canvas"));
let t_all = Instant::now();
// A panic in one group (a crash the scenario found) is reported against the row that was
// running, and the other groups still run.
type Group = fn(&mut Bench, &Sizes);
let groups: [(&str, Group); 5] = [
("layered", layered_scenarios),
("many layers", many_layer_scenarios),
("bit depths", depth_scenarios),
("long operations", job_scenarios),
("algorithms", algorithm_scenarios),
];
let use_gpu = b.gpu.is_some();
for (i, (group, run)) in groups.into_iter().enumerate() {
b.current = None;
// Each group starts on a fresh canvas device (one document open, as in the app), so
// GPU memory or an error left by one group never carries into the next.
if use_gpu && i > 0 {
b.gpu = None;
b.gpu = canvas();
}
if let Err(e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&mut b, sz))) {
let msg = e.downcast_ref::<String>().cloned().or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string())).unwrap_or_else(|| "panic".into());
let msg = format!("panicked: {}", msg.lines().filter(|l| !l.trim().is_empty()).collect::<Vec<_>>().join(" / "));
let row = b.current.take().unwrap_or_else(|| group.to_string());
println!("{row:<52} ERROR {msg}");
b.errors.push((row, msg));
}
}
println!("total {:.1} s", t_all.elapsed().as_secs_f64());
if let Some(out) = arg(&args, "--json") {
let context = json!({
"mode": if quick { "quick" } else { "full" },
"reps": reps,
"gpu_adapter": adapter,
"errors": b.errors.iter().map(|(n, e)| json!({"name": n, "error": e})).collect::<Vec<_>>(),
});
let rep = report("perf_scenarios", context, std::mem::take(&mut b.rows), Some(&b.sampler));
if let Err(e) = write_report(&out, &rep) {
eprintln!("{e}");
std::process::exit(1);
}
}
}
/// #209 rows on the layered 20 MP document.
fn layered_scenarios(b: &mut Bench, sz: &Sizes) {
let (w, h) = sz.layered;
let (mut s, target) = match layered_doc(w, h) {
Ok(v) => v,
Err(e) => {
b.errors.push(("layered document".into(), e));
return;
}
};
let _ = b.refresh(&s, true);
let reps = b.reps;
b.time("move styled layer 10 px + refresh", &mut s, reps, true, |b, s, i| {
let d = if i % 2 == 0 { 10 } else { -10 };
let t = Instant::now();
exec(s, "layer.translate", json!({"layer": target, "dx": d, "dy": d / 2}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("set layer opacity + refresh", &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": target, "opacity": if i % 2 == 0 { 0.6 } else { 0.9 }}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("set layer fill + refresh", &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": target, "fill": if i % 2 == 0 { 0.5 } else { 1.0 }}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("set blend mode + refresh", &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": target, "blend": if i % 2 == 0 { "Multiply" } else { "Normal" }}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("toggle visibility + refresh", &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.setProps", json!({"layer": target, "visible": i % 2 == 0}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
let _ = exec(&mut s, "layer.setProps", json!({"layer": target, "visible": true}));
b.time("undo move + refresh", &mut s, reps, true, |b, s, _| {
exec(s, "layer.translate", json!({"layer": target, "dx": 7, "dy": 3}))?;
b.refresh(s, false)?;
let t = Instant::now();
exec(s, "edit.undo", json!({}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("redo opacity change + refresh", &mut s, reps, true, |b, s, i| {
exec(s, "layer.setProps", json!({"layer": target, "opacity": 0.5 + 0.02 * i as f64}))?;
exec(s, "edit.undo", json!({}))?;
b.refresh(s, false)?;
let t = Instant::now();
exec(s, "edit.redo", json!({}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
// A 12 MP layer, duplicated and pasted: the first time (a fresh layer, cold caches) and
// again (the same source).
let (lw, lh) = sz.big_layer;
let fresh_layer = |s: &mut Session| -> Res<u64> {
exec(s, "layer.new.layer", json!({"name": "12 MP"}))?;
exec(s, "select.rect", json!({"x": (w - lw) / 2, "y": (h - lh) / 2, "width": lw, "height": lh}))?;
exec(s, "edit.fill", json!({"color": "#3070a0"}))?;
exec(s, "select.deselect", json!({}))?;
active_layer(s)
};
let reps_big = reps.min(5);
let mut src = 0;
b.time("duplicate 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| {
src = fresh_layer(s)?;
b.refresh(s, false)?;
let t = Instant::now();
exec(s, "layer.duplicate", json!({"layer": src}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("duplicate 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| {
let t = Instant::now();
exec(s, "layer.duplicate", json!({"layer": src}))?;
let v = ms(t) + b.refresh(s, false)?;
exec(s, "edit.undo", json!({}))?;
b.refresh(s, false)?;
Ok(v)
});
b.time("paste 12 MP layer (first) + refresh", &mut s, reps_big, false, |b, s, _| {
let id = fresh_layer(s)?;
exec(s, "layer.select", json!({"layer": id}))?;
exec(s, "select.all", json!({}))?;
exec(s, "edit.copy", json!({}))?;
exec(s, "select.deselect", json!({}))?;
b.refresh(s, false)?;
let t = Instant::now();
exec(s, "edit.paste", json!({}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time("paste 12 MP layer (repeat) + refresh", &mut s, reps_big, false, |b, s, _| {
let t = Instant::now();
exec(s, "edit.paste", json!({}))?;
let v = ms(t) + b.refresh(s, false)?;
exec(s, "edit.undo", json!({}))?;
b.refresh(s, false)?;
Ok(v)
});
}
/// #209: arrow-key nudge in the 150-layer document.
fn many_layer_scenarios(b: &mut Bench, sz: &Sizes) {
// Row names stay stable across modes: the layer count is in the mode, not the key.
let name = "nudge 1 px in the many-layer document + refresh".to_string();
if !b.wanted(&name) {
return;
}
// The document's first refresh is part of this row (a crash there is reported against it).
b.current = Some(name.clone());
let (w, h) = sz.layered;
let (mut s, mid) = match many_layers_doc(w, h, sz.many_layers) {
Ok(v) => v,
Err(e) => {
b.errors.push((name, e));
return;
}
};
let _ = b.refresh(&s, true);
let reps = b.reps;
b.time(&name, &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.translate", json!({"layer": mid, "dx": if i % 2 == 0 { 1 } else { -1 }, "dy": 0}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
}
/// #209: edits on the 15000 × 10000 16-bit document and the 32-bit document with 20 layers.
fn depth_scenarios(b: &mut Bench, sz: &Sizes) {
let reps = b.reps;
for (label, (w, h), depth, layers) in [("16-bit 15000x10000", sz.huge16, 16, 1u32), ("32-bit 20 layers", sz.float32, 32, 20)] {
let dab = format!("brush dab 60 px on {label} + refresh");
let mv = format!("move layer on {label} + refresh");
if !b.wanted(&dab) && !b.wanted(&mv) {
continue;
}
let built = (|| -> Res<(Session, u64)> {
let mut s = Session::new();
exec(&mut s, "file.new", json!({"width": w, "height": h, "depth": depth, "background": "white"}))?;
let mut first = 0;
for i in 0..layers {
exec(&mut s, "layer.new.layer", json!({"name": format!("layer {i}")}))?;
let (x, y) = ((i * 173) % (w * 3 / 4), (i * 97) % (h * 3 / 4));
exec(&mut s, "select.rect", json!({"x": x, "y": y, "width": w / 4, "height": h / 4}))?;
exec(&mut s, "edit.fill", json!({"color": format!("#{:02x}8060", (i * 41) % 256)}))?;
exec(&mut s, "select.deselect", json!({}))?;
if i == 0 {
first = active_layer(&s)?;
}
}
Ok((s, first))
})();
let (mut s, layer) = match built {
Ok(v) => v,
Err(e) => {
b.errors.push((dab, e));
continue;
}
};
let _ = b.refresh(&s, true);
let _ = exec(&mut s, "layer.select", json!({"layer": layer}));
b.time(&dab, &mut s, reps, true, |b, s, i| {
let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400));
let t = Instant::now();
exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 60, "hardness": 0.8, "color": "#c03020"}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
b.time(&mv, &mut s, reps, true, |b, s, i| {
let t = Instant::now();
exec(s, "layer.translate", json!({"layer": layer, "dx": if i % 2 == 0 { 10 } else { -10 }, "dy": 0}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
}
}
/// #210 rows that are measurable before the job system exists.
fn job_scenarios(b: &mut Bench, sz: &Sizes) {
let reps = b.reps.min(5);
let open = "open 20 MP layered PSD (decode + first composite)";
if b.wanted(open) {
let (w, h) = sz.layered;
match layered_doc(w, h).and_then(|(s, _)| {
let d = s.active().ok_or("no document")?.doc.clone();
photocraft_io::export(&d, "layered.psd", &Default::default()).map(|o| o.bytes).map_err(|e| e.to_string())
}) {
Ok(psd) => {
let mut holder = Session::new();
b.time(open, &mut holder, reps, true, |b, s, _| {
let t = Instant::now();
let d = photocraft_io::import("layered.psd", &psd).map_err(|e| e.to_string())?.document;
*s = Session::new();
s.open_document(d, Some("layered.psd".into()));
Ok(ms(t) + b.refresh(s, true)?)
});
}
Err(e) => b.errors.push((open.into(), e)),
}
}
let caf = "Content-Aware Fill 10 MP (200 px hole)";
let heal = "Spot Healing stroke 10 MP";
if b.wanted(caf) || b.wanted(heal) {
let (w, h) = sz.ten_mp;
match open_photo(w, h) {
Ok(mut s) => {
b.time(caf, &mut s, reps, false, |_, s, i| {
exec(s, "select.rect", json!({"x": w / 3 + i as u32 * 40, "y": h / 3, "width": 200, "height": 200, "ellipse": true}))?;
let t = Instant::now();
exec(s, "edit.contentAwareFill", json!({"seed": i}))?;
let v = ms(t);
exec(s, "edit.undo", json!({}))?;
exec(s, "select.deselect", json!({}))?;
Ok(v)
});
b.time(heal, &mut s, reps, false, |b, s, i| {
let (x, y) = (w / 4 + i as u32 * 60, h / 4);
let pts: Vec<Value> = (0..10).map(|k| json!([x + k * 12, y + k * 4])).collect();
let t = Instant::now();
exec(s, "paint.spotHealing", json!({"points": pts, "size": 60}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
}
Err(e) => b.errors.push((caf.into(), e)),
}
}
}
/// #211 rows: selection algorithms, Content-Aware Scale and Select Subject on 24 MP, and a brush
/// dab on an A4 300 ppi CMYK document.
fn algorithm_scenarios(b: &mut Bench, sz: &Sizes) {
let reps = b.reps.min(5);
let names = [
"Select > Modify > Smooth r 50 on 24 MP",
"Select > Modify > Feather r 50 on 24 MP",
"Select Subject 24 MP (repeat)",
"Content-Aware Scale 24 MP to 90 % width",
];
if names.iter().any(|n| b.wanted(n)) {
let (w, h) = sz.big_photo;
match open_photo(w, h) {
Ok(mut s) => {
for (name, op) in [(names[0], "select.modify.smooth"), (names[1], "select.modify.feather")] {
b.time(name, &mut s, reps, false, |_, s, _| {
exec(s, "select.rect", json!({"x": w / 6, "y": h / 6, "width": w * 2 / 3, "height": h * 2 / 3, "ellipse": true}))?;
let t = Instant::now();
exec(s, op, json!({"radius": 50}))?;
let v = ms(t);
exec(s, "select.deselect", json!({}))?;
Ok(v)
});
}
b.time(names[2], &mut s, reps, true, |_, s, _| {
let t = Instant::now();
exec(s, "select.subject", json!({}))?;
let v = ms(t);
exec(s, "select.deselect", json!({}))?;
Ok(v)
});
// One run: today this takes far longer than its budget.
b.time(names[3], &mut s, 1, false, |_, s, _| {
let t = Instant::now();
exec(s, "edit.contentAwareScale", json!({"scaleX": 90, "scaleY": 100}))?;
let v = ms(t);
exec(s, "edit.undo", json!({}))?;
Ok(v)
});
}
Err(e) => b.errors.push((names[0].into(), e)),
}
}
let cmyk = "brush dab on A4 300 ppi CMYK + refresh";
if b.wanted(cmyk) {
let (w, h) = sz.a4;
let mut s = Session::new();
let setup = exec(&mut s, "file.new", json!({"width": w, "height": h, "mode": "cmyk", "resolution": 300, "background": "white"}))
.and_then(|_| exec(&mut s, "layer.new.layer", json!({"name": "paint"})));
if let Err(e) = setup {
b.errors.push((cmyk.into(), e));
return;
}
let _ = b.refresh(&s, true);
let reps = b.reps;
b.time(cmyk, &mut s, reps, true, |b, s, i| {
let (x, y) = (200 + (i as u32 * 97) % (w - 400), 200 + (i as u32 * 61) % (h - 400));
let t = Instant::now();
exec(s, "paint.stroke", json!({"points": [[x, y]], "size": 40, "hardness": 0.8, "color": "#c03020"}))?;
Ok(ms(t) + b.refresh(s, false)?)
});
}
}
+14
View File
@@ -81,6 +81,20 @@ fn main() {
while size(&app) != start && undos < steps + 1 && app.session.undo() {
undos += 1;
}
// `--json out.json` (`cargo xtask perf`): the per-step samples.
if let Some(out) = arg(&args, "--json") {
use photocraft_testkit::perf::{process_peak_rss_bytes, report, row, write_report};
let rss = photocraft_testkit::perf::current_rss_bytes().max(process_peak_rss_bytes());
let rows = vec![
row("font size step: command", &cmd, rss, None),
row("font size step: canvas refresh", &canvas, rss, None),
row("font size step: total", &total, rss, None),
];
let rep = report("type_bench", json!({"width": w, "height": h, "layers": n, "steps": steps, "effects": effects}), rows, None);
if let Err(e) = write_report(&out, &rep) {
eprintln!("{e}");
}
}
println!(
"size step (median of {steps}): command {:.2} ms, canvas refresh {:.2} ms, total {:.2} ms; undo steps for the drag: {undos}",
median(cmd),
+23 -2
View File
@@ -366,6 +366,13 @@ struct CurveUi {
/// Dragging a point this far outside the graph removes it (it comes back if dragged back in).
pub const DRAG_OFF: f32 = 12.0;
const HIT_RADIUS: f32 = 9.0;
const CURVE_HANDLE_SIZE: f32 = 7.0;
const CURVE_GRAPH_PADDING: f32 = 5.0;
fn curve_graph(full: Rect, side: f32) -> Rect {
let base = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
Rect::from_min_max(base.min + vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING), base.max - vec2(CURVE_GRAPH_PADDING, CURVE_GRAPH_PADDING))
}
fn read_curve(v: &Value, key: &str) -> Vec<[f32; 2]> {
let pts: Vec<[f32; 2]> = v
@@ -403,7 +410,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
let side = ui.available_width().clamp(120.0, 300.0);
ui.add_space(4.0);
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
let graph = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
let graph = curve_graph(full, side);
ui.data_mut(|d| d.insert_temp(cx.mem.with("curves-graph"), graph));
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
let to_val =
@@ -535,7 +542,7 @@ fn curves(ui: &mut egui::Ui, v: &mut Value, cx: &EditorCx) -> Edit {
}
draw_curve(&pts, if ch == 0 { t.text } else { bar_color(&chan, &t) }, 1.5);
for (i, q) in pts.iter().enumerate() {
let r = Rect::from_center_size(to_scr(*q), vec2(7.0, 7.0));
let r = Rect::from_center_size(to_scr(*q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE));
if Some(i) == st.sel {
p.rect_filled(r, 0.0, t.text);
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
@@ -1404,6 +1411,20 @@ mod tests {
assert_eq!(hit(&p, to_scr, Pos2::new(50.0, 50.0), 9.0), None);
}
#[test]
fn curve_endpoint_handles_fit_inside_the_allocated_area() {
for side in [120.0, 200.0, 300.0] {
let full = Rect::from_min_size(pos2(100.0, 50.0), vec2(side, side + 14.0));
let graph = curve_graph(full, side);
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
for q in [[0.0, 0.0], [255.0, 255.0]] {
let r = Rect::from_center_size(to_scr(q), vec2(CURVE_HANDLE_SIZE, CURVE_HANDLE_SIZE)).expand(1.0);
assert!(r.left() >= full.left() && r.right() <= full.right());
assert!(r.top() >= full.top() && r.bottom() <= full.bottom());
}
}
}
#[test]
fn levels_helpers() {
for g in [0.3f32, 1.0, 1.8, 4.0] {
+5 -1
View File
@@ -41,7 +41,11 @@ fn app_harness(kind: &str, mode: &str, depth: u32) -> Harness<'static, Photocraf
s.execute("edit.fill", json!({"color": "#6a3020"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap();
PhotocraftApp::new(s, crate::Services::default())
let mut app = PhotocraftApp::new(s, crate::Services::default());
// The default Properties group gives way to Layers (#147) and scrolls taller editors;
// these tests drive every control without scrolling, so they size it like a user would.
app.ui.dock.heights.insert(crate::dock::Group::Properties, 560.0);
app
});
h.run_steps(8);
h
+127 -219
View File
@@ -8,11 +8,15 @@
//!
//! The panel never writes the session brush itself: each frame's edits become one
//! `tools.setBrush` call carrying only the changed fields (Rule 1), so the journal, the control
//! channel and the MCP server see exactly what the panel did and can do the same.
//! channel and the MCP server see exactly what the panel did and can do the same. A drag's calls
//! share a `coalesce` key, so a whole gesture is one journal entry ([`commit_gesture`]).
//!
//! The lock beside a section name keeps that section's settings when another preset is picked
//! (`locks` in the brush, applied by the engine). The Brushes tab lives in [`crate::brushes_tab`].
use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2};
use photocraft_engine::BrushSettings;
use photocraft_engine::paint::{self, TipShape};
use photocraft_engine::paint;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
@@ -22,6 +26,7 @@ use crate::{PhotocraftApp, icons, widgets};
pub use crate::brush_preview::{preview_pixels, preview_sig};
pub use crate::brush_sections::section_body;
pub use photocraft_engine::brush_cmds::brush_patch;
/// Sections in Photoshop's order. The bool says whether the section has an enable box.
pub const SECTIONS: [(&str, bool); 13] = [
@@ -41,9 +46,31 @@ pub const SECTIONS: [(&str, bool); 13] = [
];
/// Panel width and the stroke strip's size.
const WIDTH: f32 = 540.0;
pub(crate) const WIDTH: f32 = 540.0;
const STRIP: (u32, u32) = (488, 76);
/// How the Brushes tab lists presets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BrushesView {
/// Rows: tip, size, stroke preview, name.
#[default]
List,
/// Tip thumbnails with their sizes.
Grid,
}
/// A rename in progress in the Brushes tab (a preset, or a group when `group` is set).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Renaming {
pub group: bool,
/// The preset (or group) being renamed.
pub name: String,
/// The text typed so far.
pub text: String,
}
/// Brushes panel view state (serde, so the control channel can read and drive it).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -52,6 +79,8 @@ pub struct BrushesPanelState {
pub collapsed: Vec<String>,
/// Case-insensitive name filter.
pub filter: String,
pub view: BrushesView,
pub renaming: Option<Renaming>,
}
/// The enable flag behind section `i` (None for Brush Tip Shape and Smoothing).
@@ -72,63 +101,74 @@ pub fn section_flag(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
})
}
/// The brush as JSON with the bitmaps (sampled tips, pattern tiles) that `skip` names replaced by
/// placeholders: they can be megabytes of base64, and skipped ones are equal on both sides.
fn light_json(b: &BrushSettings, skip: (bool, bool, bool)) -> Value {
let mut c = b.clone();
if skip.0 {
c.tip = TipShape::Round;
}
if skip.1 {
c.dual_brush.tip = TipShape::Round;
}
if skip.2 {
c.texture.pattern = paint::Pattern::default();
}
serde_json::to_value(&c).unwrap_or(Value::Null)
}
/// Fields of `new` that differ from `old`, nested objects diffed key by key: a minimal
/// `tools.setBrush` patch (`{}` when nothing changed).
pub fn brush_patch(old: &BrushSettings, new: &BrushSettings) -> Value {
let skip = (old.tip == new.tip, old.dual_brush.tip == new.dual_brush.tip, old.texture.pattern == new.texture.pattern);
fn diff(a: &Value, b: &Value) -> Option<Value> {
match (a, b) {
(Value::Object(ao), Value::Object(bo)) => {
let mut out = Map::new();
for (k, bv) in bo {
match ao.get(k) {
Some(av) => {
if let Some(d) = diff(av, bv) {
out.insert(k.clone(), d);
}
}
None => {
out.insert(k.clone(), bv.clone());
}
}
}
(!out.is_empty()).then_some(Value::Object(out))
}
// Enums with payloads (tips, patterns) are replaced whole.
_ => (a != b).then(|| b.clone()),
}
}
diff(&light_json(old, skip), &light_json(new, skip)).unwrap_or_else(|| json!({}))
/// The lock behind section `i` (every section but Brush Tip Shape has one, as in Photoshop).
pub fn section_lock(b: &mut BrushSettings, i: usize) -> Option<&mut bool> {
let l = &mut b.locks;
Some(match i {
1 => &mut l.shape_dynamics,
2 => &mut l.scattering,
3 => &mut l.texture,
4 => &mut l.dual_brush,
5 => &mut l.color_dynamics,
6 => &mut l.transfer,
7 => &mut l.pose,
8 => &mut l.noise,
9 => &mut l.wet_edges,
10 => &mut l.build_up,
11 => &mut l.smoothing,
12 => &mut l.protect_texture,
_ => return None,
})
}
/// Send the panel's edits (`before` → `after`) through `tools.setBrush`.
pub fn commit(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings) {
send(app, before, after, None);
}
fn send(app: &mut PhotocraftApp, before: &BrushSettings, after: &BrushSettings, key: Option<String>) -> bool {
if before == after {
return;
return false;
}
let patch = brush_patch(before, after);
if patch.as_object().is_some_and(Map::is_empty) {
return;
return false;
}
if let Err(e) = app.run("tools.setBrush", json!({ "brush": patch })) {
let mut p = json!({ "brush": patch });
if let Some(k) = key {
p["coalesce"] = json!(k);
}
if let Err(e) = app.run("tools.setBrush", p) {
app.ui.status = e;
}
true
}
const GESTURE: &str = "brush-set-gesture";
/// The current brush-edit gesture number. A pointer press starts a new gesture (once per frame);
/// [`commit_gesture`] ends one after an edit made with the pointer up (a click, a typed value).
fn gesture(ctx: &egui::Context) -> u64 {
let frame = ctx.cumulative_pass_nr();
let pressed = ctx.input(|i| i.pointer.any_pressed());
ctx.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE));
if pressed && g.1 != frame {
g.0 += 1;
g.1 = frame;
}
g.0
})
}
/// [`commit`] as part of a gesture: every call of one drag carries the same `coalesce` key, so
/// the engine journals the drag as a single `tools.setBrush` (Rule 1: one command per gesture).
/// Call it every frame (it tracks presses even when nothing changed).
pub fn commit_gesture(app: &mut PhotocraftApp, ctx: &egui::Context, before: &BrushSettings, after: &BrushSettings) {
let g = gesture(ctx);
if send(app, before, after, Some(format!("brush-ui:{g}"))) && !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|d| d.get_temp_mut_or_default::<(u64, u64)>(egui::Id::new(GESTURE)).0 += 1);
}
}
/// Group label for presets saved without a group.
@@ -147,189 +187,41 @@ pub fn grouped_presets(presets: &[paint::BrushPreset]) -> Vec<(String, Vec<usize
out
}
/// Does the current brush match `preset` (everything but colour and size)? Cheap fields first:
/// the full comparison clones the preset and its tip.
/// Does the current brush match `preset` (everything but colour, size and the tool state that
/// picking a preset keeps: smoothing and the section locks)? Cheap fields first: the full
/// comparison clones the preset and its tip.
pub fn is_current(preset: &BrushSettings, brush: &BrushSettings) -> bool {
preset.hardness == brush.hardness
&& preset.spacing == brush.spacing
&& preset.tip == brush.tip
&& BrushSettings { color: brush.color, size: brush.size, background: brush.background, ..preset.clone() } == *brush
&& BrushSettings {
color: brush.color,
size: brush.size,
background: brush.background,
smoothing: brush.smoothing.clone(),
locks: brush.locks.clone(),
..preset.clone()
} == *brush
}
/// A fresh "Brush N" name.
fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
pub(crate) fn new_preset_name(presets: &[paint::BrushPreset]) -> String {
(1..).map(|n| format!("Brush {n}")).find(|n| !presets.iter().any(|p| &p.name == n)).unwrap_or_else(|| "Brush".into())
}
fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
pub(crate) fn run_or_status(app: &mut PhotocraftApp, id: &str, p: Value) {
if let Err(e) = app.run(id, p) {
app.ui.status = e;
}
}
fn full_uv() -> egui::Rect {
pub(crate) fn full_uv() -> egui::Rect {
egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0))
}
fn brushes_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// Size of the current brush (Photoshop's Brushes panel slider).
let before = app.session.tools.brush.clone();
let mut b = before.clone();
ui.horizontal(|ui| {
ui.label(RichText::new("Size").color(t.text_dim));
let mut lv = b.size.max(1.0).ln();
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
if r.changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
r
});
let mut s = b.size;
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
b.size = s.round().clamp(1.0, 5000.0);
}
});
commit(app, &before, &b);
ui.add_space(4.0);
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 60.0));
});
ui.add_space(6.0);
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
let groups = grouped_presets(&app.session.tools.presets);
let brush = &app.session.tools.brush;
let mut clicked = None;
let mut toggle = None;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for (group, items) in groups {
let items: Vec<usize> = items
.into_iter()
.filter(|i| filter.is_empty() || app.session.tools.presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter)))
.collect();
if items.is_empty() {
continue;
}
let open = !filter.is_empty() || !app.ui.brushes_panel.collapsed.contains(&group);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 4.0;
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
if open { "chevron-down" } else { "chevron-right" },
12.0,
t.text_dim,
);
icons::paint(
ui,
egui::Rect::from_min_size(egui::pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
if open { "folder-open" } else { "folder" },
14.0,
t.icon,
);
ui.painter().text(egui::pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, &group, theme::semibold(12.0), t.text);
ui.painter().text(
egui::pos2(r.right() - 8.0, r.center().y),
egui::Align2::RIGHT_CENTER,
items.len().to_string(),
theme::medium(11.0),
t.text_faint,
);
if resp.clicked() {
toggle = Some(group.clone());
}
if !open {
continue;
}
for i in items {
let Some(p) = app.session.tools.presets.get(i) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
if !ui.is_rect_visible(r) {
continue;
}
if is_current(&p.brush, brush) {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(cell.center().x, r.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.0),
t.text_faint,
);
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
let sr = egui::Rect::from_min_size(egui::pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
ui.painter().text(
egui::pos2(sr.right() + 12.0, r.center().y),
egui::Align2::LEFT_CENTER,
&p.name,
egui::FontId::proportional(12.0),
t.text_dim,
);
if resp.clicked() {
clicked = Some(p.name.clone());
}
}
ui.add_space(2.0);
}
});
if let Some(g) = toggle {
let c = &mut app.ui.brushes_panel.collapsed;
match c.iter().position(|x| *x == g) {
Some(i) => {
c.remove(i);
}
None => c.push(g),
}
}
if let Some(name) = clicked {
run_or_status(app, "tools.setBrush", json!({ "preset": name }));
}
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
&& let Some(name) = current
{
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
}
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
app.open_dialog_file();
}
});
});
// Forget previews of presets that no longer exist.
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
brush_preview::with_cache(ui.ctx(), |c| {
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
});
}
/// The section list: enable boxes, names, the selection. Clicking a name shows the section and
/// turns it on (Photoshop); clicking a box only toggles it.
/// The section list: enable boxes, names, locks, the selection. Clicking a name shows the section
/// and turns it on (Photoshop); clicking a box only toggles it; the lock on the right keeps the
/// section when another preset is picked.
fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
@@ -355,9 +247,10 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
} else {
ui.painter().rect_stroke(br, 2.0, Stroke::new(1.2, t.text_faint), egui::StrokeKind::Inside);
}
let on_lock = resp.interact_pointer_pos().is_some_and(|p| p.x > r.right() - 22.0);
if box_resp.clicked() {
*flag = !*flag;
} else if resp.clicked() {
} else if resp.clicked() && !on_lock {
*flag = true;
}
x += 20.0;
@@ -367,7 +260,22 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
let color = if sel { t.text } else { t.text_dim };
let font = if i == 0 { theme::semibold(12.0) } else { theme::medium(12.0) };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, *name, font, color);
if resp.clicked() {
let mut lock_clicked = false;
if let Some(lock) = section_lock(b, i) {
let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0));
let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click());
// Like Photoshop: the lock shows only when set or hovered.
if *lock || resp.hovered() || lresp.hovered() {
let tint = if *lock { t.text } else { t.text_faint };
icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint);
}
let tip = if *lock { "Unlock: picking a preset replaces these settings" } else { "Lock: keep these settings when picking another preset" };
if lresp.on_hover_text(tip).clicked() {
*lock = !*lock;
lock_clicked = true;
}
}
if resp.clicked() && !lock_clicked {
app.ui.brush_section = i;
}
if i == 0 {
@@ -411,11 +319,11 @@ fn settings_tab(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
run_or_status(app, "brush.presets.save", json!({ "name": name, "brush": serde_json::to_value(&b).unwrap_or(Value::Null) }));
}
if icons::button(ui, "undo-2", 24.0, false, "Reset the brush to the defaults").clicked() {
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), ..Default::default() };
b = BrushSettings { color: b.color, background: b.background, smoothing: b.smoothing.clone(), locks: b.locks.clone(), ..Default::default() };
}
});
});
commit(app, &before, &b);
commit_gesture(app, ui.ctx(), &before, &b);
}
pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
@@ -457,7 +365,7 @@ pub fn window(app: &mut PhotocraftApp, ctx: &egui::Context) {
widgets::hairline(ui);
ui.add_space(6.0);
if app.ui.brush_tab == 1 {
brushes_tab(app, ui);
crate::brushes_tab::show(app, ui);
} else {
settings_tab(app, ui);
}
+151 -3
View File
@@ -34,6 +34,7 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
b.roundness = 0.4;
b.hardness = 0.25;
b.spacing = 0.6;
b.spacing_enabled = false;
}
1 => {
let sd = &mut b.shape_dynamics;
@@ -43,6 +44,8 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
sd.roundness = dynamic(0.5, Control::PenTilt, 0.25);
sd.flip_x_jitter = true;
sd.flip_y_jitter = true;
sd.tilt_scale = 1.25;
sd.brush_projection = true;
}
2 => {
let sc = &mut b.scattering;
@@ -92,6 +95,9 @@ fn edit_section(i: usize, b: &mut BrushSettings) {
b.transfer.enabled = true;
b.transfer.opacity = dynamic(0.5, Control::PenPressure, 0.1);
b.transfer.flow = dynamic(0.25, Control::Fade, 0.3);
b.transfer.wetness = dynamic(0.35, Control::PenPressure, 0.1);
b.transfer.mix = dynamic(0.45, Control::Fade, 0.2);
b.mixer = paint::MixerSettings { wet: 0.9, load: 0.15, mix: 0.7, flow: 0.8, sample_all_layers: true };
}
7 => {
let p = &mut b.pose;
@@ -136,7 +142,7 @@ fn every_section_round_trips_through_set_brush() {
let (id, p) = last_journal(&app).expect("journaled");
assert_eq!(id, "tools.setBrush");
let patch = &p["brush"];
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 7), "section {i}: {patch}");
assert!(patch.as_object().is_some_and(|o| !o.is_empty() && o.len() <= 8), "section {i}: {patch}");
// And the engine's view (`brush.get`) reads back the same settings.
let got = app.session.execute("brush.get", json!({})).unwrap();
assert_eq!(serde_json::from_value::<BrushSettings>(got).unwrap(), after, "section {i}");
@@ -189,6 +195,144 @@ fn bad_patches_fail_without_touching_the_brush() {
}
}
#[test]
fn section_locks_round_trip_and_survive_preset_picks() {
let mut b = BrushSettings::default();
for (i, (name, _)) in SECTIONS.iter().enumerate() {
assert_eq!(section_lock(&mut b, i).is_some(), i > 0, "{name} has a lock");
}
let mut app = app();
let before = app.session.tools.brush.clone();
let mut after = before.clone();
edit_section(2, &mut after);
for i in [2, 6, 8] {
*section_lock(&mut after, i).unwrap() = true;
}
commit(&mut app, &before, &after);
let (_, p) = last_journal(&app).unwrap();
assert_eq!(p["brush"]["locks"], json!({"scattering": true, "transfer": true, "noise": true}));
// Picking a preset keeps the locked sections (and the locks), and the preset still reads as current.
let chalk = paint::presets::find(&app.session.tools.presets, "Chalk").unwrap().brush.clone();
app.run("tools.setBrush", json!({"preset": "Chalk"})).unwrap();
let b = &app.session.tools.brush;
assert_eq!(b.scattering, after.scattering);
assert!(b.locks.scattering && b.locks.noise);
assert_eq!(b.tip, chalk.tip);
}
/// A harness that shows the options bar with `tool` in the Studio (non-pro) theme.
fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.tool = tool;
let mut h = Harness::builder().with_size(vec2(1400.0, 60.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::options_bar(app, ui);
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(4);
h
}
#[test]
fn options_bar_edits_are_one_set_brush_per_gesture() {
use egui_kittest::kittest::Queryable;
let mut h = options_bar_harness(crate::state::Tool::Brush);
let n = h.state().session.journal.len();
h.run_steps(3);
assert_eq!(h.state().session.journal.len(), n, "showing the bar issues nothing");
// Drag the Opacity field left: many frames, one journal entry.
let label = h.get_by_label("Opacity").rect();
let start = egui::pos2(label.right() + 40.0, label.center().y);
h.hover_at(start);
h.run();
h.drag_at(start);
h.run();
for k in 1..=8 {
h.hover_at(start - vec2(5.0 * k as f32, 0.0));
h.run();
}
h.drop_at(start - vec2(40.0, 0.0));
h.run_steps(2);
let opacity = h.state().session.tools.brush.opacity;
assert!(opacity < 0.95, "the drag lowered the opacity: {opacity}");
let j = &h.state().session.journal;
assert_eq!(j.len(), n + 1, "{:?}", &j[n..]);
let (id, p) = j.last().unwrap();
assert_eq!(id, "tools.setBrush");
assert_eq!(p["brush"]["opacity"].as_f64().map(|v| v as f32), Some(opacity));
// A click is its own gesture: the Pressure for Size switch sits left of its label.
let label = h.get_by_label("Pressure for Size").rect();
let was = h.state().session.tools.brush.pressure_size;
let at = egui::pos2(label.left() - 18.0, label.center().y);
h.hover_at(at);
h.run();
h.drag_at(at);
h.run();
h.drop_at(at);
h.run_steps(2);
assert_eq!(h.state().session.tools.brush.pressure_size, !was);
assert_eq!(h.state().session.journal.len(), n + 2);
assert_eq!(h.state().session.journal.last().unwrap().1["brush"], json!({"pressureSize": !was}));
// Replaying the journal gives the same brush.
let mut s = photocraft_engine::Session::new();
for (id, p) in &h.state().session.journal {
s.execute(id, p.clone()).unwrap();
}
assert_eq!(s.tools.brush, h.state().session.tools.brush);
}
#[test]
fn drop_targets_and_actions_reorder_presets() {
use crate::brushes_tab::{Action, apply, drop_target, group_key};
let mut app = app();
for (n, g) in [("A1", "Alpha"), ("A2", "Alpha"), ("A3", "Alpha"), ("B1", "Beta")] {
app.session.tools.presets.push(paint::BrushPreset { name: n.into(), brush: Default::default(), builtin: false, group: g.into() });
}
let p = &app.session.tools.presets;
// Dropping A1 on A3's lower half lands after A3 (index 2 once A1 is out); on its upper half, before it.
assert_eq!(drop_target(p, "A1", "A3", true), Some(("Alpha".into(), 2)));
assert_eq!(drop_target(p, "A1", "A3", false), Some(("Alpha".into(), 1)));
assert_eq!(drop_target(p, "A3", "A1", false), Some(("Alpha".into(), 0)));
assert_eq!(drop_target(p, "A2", "B1", true), Some(("Beta".into(), 1)));
assert_eq!(drop_target(p, "A2", "Nope", true), None);
assert_eq!(group_key(p, UNGROUPED), "");
let (group, index) = drop_target(p, "A1", "A3", true).unwrap();
apply(&mut app, vec![Action::Move { name: "A1".into(), group, index: Some(index) }]);
let names = |app: &PhotocraftApp, g: &str| app.session.tools.presets.iter().filter(|x| x.group == g).map(|x| x.name.clone()).collect::<Vec<_>>();
assert_eq!(names(&app, "Alpha"), ["A2", "A3", "A1"]);
apply(&mut app, vec![Action::Move { name: "A2".into(), group: "Beta".into(), index: None }]);
assert_eq!(names(&app, "Beta"), ["B1", "A2"]);
apply(&mut app, vec![Action::MoveGroup { group: "Beta".into(), before: Some("Alpha".into()) }]);
let order = photocraft_engine::brush_preset_cmds::group_order(&app.session.tools.presets);
assert!(order.iter().position(|g| g == "Beta") < order.iter().position(|g| g == "Alpha"));
// Rename and delete from the context menu.
apply(&mut app, vec![Action::Rename(Renaming { group: false, name: "A3".into(), text: "Third".into() })]);
assert!(paint::presets::find(&app.session.tools.presets, "Third").is_some());
assert!(app.ui.brushes_panel.renaming.is_none());
apply(&mut app, vec![Action::Rename(Renaming { group: true, name: "Beta".into(), text: "Bees".into() })]);
assert_eq!(names(&app, "Bees"), ["B1", "A2"]);
apply(&mut app, vec![Action::Delete("Third".into()), Action::DeleteGroup("Bees".into())]);
assert_eq!(names(&app, "Alpha"), ["A1"]);
assert!(names(&app, "Bees").is_empty());
// Every action went through a journaled command.
let ids: Vec<&str> = app.session.journal.iter().map(|(id, _)| id.as_str()).collect();
for id in [
"brush.presets.move",
"brush.presets.moveGroup",
"brush.presets.rename",
"brush.presets.renameGroup",
"brush.presets.delete",
"brush.presets.deleteGroup",
] {
assert!(ids.contains(&id), "{id}: {ids:?}");
}
}
fn harness(tab: usize, section: usize) -> Harness<'static, PhotocraftApp> {
let mut app = app();
app.ui.panels.brush_settings = true;
@@ -229,9 +373,13 @@ fn preview_strip_re_renders_only_when_the_brush_changes() {
#[test]
fn every_section_draws_without_issuing_commands() {
for tab in 0..2 {
for tab in 0..3 {
for section in 0..SECTIONS.len() {
let mut h = harness(tab, section);
// Tab 2: the Brushes tab in grid view.
let mut h = harness(tab.min(1), section);
if tab == 2 {
h.state_mut().ui.brushes_panel.view = BrushesView::Grid;
}
// A brush with every section on and a sampled tip.
let mut b = BrushSettings::default();
for i in 0..SECTIONS.len() {
+37 -3
View File
@@ -328,12 +328,22 @@ fn tip_shape(ui: &mut egui::Ui, b: &mut BrushSettings, presets: &[BrushPreset])
ui.add_enabled_ui(matches!(b.tip, TipShape::Round), |ui| {
pct(ui, "Hardness", &mut b.hardness, 1.0);
});
signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0);
// Unchecked, the pointer's speed sets the spacing (Photoshop).
widgets::checkbox(ui, &mut b.spacing_enabled, "Spacing").on_hover_text("Off: the speed of the pointer sets the spacing");
ui.add_space(2.0);
let on = b.spacing_enabled;
ui.add_enabled_ui(on, |ui| signed_pct(ui, "Spacing", &mut b.spacing, 0.01, 10.0));
}
fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
let sd = &mut b.shape_dynamics;
dynamic(ui, "brush-size-ctl", "Size Jitter", &mut sd.size, 1.0, Some("Minimum Diameter"), false);
pct(ui, "Size Jitter", &mut sd.size.jitter, 1.0);
control_row(ui, "brush-size-ctl", &mut sd.size, false);
pct(ui, "Minimum Diameter", &mut sd.size.minimum, 1.0);
// Tilt Scale applies with the size control on Pen Tilt (greyed out otherwise, like Photoshop).
let tilt = sd.size.control == Control::PenTilt;
ui.add_enabled_ui(tilt, |ui| pct(ui, "Tilt Scale", &mut sd.tilt_scale, 2.0));
ui.add_space(6.0);
dynamic(ui, "brush-angle-ctl", "Angle Jitter", &mut sd.angle, 1.0, None, true);
dynamic(ui, "brush-round-ctl", "Roundness Jitter", &mut sd.roundness, 1.0, Some("Minimum Roundness"), false);
ui.horizontal(|ui| {
@@ -341,6 +351,8 @@ fn shape_dynamics(ui: &mut egui::Ui, b: &mut BrushSettings) {
ui.add_space(8.0);
widgets::checkbox(ui, &mut sd.flip_y_jitter, "Flip Y Jitter");
});
ui.add_space(2.0);
widgets::checkbox(ui, &mut sd.brush_projection, "Brush Projection").on_hover_text("Apply the pen's tilt and rotation to the tip shape");
}
fn scattering(ui: &mut egui::Ui, b: &mut BrushSettings) {
@@ -519,6 +531,25 @@ fn transfer(ui: &mut egui::Ui, b: &mut BrushSettings) {
let tr = &mut b.transfer;
dynamic(ui, "brush-opacity-ctl", "Opacity Jitter", &mut tr.opacity, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-flow-ctl", "Flow Jitter", &mut tr.flow, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-wet-ctl", "Wetness Jitter", &mut tr.wetness, 1.0, Some("Minimum"), false);
dynamic(ui, "brush-mix-ctl", "Mix Jitter", &mut tr.mix, 1.0, Some("Minimum"), false);
}
/// The Mixer Brush options the brush carries (Photoshop shows them in the Mixer Brush's options
/// bar; `paint.mixerBrush` uses them unless its params override them).
fn mixer_options(ui: &mut egui::Ui, b: &mut BrushSettings) {
let t = Tokens::get(ui.ctx());
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.label(RichText::new("Mixer Brush").font(theme::semibold(11.5)).color(t.text_dim));
ui.add_space(2.0);
let m = &mut b.mixer;
pct(ui, "Wet", &mut m.wet, 1.0);
pct(ui, "Load", &mut m.load, 1.0);
pct(ui, "Mix", &mut m.mix, 1.0);
pct(ui, "Flow", &mut m.flow, 1.0);
widgets::checkbox(ui, &mut m.sample_all_layers, "Sample All Layers");
}
/// Tilt is stored in degrees (±90, W3C Pointer Events); Photoshop shows it as ±100 %.
@@ -564,7 +595,10 @@ pub fn section_body(ui: &mut egui::Ui, b: &mut BrushSettings, i: usize, presets:
3 => texture(ui, b),
4 => dual_brush(ui, b, presets),
5 => color_dynamics(ui, b),
6 => transfer(ui, b),
6 => {
transfer(ui, b);
mixer_options(ui, b);
}
7 => pose(ui, b),
8 => note(ui, "Adds extra randomness to the soft edges of the brush tip. No options: turn it on in the list."),
9 => note(ui, "Builds up paint along the edges of the stroke for a watercolour look. No options: turn it on in the list."),
+415
View File
@@ -0,0 +1,415 @@
//! Window › Brushes: the brush presets in collapsible groups, as a list (tip, size, stroke
//! preview, name) or a grid of tips, with a size slider and a search field.
//!
//! Presets and groups are organised like Photoshop's panel: drag a preset within its group or
//! into another group (onto a preset, or onto a group's header to append), drag a group's header
//! to reorder groups, and right-click for Rename and Delete. Every change is a `brush.presets.*`
//! command, so it is journaled, drivable, and persisted by the preset store.
use egui::{Color32, RichText, Sense, Stroke, pos2, vec2};
use photocraft_engine::paint::BrushPreset;
use serde_json::json;
use crate::brush_panel::{BrushesView, Renaming, UNGROUPED, WIDTH, commit_gesture, full_uv, grouped_presets, is_current, new_preset_name, run_or_status};
use crate::brush_preview;
use crate::theme::{self, Tokens};
use crate::{PhotocraftApp, icons, widgets};
/// What a drag in the Brushes tab carries.
#[derive(Clone, Debug, PartialEq)]
pub enum BrushDrag {
Preset(String),
Group(String),
}
/// What the tab does after drawing (the presets are borrowed while it draws).
#[derive(Clone, Debug, PartialEq)]
pub enum Action {
Select(String),
ToggleGroup(String),
Move { name: String, group: String, index: Option<usize> },
MoveGroup { group: String, before: Option<String> },
Rename(Renaming),
Delete(String),
DeleteGroup(String),
}
/// The group key (`BrushPreset::group`) behind a panel label: ungrouped presets show as
/// [`UNGROUPED`].
pub fn group_key(presets: &[BrushPreset], label: &str) -> String {
if label == UNGROUPED && !presets.iter().any(|p| p.group == UNGROUPED) { String::new() } else { label.to_string() }
}
/// Where a preset dropped onto `target` lands: (group, index in that group after the dragged
/// preset is taken out). `after` = dropped on the target's lower (list) or right (grid) half.
pub fn drop_target(presets: &[BrushPreset], dragged: &str, target: &str, after: bool) -> Option<(String, usize)> {
let t = presets.iter().find(|p| p.name == target)?;
let members: Vec<&str> = presets.iter().filter(|p| p.group == t.group).map(|p| p.name.as_str()).collect();
let tp = members.iter().position(|n| *n == target)?;
let mut idx = tp + usize::from(after);
if let Some(dp) = members.iter().position(|n| *n == dragged)
&& dp < idx
{
idx -= 1;
}
Some((t.group.clone(), idx))
}
/// Turn the tab's actions into commands.
pub fn apply(app: &mut PhotocraftApp, acts: Vec<Action>) {
for a in acts {
match a {
Action::Select(name) => run_or_status(app, "tools.setBrush", json!({ "preset": name })),
Action::ToggleGroup(g) => {
let c = &mut app.ui.brushes_panel.collapsed;
match c.iter().position(|x| *x == g) {
Some(i) => drop(c.remove(i)),
None => c.push(g),
}
}
Action::Move { name, group, index } => {
let mut p = json!({ "name": name, "group": group });
if let Some(i) = index {
p["index"] = json!(i);
}
run_or_status(app, "brush.presets.move", p);
}
Action::MoveGroup { group, before } => run_or_status(app, "brush.presets.moveGroup", json!({ "group": group, "before": before })),
Action::Rename(r) => {
let text = r.text.trim().to_string();
if !text.is_empty() && text != r.name {
if r.group {
run_or_status(app, "brush.presets.renameGroup", json!({ "group": r.name, "newName": text }));
} else {
run_or_status(app, "brush.presets.rename", json!({ "name": r.name, "newName": text }));
}
}
app.ui.brushes_panel.renaming = None;
}
Action::Delete(name) => run_or_status(app, "brush.presets.delete", json!({ "name": name })),
Action::DeleteGroup(g) => run_or_status(app, "brush.presets.deleteGroup", json!({ "group": g })),
}
}
}
/// An accent insertion line on one edge of `r` while a drag hovers it.
fn drop_line(ui: &egui::Ui, r: egui::Rect, after: bool, vertical: bool, t: &Tokens) {
let s = Stroke::new(2.0, t.accent);
if vertical {
let x = if after { r.right() } else { r.left() };
ui.painter().line_segment([pos2(x, r.top() + 2.0), pos2(x, r.bottom() - 2.0)], s);
} else {
let y = if after { r.bottom() } else { r.top() };
ui.painter().line_segment([pos2(r.left() + 4.0, y), pos2(r.right() - 4.0, y)], s);
}
}
/// Is the pointer on the second half of `r` (lower in a list, right in a grid)?
fn second_half(ui: &egui::Ui, r: egui::Rect, grid: bool) -> bool {
ui.ctx().pointer_interact_pos().is_some_and(|p| if grid { p.x > r.center().x } else { p.y > r.center().y })
}
/// Drag, drop and context menu of one preset cell or row.
fn preset_interactions(ui: &egui::Ui, resp: &egui::Response, r: egui::Rect, p: &BrushPreset, presets: &[BrushPreset], grid: bool, acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
resp.dnd_set_drag_payload(BrushDrag::Preset(p.name.clone()));
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>()
&& matches!(&*d, BrushDrag::Preset(n) if *n != p.name)
{
drop_line(ui, r, second_half(ui, r, grid), grid, &t);
}
if let Some(d) = resp.dnd_release_payload::<BrushDrag>()
&& let BrushDrag::Preset(n) = &*d
&& *n != p.name
&& let Some((group, index)) = drop_target(presets, n, &p.name, second_half(ui, r, grid))
{
acts.push(Action::Move { name: n.clone(), group, index: Some(index) });
}
if resp.clicked() {
acts.push(Action::Select(p.name.clone()));
}
resp.context_menu(|ui| {
if ui.button("Rename Brush…").clicked() {
acts.push(Action::Rename(Renaming { group: false, name: p.name.clone(), text: String::new() }));
ui.close();
}
if ui.button("Delete Brush").clicked() {
acts.push(Action::Delete(p.name.clone()));
ui.close();
}
});
}
fn list_row(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click_and_drag());
if !ui.is_rect_visible(r) {
return;
}
if current {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let cell = egui::Rect::from_min_size(r.left_top() + vec2(22.0, 2.0), vec2(36.0, 30.0));
let tr = egui::Rect::from_center_size(cell.center(), vec2(30.0, 30.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), tr, full_uv(), Color32::WHITE);
ui.painter().text(
pos2(cell.center().x, r.bottom() - 1.0),
egui::Align2::CENTER_BOTTOM,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.0),
t.text_faint,
);
let stroke = brush_preview::stroke_texture(ui.ctx(), &format!("brushes-stroke:{}", p.name), pb, 170, 36, t.text);
let sr = egui::Rect::from_min_size(pos2(cell.right() + 8.0, r.top() + 4.0), vec2(170.0, 36.0));
ui.painter().image(stroke.id(), sr, full_uv(), Color32::WHITE);
ui.painter().text(pos2(sr.right() + 12.0, r.center().y), egui::Align2::LEFT_CENTER, &p.name, egui::FontId::proportional(12.0), t.text_dim);
preset_interactions(ui, &resp, r, p, presets, false, acts);
}
fn grid_cell(ui: &mut egui::Ui, p: &BrushPreset, current: bool, presets: &[BrushPreset], acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 58.0), Sense::click_and_drag());
if !ui.is_rect_visible(r) {
return;
}
if current {
ui.painter().rect_filled(r, 3.0, t.accent_soft);
ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let pb = &p.brush;
let tip = brush_preview::tip_texture(ui.ctx(), &format!("brushes-tip:{}", p.name), &pb.tip, (pb.hardness, pb.angle, pb.roundness), 36, t.text);
let ir = egui::Rect::from_center_size(pos2(r.center().x, r.top() + 23.0), vec2(38.0, 38.0) * crate::brush_sections::thumb_scale(pb.size));
ui.painter().image(tip.id(), ir, full_uv(), Color32::WHITE);
ui.painter().text(
pos2(r.center().x, r.bottom() - 7.0),
egui::Align2::CENTER_CENTER,
format!("{}", pb.size.round() as i64),
egui::FontId::proportional(9.5),
t.text_dim,
);
let resp = resp.on_hover_text(&p.name);
preset_interactions(ui, &resp, r, p, presets, true, acts);
}
/// A group's header: chevron, folder, name, count. Click toggles; drag reorders groups; a
/// preset dropped here moves to the end of the group.
fn group_header(ui: &mut egui::Ui, label: &str, key: &str, open: bool, count: usize, acts: &mut Vec<Action>) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover);
}
let x = r.left() + 4.0;
icons::paint(
ui,
egui::Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)),
if open { "chevron-down" } else { "chevron-right" },
12.0,
t.text_dim,
);
icons::paint(
ui,
egui::Rect::from_min_size(pos2(x + 18.0, r.center().y - 8.0), vec2(16.0, 16.0)),
if open { "folder-open" } else { "folder" },
14.0,
t.icon,
);
ui.painter().text(pos2(x + 40.0, r.center().y), egui::Align2::LEFT_CENTER, label, theme::semibold(12.0), t.text);
ui.painter().text(pos2(r.right() - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, count.to_string(), theme::medium(11.0), t.text_faint);
resp.dnd_set_drag_payload(BrushDrag::Group(key.to_string()));
if let Some(d) = resp.dnd_hover_payload::<BrushDrag>() {
match &*d {
BrushDrag::Group(g) if g != key => drop_line(ui, r, false, false, &t),
BrushDrag::Preset(_) => {
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
_ => {}
}
}
if let Some(d) = resp.dnd_release_payload::<BrushDrag>() {
match &*d {
BrushDrag::Group(g) if g != key => acts.push(Action::MoveGroup { group: g.clone(), before: Some(key.to_string()) }),
BrushDrag::Preset(n) => acts.push(Action::Move { name: n.clone(), group: key.to_string(), index: None }),
_ => {}
}
}
if resp.clicked() {
acts.push(Action::ToggleGroup(label.to_string()));
}
resp.context_menu(|ui| {
if ui.button("Rename Group…").clicked() {
acts.push(Action::Rename(Renaming { group: true, name: key.to_string(), text: String::new() }));
ui.close();
}
if ui.button("Delete Group").clicked() {
acts.push(Action::DeleteGroup(key.to_string()));
ui.close();
}
});
}
/// The rename bar shown while a preset or group is being renamed. Enter or OK renames, Escape or
/// Cancel stops.
fn rename_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, acts: &mut Vec<Action>) {
let Some(r) = app.ui.brushes_panel.renaming.as_mut() else { return };
let t = Tokens::get(ui.ctx());
if r.text.is_empty() {
r.text = r.name.clone();
}
let mut cancel = false;
ui.horizontal(|ui| {
ui.label(RichText::new(if r.group { "Group name" } else { "Brush name" }).color(t.text_dim));
let resp = ui.add(egui::TextEdit::singleline(&mut r.text).desired_width(WIDTH - 230.0).id_salt("brush-rename"));
if !resp.has_focus() && !resp.lost_focus() {
resp.request_focus();
}
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if widgets::primary_button(ui, "OK", 52.0).clicked() || enter {
acts.push(Action::Rename(r.clone()));
}
if widgets::secondary_button(ui, "Cancel", 60.0).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
}
});
if cancel {
app.ui.brushes_panel.renaming = None;
}
ui.add_space(4.0);
}
/// The Brushes tab.
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// Size of the current brush (Photoshop's Brushes panel slider).
let before = app.session.tools.brush.clone();
let mut b = before.clone();
ui.horizontal(|ui| {
ui.label(RichText::new("Size").color(t.text_dim));
let mut lv = b.size.max(1.0).ln();
ui.add_sized(vec2(WIDTH - 140.0, 18.0), |ui: &mut egui::Ui| {
let r = widgets::slider(ui, &mut lv, 0.0..=5000f32.ln(), None);
if r.changed() {
b.size = lv.exp().round().clamp(1.0, 5000.0);
}
r
});
let mut s = b.size;
if widgets::value_field(ui, &mut s, 1.0..=5000.0, "px", 74.0).changed() {
b.size = s.round().clamp(1.0, 5000.0);
}
});
commit_gesture(app, ui.ctx(), &before, &b);
ui.add_space(4.0);
ui.horizontal(|ui| {
icons::paint(ui, egui::Rect::from_min_size(ui.cursor().min + vec2(0.0, 3.0), vec2(16.0, 16.0)), "search", 14.0, t.text_faint);
ui.add_space(20.0);
ui.add(egui::TextEdit::singleline(&mut app.ui.brushes_panel.filter).hint_text("Search Brushes").desired_width(WIDTH - 120.0));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let view = &mut app.ui.brushes_panel.view;
if icons::button(ui, "grid-2x2", 24.0, *view == BrushesView::Grid, "Grid view").clicked() {
*view = BrushesView::Grid;
}
if icons::button(ui, "align-justify", 24.0, *view == BrushesView::List, "List view").clicked() {
*view = BrushesView::List;
}
});
});
ui.add_space(6.0);
let mut acts = Vec::new();
rename_bar(app, ui, &mut acts);
let filter = app.ui.brushes_panel.filter.trim().to_lowercase();
let grid = app.ui.brushes_panel.view == BrushesView::Grid;
let presets = &app.session.tools.presets;
let groups = grouped_presets(presets);
let brush = &app.session.tools.brush;
let collapsed = &app.ui.brushes_panel.collapsed;
egui::ScrollArea::vertical().id_salt("brush-presets").max_height(400.0).auto_shrink([false, true]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
for (label, items) in groups {
let items: Vec<usize> =
items.into_iter().filter(|i| filter.is_empty() || presets.get(*i).is_some_and(|p| p.name.to_lowercase().contains(&filter))).collect();
if items.is_empty() {
continue;
}
let key = group_key(presets, &label);
let open = !filter.is_empty() || !collapsed.contains(&label);
group_header(ui, &label, &key, open, items.len(), &mut acts);
if !open {
continue;
}
if grid {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
ui.add_space(20.0);
for i in items {
if let Some(p) = presets.get(i) {
grid_cell(ui, p, is_current(&p.brush, brush), presets, &mut acts);
}
}
});
} else {
for i in items {
if let Some(p) = presets.get(i) {
list_row(ui, p, is_current(&p.brush, brush), presets, &mut acts);
}
}
}
ui.add_space(2.0);
}
});
// The dragged preset or group follows the pointer.
if let Some(d) = egui::DragAndDrop::payload::<BrushDrag>(ui.ctx())
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
let name = match &*d {
BrushDrag::Preset(n) => n.clone(),
BrushDrag::Group(g) => {
if g.is_empty() {
UNGROUPED.to_string()
} else {
g.clone()
}
}
};
egui::Area::new(egui::Id::new("brush-drag-label")).order(egui::Order::Tooltip).fixed_pos(p + vec2(12.0, 8.0)).interactable(false).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| ui.label(name));
},
);
}
apply(app, acts);
ui.add_space(4.0);
widgets::hairline(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label(RichText::new(format!("{} presets", app.session.tools.presets.len())).color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let current = app.session.tools.presets.iter().find(|p| is_current(&p.brush, &app.session.tools.brush)).map(|p| p.name.clone());
if ui.add_enabled_ui(current.is_some(), |ui| icons::button(ui, "trash", 24.0, false, "Delete brush")).inner.clicked()
&& let Some(name) = current
{
run_or_status(app, "brush.presets.delete", json!({ "name": name }));
}
if icons::button(ui, "square-plus", 24.0, false, "Create new brush from the current settings").clicked() {
let name = new_preset_name(&app.session.tools.presets);
run_or_status(app, "brush.presets.save", json!({ "name": name }));
}
if icons::button(ui, "folder-open", 24.0, false, "Import Brushes… (.abr)").clicked() {
app.open_dialog_file();
}
});
});
// Forget previews of presets that no longer exist.
let names: std::collections::HashSet<&str> = app.session.tools.presets.iter().map(|p| p.name.as_str()).collect();
brush_preview::with_cache(ui.ctx(), |c| {
c.retain(|slot| slot.split_once(':').is_none_or(|(_, n)| names.contains(n)));
});
}
+258 -69
View File
@@ -40,6 +40,8 @@ pub struct Drag {
/// A Brush/Eraser stroke that erases: the Eraser, or a right-button Brush drag with
/// Preferences › Tools › Right-click with painting tools set to Erase.
pub erase: bool,
/// ⇧ constraint of a painting stroke (stroke_constraint.rs).
pub constrain: Option<crate::stroke_constraint::Axis>,
}
/// A Brush/Eraser stroke shown while it is drawn: the engine renders the real dabs onto a copy of
@@ -223,6 +225,10 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
if let Some(shown) = crate::adjust_preview::display_doc(app, idx) {
return shown;
}
// Move tool drag: the moving layers at the pointer.
if let Some(shown) = crate::move_ui::display_doc(app, idx) {
return shown;
}
let st = &app.session.documents()[idx];
if let Some(l) = live_stroke(app, idx) {
return (l.stroke.doc.clone(), l.display_key());
@@ -282,21 +288,30 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
if app.gpu.is_none() {
return ensure_texture(app, ctx, idx).map(|(t, _)| t);
}
let (revision, id) = app.session.documents().get(idx).map(|st| (st.revision, st.doc.id))?;
// Keyed by the document snapshot, not its revision: selecting a layer changes no pixels.
let (snapshot, id) = app.session.documents().get(idx).map(|st| (std::sync::Arc::downgrade(&st.doc), st.doc.id))?;
let (doc, preview_key) = display_doc(app, idx);
let (display, display_key) = canvas_display(app, &doc);
let preview_key = preview_key ^ display_key;
let key = egui::Id::new(("navigator", id.0));
let cached: Option<(u64, u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key));
type Cached = (std::sync::Weak<Document>, u64, egui::TextureHandle);
let cached: Option<Cached> = ctx.data(|d| d.get_temp(key));
if let Some((r, p, t)) = &cached
&& (*r, *p) == (revision, preview_key)
&& r.ptr_eq(&snapshot)
&& *p == preview_key
{
return Some(t.id());
}
// While a Move drag is under way the navigator keeps its image and catches up on release.
if let Some((_, _, t)) = &cached
&& crate::move_ui::showing(app)
{
return Some(t.id());
}
// While an adjustment dialog's settings are changing, the navigator keeps its image and
// catches up once they settle (a thumbnail composite per change would cost more than the canvas).
if let Some((r, _, t)) = &cached
&& *r == revision
&& r.ptr_eq(&snapshot)
&& crate::adjust_preview::settling(app, NAVIGATOR_SETTLE_MS)
{
ctx.request_repaint_after(std::time::Duration::from_millis(NAVIGATOR_SETTLE_MS as u64));
@@ -312,7 +327,7 @@ pub fn navigator_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usiz
None => ctx.load_texture(format!("navigator-{}", id.0), image, TextureOptions::LINEAR),
};
app.perf.span("navigator", crate::gpu_canvas::now_ms() - t0);
ctx.data_mut(|d| d.insert_temp(key, (revision, preview_key, tex.clone())));
ctx.data_mut(|d| d.insert_temp(key, (snapshot, preview_key, tex.clone())));
Some(tex.id())
}
@@ -393,11 +408,34 @@ fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), now: (u64, u6
{
return Some(r);
}
// Between a Move drag's offsets: where the moving layers were and are (Auto-Select's pick
// at the press changes no pixels).
if (seen.0 == now.0 || (seen.0 + 1 == now.0 && last_damage.is_some_and(|r| r.is_empty())))
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::move_ui::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
let r = l.since(seen.1 ^ display_key)?;
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc.layers)) })
}
/// Document `doc`'s canvas caches showed a preview that the edit just committed reproduces
/// (`was_preview` tells its keys): count it as the document itself, so the commit's damage rect
/// refreshes only that area instead of everything.
pub(crate) fn shown_as_document(app: &mut PhotocraftApp, doc: photocraft_doc::DocId, was_preview: impl Fn(u64) -> bool) {
let display_key = app.session.active().map_or(0, |st| canvas_display(app, &st.doc).1);
if let Some(c) = app.canvases.get_mut(&doc) {
if was_preview(c.preview_key ^ display_key) {
c.preview_key = display_key;
}
if was_preview(c.tex_preview_key ^ display_key) {
c.tex_preview_key = display_key;
}
}
}
/// How far beyond an edit's damage rect the composite can change: layer effects (shadows, glows,
/// strokes, …) on the edited layer and on the groups around it reach that far.
pub(crate) fn effect_reach(layers: &[photocraft_doc::Layer]) -> i32 {
@@ -460,6 +498,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
return true;
}
let partial = present && damage.is_some();
gpu_budget(app, &gpu, idx, partial, visible);
let r = gpu.refresh(id.0, &doc, if partial { damage } else { None }, display.as_deref());
if let Some(e) = &r.fallback
&& app.perf.gpu_fallback.as_deref() != Some(e.as_str())
@@ -480,6 +519,23 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize, visible: DRect) -> bool {
true
}
/// Point the wgpu compositor at what the view shows, and size its memory budget from Memory
/// Usage, the document's pixels and History, and physical memory: on full refreshes (pixels
/// counted once per structural change, not per brush dab) and whenever the preference changes.
fn gpu_budget(app: &mut PhotocraftApp, gpu: &crate::gpu_canvas::GpuCanvas, idx: usize, partial: bool, visible: DRect) {
gpu.set_focus(Some(visible));
let allowance = u64::from(app.session.prefs().performance.memory_usage_mb).saturating_mul(1 << 20);
if partial && app.perf.gpu_budget_allowance == allowance && gpu.memory_budget().is_some() {
return;
}
let Some(st) = app.session.documents().get(idx) else { return };
let pixels = st.history.pixel_bytes(&st.doc) as u64;
let budget = crate::gpu_canvas::memory_budget(allowance, pixels, crate::gpu_canvas::physical_memory());
gpu.set_memory_budget(budget);
app.perf.gpu_budget_mb = budget >> 20;
app.perf.gpu_budget_allowance = allowance;
}
/// Live preview for an open filter dialog: run the filter on the proxy and upload it.
fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64)> {
if crate::adjust_preview::on_layer(app, idx) {
@@ -1132,7 +1188,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Tools follow the left button; the right one opens the Brush Preset picker or erases
// (Preferences › Tools, `paint_mouse`).
crate::paint_mouse::sync_tool_smoothing(app);
let buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
let mut buttons = crate::paint_mouse::canvas_buttons(app, &response, tool);
// Zoom tool drags: scrubby zoom or a zoom rectangle (zoom_tool.rs); clicks step below.
if tool == Tool::Zoom && crate::zoom_tool::drag(app, &ctx, &mut view, &xf, &buttons, response.interact_pointer_pos()) {
(buttons.started, buttons.dragged, buttons.stopped) = (false, false, false);
}
// A drag is only recognised once the pointer has moved past egui's click distance: the
// gesture starts where the button went down, not where it is now (#123).
if buttons.started
@@ -1187,6 +1247,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
crate::rulers::draw_guides(app, &painter, &xf, &doc);
}
draw_drag_preview(app, &painter, &xf);
crate::zoom_tool::draw(&ctx, &painter);
draw_transform_controls(app, &painter, &xf);
crate::paint_mouse::show_picker(app, &ctx);
crate::snap_ui::draw(app, &painter, &xf);
@@ -1243,7 +1304,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
// The Background Eraser always shows its sampling hotspot (Photoshop).
if cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser {
// Quick Selection shows its +/− badge there instead.
if (cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser) && tool != Tool::QuickSelection {
crosshair(3.0);
}
egui::CursorIcon::None
@@ -1273,6 +1335,13 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
_ => egui::CursorIcon::Crosshair,
};
ui.ctx().set_cursor_icon(icon);
// Selection tools: + / − / × badge for the effective mode (#170). A gesture keeps the
// mode it started with (⇧ then constrains the marquee instead of adding).
let held = crate::workspace_ui::sticky_mods(app, ui.input(|i| i.modifiers));
let mods = app.drag.as_ref().map_or(held, |d| d.modifiers);
if let Some(b) = crate::tool_feedback::badge(app, tool, mods) {
crate::tool_feedback::draw_badge(&painter, p, b, tool == Tool::QuickSelection);
}
}
}
if primary {
@@ -1393,10 +1462,10 @@ fn draw_tool_state(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
if let Some(h) = hover {
pts.push(h);
}
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
for p in &pts[..app.ui.polygon.len()] {
crate::tool_feedback::draw_ants(painter, &pts, false);
for p in pts.iter().take(app.ui.polygon.len()) {
painter.rect_filled(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Color32::WHITE);
painter.rect_stroke(Rect::from_center_size(*p, vec2(5.0, 5.0)), 0.0, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Outside);
}
}
if let Some(c) = app.ui.crop_rect {
@@ -1456,9 +1525,12 @@ fn draw_transform_controls(app: &mut PhotocraftApp, painter: &egui::Painter, xf:
}
}
fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &ViewXform) {
draw_tool_state(app, painter, xf, painter.ctx().input(|i| i.pointer.hover_pos()));
let Some(d) = &app.drag else { return };
let Some(d) = &app.drag else {
app.trail = None;
return;
};
let mut last = d.points.last().map(|p| [p[0], p[1]]).unwrap_or(d.start);
if matches!(d.tool, Tool::RectMarquee | Tool::EllipseMarquee) {
let o = &app.ui.tool_options;
@@ -1468,31 +1540,31 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
// The canvas shows the live stroke itself (`LiveStroke`).
Tool::Brush | Tool::Eraser => {}
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Retouching strokes preview as a translucent trail of the brush footprint.
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
let w = (app.session.tools.brush.size * xf.zoom).max(1.0);
// Retouching strokes preview as a translucent trail of the brush footprint: a mask,
// not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189).
let col = Color32::from_white_alpha(if t == Tool::QuickSelection { 40 } else { 60 });
if pts.len() == 1 {
painter.circle_filled(pts[0], w / 2.0, col);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(w, col)));
}
let Some(st) = app.session.active() else { return };
let size = [st.doc.size.width, st.doc.size.height];
let doc_rect = xf.doc_rect(st.doc.bounds());
let trail = app.trail.get_or_insert_with(|| crate::stroke_trail::Trail::new(size));
trail.feed(&d.points, app.session.tools.brush.size);
trail.draw(painter, doc_rect, xf.flip, col);
}
t if crate::vector_ui::is_shape_tool(t) => crate::vector_ui::draw_shape_preview(app, painter, xf, t, d.start, last, d.modifiers),
Tool::RectMarquee | Tool::EllipseMarquee | Tool::ObjectSelection => {
// Marching ants, visible on any pixels (#172).
let r = Rect::from_two_pos(xf.to_screen(d.start[0] as f32, d.start[1] as f32), xf.to_screen(last[0] as f32, last[1] as f32));
if d.tool == Tool::EllipseMarquee {
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, Stroke::new(1.0, Color32::WHITE)));
let r = Rect::from_min_max(r.min.round() + vec2(0.5, 0.5), r.max.round() + vec2(0.5, 0.5));
let pts = if d.tool == Tool::EllipseMarquee {
crate::tool_feedback::ellipse_points(r)
} else {
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Middle);
}
vec![r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]
};
crate::tool_feedback::draw_ants(painter, &pts, true);
}
Tool::Lasso => {
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
if pts.len() > 1 {
painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 4.0, 4.0));
}
crate::tool_feedback::draw_ants(painter, &pts, false);
}
Tool::Gradient => {
let a = xf.to_screen(d.start[0] as f32, d.start[1] as f32);
@@ -1508,7 +1580,8 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
painter.add(egui::Shape::line(pts.to_vec(), Stroke::new(1.0, Color32::WHITE)));
painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, Color32::BLACK), 3.0, 3.0));
}
Tool::Move => {
// The layers themselves follow the pointer (`move_ui`); the arrow only when they can't.
Tool::Move if !crate::move_ui::showing(app) => {
let off = vec2(((last[0] - d.start[0]) as f32) * xf.zoom, ((last[1] - d.start[1]) as f32) * xf.zoom);
painter.arrow(xf.to_screen(d.start[0] as f32, d.start[1] as f32), off, Stroke::new(2.0, crate::theme::Tokens::get(painter.ctx()).accent));
}
@@ -1516,9 +1589,20 @@ fn draw_drag_preview(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXfor
}
}
fn sample_eyedropper(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers) {
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
let color: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
if color.len() == 4 && color[3] > 0.0 {
let key = if mods.alt { "background" } else { "foreground" };
let _ = app.run("tools.setColors", json!({ key: [color[0], color[1], color[2], 1.0] }));
}
}
}
/// Tool state machine. Shared by mouse input and automation.
pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) {
// View › Snap / Snap To and smart guides (snap_ui.rs).
let raw = ev;
let ev = crate::snap_ui::filter_event(app, ev, mods);
if crate::transform_tool::pointer(app, ev, mods) {
return;
@@ -1528,6 +1612,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
// Window › Modifier Keys: sticky Shift/⌘/⌥ act as held keys.
let mods = crate::workspace_ui::sticky_mods(app, mods);
// Move tool: ⇧ locks the axis, ⌥ duplicates (move_mods.rs).
let ev = crate::move_mods::filter_event(app, ev, mods);
// Ruler, Count and Note tools.
if crate::analysis_ui::pointer(app, ev, mods) {
return;
@@ -1541,6 +1627,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
return;
}
let tool = app.ui.tool;
if tool == Tool::Eyedropper {
match ev {
ToolEvent::Down { x, y, .. } | ToolEvent::Move { x, y, .. } => {
sample_eyedropper(app, x, y, mods);
return;
}
ToolEvent::Up { .. } => return,
}
}
// Move tool over a guide drags the guide (off the canvas deletes it).
match ev {
ToolEvent::Down { x, y, .. } if tool == Tool::Move => {
@@ -1573,16 +1668,6 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
match ev {
ToolEvent::Down { x, y, pressure } => {
if tool == Tool::Eyedropper {
if let Ok(v) = app.run("document.pixel", json!({"x": x.floor(), "y": y.floor()})) {
let c: Vec<f32> = serde_json::from_value(v).unwrap_or_default();
if c.len() == 4 && c[3] > 0.0 {
let key = if mods.alt { "background" } else { "foreground" };
let _ = app.run("tools.setColors", json!({ key: [c[0], c[1], c[2], 1.0] }));
}
}
return;
}
match tool {
Tool::Pen => {
crate::vector_ui::pen_down(app, x, y);
@@ -1625,8 +1710,19 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
}
crate::paint_mouse::sync_tool_smoothing(app);
let erase = tool == Tool::Eraser || std::mem::take(&mut app.secondary_erase);
app.drag = Some(Drag { tool, start: [x, y], points: vec![[x, y, pressure as f64]], modifiers: mods, erase });
// ⇧-click after a stroke: a straight line from where it ended (stroke_constraint.rs).
let active = app.session.active().map(|st| st.doc.id);
let from = app.last_stroke_end.filter(|(doc, _)| mods.shift && crate::stroke_constraint::connects(tool) && Some(*doc) == active).map(|(_, p)| p);
let mut points = vec![[x, y, pressure as f64]];
if let Some(p) = from {
points.insert(0, [p[0], p[1], pressure as f64]);
}
app.drag = Some(Drag { tool, start: from.unwrap_or([x, y]), points, modifiers: mods, erase, constrain: None });
app.trail = None;
app.stylus.begin_stroke();
if from.is_some() {
app.stylus.record_point();
}
app.live_stroke = if matches!(tool, Tool::Brush | Tool::Eraser) { begin_live_stroke(app) } else { None };
}
ToolEvent::Move { x, y, pressure } => {
@@ -1636,11 +1732,15 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
if tool == Tool::Pen {
crate::vector_ui::pen_move(app, x, y);
}
if let Some(d) = &mut app.drag
&& d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25)
{
d.points.push([x, y, pressure as f64]);
app.stylus.record_point();
let zoom = app.current_zoom();
if let Some(d) = &mut app.drag {
// ⇧: straight 0/45/90° strokes, 45° gradient angles (stroke_constraint.rs).
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
if d.points.last().is_none_or(|p| (p[0] - x).abs() + (p[1] - y).abs() > 0.25) {
d.points.push([x, y, pressure as f64]);
app.stylus.record_point();
}
}
feed_live_stroke(app);
}
@@ -1653,18 +1753,35 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
if tool == Tool::Pen {
crate::vector_ui::pen_up(app);
}
let zoom = app.current_zoom();
let Some(mut d) = app.drag.take() else { return };
let last = d.points.last().map_or(d.start, |p| [p[0], p[1]]);
let [x, y] = crate::stroke_constraint::constrain(d.tool, &mut d.constrain, d.start, last, [x, y], mods.shift, zoom);
// A Move-tool click (released where it was pressed) selects, it never moves: snapping
// the release point would otherwise nudge the layer onto a nearby edge.
if d.tool == Tool::Move && d.points.len() < 2 && matches!(raw, ToolEvent::Up { x, y } if [x, y] == d.start) {
app.move_preview = None;
crate::move_mods::finish(app);
return;
}
if d.points.last().is_none_or(|p| p[0] != x || p[1] != y) {
d.points.push([x, y, d.points.last().map_or(1.0, |p| p[2])]);
app.stylus.record_point();
}
finish_gesture(app, d);
crate::move_mods::finish(app);
}
}
}
fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]);
// Where the next ⇧-click line starts.
if crate::stroke_constraint::connects(d.tool)
&& let Some(st) = app.session.active()
{
app.last_stroke_end = Some((st.doc.id, [end[0], end[1]]));
}
if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) {
return;
}
@@ -1708,18 +1825,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
}
return;
}
let bar = ["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize];
let mode = if !d.modifiers.shift && !d.modifiers.alt {
bar
} else if d.modifiers.shift && d.modifiers.alt {
"intersect"
} else if d.modifiers.shift {
"add"
} else if d.modifiers.alt {
"subtract"
} else {
"replace"
};
let mode = selection_mode(app, d.modifiers);
let (aa, feather) = (app.ui.tool_options.anti_alias, app.ui.tool_options.feather);
let _ = app.run("select.rect", json!({"x": x0, "y": y0, "width": x1 - x0, "height": y1 - y0, "mode": mode, "ellipse": d.tool == Tool::EllipseMarquee, "antiAlias": aa, "feather": feather}));
}
@@ -1745,9 +1851,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
}
Tool::Move => {
let (dx, dy) = ((end[0] - d.start[0]).round(), (end[1] - d.start[1]).round());
if dx != 0.0 || dy != 0.0 {
let _ = app.run("layer.translate", json!({"dx": dx, "dy": dy}));
}
crate::move_ui::finish(app, dx, dy);
}
_ => {}
}
@@ -1783,16 +1887,9 @@ pub fn extra_windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
/// Selection mode from the options bar, overridden by modifier keys (⇧ add, ⌥ subtract, ⇧⌥ intersect).
/// The cursor badge announces the same mode (`tool_feedback`).
fn selection_mode(app: &PhotocraftApp, m: egui::Modifiers) -> &'static str {
if m.shift && m.alt {
"intersect"
} else if m.shift {
"add"
} else if m.alt {
"subtract"
} else {
["replace", "add", "subtract", "intersect"][app.ui.selection_mode.min(3) as usize]
}
crate::tool_feedback::selection_mode(Tool::Lasso, app.ui.selection_mode, m)
}
/// Close the polygonal lasso and make the selection.
@@ -1856,6 +1953,21 @@ mod tests {
}
}
#[test]
fn eyedropper_drag_updates_the_sampled_colour() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 40, "height": 20, "background": "transparent"})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [0, 0, 20, 20], "fill": "#ff0000"})).unwrap();
app.run("shape.create", json!({"kind": "rect", "rect": [20, 0, 20, 20], "fill": "#00ff00"})).unwrap();
app.ui.tool = Tool::Eyedropper;
tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
assert!(app.session.tools.foreground[0] > 0.99 && app.session.tools.foreground[1] < 0.01);
tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 10.0, pressure: 1.0 }, egui::Modifiers::NONE);
assert!(app.session.tools.foreground[1] > 0.99 && app.session.tools.foreground[0] < 0.01);
}
#[test]
fn brush_drag_shows_the_real_stroke_and_commits_it() {
// The drag used to draw a hard, flat stand-in and only showed the soft brush on release.
@@ -2005,6 +2117,83 @@ mod tests {
assert!((0..120).all(|y| (0..200).all(|x| a.rgba(x, y) == b.rgba(x, y))), "no stale tails in the preview");
}
/// Shapes the drag preview paints for `app` this frame.
fn drag_preview_shapes(app: &mut PhotocraftApp, ctx: &egui::Context, zoom: f32) -> Vec<egui::epaint::ClippedShape> {
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
let ctx = ui.ctx();
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let painter = ctx.layer_painter(egui::LayerId::background()).with_clip_rect(rect);
let xf = ViewXform { rect, zoom, center: [60.0, 30.0], flip: false };
draw_drag_preview(app, &painter, &xf);
});
let egui::FullOutput { mut textures_delta, shapes, .. } = out;
textures_delta.clear();
shapes
}
#[test]
fn brush_drags_paint_no_stand_in_shape_over_the_canvas() {
// #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide
// as the brush; zoomed in, egui tessellated it into hard black wedges fanning out from the
// start. The canvas shows the real stroke (`LiveStroke`) and nothing is drawn over it.
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 120, "height": 60})).unwrap();
app.run("tools.setBrush", json!({"brush": {"size": 30, "hardness": 0.0}})).unwrap();
for tool in [Tool::Brush, Tool::Eraser] {
app.ui.tool = tool;
let idle = drag_preview_shapes(&mut app, &ctx, 12.0).len();
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE);
for i in 1..40 {
let t = f64::from(i);
tool_event(&mut app, ToolEvent::Move { x: 20.0 + t, y: 30.0 + (t / 3.0).sin() * 4.0, pressure: 1.0 }, egui::Modifiers::NONE);
}
assert!(app.live_stroke.is_some(), "{tool:?}: the canvas shows the live stroke");
assert_eq!(drag_preview_shapes(&mut app, &ctx, 12.0).len(), idle, "{tool:?}: no overlay while dragging");
tool_event(&mut app, ToolEvent::Up { x: 59.0, y: 30.0 }, egui::Modifiers::NONE);
}
}
#[test]
fn retouch_drags_show_the_footprint_without_wedges() {
// The retouching tools' trail was the same brush-wide polyline (#189): now a mask of the
// footprint, with nothing outside the brush radius of the path, however small the steps.
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 120, "height": 60})).unwrap();
app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap();
app.ui.tool = Tool::Dodge;
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE);
let mut pts = vec![[20.0, 30.0]];
for i in 1..80 {
let t = f64::from(i) * 0.5;
let p = [20.0 + t, 30.0 + (t / 2.0).sin() * 3.0];
tool_event(&mut app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, egui::Modifiers::NONE);
pts.push(p);
if i % 20 == 0 {
// One mesh for the trail, however long the drag.
let shapes = drag_preview_shapes(&mut app, &ctx, 12.0);
assert!(!shapes.iter().any(|s| matches!(s.shape, egui::Shape::Path(_))), "no polyline");
}
}
drag_preview_shapes(&mut app, &ctx, 12.0);
let trail = app.trail.as_ref().unwrap();
assert_eq!(trail.scale(), 1.0);
for y in 0..60 {
for x in 0..120 {
let d = pts.iter().map(|p| (x as f64 + 0.5 - p[0]).hypot(y as f64 + 0.5 - p[1])).fold(f64::MAX, f64::min);
if d < 14.0 {
assert_eq!(trail.coverage(x, y), 255, "({x}, {y}) inside the footprint");
} else if d > 16.0 {
assert_eq!(trail.coverage(x, y), 0, "({x}, {y}) outside the footprint");
}
}
}
tool_event(&mut app, ToolEvent::Up { x: 59.5, y: 30.0 }, egui::Modifiers::NONE);
drag_preview_shapes(&mut app, &ctx, 12.0);
assert!(app.trail.is_none(), "the trail ends with the drag");
}
#[test]
fn layer_style_dialog_previews_live_and_cancel_restores() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
+75 -11
View File
@@ -17,6 +17,8 @@ enum Row {
Color(usize),
Alpha(usize),
QuickMask,
/// The selected layer's mask: a temporary channel, listed while the layer is selected.
LayerMask,
}
impl Row {
@@ -27,6 +29,7 @@ impl Row {
Row::Color(k) => json!({ "color": k }),
Row::Alpha(i) => json!(i),
Row::QuickMask => json!("quickMask"),
Row::LayerMask => json!("mask"),
}
}
}
@@ -50,25 +53,38 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
};
let doc = st.doc.clone();
let view = st.channel_view.clone();
let active_layer = st.active_layer;
let mode = doc.pixel_format().mode;
let colors = mode.color_channels();
let quick = doc.quick_mask.is_some();
let ctx = ui.ctx().clone();
let thumbs = app.channel_thumbs(&ctx);
// The active layer's mask (Photoshop lists it, in italics, below the colour channels).
let masked = active_layer.and_then(|id| doc.layer(id)).filter(|l| l.mask.is_some()).cloned();
let mask_tex = masked.as_ref().and_then(|l| Some(app.mask_thumb(&ctx, &doc, l.id, l.mask.as_ref()?)));
let mask_targeted = masked.is_some() && app.ui.mask_target && view.target == ChannelTarget::Composite && !quick;
// Multichannel images are their ink channels only (no composite or colour rows).
let multichannel = doc.mode == photocraft_doc::ColorMode::Multichannel;
let mut rows = if multichannel { Vec::new() } else { vec![Row::Composite] };
if colors > 1 && !multichannel {
rows.extend((0..colors).map(Row::Color));
}
if masked.is_some() {
rows.push(Row::LayerMask);
}
rows.extend((0..doc.channels.len()).map(Row::Alpha));
if quick {
rows.push(Row::QuickMask);
}
let shown_colors = if colors > 1 { colors } else { 0 };
let mut actions: Vec<(String, Value)> = Vec::new();
// The buttons sit in a footer at the panel's bottom, like Photoshop's.
let footer = 34.0;
egui::ScrollArea::vertical().max_height((ui.available_height() - footer).max(60.0)).auto_shrink([false, true]).show(ui, |ui| {
let fill = ui.available_height() > footer + 60.0;
let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY };
let mut mask_click = None;
let mut drawn = Vec::new();
egui::ScrollArea::vertical().max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show(ui, |ui| {
for row in rows {
let (name, thumb_idx, slot, visible, selected) = match row {
Row::Composite => (
@@ -76,14 +92,14 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
0,
Some(2),
view.visible_colors(colors) == colors,
view.target == ChannelTarget::Composite && !quick,
view.target == ChannelTarget::Composite && !quick && !mask_targeted,
),
Row::Color(k) => (
channel_cmds::color_names(mode)[k].to_string(),
1 + k,
Some(3 + k),
view.color_visible(k),
(view.target == ChannelTarget::Composite && !quick) || view.target == ChannelTarget::Color(k),
(view.target == ChannelTarget::Composite && !quick && !mask_targeted) || view.target == ChannelTarget::Color(k),
),
Row::Alpha(i) => (
doc.channels[i].name.clone(),
@@ -95,6 +111,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
Row::QuickMask => {
("Quick Mask".to_string(), 1 + shown_colors + doc.channels.len(), None, !view.quick_mask_hidden, view.target == ChannelTarget::Composite)
}
Row::LayerMask => (masked.as_ref().map(|l| format!("{} Mask", l.name)).unwrap_or_default(), usize::MAX, None, false, mask_targeted),
};
let row_h = if t.pro { 36.0 } else { 40.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
@@ -110,15 +127,24 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
let eye = Rect::from_min_size(pos2(rect.left() + 6.0, rect.center().y - 11.0), vec2(22.0, 22.0));
let eye_resp = ui.interact(eye, ui.id().with(("chan-eye", format!("{:?}", row.reference()))), Sense::click());
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
if eye_resp.clicked() {
// The layer mask's eye slot stays empty: its overlay view isn't supported yet.
if row != Row::LayerMask {
icons::paint(ui, eye, if visible { "eye" } else { "eye-off" }, 14.0, if visible { t.icon } else { t.text_faint });
}
if eye_resp.clicked() && row != Row::LayerMask {
actions.push(("channel.setVisible".into(), json!({ "channel": row.reference(), "visible": !visible })));
}
let ts = if t.pro { 28.0 } else { 30.0 };
let thumb = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - ts / 2.0), vec2(ts, ts));
if let Some(tex) = thumbs.get(thumb_idx) {
painter.image(*tex, thumb, Rect::from_min_max(egui::Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
let cell = Rect::from_min_size(pos2(rect.left() + 36.0, rect.center().y - ts / 2.0), vec2(ts, ts));
// Thumbnails keep the document's aspect ratio (the textures are letterboxed squares).
let (thumb, uv) = fit_thumb(cell, doc.size.width, doc.size.height);
let tex = if row == Row::LayerMask { mask_tex } else { thumbs.get(thumb_idx).copied() };
if let Some(tex) = tex {
painter.image(tex, thumb, uv, Color32::WHITE);
}
drawn.push((name.clone(), thumb));
// Rows are painted: name them for screen readers and UI tests.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &name));
// Spot channels: a swatch of the ink on the thumbnail corner.
if let Row::Alpha(i) = row
&& let Some((ink, _)) = doc.channels[i].spot
@@ -127,12 +153,12 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
painter.rect_filled(Rect::from_min_size(thumb.right_bottom() - vec2(9.0, 9.0), vec2(9.0, 9.0)), 0.0, Color32::from_rgb(c[0], c[1], c[2]));
}
painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, if t.pro { Color32::from_gray(20) } else { t.field_border }), StrokeKind::Outside);
let italic = row == Row::QuickMask || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
let italic = matches!(row, Row::QuickMask | Row::LayerMask) || matches!(row, Row::Alpha(i) if doc.channels[i].spot.is_some());
let mut job = egui::text::LayoutJob::default();
let font = if selected && !t.pro { theme::medium(12.5) } else { egui::FontId::proportional(12.0) };
job.append(&name, 0.0, egui::TextFormat { font_id: font, color: t.text, italics: italic, ..Default::default() });
let galley = painter.layout_job(job);
let text_pos = pos2(thumb.right() + 10.0, rect.center().y - galley.size().y / 2.0);
let text_pos = pos2(cell.right() + 10.0, rect.center().y - galley.size().y / 2.0);
painter.galley(text_pos, galley, t.text);
if let Some(slot) = slot {
painter.text(
@@ -146,9 +172,19 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let rename_id = egui::Id::new(("chan-rename", doc.id.0, format!("{:?}", row.reference())));
if resp.clicked() && !eye_resp.clicked() {
let m = ui.input(|i| i.modifiers);
if m.command {
if m.command && row == Row::LayerMask {
let layer = masked.as_ref().map(|l| l.id.0);
actions.push(("select.loadSelection".into(), json!({ "channel": "mask", "layer": layer, "operation": load_operation(m) })));
} else if m.command {
actions.push(("select.loadSelection".into(), json!({ "channel": row.reference(), "operation": load_operation(m) })));
} else if row == Row::LayerMask {
// Targets the mask, as clicking its thumbnail in the Layers panel does.
mask_click = Some(true);
actions.push(("channel.target".into(), json!({ "channel": "composite" })));
} else {
if matches!(row, Row::Composite | Row::Color(_)) {
mask_click = Some(false);
}
let target = if row == Row::QuickMask { json!("composite") } else { row.reference() };
actions.push(("channel.target".into(), json!({ "channel": target })));
}
@@ -200,6 +236,11 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
Row::Composite | Row::Color(_) => {
item(ui, a, "Duplicate Channel", "channel.duplicate", json!({ "channel": row.reference() }));
}
Row::LayerMask => {
let enabled = masked.as_ref().and_then(|l| l.mask.as_ref()).is_none_or(|m| m.enabled);
item(ui, a, if enabled { "Disable Layer Mask" } else { "Enable Layer Mask" }, "layer.layerMask.enabled", json!({}));
item(ui, a, "Delete Layer Mask", "layer.layerMask.delete", json!({}));
}
}
ui.separator();
item(ui, a, "New Channel…", "channel.new", json!({}));
@@ -235,6 +276,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
});
});
ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn));
if let Some(on) = mask_click {
app.ui.mask_target = on;
}
for (id, p) in actions {
if id == "ui.renameChannel" {
if let Some(i) = p.as_u64().and_then(|i| doc.channels.get(i as usize)) {
@@ -247,6 +292,25 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
}
fn thumbs_id() -> egui::Id {
egui::Id::new("channel-thumb-rects")
}
/// The Channels rows drawn last frame: (name, thumbnail rect).
pub fn recorded(ctx: &egui::Context) -> Vec<(String, Rect)> {
ctx.data(|d| d.get_temp(thumbs_id())).unwrap_or_default()
}
/// The part of a square `cell` a `w`×`h` document fills, and the matching UVs of a square,
/// letterboxed thumbnail texture: thumbnails keep the document's aspect ratio.
pub fn fit_thumb(cell: Rect, w: u32, h: u32) -> (Rect, Rect) {
let (w, h) = (w.max(1) as f32, h.max(1) as f32);
let (fw, fh) = (w / w.max(h), h / w.max(h));
let size = vec2(cell.width() * fw, cell.height() * fh).max(vec2(1.0, 1.0));
let uv = Rect::from_center_size(pos2(0.5, 0.5), vec2(fw, fh));
(Rect::from_center_size(cell.center(), size), uv)
}
fn item(ui: &mut egui::Ui, actions: &mut Vec<(String, Value)>, label: &str, cmd: &str, p: Value) {
if ui.button(label).clicked() {
actions.push((cmd.into(), p));
+318
View File
@@ -0,0 +1,318 @@
//! Lazy CJK fallback fonts for the UI.
//!
//! The bundled Inter / JetBrains Mono have no Japanese, Chinese or Korean glyphs, and the OS
//! fonts that do are large (Hiragino ~10 MB, Apple SD Gothic Neo 28 MB, PingFang 78 MB, Noto
//! Sans CJK ~20 MB). Instead of reading them at startup, an egui plugin scans each frame's text
//! for CJK characters no registered font covers, and registers the next system font for that
//! character's script (`ctx.add_font`, active from the next frame, which it requests). Fonts are
//! appended at the lowest priority to every family, so Latin text keeps Inter.
//!
//! Script order follows the UI locale ([`photocraft_text::cjk::script_order`]): Kana prefers a
//! Japanese font, Hangul a Korean one, Bopomofo a Traditional Chinese one, and Han the
//! locale's script (Japanese forms only for a Japanese locale). At most one font is read per
//! frame, each file at most once, and once every script has been tried the scan stops.
use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily};
use egui::{FontData, FontFamily, FontId, Shape};
use photocraft_text::cjk::{self, CjkChar, CjkScript, FontFile};
use std::path::PathBuf;
/// Skip absurdly large files (a corrupt or non-font path must not eat memory).
pub const MAX_FONT_BYTES: u64 = 128 << 20;
/// Name prefix of the registered fallback fonts.
pub const FONT_PREFIX: &str = "system-cjk";
/// Where fonts come from; swapped out in tests.
#[derive(Clone)]
pub struct Sources {
pub locale: fn() -> Option<String>,
pub files: fn(CjkScript) -> Vec<FontFile>,
pub last_resort: fn() -> Vec<FontFile>,
}
impl Sources {
#[cfg(not(target_arch = "wasm32"))]
pub fn system() -> Self {
Self { locale: || cjk::ui_locale().map(str::to_string), files: cjk::font_files, last_resort: cjk::last_resort_files }
}
#[cfg(target_arch = "wasm32")]
pub fn system() -> Self {
Self { locale: || None, files: |_| Vec::new(), last_resort: Vec::new }
}
}
/// The lazy loader's state: which scripts were tried and which files were read.
pub struct CjkFallback {
sources: Sources,
order: Option<[CjkScript; 4]>,
tried: Vec<CjkScript>,
last_resort_tried: bool,
loaded: Vec<PathBuf>,
/// Fonts registered so far: (name, path, face index).
pub registered: Vec<(String, PathBuf, u32)>,
}
impl CjkFallback {
pub fn new(sources: Sources) -> Self {
Self { sources, order: None, tried: Vec::new(), last_resort_tried: false, loaded: Vec::new(), registered: Vec::new() }
}
/// Script order for the UI locale (resolved on first use, not at startup).
pub fn order(&mut self) -> [CjkScript; 4] {
*self.order.get_or_insert_with(|| cjk::script_order((self.sources.locale)().as_deref()))
}
/// Every script and the last-resort fonts have been tried: nothing more to load.
pub fn exhausted(&self) -> bool {
self.tried.len() >= 4 && self.last_resort_tried
}
/// The next font to register for a character of kind `kind` that no current font covers:
/// the first readable file of the first untried script (the character's preferred script,
/// then the locale order), then the last-resort fonts. `None` when nothing is left.
pub fn next_font(&mut self, kind: CjkChar) -> Option<(String, FontData)> {
let order = self.order();
let scripts: Vec<CjkScript> = std::iter::once(kind.preferred(&order)).chain(order).collect();
for s in scripts {
if self.tried.contains(&s) {
continue;
}
self.tried.push(s);
let files = (self.sources.files)(s);
if let Some(f) = self.load_first(&files) {
return Some(f);
}
}
if !self.last_resort_tried {
self.last_resort_tried = true;
let files = (self.sources.last_resort)();
return self.load_first(&files);
}
None
}
fn load_first(&mut self, files: &[FontFile]) -> Option<(String, FontData)> {
for f in files {
if self.loaded.contains(&f.path) {
continue;
}
let Ok(meta) = std::fs::metadata(&f.path) else { continue };
if !meta.is_file() || meta.len() == 0 || meta.len() > MAX_FONT_BYTES {
continue;
}
let Ok(bytes) = std::fs::read(&f.path) else { continue };
let index = cjk::face_index_for_family(&bytes, f.family);
self.loaded.push(f.path.clone());
let name = format!("{FONT_PREFIX}-{}", self.registered.len());
log::info!("UI font fallback: registered {} (face {index}) as {name}", f.path.display());
self.registered.push((name.clone(), f.path.clone(), index));
let mut data = FontData::from_owned(bytes);
data.index = index;
return Some((name, data));
}
None
}
}
/// First CJK character in the frame's text that the UI fonts can't draw.
fn missing_char(ctx: &egui::Context, shapes: &[egui::epaint::ClippedShape]) -> Option<char> {
fn collect(shape: &Shape, out: &mut Vec<char>) {
match shape {
Shape::Text(t) => {
let text = &t.galley.job.text;
if !text.is_ascii() {
for c in text.chars().filter(|c| cjk::classify(*c).is_some()) {
if out.len() >= 256 {
return;
}
if !out.contains(&c) {
out.push(c);
}
}
}
}
Shape::Vec(v) => v.iter().for_each(|s| collect(s, out)),
_ => {}
}
}
let mut chars = Vec::new();
for s in shapes {
collect(&s.shape, &mut chars);
}
if chars.is_empty() {
return None;
}
let font = FontId::proportional(12.0);
ctx.fonts_mut(|f| chars.into_iter().find(|c| !f.has_glyph(&font, *c)))
}
/// Registers `name` at the lowest priority in every font family.
fn add_to_all_families(ctx: &egui::Context, name: String, data: FontData) {
let mut families: Vec<FontFamily> = ctx.fonts(|f| f.definitions().families.keys().cloned().collect());
for f in [FontFamily::Proportional, FontFamily::Monospace] {
if !families.contains(&f) {
families.push(f);
}
}
let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect();
ctx.add_font(FontInsert { name, data, families });
}
/// The egui plugin that watches drawn text and loads fallback fonts on demand.
pub struct CjkFontPlugin(pub CjkFallback);
impl egui::Plugin for CjkFontPlugin {
fn debug_name(&self) -> &'static str {
"photocraft-cjk-fonts"
}
fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) {
if self.0.exhausted() {
return;
}
let Some(c) = missing_char(ctx, &output.shapes) else { return };
let Some(kind) = cjk::classify(c) else { return };
if let Some((name, data)) = self.0.next_font(kind) {
add_to_all_families(ctx, name, data);
ctx.request_repaint();
} else if !self.0.exhausted() {
// Nothing readable for this script; try the next one on the next frame.
ctx.request_repaint();
}
}
}
/// Installs the lazy CJK fallback (system fonts; nothing on the web).
pub fn install(ctx: &egui::Context) {
install_with(ctx, Sources::system());
}
pub fn install_with(ctx: &egui::Context, sources: Sources) {
ctx.add_plugin(CjkFontPlugin(CjkFallback::new(sources)));
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
use std::sync::Mutex;
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
fn fake_dir() -> PathBuf {
let mut g = DIR.lock().unwrap();
g.get_or_insert_with(|| {
let dir = std::env::temp_dir().join(format!("photocraft-cjk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("font.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
std::fs::write(dir.join("empty.ttf"), b"").unwrap();
dir
})
.clone()
}
fn fake(name: &str) -> FontFile {
FontFile { path: fake_dir().join(name), family: "" }
}
fn fake_sources(locale: fn() -> Option<String>) -> Sources {
Sources {
locale,
// Korean has a readable "font", Japanese only broken files, Chinese none.
files: |s| match s {
CjkScript::Korean => vec![fake("missing.ttc"), fake("empty.ttf"), fake("font.ttf")],
CjkScript::Japanese => vec![fake("empty.ttf")],
_ => vec![],
},
last_resort: || vec![fake("font.ttf")],
}
}
#[test]
fn picks_preferred_script_skips_unreadable_and_exhausts() {
let mut fb = CjkFallback::new(fake_sources(|| Some("en_US".into())));
assert!(!fb.exhausted());
// Hangul: Korean first; missing and empty files are skipped.
let (name, data) = fb.next_font(CjkChar::Hangul).expect("korean font");
assert!(name.starts_with(FONT_PREFIX));
assert_eq!(data.index, 0);
assert_eq!(fb.tried, vec![CjkScript::Korean]);
// Han on an English locale: SC, TC, JA have nothing readable, and the last-resort file was
// already loaded, so nothing new; everything is now tried.
assert!(fb.next_font(CjkChar::Han).is_none());
assert!(fb.exhausted());
assert_eq!(fb.tried, vec![CjkScript::Korean, CjkScript::SimplifiedChinese, CjkScript::TraditionalChinese, CjkScript::Japanese]);
assert_eq!(fb.registered.len(), 1, "a file is read at most once");
assert!(fb.next_font(CjkChar::Kana).is_none());
}
#[test]
fn han_follows_locale() {
for (loc, first) in [
("ja_JP.UTF-8", CjkScript::Japanese),
("zh-Hans-CN", CjkScript::SimplifiedChinese),
("zh-Hant-TW", CjkScript::TraditionalChinese),
("ko_KR", CjkScript::Korean),
] {
let mut fb = CjkFallback::new(Sources { locale: || None, files: |_| vec![], last_resort: Vec::new });
fb.order = Some(cjk::script_order(Some(loc)));
assert!(fb.next_font(CjkChar::Han).is_none());
assert_eq!(fb.tried[0], first, "{loc}");
}
}
/// Runs frames showing `text` until fonts settle; returns whether every glyph is covered.
fn render(ctx: &egui::Context, text: &str) -> bool {
for _ in 0..12 {
let mut out = ctx.run_ui(Default::default(), |ui| {
ui.label(text);
});
out.textures_delta.clear();
}
ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(12.0), &text.replace(' ', "")))
}
#[test]
fn lazy_registration_triggers_only_for_cjk() {
let ctx = egui::Context::default();
install_with(&ctx, fake_sources(|| Some("ko".into())));
let _ = render(&ctx, "Layer 1 – café");
let n = ctx.fonts(|f| f.definitions().font_data.keys().filter(|k| k.starts_with(FONT_PREFIX)).count());
assert_eq!(n, 0, "Latin text loads nothing");
// Hangul isn't in the stand-in font, but the Korean candidate must have been registered.
let _ = render(&ctx, "카드 배경");
let fonts = ctx.fonts(|f| f.definitions().clone());
let name = format!("{FONT_PREFIX}-0");
assert!(fonts.font_data.contains_key(&name));
for (fam, stack) in &fonts.families {
assert_eq!(stack.last(), Some(&name), "{fam:?}: appended last so Latin keeps Inter");
}
}
/// With the real system fonts, every CJK sample renders (skipped per script when the
/// machine has no font for it, e.g. CI Linux without Noto CJK).
#[test]
fn system_fonts_cover_cjk_samples() {
let available = |s: CjkScript| cjk::font_files(s).iter().any(|f| f.path.is_file());
let samples = [
("图层", CjkScript::SimplifiedChinese),
("圖層", CjkScript::TraditionalChinese),
("카드 배경", CjkScript::Korean),
("レイヤー", CjkScript::Japanese),
];
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
for (text, script) in samples {
if !available(script) {
eprintln!("skipping {text}: no {script:?} system font");
continue;
}
assert!(render(&ctx, text), "{text} still has missing glyphs");
}
// All at once in a fresh context too (several scripts in one frame).
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let all: String = samples.iter().filter(|(_, s)| available(*s)).map(|(t, _)| *t).collect::<Vec<_>>().join(" ");
assert!(render(&ctx, &all), "{all}");
}
}
+94 -9
View File
@@ -162,6 +162,8 @@ pub struct Preview {
/// Hash of the params the mask texture shows.
mask_key: u64,
mask: Option<egui::TextureHandle>,
/// Why the preview couldn't be drawn (shown in the dialog and logged, never silent: #145).
error: Option<String>,
}
fn hash(text: &str) -> u64 {
@@ -170,13 +172,13 @@ fn hash(text: &str) -> u64 {
/// The selection mask `params` would make, on the proxy: the engine command run on a scratch
/// session (Out Of Gamut uses the document's own proof setup).
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Option<Vec<f32>> {
fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) -> Result<Vec<f32>, String> {
let area = proxy.bounds();
if params.get("select").and_then(Value::as_str) == Some("outOfGamut") {
let pv = app.session.color.proof(proxy.id);
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).ok()?;
let (m, _) = photocraft_engine::color_cmds::gamut_mask(proxy, &pv.setup, pv.gamut_threshold).map_err(|e| e.to_string())?;
let invert = params.get("invert").and_then(Value::as_bool).unwrap_or(false);
return Some(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
return Ok(m.iter().map(|v| f32::from(*v) / 255.0).map(|v| if invert { 1.0 - v } else { v }).collect());
}
let mut p = params.clone();
// Eyedropper points are in document pixels; the proxy is 1/k the size.
@@ -203,9 +205,20 @@ fn proxy_mask(app: &PhotocraftApp, proxy: &Document, k: u32, params: &Value) ->
if let Some(id) = app.session.active().and_then(|d| d.active_layer) {
let _ = s.select_layer(id);
}
s.execute(COMMAND, p).ok()?;
let d = s.active()?;
Some(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
s.execute(COMMAND, p).map_err(|e| e.to_string())?;
let d = s.active().ok_or("no preview document")?;
Ok(photocraft_algo::selection::mask_from_surface(d.doc.selection.as_ref(), area))
}
/// Record why the preview failed (logged once per new reason), or clear it.
fn report(app: &mut PhotocraftApp, error: Option<String>) {
let Some(p) = app.color_range.as_mut() else { return };
if p.error != error {
if let Some(e) = &error {
log::warn!("Color Range preview: {e}");
}
p.error = error;
}
}
/// Refresh the cached proxy / textures for the active document and the dialog's params. Returns
@@ -219,7 +232,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
let side = doc.size.width.max(doc.size.height).max(1);
let k = side.div_ceil(PREVIEW).max(1);
let proxy = Arc::new(crate::proxy::proxy_document(&doc, k));
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None });
app.color_range = Some(Preview { doc: doc_id, revision, k, proxy, image: None, mask_key: 0, mask: None, error: None });
}
let image_view = s(f, "__view", "selection") == "image";
let params = params(f);
@@ -234,6 +247,7 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
let thumb = photocraft_compose::thumbnail(&proxy, proxy.size.width.max(proxy.size.height));
let size = [thumb.width as usize, thumb.height as usize];
if size != [w, h] || thumb.pixels.len() != w * h * 4 {
report(app, Some(format!("the image thumbnail is {}×{}, expected {w}×{h}", thumb.width, thumb.height)));
return None;
}
let img = egui::ColorImage::from_rgba_unmultiplied(size, &thumb.pixels);
@@ -245,8 +259,18 @@ fn preview(app: &mut PhotocraftApp, ctx: &egui::Context, f: &Map<String, Value>)
return app.color_range.as_ref()?.image.as_ref().map(|t| (t.id(), [w, h], k));
}
if app.color_range.as_ref().is_some_and(|p| p.mask_key != key || p.mask.is_none()) {
let mask = proxy_mask(app, &proxy, k, &params).unwrap_or_else(|| vec![0.0; w * h]);
let mask = match proxy_mask(app, &proxy, k, &params) {
Ok(m) => {
report(app, None);
m
}
Err(e) => {
report(app, Some(e));
vec![0.0; w * h]
}
};
if mask.len() != w * h {
report(app, Some(format!("the selection preview has {} pixels, expected {}", mask.len(), w * h)));
return None;
}
let px: Vec<Color32> = mask.iter().map(|v| Color32::from_gray((v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8)).collect();
@@ -368,8 +392,22 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
pick(app, f, at, ui.input(|i| i.modifiers));
}
}
// A preview that can't be drawn says why (#145) instead of staying blank.
if let Some(e) = app.color_range.as_ref().and_then(|p| p.error.clone()) {
ui.painter().rect_filled(frame, 0.0, t.canvas);
let msg = format!(
"Preview unavailable:
{e}"
);
let g = ui.painter().layout(msg, egui::FontId::proportional(11.0), t.text_faint, frame.width() - 16.0);
ui.painter().galley(frame.center() - g.size() / 2.0, g, t.text_faint);
}
ui.painter().rect_stroke(frame, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), "Color Range preview"));
let label = match app.color_range.as_ref().and_then(|p| p.error.as_deref()) {
Some(e) => format!("Color Range preview unavailable: {e}"),
None => "Color Range preview".to_string(),
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, ui.is_enabled(), &label));
ui.add_space(10.0);
ui.vertical(|ui| {
ui.add_enabled_ui(c.sampling, |ui| {
@@ -704,4 +742,51 @@ mod tests {
app.run(COMMAND, params(&f)).unwrap();
}
}
/// #145: the whole app without a GPU (no wgpu render state: the CPU canvas, as on a Linux
/// machine whose adapter can't run the GPU canvas), on a small window. Select › Color Range…
/// from the menu opens the dialog on screen and draws both previews.
#[test]
fn opens_and_previews_in_the_full_app_without_a_gpu() {
let mut h = Harness::builder().with_size(egui::vec2(1024.0, 600.0)).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
assert!(cc.wgpu_render_state.is_none(), "this test covers the no-GPU path");
app_with_doc()
});
h.run_steps(6);
assert!(h.state().gpu.is_none(), "the CPU canvas");
h.get_all_by_label("Select").next().expect("the Select menu").click();
h.run_steps(3);
h.get_by_label("Color Range…").click();
h.run_steps(6);
let id = dialog_id(h.state());
let area = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", id)))).expect("the dialog is drawn");
assert!(h.ctx.content_rect().contains_rect(area), "the dialog is on screen: {area:?}");
assert!(h.query_by_label("Color Range preview").is_some());
let p = h.state().color_range.as_ref().expect("the preview is cached");
assert!(p.mask.is_some() && p.error.is_none(), "the selection preview is drawn: {:?}", p.error);
set(&mut h, "__view", json!("image"));
let p = h.state().color_range.as_ref().unwrap();
assert!(p.image.is_some() && p.error.is_none(), "the image preview is drawn: {:?}", p.error);
set(&mut h, "select", json!("reds"));
ok(&mut h);
h.run_steps(3);
assert_eq!(coverage(h.state(), 5, 5), 1.0);
}
#[test]
fn a_preview_failure_is_shown_not_silent() {
let mut h = harness(app_with_doc());
open(h.state_mut());
h.run_steps(3);
// The engine refuses these params: the preview says so instead of staying blank.
let id = dialog_id(h.state());
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("select".into(), json!("noSuchMode"));
h.run_steps(3);
let err = h.state().color_range.as_ref().and_then(|p| p.error.clone());
assert!(err.is_some(), "the failure is recorded");
assert!(h.query_by_label_contains("Color Range preview unavailable").is_some(), "and shown");
set(&mut h, "select", json!("reds"));
assert!(h.state().color_range.as_ref().unwrap().error.is_none(), "and cleared once the preview works again");
}
}
+10
View File
@@ -123,6 +123,10 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
if let Some(m) = p.get("maskTarget").and_then(Value::as_bool) {
app.ui.mask_target = m;
}
// Selection tools' options-bar mode: 0 New, 1 Add, 2 Subtract, 3 Intersect.
if let Some(m) = p.get("selectionMode").and_then(Value::as_u64) {
app.ui.selection_mode = m.min(3) as u8;
}
if let Some(tabs) = p.get("dockTabs") {
let mut cur = serde_json::to_value(app.ui.dock_tabs).unwrap_or_default();
if let (Some(c), Some(n)) = (cur.as_object_mut(), tabs.as_object()) {
@@ -173,6 +177,12 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
if let Some(i) = u("brushTab") {
app.ui.brush_tab = (i as usize).min(1);
}
if let Some(v) = p.get("brushesView").cloned() {
match serde_json::from_value(v) {
Ok(v) => app.ui.brushes_panel.view = v,
Err(e) => return err(format!("brushesView: {e} (list, grid)")),
}
}
if let Some(size) = p.get("brushSize").and_then(Value::as_f64) {
app.session.tools.brush.size = size as f32;
}
+106 -10
View File
@@ -29,6 +29,8 @@ pub enum Group {
Color,
/// Properties | Adjustments.
Properties,
/// Character | Paragraph (not in the default workspace; Window › Character opens it, #150).
Character,
/// Navigator | Histogram | Info.
Navigator,
/// History | Actions | Layer Comps.
@@ -39,29 +41,54 @@ pub enum Group {
impl Group {
/// Photoshop Essentials order, top to bottom.
pub const ALL: [Group; 5] = [Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers];
pub const ALL: [Group; 6] = [Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History, Group::Layers];
pub fn key(self) -> &'static str {
match self {
Group::Color => "color",
Group::Properties => "properties",
Group::Character => "character",
Group::Navigator => "navigator",
Group::History => "history",
Group::Layers => "layers",
}
}
/// Height (points, tab strip included) a group gets until the user resizes it.
/// Height (points, tab strip included) a group gets until the user resizes it, when the
/// column has room (see [`DockLayout::heights_for`]).
pub fn default_height(self) -> f32 {
match self {
Group::Color => 200.0,
Group::Properties => 340.0,
Group::Navigator => 230.0,
Group::History => 220.0,
Group::Color => 190.0,
Group::Properties => 250.0,
Group::Character => 270.0,
Group::Navigator => 210.0,
Group::History => 200.0,
Group::Layers => 320.0,
}
}
/// What a group left at its default height gives way down to so the filler (Layers) keeps
/// [`Group::preferred_fill`] in a short column (#147). Content taller than this scrolls.
pub fn compact_height(self) -> f32 {
match self {
Group::Color => 130.0,
Group::Properties => 160.0,
Group::Character => 160.0,
Group::Navigator => 140.0,
Group::History => 130.0,
Group::Layers => 200.0,
}
}
/// The height the filling group asks for before default-sized groups above it get their
/// full defaults: Layers wants room for about ten rows at 900 pt (#147).
pub fn preferred_fill(self) -> f32 {
match self {
Group::Layers => 500.0,
g => g.min_height(),
}
}
/// The smallest height an expanded group can be dragged or squeezed to.
pub fn min_height(self) -> f32 {
match self {
@@ -76,6 +103,7 @@ impl Group {
Group::Color if pro => &["Color", "Swatches", "Gradients", "Patterns"],
Group::Color => &["Swatches", "Color", "Gradients", "Patterns"],
Group::Properties => &["Properties", "Adjustments"],
Group::Character => &["Character", "Paragraph"],
Group::Navigator => &["Navigator", "Histogram", "Info"],
Group::History => &["History", "Actions", "Layer Comps"],
Group::Layers => &["Layers", "Channels", "Paths"],
@@ -91,6 +119,7 @@ impl Group {
match self {
Group::Color => &mut tabs.color,
Group::Properties => &mut tabs.properties,
Group::Character => &mut tabs.character,
Group::Navigator => &mut tabs.navigator,
Group::History => &mut tabs.history,
Group::Layers => &mut tabs.layers,
@@ -106,6 +135,7 @@ impl Group {
match self {
Group::Color => panels.color,
Group::Properties => panels.properties,
Group::Character => panels.character,
Group::Navigator => panels.navigator,
Group::History => panels.history,
Group::Layers => panels.layers,
@@ -116,6 +146,7 @@ impl Group {
match self {
Group::Color => &mut panels.color,
Group::Properties => &mut panels.properties,
Group::Character => &mut panels.character,
Group::Navigator => &mut panels.navigator,
Group::History => &mut panels.history,
Group::Layers => &mut panels.layers,
@@ -127,7 +158,8 @@ impl Group {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DockLayout {
/// Top-to-bottom order; groups missing here keep their default place after the listed ones.
/// Top-to-bottom order; a group missing here (say one added after the layout was saved)
/// goes just below the nearest group that precedes it by default (or last).
pub order: Vec<Group>,
/// Heights the user dragged groups to (points, tab strip included). Unset = default.
pub heights: BTreeMap<Group, f32>,
@@ -144,11 +176,23 @@ impl DockLayout {
/// Every group once, in display order.
pub fn order(&self) -> Vec<Group> {
let mut out: Vec<Group> = Vec::with_capacity(Group::ALL.len());
for g in self.order.iter().chain(Group::ALL.iter()) {
for g in &self.order {
if !out.contains(g) {
out.push(*g);
}
}
// Missing groups slot in below their default predecessor, so a group new to an old
// saved layout (Character) never lands below Layers and takes over as the filler.
for (i, g) in Group::ALL.iter().enumerate() {
if out.contains(g) {
continue;
}
let prev = Group::ALL.iter().take(i).rev().find_map(|p| out.iter().position(|x| x == p));
match prev {
Some(at) => out.insert(at + 1, *g),
None => out.push(*g),
}
}
out
}
@@ -185,7 +229,9 @@ impl DockLayout {
/// Lay out the `shown` groups (in display order) in a column `avail` points tall with
/// `strip`-high tab strips. Returns each group's height. The last expanded group fills the
/// rest; when the column is too short the others give way down to their minimum heights.
/// rest. Groups the user never resized give way first, down to their compact heights, so
/// the filler gets its preferred height (Layers: ~10 rows, #147); when the column is still
/// too short every group gives way down to its minimum height.
pub fn heights_for(&self, shown: &[Group], avail: f32, strip: f32) -> Vec<(Group, f32)> {
let avail = if avail.is_finite() { avail.max(0.0) } else { 0.0 };
let filler = shown.iter().rposition(|g| !self.is_collapsed(*g));
@@ -205,6 +251,23 @@ impl DockLayout {
if let Some(f) = filler {
let gaps = GAP * shown.len().saturating_sub(1) as f32;
let min_fill = shown.get(f).map_or(0.0, |g| g.min_height());
let pref_fill = shown.get(f).map_or(0.0, |g| g.preferred_fill());
let used: f32 = hs.iter().sum::<f32>() + gaps;
let mut deficit = (used + pref_fill - avail).max(0.0);
for i in (0..f).rev() {
if deficit <= 0.0 {
break;
}
let Some(g) = shown.get(i) else { continue };
if self.is_collapsed(*g) || self.heights.contains_key(g) {
continue;
}
if let Some(h) = hs.get_mut(i) {
let give = (*h - g.compact_height()).max(0.0).min(deficit);
*h -= give;
deficit -= give;
}
}
let used: f32 = hs.iter().sum::<f32>() + gaps;
let mut deficit = (used + min_fill - avail).max(0.0);
// Squeeze the expanded groups nearest the filler first.
@@ -269,6 +332,27 @@ fn rects_id() -> egui::Id {
egui::Id::new("dock-group-rects")
}
/// A group's tab strip as drawn last frame (screen points), for tests and automation.
#[derive(Clone, Debug, PartialEq)]
pub struct StripRects {
pub group: Group,
/// `(tab index, rect)` of the tabs on the strip (the others are in the chevron menu).
pub tabs: Vec<(usize, Rect)>,
/// The panel menu button.
pub menu: Rect,
/// The » overflow button, when some tabs didn't fit.
pub chevron: Option<Rect>,
}
/// The tab strips drawn last frame.
pub fn last_strips(ctx: &egui::Context) -> Vec<StripRects> {
ctx.data(|d| d.get_temp::<Vec<StripRects>>(strips_id())).unwrap_or_default()
}
fn strips_id() -> egui::Id {
egui::Id::new("dock-strip-rects")
}
/// Draw the `shown` groups (any order; the layout decides) filling `ui`. `body` draws one
/// group's tab content.
pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut body: impl FnMut(&mut PhotocraftApp, &mut egui::Ui, Group, usize)) {
@@ -281,6 +365,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
let rects = rects_after_layout(&heights, area);
let mut actions: Vec<Action> = Vec::new();
let mut dragging: Option<Group> = None;
let mut strips: Vec<StripRects> = Vec::with_capacity(rects.len());
for (i, (g, rect)) in rects.iter().copied().enumerate() {
let collapsed = app.ui.dock.is_collapsed(g);
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(("dock-group", g.key())).max_rect(rect));
@@ -302,6 +387,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
.show(ui, |ui| body(app, ui, g, tab));
}
});
strips.push(StripRects { group: g, tabs: resp.tabs.clone(), menu: resp.menu.rect, chevron: resp.chevron });
// The body may switch tabs itself (Adjustments jumps back to Properties).
if sel != before {
*g.tab_mut(&mut app.ui.dock_tabs) = sel.min(tabs.len().saturating_sub(1));
@@ -368,7 +454,10 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui, shown: &[Group], mut bod
};
ui.painter().line_segment([pos2(area.left(), line_y), pos2(area.right(), line_y)], Stroke::new(3.0, t.accent));
}
ui.ctx().data_mut(|d| d.insert_temp(rects_id(), rects));
ui.ctx().data_mut(|d| {
d.insert_temp(rects_id(), rects);
d.insert_temp(strips_id(), strips);
});
ui.advance_cursor_after_rect(area);
for a in actions {
match a {
@@ -422,6 +511,13 @@ fn resize(layout: &mut DockLayout, heights: &[(Group, f32)], i: usize, dy: f32)
let filler = heights.iter().rposition(|(g, _)| !layout.is_collapsed(*g));
let Some(j) = heights.iter().enumerate().skip(i + 1).find(|(_, (n, _))| !layout.is_collapsed(*n)).map(|(j, _)| j) else { return };
let Some(&(n, nh)) = heights.get(j) else { return };
// The first drag pins the other groups at the heights they show, so groups still at their
// defaults (which give way to the filler) don't shift while this one is resized.
for (k, (o, oh)) in heights.iter().enumerate() {
if Some(k) != filler && !layout.is_collapsed(*o) {
layout.heights.entry(*o).or_insert(*oh);
}
}
let new_h = (h + dy).clamp(g.min_height(), (h + nh - n.min_height()).max(g.min_height()));
layout.heights.insert(g, new_h);
if Some(j) != filler {
+229 -11
View File
@@ -12,15 +12,15 @@ const ESSENTIALS: [Group; 3] = [Group::Color, Group::Properties, Group::Layers];
#[test]
fn last_expanded_group_fills_the_column() {
let l = DockLayout::default();
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
assert_eq!(hs[0], (Group::Color, Group::Color.default_height()));
assert_eq!(hs[1], (Group::Properties, Group::Properties.default_height()));
let total: f32 = hs.iter().map(|(_, h)| h).sum::<f32>() + 2.0 * GAP;
assert!((total - 800.0).abs() < 1e-3, "{hs:?}");
assert!((total - 1200.0).abs() < 1e-3, "{hs:?}");
// Collapsed groups shrink to the tab strip; the one above Layers keeps its height.
let mut l = DockLayout::default();
l.set_collapsed(Group::Color, true);
let hs = l.heights_for(&ESSENTIALS, 800.0, 28.0);
let hs = l.heights_for(&ESSENTIALS, 1000.0, 28.0);
assert_eq!(hs[0].1, 28.0);
assert_eq!(hs[1].1, Group::Properties.default_height());
// Layers collapsed: Properties becomes the filler.
@@ -33,10 +33,10 @@ fn last_expanded_group_fills_the_column() {
#[test]
fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
let l = DockLayout::default();
let hs = l.heights_for(&ESSENTIALS, 500.0, 28.0);
let hs = l.heights_for(&ESSENTIALS, 400.0, 28.0);
assert_eq!(hs[2].1, Group::Layers.min_height(), "Layers keeps its minimum: {hs:?}");
assert_eq!(hs[0].1, Group::Color.default_height(), "the group farthest from Layers gives way last");
assert!(hs[1].1 < Group::Properties.default_height());
assert_eq!(hs[0].1, Group::Color.compact_height(), "the group farthest from Layers gives way last");
assert!(hs[1].1 < Group::Properties.compact_height());
for avail in [0.0, -50.0, 1.0, f32::NAN, f32::INFINITY, 1e9] {
for (g, h) in l.heights_for(&ESSENTIALS, avail, 28.0) {
assert!(h.is_finite() && h >= 0.0, "{g:?} at {avail}: {h}");
@@ -48,7 +48,7 @@ fn short_columns_squeeze_groups_down_to_their_minimum_and_never_go_negative() {
#[test]
fn bad_stored_values_are_sanitised() {
let mut l = DockLayout { order: vec![Group::Layers, Group::Layers, Group::Color], ..Default::default() };
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
assert_eq!(l.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
for bad in [f32::NAN, -10.0, f32::INFINITY, 1e12] {
l.heights.insert(Group::Color, bad);
let h = l.height(Group::Color);
@@ -56,7 +56,7 @@ fn bad_stored_values_are_sanitised() {
}
// Unknown fields, wrong types and old UI state all load.
let back: DockLayout = serde_json::from_value(json!({"order": ["layers"], "bogus": 1})).unwrap();
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Navigator, Group::History]);
assert_eq!(back.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
let mut ui = serde_json::to_value(crate::state::UiState::default()).unwrap();
ui.as_object_mut().unwrap().remove("dock");
let ui: crate::state::UiState = serde_json::from_value(ui).unwrap();
@@ -150,11 +150,13 @@ fn dragging_the_splitter_resizes_and_survives_a_ui_state_round_trip() {
let props = rect_of(&h, Group::Properties);
let layers = rect_of(&h, Group::Layers);
let split = Pos2::new(props.center().x, props.bottom() + GAP / 2.0);
drag(&mut h, split, split - vec2(0.0, 90.0));
let color = rect_of(&h, Group::Color);
drag(&mut h, split, split - vec2(0.0, 60.0));
let props2 = rect_of(&h, Group::Properties);
let layers2 = rect_of(&h, Group::Layers);
assert!((props2.height() - (props.height() - 90.0)).abs() < 2.0, "{props:?} -> {props2:?}");
assert!((layers2.top() - (layers.top() - 90.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
assert!((props2.height() - (props.height() - 60.0)).abs() < 2.0, "{props:?} -> {props2:?}");
assert!((layers2.top() - (layers.top() - 60.0)).abs() < 2.0, "{layers:?} -> {layers2:?}");
assert_eq!(rect_of(&h, Group::Color), color, "the group above keeps its place");
assert_eq!(layers2.bottom(), layers.bottom());
let stored = h.state().ui.dock.heights.get(&Group::Properties).copied().unwrap();
// Dragging past the minimum stops at it.
@@ -348,3 +350,219 @@ fn clicking_around_the_ui_keeps_the_panels_put() {
h.state_mut().ui.dialogs.clear();
}
}
fn is_pro(theme: ThemeKind) -> bool {
matches!(theme, ThemeKind::Pro | ThemeKind::ProMedium)
}
/// Full app at `size` (1× scale) on a document with `n` layers named "Row 00", "Row 01", …
fn app_harness_rows(size: egui::Vec2, theme: ThemeKind, n: usize) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(size).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, theme);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.theme = theme;
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
for i in 0..n {
app.run("layer.new.layer", json!({"name": format!("Row {i:02}")})).unwrap();
}
app.sync_views();
app
});
h.run_steps(8);
h
}
/// Layer rows fully inside the Layers rows viewport.
fn rows_in_view(h: &Harness<'static, PhotocraftApp>, n: usize) -> usize {
use egui_kittest::kittest::Queryable;
let layers = rect_of(h, Group::Layers);
(0..n)
.filter(|i| {
h.query_all_by_label(&format!("Row {i:02}"))
.map(|q| q.rect())
.any(|r| r.left() >= layers.left() - 1.0 && r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0)
})
.count()
}
/// #147: the default workspace gives Layers the column's spare height: at least ten rows on a
/// 900 pt window, and a usable list on a 720 pt one.
#[test]
fn default_layout_shows_ten_layer_rows_at_900pt() {
for (size, want) in [(vec2(1440.0, 900.0), 10), (vec2(1280.0, 720.0), 4)] {
let h = app_harness_rows(size, ThemeKind::ProMedium, 40);
let rows = rows_in_view(&h, 40);
let groups = last_rects(&h.ctx);
assert!(rows >= want, "{size:?}: {rows} rows visible, want ≥ {want}: {groups:?}");
// Color and Properties stay open, just not at the expense of Layers.
for g in [Group::Color, Group::Properties] {
assert!(rect_of(&h, g).height() >= g.compact_height() - 0.5, "{g:?} at {size:?}: {groups:?}");
}
}
}
#[test]
fn defaults_give_way_to_layers_but_user_sizes_stay() {
let l = DockLayout::default();
// A tall column: everyone at their defaults, Layers takes the rest.
let hs = l.heights_for(&ESSENTIALS, 1200.0, 28.0);
assert_eq!(hs[0].1, Group::Color.default_height());
assert_eq!(hs[1].1, Group::Properties.default_height());
// A short one: default-sized groups shrink toward their compact heights so Layers keeps
// its preferred height, the one nearest Layers first.
let hs = l.heights_for(&ESSENTIALS, 820.0, 28.0);
assert!(hs[2].1 >= Group::Layers.preferred_fill() - 0.5, "{hs:?}");
assert!(hs[1].1 < Group::Properties.default_height() && hs[1].1 >= Group::Properties.compact_height(), "{hs:?}");
// Heights the user dragged to (saved in prefs) are kept as they are.
let mut mine = DockLayout::default();
mine.heights.insert(Group::Properties, 340.0);
mine.heights.insert(Group::Color, 200.0);
let hs = mine.heights_for(&ESSENTIALS, 800.0, 28.0);
assert_eq!((hs[0].1, hs[1].1), (200.0, 340.0), "{hs:?}");
}
/// #150: Window › Character opens a Character | Paragraph group next to Properties (which
/// stays), and toggles closed again; old saved layouts place it above Layers.
#[test]
fn window_character_opens_its_own_group_and_keeps_properties() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
let (app, _, _) = app_with_layers();
let mut h = harness(app, vec2(1440.0, 900.0), theme);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.character", json!({})).unwrap();
h.run_steps(3);
let app = h.state();
assert!(app.ui.panels.character && app.ui.panels.properties && app.ui.dock_tabs.character == 0, "{theme:?}");
let ch = rect_of(&h, Group::Character);
assert!(ch.height() > 60.0, "{theme:?}: Character expanded: {ch:?}");
if is_pro(theme) {
let props = rect_of(&h, Group::Properties);
assert!(props.height() > 60.0 && props.bottom() <= ch.top(), "{theme:?}: Properties visible above Character");
}
assert!(rect_of(&h, Group::Layers).top() > ch.bottom(), "{theme:?}: Layers stays the filler at the bottom");
assert_eq!(crate::view_cmds::checked(h.state(), "window.panel.character"), Some(true));
// Paragraph is the group's second tab; choosing it again on its tab closes the group.
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
assert!(h.state().ui.panels.character && h.state().ui.dock_tabs.character == 1);
crate::menus::invoke(h.state_mut(), &ctx, "window.panel.paragraph", json!({})).unwrap();
h.run_steps(3);
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties, "{theme:?}");
assert!(!last_rects(&h.ctx).iter().any(|(g, _)| *g == Group::Character));
// Type › Panels › Character Panel always shows it; Reset Workspace closes it.
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
crate::menus::invoke(h.state_mut(), &ctx, "type.panels.character", json!({})).unwrap();
assert!(h.state().ui.panels.character);
crate::menus::invoke(h.state_mut(), &ctx, "window.workspace.resetWorkspace", json!({})).unwrap();
assert!(!h.state().ui.panels.character && h.state().ui.panels.properties);
}
// A layout saved before the group existed keeps Layers last.
let old = DockLayout { order: vec![Group::Color, Group::Properties, Group::Navigator, Group::History, Group::Layers], ..Default::default() };
assert_eq!(old.order(), Group::ALL.to_vec());
let moved = DockLayout { order: vec![Group::Layers, Group::Color], ..Default::default() };
assert_eq!(moved.order(), vec![Group::Layers, Group::Color, Group::Properties, Group::Character, Group::Navigator, Group::History]);
}
type StripProbe = Option<(Vec<(usize, Rect)>, Rect, Option<Rect>, Rect)>;
/// #151: at narrow widths and 2× scale, in every theme, tabs elide or overflow into a chevron
/// and never run under the panel menu button.
#[test]
fn tab_strips_never_overlap_the_menu_button() {
let overlap = |a: Rect, b: Rect| {
let i = a.intersect(b);
i.width() > 0.5 && i.height() > 0.5
};
for theme in ThemeKind::ALL {
for scale in [1.0, 2.0] {
for width in [180.0, 250.0, 290.0, 420.0] {
for g in Group::ALL {
let tabs = g.tabs(is_pro(theme));
for sel in 0..tabs.len() {
let mut h = Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).build_ui_state(
move |ui, out: &mut StripProbe| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
let mut s = sel;
let r = crate::widgets::card_ex(ui, "t", tabs, &mut s, false, |ui, _| {
ui.label("body");
});
*out = Some((r.tabs, r.menu.rect, r.chevron, ui.max_rect()));
},
None,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.run_steps(3);
let (shown, menu, chevron, card) = h.state().clone().unwrap();
let what = format!("{theme:?} {scale}x {width}pt {g:?} selected {sel}");
assert!(shown.iter().any(|(i, _)| *i == sel), "{what}: the selected tab is on the strip: {shown:?}");
for (i, r) in &shown {
assert!(!overlap(*r, menu), "{what}: tab {i} {r:?} runs under the menu {menu:?}");
assert!(r.left() >= card.left() - 0.5 && r.right() <= card.right() + 0.5, "{what}: tab {i} outside the card");
}
for w in shown.windows(2) {
assert!(!overlap(w[0].1, w[1].1), "{what}: tabs overlap");
}
if let Some(c) = chevron {
assert!(!overlap(c, menu) && shown.iter().all(|(_, r)| !overlap(*r, c)), "{what}: chevron overlaps");
} else {
assert_eq!(shown.len(), tabs.len(), "{what}: a tab went missing without a chevron");
}
}
}
}
}
}
}
/// #151 in the real dock: every strip at 2× scale fits beside its menu button, and a tab in the
/// chevron menu can still be chosen.
#[test]
fn dock_strips_fit_and_the_chevron_menu_switches_tabs() {
for theme in ThemeKind::ALL {
let (mut app, _, _) = app_with_layers();
app.ui.panels.history = true;
app.ui.panels.navigator = true;
app.ui.panels.character = true;
let mut h = Harness::builder().with_size(vec2(900.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.state_mut().ui.theme = theme;
h.run_steps(4);
let strips = last_strips(&h.ctx);
assert!(strips.len() >= 5, "{theme:?}: {strips:?}");
for s in &strips {
for (i, r) in &s.tabs {
assert!(r.intersect(s.menu).width() <= 0.5, "{theme:?} {:?}: tab {i} under the menu", s.group);
}
}
}
// A strip too narrow for its tabs: pick a hidden tab from the chevron menu.
let mut h = Harness::builder().with_size(vec2(150.0, 200.0)).build_ui_state(
|ui, sel: &mut usize| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
let _ = crate::widgets::card_ex(ui, "color", &["Color", "Swatches", "Gradients", "Patterns"], sel, false, |ui, _| {
ui.label("body");
});
},
0usize,
);
PhotocraftApp::setup_context(&h.ctx, ThemeKind::ProMedium);
h.run_steps(3);
use egui_kittest::kittest::Queryable;
h.get_by_label("More panels").click();
h.run_steps(3);
h.get_by_label("Patterns").click();
h.run_steps(3);
assert_eq!(*h.state(), 3, "Patterns chosen from the chevron menu");
}
+24 -8
View File
@@ -243,6 +243,10 @@ fn choice_label(v: &str) -> String {
label(v)
}
fn uses_logarithmic_slider(min: f32, max: f32) -> bool {
min > 0.0 && max / min > 500.0
}
/// Dialog body for filter commands.
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let t = Tokens::get(ui.ctx());
@@ -268,16 +272,20 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
} else {
""
};
let logarithmic = min > 0.0 && max / min > 500.0;
if logarithmic {
// Scrub the value field directly; add a log-scaled slider for huge ranges.
let mut lv = v.max(min.max(0.1)).ln();
let r = crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
let _ = r;
let r2 = crate::widgets::slider(ui, &mut lv, min.max(0.1).ln()..=max.ln(), None);
if r2.changed() {
if uses_logarithmic_slider(min, max) {
// Keep one numeric field for direct entry and one log-scaled slider for
// wide ranges. slider_row would add a second, linear slider (#146).
ui.horizontal(|ui| {
ui.label(egui::RichText::new(label(&p.key)).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
crate::widgets::value_field(ui, &mut v, min..=max, unit, 74.0);
});
});
let mut lv = v.clamp(min, max).ln();
if crate::widgets::slider(ui, &mut lv, min.ln()..=max.ln(), None).changed() {
v = lv.exp();
}
ui.add_space(4.0);
} else {
crate::widgets::slider_row(ui, &label(&p.key), &mut v, min..=max, unit, None);
}
@@ -421,6 +429,14 @@ mod tests {
);
}
#[test]
fn wide_positive_ranges_use_the_logarithmic_slider_path() {
assert!(uses_logarithmic_slider(0.1, 1000.0), "Gaussian Blur radius");
assert!(uses_logarithmic_slider(1.0, 9999.0));
assert!(!uses_logarithmic_slider(1.0, 500.0));
assert!(!uses_logarithmic_slider(0.0, 1000.0));
}
#[test]
fn new_filters_have_dialogs_or_run_directly() {
for id in [
+131 -5
View File
@@ -124,6 +124,11 @@ impl GpuCanvas {
/// The canvas texture format for a document of `depth` and `size`: `Rgba16Float` for 16/32-bit
/// documents when the adapter supports it and the document fits [`F16_BUDGET_PX`], else
/// `Rgba8Unorm`.
/// The device's largest 2D texture side (egui textures can use it too).
pub fn max_texture_side(&self) -> usize {
self.rs.device.limits().max_texture_dimension_2d as usize
}
pub fn format_for(&self, depth: photocraft_doc::SampleType, size: [u32; 2]) -> wgpu::TextureFormat {
let px = size[0] as u64 * size[1] as u64;
let high = match self.high {
@@ -147,6 +152,37 @@ impl GpuCanvas {
Some((d.format, bytes))
}
/// Set the GPU memory the wgpu compositor may hold for layer pages and effect maps (see
/// [`memory_budget`]); pages beyond it are evicted least recently used first.
pub fn set_memory_budget(&self, bytes: u64) {
let mut r = self.rs.renderer.write();
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
res.compositor_budget = Some(bytes);
}
}
/// The compositor's memory budget (`None` until [`GpuCanvas::set_memory_budget`]).
pub fn memory_budget(&self) -> Option<u64> {
let r = self.rs.renderer.read();
r.callback_resources.get::<Resources>()?.compositor_budget
}
/// The document area the view shows: full refreshes composite it last, so its layer pages
/// are the ones still resident for the edits that follow.
pub fn set_focus(&self, focus: Option<photocraft_geom::Rect>) {
let mut r = self.rs.renderer.write();
if let Some(res) = r.callback_resources.get_mut::<Resources>() {
res.compositor_focus = focus;
}
}
/// GPU bytes the wgpu compositor holds: resident layer pages and cached effect maps.
pub fn compositor_bytes(&self) -> Option<(u64, usize)> {
let r = self.rs.renderer.read();
let c = r.callback_resources.get::<Resources>()?.compositor.as_ref()?;
Some((c.resident_bytes(), c.fx_cache_bytes()))
}
/// Set the checkerboard and gamut warning colours.
pub fn set_style(&self, style: CanvasStyle) {
let mut r = self.rs.renderer.write();
@@ -174,10 +210,8 @@ impl GpuCanvas {
}
match &res.compositor {
Some(c) => c.supports(doc).is_ok(),
None => {
let max = self.rs.device.limits().max_texture_dimension_2d;
doc.size.width <= max && doc.size.height <= max && photocraft_gpu::plan(doc).is_ok()
}
// Layers larger than the texture limit are stored in pages, so any size qualifies.
None => photocraft_gpu::plan(doc).is_ok(),
}
}
@@ -286,14 +320,26 @@ impl GpuCanvas {
}
},
};
if let Some(b) = res.compositor_budget {
comp.set_memory_budget(b);
}
comp.set_focus(res.compositor_focus);
let key = doc.id.0;
let format = self.format_for(doc.depth, size);
let fresh = res.docs.get(&key).is_none_or(|d| d.size != size || d.format != format);
let region = if fresh { doc.bounds() } else { region.intersect(&doc.bounds()) };
if let Err(e) = comp.supports(doc) {
// Checked before allocating a new canvas texture only: `render` plans (and refuses) itself.
if fresh && let Err(e) = comp.supports(doc) {
res.compositor = Some(comp);
return Err(e);
}
// A full refresh whose layer pages don't fit the memory budget would re-upload the
// evicted ones every time (no faster than the CPU, and more memory): the banded CPU
// compositor does it, and edits in the view (damage rects) stay on the GPU.
if region == doc.bounds() && !comp.fits_budget(doc, region) {
res.compositor = Some(comp);
return Err(photocraft_gpu::Unsupported("layers exceed the GPU memory budget; full refresh on the CPU".into()));
}
if fresh {
let tex = DocTextures::new(device, res, size, self.tile, format);
res.docs.insert(key, tex);
@@ -376,6 +422,11 @@ impl GpuCanvas {
let damage = damage.filter(|_| self.has(key, size) && self.format_of(key) == Some(format)).map(|r| r.intersect(&bounds));
let t0 = now_ms();
let mut out = Refresh::default();
if damage.is_some_and(|r| r.is_empty()) {
// Nothing changed on the canvas (selecting a layer, say): don't even plan.
out.kind = "rect";
return out;
}
let region = damage.unwrap_or(bounds);
let encode_srgb = display.is_some_and(|d| d.encode_srgb);
let gpu = if key == doc.id.0 { self.composite(doc, region, encode_srgb) } else { Err(photocraft_gpu::Unsupported("preview texture".into())) };
@@ -642,6 +693,12 @@ pub struct Perf {
pub gpu_uploads: u64,
/// Why the last refresh fell back to the CPU compositor (None = GPU composited).
pub gpu_fallback: Option<String>,
/// GPU memory the compositor may hold for layer pages and effect maps (MB; see
/// [`memory_budget`]).
pub gpu_budget_mb: u64,
/// The Memory Usage allowance (bytes) `gpu_budget_mb` was computed from.
#[serde(skip)]
pub gpu_budget_allowance: u64,
/// Last command run through the app and its duration (ms).
pub last_command: String,
pub command_ms: f64,
@@ -804,6 +861,56 @@ fn texel_to_f32(format: wgpu::TextureFormat, b: &[u8]) -> [f32; 4] {
// ---------------------------------------------------------------------------------------------
// GPU resources
/// Floor of the compositor's memory budget: enough for a viewport's pages of a dozen layers.
pub const MIN_GPU_BUDGET: u64 = 512 << 20;
/// GPU memory the wgpu compositor may hold for layer pages and effect maps: what Memory Usage
/// (`allowance`, Preferences › Performance) leaves after the document's pixels and History
/// (`pixels`), at most a quarter of physical memory (`ram`; 16 GB assumed when unknown), and at
/// least [`MIN_GPU_BUDGET`]. On unified-memory machines these textures share RAM with the
/// document, so a huge document keeps its layers on the GPU only as far as memory allows; the
/// rest is uploaded per refresh as the view needs it.
pub fn memory_budget(allowance: u64, pixels: u64, ram: Option<u64>) -> u64 {
let ram = ram.unwrap_or(16 << 30);
allowance.saturating_sub(pixels).min(ram / 4).max(MIN_GPU_BUDGET)
}
/// Physical memory of this machine in bytes, if known. `PHOTOCRAFT_RAM_MB` overrides it (to
/// simulate a smaller machine).
pub fn physical_memory() -> Option<u64> {
static RAM: std::sync::OnceLock<Option<u64>> = std::sync::OnceLock::new();
*RAM.get_or_init(|| {
if let Some(mb) = std::env::var("PHOTOCRAFT_RAM_MB").ok().and_then(|v| v.trim().parse::<u64>().ok()) {
return mb.checked_mul(1 << 20);
}
detect_physical_memory()
})
}
#[cfg(target_os = "macos")]
fn detect_physical_memory() -> Option<u64> {
let out = std::process::Command::new("/usr/sbin/sysctl").args(["-n", "hw.memsize"]).output().ok()?;
String::from_utf8(out.stdout).ok()?.trim().parse().ok()
}
#[cfg(target_os = "linux")]
fn detect_physical_memory() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb: u64 = info.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
kb.checked_mul(1024)
}
#[cfg(target_os = "freebsd")]
fn detect_physical_memory() -> Option<u64> {
let out = std::process::Command::new("/sbin/sysctl").args(["-n", "hw.physmem"]).output().ok()?;
String::from_utf8(out.stdout).ok()?.trim().parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "freebsd")))]
fn detect_physical_memory() -> Option<u64> {
None
}
struct Resources {
view_bgl: wgpu::BindGroupLayout,
tile_bgl: wgpu::BindGroupLayout,
@@ -823,6 +930,10 @@ struct Resources {
compositor: Option<photocraft_gpu::Compositor>,
/// Why the wgpu compositor couldn't be created (then the CPU compositor is used).
compositor_failed: Option<photocraft_gpu::Unsupported>,
/// GPU memory the compositor may hold (`None`: its default), and the document area the
/// view shows; applied before every composite.
compositor_budget: Option<u64>,
compositor_focus: Option<photocraft_geom::Rect>,
encode_bgl: wgpu::BindGroupLayout,
encode_pipeline: wgpu::RenderPipeline,
lut_bgl: wgpu::BindGroupLayout,
@@ -1082,6 +1193,8 @@ impl Resources {
out_linear: target.is_srgb(),
compositor: None,
compositor_failed: None,
compositor_budget: None,
compositor_focus: None,
encode_bgl,
encode_pipeline,
}
@@ -1574,6 +1687,19 @@ fn fs(in: V) -> @location(0) vec4<f32> {
mod tests {
use super::*;
#[test]
fn gpu_budget_follows_memory_usage_and_ram() {
const G: u64 = 1 << 30;
// What Memory Usage leaves after the document, within a quarter of RAM.
assert_eq!(memory_budget(8 * G, 3 * G, Some(48 * G)), 5 * G);
assert_eq!(memory_budget(8 * G, 3 * G, Some(16 * G)), 4 * G);
assert_eq!(memory_budget(64 * G, 3 * G, Some(16 * G)), 4 * G);
// Never below the floor, even with nothing left; 16 GB assumed when RAM is unknown.
assert_eq!(memory_budget(8 * G, 10 * G, Some(48 * G)), MIN_GPU_BUDGET);
assert_eq!(memory_budget(64 * G, 0, None), 4 * G);
assert!(physical_memory().is_none_or(|b| b >= 256 << 20));
}
#[test]
fn filter_modes() {
assert_eq!(filter_mode(0.25), (0.0, 2.0));
+41 -51
View File
@@ -1,12 +1,13 @@
//! Properties panel for pixel (and other bounded) layers, Photoshop 2026 style: a Transform section
//! (W/H/X/Y of the layer content, editable), Align and Distribute, and Quick Actions. Edits dispatch
//! (W/H/X/Y of the layer content, editable), Align and Distribute, the kind's sections and Quick Actions. Edits dispatch
//! `edit.transform`, `layer.translate`, `layer.align.*` and selection commands.
use egui::{Rect, Sense, Stroke, pos2, vec2};
use photocraft_doc::Layer;
use photocraft_doc::{Layer, LayerContent};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::props_layout::{LABEL_GAP, LABEL_W, quick_actions_ui, section};
use crate::theme::{ROW_GAP, Tokens};
use crate::{PhotocraftApp, widgets};
/// `edit.transform` params that scale the content box `b` = [x0, y0, x1, y1] to `w` x `h`, keeping
@@ -32,34 +33,14 @@ pub fn transform_params(layer: u64, b: [i32; 4], w: Option<f32>, h: Option<f32>,
Some(json!({"layer": layer, "rect": b, "quad": quad}))
}
fn section(ui: &mut egui::Ui, id: &str, title: &str) -> bool {
let t = Tokens::get(ui.ctx());
let key = egui::Id::new(("layer-props-section", id));
let mut open = ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(true);
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
crate::icons::paint(
ui,
Rect::from_center_size(pos2(r.left() + 7.0, r.center().y), vec2(12.0, 12.0)),
if open { "chevron-down" } else { "chevron-right" },
11.0,
t.text_dim,
);
ui.painter().text(pos2(r.left() + 18.0, r.center().y), egui::Align2::LEFT_CENTER, title, crate::theme::semibold(12.0), t.text);
if resp.clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(key, open));
}
open
}
/// A number field that reports a value once committed (drag released, Enter, focus lost).
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32) -> Option<f32> {
fn field(ui: &mut egui::Ui, id: &str, label: &str, current: f32, width: f32) -> Option<f32> {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(16.0, 22.0), Sense::hover());
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 22.0), Sense::hover());
ui.painter().text(pos2(r.right() - 2.0, r.center().y), egui::Align2::RIGHT_CENTER, label, egui::FontId::proportional(12.0), t.text_dim);
let key = egui::Id::new(("layer-props-field", id));
let mut v = ui.data(|d| d.get_temp::<f32>(key)).unwrap_or(current);
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", 80.0);
let resp = widgets::value_field(ui, &mut v, -300_000.0..=300_000.0, "px", width);
if resp.dragged() || resp.has_focus() {
ui.data_mut(|d| d.insert_temp(key, v));
return None;
@@ -107,7 +88,12 @@ fn align_glyph(ui: &egui::Ui, r: Rect, kind: &str, color: egui::Color32) {
}
}
/// Pro Properties body for a non-adjustment layer.
/// Width of the link toggle between the W and H fields (the X/Y row leaves the same gap).
const LINK_W: f32 = 20.0;
/// Pro Properties body for a non-adjustment layer: Transform, Align and Distribute, the kind's own
/// sections (Character/Paragraph/Type Options for type, Appearance/Shape for shapes), then Quick
/// Actions, all with the same collapsible headers (#155).
pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let t = Tokens::get(ui.ctx());
let mut run: Vec<(String, Value)> = Vec::new();
@@ -117,32 +103,33 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let link_key = egui::Id::new("layer-props-link");
let linked = ui.data(|d| d.get_temp::<bool>(link_key)).unwrap_or(true);
let bb = [b.x0, b.y0, b.x1, b.y1];
// Two label+field columns with the link toggle between them, filling the panel.
let w = ((ui.available_width() - 2.0 * (LABEL_W + LABEL_GAP) - LINK_W - 2.0 * LABEL_GAP) / 2.0).max(36.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if let Some(v) = field(ui, "w", "W", b.width() as f32) {
ui.spacing_mut().item_spacing.x = LABEL_GAP;
if let Some(v) = field(ui, "w", "W", b.width() as f32, w) {
run.extend(transform_params(layer.id.0, bb, Some(v), None, linked).map(|p| ("edit.transform".to_string(), p)));
}
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, 20.0, linked, "Link width and height").clicked() {
if crate::icons::button(ui, if linked { "link" } else { "unlink" }, LINK_W, linked, "Link width and height").clicked() {
ui.data_mut(|d| d.insert_temp(link_key, !linked));
}
if let Some(v) = field(ui, "h", "H", b.height() as f32) {
if let Some(v) = field(ui, "h", "H", b.height() as f32, w) {
run.extend(transform_params(layer.id.0, bb, None, Some(v), linked).map(|p| ("edit.transform".to_string(), p)));
}
});
ui.add_space(ROW_GAP);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if let Some(v) = field(ui, "x", "X", b.x0 as f32) {
ui.spacing_mut().item_spacing.x = LABEL_GAP;
if let Some(v) = field(ui, "x", "X", b.x0 as f32, w) {
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": (v - b.x0 as f32).round() as i32, "dy": 0})));
}
ui.add_space(24.0);
if let Some(v) = field(ui, "y", "Y", b.y0 as f32) {
ui.add_space(LINK_W + LABEL_GAP);
if let Some(v) = field(ui, "y", "Y", b.y0 as f32, w) {
run.push(("layer.translate".into(), json!({"layer": layer.id.0, "dx": 0, "dy": (v - b.y0 as f32).round() as i32})));
}
});
ui.add_space(4.0);
ui.add_space(ROW_GAP);
}
widgets::hairline(ui);
ui.add_space(2.0);
}
if section(ui, "align", "Align and Distribute") {
ui.horizontal(|ui| {
@@ -164,25 +151,28 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
}
}
});
ui.add_space(4.0);
ui.add_space(ROW_GAP);
}
widgets::hairline(ui);
ui.add_space(2.0);
if section(ui, "quick", "Quick Actions") {
ui.horizontal(|ui| {
for (label, id) in [("Remove Background", "layer.removeBackground"), ("Select Subject", "select.subject")] {
if photocraft_engine::commands::find(id).is_some() && widgets::secondary_button(ui, label, 0.0).clicked() {
run.push((id.to_string(), json!({})));
}
}
});
}
for (id, p) in run {
for (id, p) in run.drain(..) {
if let Err(e) = app.run(&id, p) {
app.ui.status = e;
app.ui.status_error = true;
}
}
match &layer.content {
LayerContent::Text(_) => crate::type_tool::type_properties(app, ui),
LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id),
_ => {}
}
// Re-read: a section above may have changed the layer (e.g. point to paragraph type).
let content = app.session.active().and_then(|s| s.doc.layer(layer.id)).map_or_else(|| layer.content.clone(), |l| l.content.clone());
if let Some(id) = quick_actions_ui(app, ui, &content) {
let ctx = ui.ctx().clone();
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
app.ui.status = e;
app.ui.status_error = true;
}
}
}
#[cfg(test)]
+64
View File
@@ -0,0 +1,64 @@
//! Reveal the active layer in the Layers panel (#152), like Photoshop: whenever the active
//! layer changes, by whatever means (a click on the canvas with Auto-Select, Select › layer
//! commands, ⌥[ / ⌥], undo, automation), its closed parent groups open (`layer.setExpanded`)
//! and the panel scrolls just enough to show its row, unless the row is already in view.
//!
//! Only a change of the active layer reveals: closing the group that holds it, or scrolling
//! away, is left alone until the next change.
use egui::{Rect, pos2};
use photocraft_doc::{LayerContent, LayerId};
use serde_json::json;
use crate::PhotocraftApp;
/// Last active layer the panel saw: (document index, layer).
#[derive(Clone, Copy, PartialEq)]
struct Seen(usize, Option<LayerId>);
fn seen_id() -> egui::Id {
egui::Id::new("layers-reveal-seen")
}
/// Called once per Layers panel frame, before its rows are listed. When the active layer
/// changed since the last frame, opens its closed parent groups and returns it: the row to
/// scroll into view (see [`scroll_to_row`]). The first frame of a document only records it.
pub fn track(app: &mut PhotocraftApp, ctx: &egui::Context) -> Option<LayerId> {
let index = app.session.active_index()?;
let active = app.session.active()?.active_layer;
let now = Seen(index, active);
let before = ctx.data(|d| d.get_temp::<Seen>(seen_id()));
ctx.data_mut(|d| d.insert_temp(seen_id(), now));
match before {
Some(Seen(i, prev)) if i == index && prev != active => {}
_ => return None,
}
let id = active?;
for group in closed_ancestors(app, id) {
// A view change (no history step); a failure just leaves the group closed.
let _ = app.session.execute("layer.setExpanded", json!({"layer": group.0, "expanded": true}));
}
Some(id)
}
/// The groups containing `id` that are closed, outermost first.
fn closed_ancestors(app: &PhotocraftApp, id: LayerId) -> Vec<LayerId> {
let Some(doc) = app.session.active().map(|s| &s.doc) else { return Vec::new() };
let Some(path) = doc.path_of(id) else { return Vec::new() };
(1..path.len()).filter_map(|n| doc.layer_at(path.get(..n)?)).filter(|l| matches!(&l.content, LayerContent::Group(g) if !g.expanded)).map(|l| l.id).collect()
}
/// After drawing the row that started at `top` in the Layers scroll area, scroll it into view
/// if any of it is hidden (no animation: Photoshop jumps).
pub fn scroll_to_row(ui: &egui::Ui, top: f32) {
let bottom = ui.min_rect().bottom().max(top);
let row = Rect::from_min_max(pos2(ui.min_rect().left(), top), pos2(ui.min_rect().right(), bottom));
let view = ui.clip_rect();
if row.top() < view.top() - 0.5 || row.bottom() > view.bottom() + 0.5 {
ui.scroll_to_rect_animation(row, None, egui::style::ScrollAnimation::none());
}
}
#[cfg(test)]
#[path = "layer_reveal_tests.rs"]
mod tests;
+132
View File
@@ -0,0 +1,132 @@
//! #152: a new active layer opens its parent groups and is scrolled into view.
use egui::{Rect, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_doc::LayerId;
use serde_json::json;
use crate::PhotocraftApp;
use crate::dock::{Group, last_rects};
use crate::theme::ThemeKind;
/// 30 layers above a closed group holding `inner` (near the bottom of the panel).
fn tall_doc() -> (PhotocraftApp, LayerId, LayerId, LayerId, LayerId) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 200, "height": 150})).unwrap();
app.run("layer.new.layer", json!({"name": "Deep inside"})).unwrap();
let active = |app: &PhotocraftApp| app.session.active().unwrap().active_layer.unwrap();
let inner = active(&app);
app.run("layer.groupLayers", json!({})).unwrap();
let group = app.session.active().unwrap().doc.walk().into_iter().find(|(_, _, l)| l.is_group()).map(|(_, _, l)| l.id).unwrap();
app.run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
app.run("layer.select", json!({"layer": group.0})).unwrap();
let mut stack = Vec::new();
for i in 0..30 {
app.run("layer.new.layer", json!({"name": format!("Stack {i:02}")})).unwrap();
stack.push(active(&app));
}
app.sync_views();
(app, inner, group, stack[29], stack[28])
}
fn harness(app: PhotocraftApp, theme: ThemeKind) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
crate::panels::right_dock(app, ui);
egui::CentralPanel::default().show(ui, |_| {});
},
app,
);
PhotocraftApp::setup_context(&h.ctx, theme);
h.state_mut().ui.theme = theme;
h.run_steps(4);
h
}
/// The Layers panel row of `name`, if drawn.
fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers)?.1;
h.query_all_by_label(name).map(|n| n.rect()).find(|r| layers.contains(r.center()) || (r.left() >= layers.left() && r.right() <= layers.right()))
}
/// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer).
fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool {
let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1;
r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0
}
fn expanded(app: &PhotocraftApp, group: LayerId) -> bool {
matches!(&app.session.active().unwrap().doc.layer(group).unwrap().content, photocraft_doc::LayerContent::Group(g) if g.expanded)
}
#[test]
fn a_new_active_layer_opens_its_groups_and_scrolls_into_view() {
for theme in [ThemeKind::ProMedium, ThemeKind::Studio] {
let (app, inner, group, top, below_top) = tall_doc();
let mut h = harness(app, theme);
let top_row = row(&h, "Stack 29").expect("top row drawn");
assert!(in_view(&h, top_row), "{theme:?}: the panel starts at the top");
assert!(row(&h, "Deep inside").is_none(), "{theme:?}: hidden in its closed group");
// Select › layer, Auto-Select, automation: all end in a new active layer.
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
h.run_steps(4);
assert!(expanded(h.state(), group), "{theme:?}: the parent group opened");
let r = row(&h, "Deep inside").expect("row listed once its group is open");
assert!(in_view(&h, r), "{theme:?}: scrolled into view: {r:?}");
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: the top scrolled away");
// Back to the top layer: scrolls back up.
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
h.run_steps(4);
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: scrolled back to the top");
// A layer already in view doesn't move the list.
let before = row(&h, "Stack 29").unwrap();
h.state_mut().run("layer.select", json!({"layer": below_top.0})).unwrap();
h.run_steps(4);
assert_eq!(row(&h, "Stack 29").unwrap(), before, "{theme:?}: no scroll for a visible row");
// Closing the group by hand is respected until the active layer changes again.
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
h.run_steps(3);
h.state_mut().run("layer.setExpanded", json!({"layer": group.0, "expanded": false})).unwrap();
h.run_steps(4);
assert!(!expanded(h.state(), group), "{theme:?}: stays closed");
// Undo and other commands that change the active layer reveal it too: undoing a new
// layer deep in the list makes the top layer active again.
h.state_mut().run("layer.select", json!({"layer": top.0})).unwrap();
h.run_steps(3);
h.state_mut().run("layer.select", json!({"layer": inner.0})).unwrap();
h.run_steps(3);
assert!(expanded(h.state(), group));
h.state_mut().run("layer.new.layer", json!({"name": "Scratch"})).unwrap();
h.run_steps(4);
assert!(!in_view(&h, row(&h, "Stack 29").unwrap()));
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().active_layer, Some(top), "{theme:?}: undo made the top layer active");
assert!(in_view(&h, row(&h, "Stack 29").unwrap()), "{theme:?}: undo revealed it");
}
}
#[test]
fn reveal_survives_odd_documents() {
// No document, a document without layers in groups, a layer deleted between frames.
let app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut h = harness(app, ThemeKind::ProMedium);
h.run_steps(2);
h.state_mut().run("file.new", json!({"width": 20, "height": 20})).unwrap();
h.state_mut().sync_views();
h.run_steps(3);
h.state_mut().run("layer.new.layer", json!({})).unwrap();
h.run_steps(3);
h.state_mut().run("layer.delete", json!({})).unwrap();
h.run_steps(3);
h.state_mut().run("file.close", json!({})).ok();
h.state_mut().sync_views();
h.run_steps(3);
}
+1 -1
View File
@@ -25,7 +25,7 @@ const ICON_W: f32 = 14.0;
/// Width of the effects triangle beside the fx badge.
const TRIANGLE_W: f32 = 10.0;
/// The blend-mode label is shown only while the name keeps at least this much room.
const MIN_NAME_W: f32 = 64.0;
pub const MIN_NAME_W: f32 = 64.0;
/// A right-hand row indicator.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more