UI.9: refresh shared panel integration against current main
This commit is contained in:
commit
77f50330c6
|
|
@ -0,0 +1,15 @@
|
|||
# cargo-nextest (https://nexte.st): runs every test in its own process, in parallel across test
|
||||
# binaries. Optional for local runs; `cargo test` keeps working and stays what CI runs (#2204).
|
||||
#
|
||||
# cargo nextest run --workspace # all tests except doctests
|
||||
# cargo test --doc --workspace # nextest does not run doctests
|
||||
#
|
||||
# GPU tests: each test is its own process here, so the in-process `gpu_lock()` mutexes no longer
|
||||
# serialize them. Too many concurrent wgpu devices time out (`PollError: ... Wait timed out`), so
|
||||
# this group caps them at 4.
|
||||
[test-groups]
|
||||
gpu = { max-threads = 4 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(photocraft-gpu) | (package(photocraft-ui-egui) & (kind(test) | test(/canvas|gpu|mip|type_tool/)))'
|
||||
test-group = 'gpu'
|
||||
26
AGENTS.md
26
AGENTS.md
|
|
@ -11,8 +11,12 @@ 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` | 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 |
|
||||
| `ROADMAP.md` | One-page summary: stage, the two parity numbers, every dimension, languages, what's next |
|
||||
| `docs/target-app-parity.md` | The authoritative parity assessment against Photoshop 2026: numbers, weights, evidence, hours |
|
||||
| `docs/gaps.md` | Every known shortfall, ranked: the work list to pick from |
|
||||
| `docs/roadmap.md` | Milestones, **current focus**, what's next with estimates |
|
||||
| `docs/ui-parity.md`, `docs/brush-parity.md`, `docs/context-menu-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` | Per-dimension parity checklists |
|
||||
| `docs/parity-checklist.md` | Generated list of every Photoshop menu item, live or missing |
|
||||
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
|
||||
| `crates/<name>/README.md` (where present) | Public API of that crate |
|
||||
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
|
||||
|
|
@ -70,15 +74,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.
|
||||
|
||||
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.
|
||||
0. **Read `ROADMAP.md`, then `docs/gaps.md`.** `docs/target-app-parity.md` says, dimension by
|
||||
dimension, where PhotoCraft is lacking; `docs/gaps.md` ranks the work. `docs/parity-checklist.md`
|
||||
(menu wiring) is not a measure of behaviour. When your work moves a number or closes a gap,
|
||||
update the parity doc and gap entry it belongs to, with the date, and bump their status line
|
||||
and revision history (craftrules `standards/progress-docs.md`).
|
||||
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`.
|
||||
3. `cargo xtask parity` → `docs/parity-checklist.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).
|
||||
|
|
@ -90,7 +95,7 @@ cargo test -p <crates you touched>
|
|||
cargo clippy -p <crates> --all-targets -- -D warnings
|
||||
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 xtask parity # if you added commands; commit the regenerated docs/parity-checklist.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
|
||||
|
|
@ -99,6 +104,8 @@ cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --upd
|
|||
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
|
||||
```
|
||||
|
||||
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md` › Faster test loops.
|
||||
|
||||
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
|
||||
|
||||
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
|
||||
|
|
@ -117,7 +124,8 @@ 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.
|
||||
- `ROADMAP.md`, `docs/target-app-parity.md`, `docs/gaps.md` and the `docs/*-parity.md` checklists: where we stand against Photoshop.
|
||||
- `docs/parity-checklist.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.
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@ next to them.
|
|||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
|
||||
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg` and `symmetry-*.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
|
||||
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
|
||||
|
|
@ -36,6 +37,7 @@ next to them.
|
|||
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
|
||||
| `packaging/macos/dmg/` (all files) | PhotoCraft DMG window background and layout | @XusBadia, from the PhotoCraft app icon | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md) | MIT OR Apache-2.0, like the code |
|
||||
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
|
||||
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
|
||||
|
||||
### Optional build input: craft-fonts (not files in this repo)
|
||||
|
||||
|
|
|
|||
|
|
@ -4348,6 +4348,7 @@ name = "photocraft-engine"
|
|||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"exr",
|
||||
"flate2",
|
||||
"photocraft-affinity",
|
||||
"photocraft-algo",
|
||||
|
|
@ -4513,11 +4514,13 @@ name = "photocraft-raw"
|
|||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"jxl-oxide",
|
||||
"photocraft-raw",
|
||||
"png 0.18.1",
|
||||
"rayon",
|
||||
"zune-core 0.5.3",
|
||||
"zune-jpeg 0.5.15",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ arc-swap = "1"
|
|||
bytemuck = { version = "1", features = ["derive"] }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
image = { version = "0.25", default-features = false }
|
||||
# Baseline-JPEG decode of lossy DNG tiles (compression 34892), shared with zune-jpeg.
|
||||
zune-core = "0.5"
|
||||
zune-jpeg = "0.5"
|
||||
proptest = "1"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui = "0.36"
|
||||
|
|
@ -67,6 +70,10 @@ opt-level = 2
|
|||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
# Line tables keep file:line in panic backtraces but drop variable info, which roughly halves link
|
||||
# time and PDB size for the ~260 test binaries (#2204). CI sets the same through
|
||||
# CARGO_PROFILE_DEV_DEBUG. To inspect locals in a debugger, build with CARGO_PROFILE_DEV_DEBUG=true.
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
|
|
|||
|
|
@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
|||
<table>
|
||||
<tr>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🧰 53 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
<h4>🧰 55 tools</h4>
|
||||
Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom
|
||||
</td>
|
||||
<td width="33%" valign="top">
|
||||
<h4>🖌️ A real brush engine</h4>
|
||||
|
|
@ -298,7 +298,7 @@ photocraft
|
|||
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **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).
|
||||
> **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, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). 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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
# PhotoCraft roadmap
|
||||
|
||||
**Stage: alpha** · next: beta, ~30 points (ready for real work ~45% → ~75%, with reliable PSD) and ~1,100–1,800 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first version: full re-measure against Photoshop 2026 27.11.0) · **Target:** Adobe Photoshop 2026
|
||||
|
||||
PhotoCraft's one-page summary of how close it is to Adobe Photoshop 2026 (27.11.0, measured from
|
||||
the installed app on 2026-10-10) at version 0.6.0. Detail: [target-app parity](docs/target-app-parity.md)
|
||||
(methodology, weights, evidence), [gaps](docs/gaps.md) (the ranked work list),
|
||||
[roadmap](docs/roadmap.md) (milestones, current focus), [scorecard](docs/scorecard.md) and
|
||||
[menu checklist](docs/parity-checklist.md) (both generated and measured),
|
||||
[architecture](docs/architecture.md).
|
||||
|
||||
Hours are Opus 5.5 agent wall-clock hours for one agent working sequentially, calibrated from this
|
||||
repository's 1,004 merged PRs (see [calibration](docs/target-app-parity.md#remaining-effort-and-calibration)).
|
||||
About 80% of the work parallelizes.
|
||||
|
||||
## Headline numbers
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **~76%** | partly measured: menus 628/628, tools 53/68, panels 30/35, formats 13+4 of 31 |
|
||||
| **Ready for real work** | **~45%** (40–50%) | estimated: weighted by dimension below |
|
||||
| Mainstream practitioner | ~43% (38–48%) | estimated: weekly areas of a typical pro, with discounts ([method](docs/target-app-parity.md#mainstream-practitioner-43)) |
|
||||
| Essentials user | ~61% (55–67%) | estimated: core features only ([method](docs/target-app-parity.md#essentials-user-61)) |
|
||||
| Remaining to **beta** | **~1,100–1,800 h** | estimated |
|
||||
| Remaining to **full parity** | **~2,300–4,100 h** | estimated; AI and plug-ins need owner decisions |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
| Audience | Ready % | Opus 5.5 agent-hours to ~95% | Work that dominates |
|
||||
|---|---:|---:|---|
|
||||
| Full target (ready for real work) | ~45% (additive weighted sum: 46%) | 2,300–4,100 | Everything below, plus AI tier, plug-in/scripting ecosystem, video, localization, hardware |
|
||||
| Mainstream practitioner | ~43% | 1,700–2,800 | Depth in the 11 weekly areas (1,180–2,000), performance budgets, stability on real machines, PSD exchange with Photoshop |
|
||||
| Essentials user | ~61% | 400–700 | Startup and GPU robustness, brush and transform responsiveness, Type tool, discoverability (flyouts, commit buttons), AVIF/PDF open |
|
||||
|
||||
Hours calibrated as in [Remaining effort](docs/target-app-parity.md#remaining-effort-and-calibration) (2–9 h per checklist
|
||||
item, 8–20 h per format or subsystem, from the repo's 1,004 merged PRs); each tier is a subset of
|
||||
the one above. About 80% parallelizes (independent crates and command modules); performance
|
||||
architecture and the PSD writer are the serial parts.
|
||||
|
||||
**Why alpha.** Core workflows (open, layer, select, paint, adjust, filter, type, save) work end to
|
||||
end, and breadth is high, but users of 0.5/0.6 have 579 open issues, 22 of 25 performance budgets
|
||||
are missed, and PSDs we write are not yet reliably accepted by Photoshop (#1281, #2469). Beta
|
||||
needs ~75% ready and no blocking PSD gap. The [alpha gate](docs/roadmap.md#alpha-gate) passes: all six core workflows
|
||||
(retouch, composite, cut-out, design, paint, PSD exchange) complete end to end and save and reopen;
|
||||
four are partial in depth, none blocked.
|
||||
|
||||
## By dimension
|
||||
|
||||
| Dimension | % ready | Hours to full | Doc |
|
||||
|---|---:|---:|---|
|
||||
| Features (depth) | 55% (breadth 76%) | 600–1,000 | [target-app-parity](docs/target-app-parity.md#feature-areas) |
|
||||
| UI/UX fidelity (tools, handles, modifiers, brush feel, panels) | 40% | 350–600 | [ui-parity](docs/ui-parity.md), [brush-parity](docs/brush-parity.md), [context-menu-parity](docs/context-menu-parity.md) |
|
||||
| File formats | 60% (presence ~80%) | 300–500 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware (GPU, pen, displays) | 50% | 80–150 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 55% | 80–140 | [localization-parity](docs/localization-parity.md) |
|
||||
| Performance | 25% (3/25 budgets met, measured) | 200–350 | [scorecard](docs/scorecard.md#performance) |
|
||||
| Stability | 45% | 150–250 | [gaps](docs/gaps.md#g3-user-reported-bug-backlog) |
|
||||
| Platforms | 80% | 30–60 | [scorecard](docs/scorecard.md#distribution) |
|
||||
| Ecosystem / plug-ins / scripting | 15% | 250–450 | [gaps](docs/gaps.md#g16-plug-in-compatibility) |
|
||||
| AI features | 10% | 300–600 | [gaps](docs/gaps.md#g15-ai-and-generative-features) |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | % ready | Hours |
|
||||
|---|---:|---:|
|
||||
| Layers, masks, blend modes, smart objects, styles | 65% | 150–250 |
|
||||
| Selections, Select and Mask, channels | 55% | 120–200 |
|
||||
| Painting and brush engine | 55% | 150–250 |
|
||||
| Retouching (healing, clone, patch, remove, content-aware) | 50% | 100–180 |
|
||||
| Transform, warp, puppet, liquify | 55% | 100–180 |
|
||||
| Adjustments and filters | 70% | 120–200 |
|
||||
| Type | 40% | 120–200 |
|
||||
| Vector shapes and paths | 55% | 80–140 |
|
||||
| Colour management and modes | 70% | 60–100 |
|
||||
| Camera Raw and computational photo | 45% | 120–200 |
|
||||
| Automation, actions, scripting | 45% | 120–200 |
|
||||
| File I/O | 60% | 300–500 |
|
||||
| Print, export, Save for Web | 50% | 60–100 |
|
||||
| Video / timeline, data-driven graphics | 30% | 100–200 |
|
||||
| Workspace, panels, preferences | 40% | 150–250 |
|
||||
| AI / generative | 10% | 300–600 |
|
||||
|
||||
Rows and evidence: [target-app-parity › Feature areas](docs/target-app-parity.md#feature-areas).
|
||||
|
||||
## Languages
|
||||
|
||||
Measured with the `cargo xtask i18n-coverage` key universe (1,608 keys). `partial` everywhere
|
||||
because engine messages are still English. Detail: [localization-parity](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | UI strings | Status |
|
||||
|---|---|---:|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 100% | partial |
|
||||
| Spanish | `es` | 100% | partial |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 0% (no RTL UI) | none |
|
||||
| French | `fr` | 100% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% | partial |
|
||||
| Indonesian | `id` | 100% | partial |
|
||||
| Japanese | `ja` | 100% | partial |
|
||||
| German | `de` | 100% | partial |
|
||||
| Korean | `ko` | 100% | partial |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Ukrainian, Czech, Polish, Greek, Dutch, Italian (8).
|
||||
|
||||
## Upcoming
|
||||
|
||||
| Rank | Milestone | Estimate |
|
||||
|---|---|---:|
|
||||
| 1 | PSD that Photoshop trusts: Save As corruption, Photoshop re-open check, Photoshop-oracle floor 133 → 200+ of 256 | 150–250 h |
|
||||
| 2 | Performance budgets: the 22 over-budget scenarios and the 150-layer GPU crash | 200–350 h |
|
||||
| 3 | User bug backlog to under 100 open | 250–400 h |
|
||||
| 4 | Brush and transform feel (brush-parity, ui-parity rows) | 160–290 h |
|
||||
| 5 | The 15 missing tools | 60–100 h |
|
||||
|
||||
Detail and the next releases: [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-10 | Full re-measure against Photoshop 2026 27.11.0 (installed bundle inspected): breadth ~76%, ready ~45%, alpha. Progress docs reorganized to the craftrules standard (`parity.md` → `parity-checklist.md`, `parity-estimate.md` merged into `target-app-parity.md`). 0.6.0 released. |
|
||||
| 2026-10-09 | Remove tool, Rotate View, crop rotation; OpenRaster and Paint.NET; 39 Affinity documents in the corpus |
|
||||
| 2026-10-08 | 0.5.0. Affinity import; Magnetic Lasso, Pattern Stamp, Mixer Brush, Pencil, Patch, Vertical Type on the toolbar |
|
||||
| 2026-10-07 | 0.3.0. 10 UI languages; shortcut audit 214 → 0 failures; TIFF decode 10× faster |
|
||||
| 2026-10-05 | 0.2.0, first signed and notarized release. Honest assessment: ready for real work 25–35% |
|
||||
| 2026-10-03 | Menu breadth 625/625 |
|
||||
| 2026-10-01 | Menu breadth 224 → 532 of 625 in a day |
|
||||
| 2026-09-30 | First commit |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-10 | minor | Readiness by audience with hours per tier |
|
||||
| 2026-10-10 | minor | Mainstream-practitioner and essentials-user numbers added |
|
||||
| 2026-10-10 | minor | Alpha gate result (passes; stays alpha) in the stage explanation |
|
||||
| 2026-10-10 | major | First version, per craftrules `standards/progress-docs.md` |
|
||||
|
|
@ -353,17 +353,30 @@ fn services(inbox: Inbox) -> Services {
|
|||
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
|
||||
})),
|
||||
file_dialog: Some(Box::new(|request, _parent, reply| match request {
|
||||
// The browser's file picker hands over the file's contents, not a path.
|
||||
FileDialogRequest::Open { extensions, .. } => wasm_bindgen_futures::spawn_local(async move {
|
||||
// The browser's file picker hands over the files' contents, not paths. File › Open
|
||||
// asks for several files (`multiple`), each opened in its own tab.
|
||||
FileDialogRequest::Open { multiple, extensions, .. } => wasm_bindgen_futures::spawn_local(async move {
|
||||
let dialog = if let Some(exts) = extensions {
|
||||
rfd::AsyncFileDialog::new().add_filter("Supported Files", &exts)
|
||||
} else {
|
||||
rfd::AsyncFileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("SVG", SVG_EXTS)
|
||||
};
|
||||
let picked = dialog.pick_file().await;
|
||||
let answer = match picked {
|
||||
Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)),
|
||||
None => None,
|
||||
let answer = if multiple {
|
||||
match dialog.pick_files().await {
|
||||
Some(picked) if !picked.is_empty() => {
|
||||
let mut files = Vec::with_capacity(picked.len());
|
||||
for file in picked {
|
||||
files.push((file.file_name(), file.read().await));
|
||||
}
|
||||
Some(FileDialogAnswer::ContentsMany(files))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
match dialog.pick_file().await {
|
||||
Some(file) => Some(FileDialogAnswer::Contents(file.file_name(), file.read().await)),
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
reply.send(answer);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -74,8 +74,9 @@ async-io = "=2.6.0"
|
|||
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
|
||||
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
|
||||
|
||||
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
|
||||
# its own. Pinned exactly: review the diff before bumping.
|
||||
# Safe Win32 wrappers on Windows: the pointer position for file drops (`cursor.rs`,
|
||||
# GetCursorPos) and the window frame for the custom title bar (`window_frame.rs`, window styles),
|
||||
# no dependencies of its own. Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
|
||||
xcap = "=0.9.8"
|
||||
|
|
|
|||
|
|
@ -69,52 +69,37 @@ fn code_of(theme: Option<egui::Theme>) -> u8 {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_startup<T>(subscribe: impl FnOnce(), read: impl FnOnce() -> T) -> T {
|
||||
subscribe();
|
||||
read()
|
||||
}
|
||||
|
||||
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
|
||||
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
|
||||
pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("appearance-portal".into())
|
||||
.spawn(move || {
|
||||
let proxy = portal_proxy();
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
worker_value.store(code_of(initial), Ordering::Relaxed);
|
||||
let _ = ready.send(());
|
||||
let Some(proxy) = proxy else { return };
|
||||
let Ok(signals) = proxy.receive_signal("SettingChanged") else { return };
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
if worker_value.swap(code, Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = worker_wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
start(|publisher| {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before the first read, so a change during start-up isn't lost.
|
||||
let mut signals = None;
|
||||
let initial = portal_startup(
|
||||
|| signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()),
|
||||
|| proxy.as_ref().and_then(portal_theme).or_else(gtk_theme),
|
||||
);
|
||||
publisher.publish(initial);
|
||||
let Some(signals) = signals else { return };
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
publisher.publish(portal_code(changed).and_then(decode));
|
||||
}
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
|
|
@ -122,8 +107,72 @@ pub fn service() -> Option<photocraft_ui_egui::SystemThemeFn> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The worker's handle on the shared value. Every change wakes the registered UI, including the
|
||||
/// first read when it lands after the start-up wait below.
|
||||
#[cfg(target_os = "linux")]
|
||||
struct Publisher {
|
||||
value: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
||||
wake: std::sync::Arc<std::sync::OnceLock<egui::Context>>,
|
||||
ready: std::sync::mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
impl Publisher {
|
||||
fn publish(&self, theme: Option<egui::Theme>) {
|
||||
let code = code_of(theme);
|
||||
if self.value.swap(code, std::sync::atomic::Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = self.wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let _ = self.ready.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `worker` on its own thread and waits up to 250 ms for its first value, so a fast desktop
|
||||
/// answers before the first frame.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn start(worker: impl FnOnce(&Publisher) + Send + 'static) -> Option<photocraft_ui_egui::SystemThemeFn> {
|
||||
use std::sync::{Arc, OnceLock, atomic::AtomicU8};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
let publisher = Publisher { value: Arc::clone(&value), wake: Arc::clone(&wake), ready };
|
||||
std::thread::Builder::new().name("appearance-portal".into()).spawn(move || worker(&publisher)).ok()?;
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(std::sync::atomic::Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn startup_keeps_changes_emitted_during_initial_read() {
|
||||
use std::cell::Cell;
|
||||
|
||||
for (initial_value, changed_value) in [(2, 1), (1, 2), (2, 2)] {
|
||||
let subscribed = Cell::new(false);
|
||||
let mut portal_value = initial_value;
|
||||
let mut pending_signal = None;
|
||||
let initial = super::portal_startup(
|
||||
|| subscribed.set(true),
|
||||
|| {
|
||||
let initial = portal_value;
|
||||
portal_value = changed_value;
|
||||
if subscribed.get() && portal_value != initial {
|
||||
pending_signal = Some(portal_value);
|
||||
}
|
||||
initial
|
||||
},
|
||||
);
|
||||
let cached = pending_signal.unwrap_or(initial);
|
||||
assert_eq!(cached, changed_value, "portal changed during startup but cached {cached}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_scheme_values() {
|
||||
|
|
@ -137,4 +186,33 @@ mod tests {
|
|||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
}
|
||||
|
||||
/// #2798: a first read that misses the start-up wait still wakes the idle UI.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn late_initial_read_requests_repaint() {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
let (release, gate) = std::sync::mpsc::channel::<()>();
|
||||
let (done, finished) = std::sync::mpsc::channel::<()>();
|
||||
let reader = super::start(move |publisher| {
|
||||
let _ = gate.recv();
|
||||
publisher.publish(Some(egui::Theme::Light));
|
||||
let _ = done.send(());
|
||||
})
|
||||
.expect("worker thread");
|
||||
let ctx = egui::Context::default();
|
||||
let repaints = Arc::new(AtomicUsize::new(0));
|
||||
let counter = Arc::clone(&repaints);
|
||||
ctx.set_request_repaint_callback(move |_| {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
assert_eq!(reader(&ctx), None, "start-up wait elapsed without an answer");
|
||||
release.send(()).unwrap();
|
||||
finished.recv().unwrap();
|
||||
assert_eq!(repaints.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(reader(&ctx), Some(egui::Theme::Light));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ use std::io::{BufReader, Write};
|
|||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_automation::budgets::write_reply;
|
||||
use photocraft_automation::security::{
|
||||
ConnectionLimiter, LineRead, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, read_bounded_line,
|
||||
};
|
||||
use photocraft_ui_egui::ControlRequest;
|
||||
use photocraft_ui_egui::control::{REPLY_DEADLINE, timeout_error};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn start(port: u16, token: String, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
|
|
@ -83,14 +84,15 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
|||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let deadline = Instant::now() + REPLY_DEADLINE;
|
||||
let (mut req, rrx) = ControlRequest::new(method, params);
|
||||
req.deadline = Some(deadline);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
let mut r =
|
||||
rrx.recv_timeout(deadline.saturating_duration_since(Instant::now())).unwrap_or_else(|_| json!({"ok": false, "error": timeout_error()}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
|
|
@ -111,6 +113,7 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
|||
mod tests {
|
||||
use super::*;
|
||||
use std::io::BufRead;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_preserves_framing_id_and_the_next_control_request() {
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
//!
|
||||
//! 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.
|
||||
//! it can't be read (musl, NixOS, some containers) and a library isn't found in the directories,
|
||||
//! that proves nothing, so we start silently. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
|
||||
//!
|
||||
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
|
||||
//! and [`available`] touch the system, and they are only called on Linux.
|
||||
|
|
@ -200,7 +200,7 @@ fn install_command(packages: &[&str], distro: Distro) -> String {
|
|||
}
|
||||
|
||||
/// 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 {
|
||||
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro) -> String {
|
||||
let session_name = match session {
|
||||
DisplaySession::Wayland => "Wayland",
|
||||
DisplaySession::X11 => "X11",
|
||||
|
|
@ -214,19 +214,13 @@ pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distr
|
|||
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(", "))
|
||||
};
|
||||
let mut msg = format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\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.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
|
||||
msg
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +263,7 @@ pub fn available(session: DisplaySession) -> bool {
|
|||
}
|
||||
|
||||
/// Run the check for the display server the window opens on 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.
|
||||
/// the message to print before exiting with a non-zero status.
|
||||
pub fn preflight(session: DisplaySession) -> Result<(), String> {
|
||||
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
|
||||
return Ok(());
|
||||
|
|
@ -278,18 +271,17 @@ pub fn preflight(session: DisplaySession) -> Result<(), String> {
|
|||
if session == DisplaySession::None {
|
||||
return Ok(());
|
||||
}
|
||||
match check(session, &system_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(())
|
||||
}
|
||||
}
|
||||
let verdict = check(session, &system_probe(), |p| p.exists());
|
||||
decide(session, verdict, || std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown))
|
||||
}
|
||||
|
||||
/// `Err` with the message to print only when the linker cache was read and the libraries are
|
||||
/// really absent. Without the cache (musl has no `ldconfig -p`; NixOS, AppImages that ship their
|
||||
/// own libraries) not finding a file proves nothing, so we start without a misleading warning.
|
||||
fn decide(session: DisplaySession, verdict: Verdict, distro: impl FnOnce() -> Distro) -> Result<(), String> {
|
||||
match verdict {
|
||||
Verdict::Missing { missing, conclusive: true } => Err(missing_message(session, &missing, distro())),
|
||||
Verdict::Missing { conclusive: false, .. } | Verdict::Ok => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +354,7 @@ mod tests {
|
|||
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);
|
||||
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian);
|
||||
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}");
|
||||
|
|
@ -384,13 +376,13 @@ mod tests {
|
|||
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);
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora);
|
||||
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() {
|
||||
fn library_dirs_count_and_unread_cache_is_inconclusive() {
|
||||
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"));
|
||||
|
|
@ -398,16 +390,29 @@ mod tests {
|
|||
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.
|
||||
// Not found and no cache to confirm it: inconclusive (the app starts, see #1144).
|
||||
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}");
|
||||
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown);
|
||||
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
|
||||
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_cache_never_warns_or_fails() {
|
||||
// Issue #1144: Alpine (musl) has no `ldconfig -p`, so a library that exists was reported missing.
|
||||
let probe = Probe { ldconfig: None, dirs: vec![] };
|
||||
let verdict = check(DisplaySession::X11, &probe, |_| false);
|
||||
assert_eq!(decide(DisplaySession::X11, verdict, || Distro::Debian), Ok(()));
|
||||
// A readable cache that lacks a library is still an error.
|
||||
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 verdict = check(DisplaySession::X11, &probe, |_| false);
|
||||
let Err(msg) = decide(DisplaySession::X11, verdict, || Distro::Debian) else { panic!("expected an error") };
|
||||
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_detection() {
|
||||
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ mod services;
|
|||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
mod tablet;
|
||||
mod ui_state;
|
||||
mod window_frame;
|
||||
|
||||
use photocraft_engine::Session;
|
||||
use photocraft_ui_egui::PhotocraftApp;
|
||||
|
|
@ -447,6 +448,9 @@ fn main() -> eframe::Result {
|
|||
let _ = in_window_menus;
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
// The custom title bar's undecorated window must stay borderless, or its caption and
|
||||
// border offset the content and the pointer on some Windows systems (#2246).
|
||||
app.services.window_frame = window_frame::service(cc, custom_titlebar);
|
||||
// Caps Lock state (X11/Windows/macOS; `None` on native Wayland): the canvas shows the
|
||||
// precise crosshair for painting tools while it is toggled on (#1758).
|
||||
app.services.caps_lock = caps_lock::service(cc);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
//! Keeps the custom (undecorated) title bar window truly borderless (#2246).
|
||||
//!
|
||||
//! winit 0.30 gives an undecorated window the `WS_CAPTION | WS_BORDER` styles and hides the
|
||||
//! non-client area by spreading the client rect over it (`WM_NCCALCSIZE`). On some systems
|
||||
//! (reported: Windows 10 LTSB, Intel HD Graphics 4600, 1366 × 768) the desktop compositor still
|
||||
//! reserves that frame for the window surface: the content is drawn offset by the caption and
|
||||
//! border, leaving black bars at the window's top-left, while the pointer keeps the un-offset
|
||||
//! client coordinates, so it no longer points at what is drawn under it.
|
||||
//!
|
||||
//! winit 0.27 — before rust-windowing/winit#2419 — simply omitted those styles for undecorated
|
||||
//! windows, and there was no offset. [`service`] restores that: it strips `WS_CAPTION` and
|
||||
//! `WS_BORDER` and forces Windows to recalculate the frame. winit puts them back whenever it
|
||||
//! changes the window flags (maximize, restore, …), so the returned guard checks every frame.
|
||||
//!
|
||||
//! The Windows calls go through safe `winsafe` wrappers; the workspace forbids `unsafe`, and
|
||||
//! winsafe can't wrap a raw handle without it, so the window is found by its handle instead.
|
||||
|
||||
pub use platform::service;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod platform {
|
||||
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
use winsafe::{HWND, HwndPlace, POINT, SIZE, co};
|
||||
|
||||
/// A guard that keeps the window borderless each frame, or `None` when the app uses the
|
||||
/// system title bar (the window is decorated) or its handle can't be found.
|
||||
pub fn service(cc: &eframe::CreationContext<'_>, custom_titlebar: bool) -> Option<WindowFrameFn> {
|
||||
if !custom_titlebar {
|
||||
return None;
|
||||
}
|
||||
let target = match cc.window_handle().ok()?.as_raw() {
|
||||
RawWindowHandle::Win32(h) => h.hwnd.get(),
|
||||
_ => return None,
|
||||
};
|
||||
let hwnd = find_window(target)?;
|
||||
strip(&hwnd);
|
||||
Some(Box::new(move || strip(&hwnd)))
|
||||
}
|
||||
|
||||
/// The top-level window with the given raw handle. `EnumWindows` hands out real `HWND`s, so
|
||||
/// this matches winit's own window without wrapping a raw handle (which needs `unsafe`).
|
||||
fn find_window(target: isize) -> Option<HWND> {
|
||||
let mut found = None;
|
||||
let _ = winsafe::EnumWindows(|hwnd| {
|
||||
if hwnd.ptr() as isize == target {
|
||||
found = Some(hwnd);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// Removes `WS_CAPTION`/`WS_BORDER` and has Windows recalculate the frame. A no-op once they
|
||||
/// are gone, so it is cheap to call every frame.
|
||||
fn strip(hwnd: &HWND) {
|
||||
let style = hwnd.style();
|
||||
let without = borderless(style);
|
||||
if without.raw() == style.raw() {
|
||||
return;
|
||||
}
|
||||
hwnd.set_style(without);
|
||||
let _ = hwnd.SetWindowPos(
|
||||
HwndPlace::None,
|
||||
POINT::new(),
|
||||
SIZE::new(),
|
||||
co::SWP::FRAMECHANGED | co::SWP::NOMOVE | co::SWP::NOSIZE | co::SWP::NOZORDER | co::SWP::NOACTIVATE,
|
||||
);
|
||||
}
|
||||
|
||||
/// `style` without the OS title bar and frame (`WS_CAPTION`, which already includes
|
||||
/// `WS_BORDER`). The sizing border (`WS_THICKFRAME`) stays, so edge resizing and Aero snap
|
||||
/// keep working, as they did with winit 0.27's undecorated windows.
|
||||
fn borderless(style: co::WS) -> co::WS {
|
||||
style & !(co::WS::CAPTION | co::WS::BORDER)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::borderless;
|
||||
use winsafe::co;
|
||||
|
||||
#[test]
|
||||
fn the_borderless_style_drops_the_caption_and_border_only() {
|
||||
// `WS_CAPTION` is 0x00c0_0000 and already contains `WS_BORDER` (0x0080_0000).
|
||||
let decorated = co::WS::CAPTION | co::WS::BORDER | co::WS::SYSMENU | co::WS::THICKFRAME | co::WS::MAXIMIZEBOX | co::WS::MINIMIZEBOX;
|
||||
let bare = borderless(decorated);
|
||||
assert!(!bare.has(co::WS::CAPTION), "caption gone");
|
||||
assert!(!bare.has(co::WS::BORDER), "border gone");
|
||||
assert!(bare.has(co::WS::THICKFRAME), "edge resizing kept");
|
||||
assert!(bare.has(co::WS::MAXIMIZEBOX) && bare.has(co::WS::SYSMENU), "window buttons kept");
|
||||
// Already borderless: unchanged, so the per-frame call does nothing.
|
||||
assert_eq!(bare.raw(), borderless(bare).raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod platform {
|
||||
use photocraft_ui_egui::WindowFrameFn;
|
||||
|
||||
pub fn service(_cc: &eframe::CreationContext<'_>, _custom_titlebar: bool) -> Option<WindowFrameFn> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Single Column Marquee glyph: a dashed column from edge to edge. -->
|
||||
<path d="M8 3h8" />
|
||||
<path d="M8 21h8" />
|
||||
<path d="M12 6.5v1" />
|
||||
<path d="M12 11.5v1" />
|
||||
<path d="M12 16.5v1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
|
|
@ -0,0 +1,18 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<!-- PhotoCraft's own Single Row Marquee glyph: a dashed row from edge to edge. -->
|
||||
<path d="M3 8v8" />
|
||||
<path d="M21 8v8" />
|
||||
<path d="M6.5 12h1" />
|
||||
<path d="M11.5 12h1" />
|
||||
<path d="M16.5 12h1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
|
|
@ -11,6 +11,11 @@ PhotoCraft opens native Affinity documents: `.af` files from Affinity 3 and `.af
|
|||
- placed JPEG and PNG images become embedded smart objects, and pixel layers become pixel layers;
|
||||
- vector and pixel masks, opacity, visibility, locks, names and blend modes are kept.
|
||||
|
||||
Artboards are recognized from both the legacy flag and current Affinity 3 artboard properties.
|
||||
They keep their names, bounds and editable children when saved as `.pcraft` or PSD; moving an
|
||||
artboard moves its children together. Nested boards become masked groups, and rotated or curved
|
||||
boards currently use their bounding rectangle.
|
||||
|
||||
The document is 8-bit RGB at the Affinity document's resolution. What PhotoCraft can't reproduce is
|
||||
listed in a warning when the file opens, for example layer effects, adjustment layers and live
|
||||
filters, brush strokes (drawn as plain strokes), special shapes such as clouds and hearts (drawn as
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP,
|
|||
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
|
||||
|
||||
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead.
|
||||
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
|
||||
|
||||
## Implemented decode limits
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
PhotoCraft uses the Rust workspace at the repository root. Install the stable Rust toolchain, clone the repository, and run commands from that root.
|
||||
|
||||
On Linux, install the windowing, GL and GTK development packages first. The development guide's
|
||||
[Prerequisites](https://github.com/storytold/photocraft/blob/main/docs/development.md#prerequisites)
|
||||
list them for Debian/Ubuntu, Fedora, Arch and openSUSE.
|
||||
|
||||
```sh
|
||||
cargo build --workspace
|
||||
cargo run --release -p photocraft -- image.psd
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap
|
||||
|
||||
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity.md`](https://github.com/storytold/photocraft/blob/main/docs/parity.md) by `cargo xtask parity`.
|
||||
The live product roadmap, milestone definitions, parity measurements, and current focus are maintained in [`docs/roadmap.md`](https://github.com/storytold/photocraft/blob/main/docs/roadmap.md). Menu coverage is generated into [`docs/parity-checklist.md`](https://github.com/storytold/photocraft/blob/main/docs/parity-checklist.md) by `cargo xtask parity`.
|
||||
|
||||
Security hardening is cross-cutting rather than a replacement for those product milestones. The current security sequence is:
|
||||
|
||||
|
|
|
|||
|
|
@ -74,3 +74,22 @@ sync_github_name = true
|
|||
real_name = "Ahmed Moussa"
|
||||
display_name = "Ahmed Moussa"
|
||||
sync_github_name = false
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
display_name = "Alexander Alber"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Erica4fr]
|
||||
sync_github_name = true
|
||||
|
||||
[people.itsF4LCON]
|
||||
real_name = "Ran Geerts"
|
||||
display_name = "Ran Geerts"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
|
||||
[people.FinnNk]
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Affinity files.
|
|||
| Affinity | PhotoCraft |
|
||||
|---|---|
|
||||
| Pages, Publisher spreads | one artboard per spread, spreads side by side 64 px apart |
|
||||
| Artboards (`ShpN` with `ABEn`), with name, solid background, any transform | artboards (nested ones as groups with a vector mask) |
|
||||
| Artboards (legacy `ABEn`, or current `.af` `phrp`/`aprp` properties, including converted curves), with name, solid background, any transform | artboards (nested ones as groups with a vector mask; rotated/curved boards use their bounding rectangle) |
|
||||
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | groups (pass-through or Normal) |
|
||||
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | shape layers (multi-subpath curves fill even-odd) |
|
||||
| Rectangles with corner radii (relative or absolute), ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids, clouds, hearts, cogs, rounded and elliptical callouts, arrows, double stars, tears, crescents, diamonds and circular segments | shape layers |
|
||||
|
|
|
|||
|
|
@ -431,7 +431,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
|||
b"Scop" => Kind::Layer,
|
||||
b"Grup" => Kind::Group,
|
||||
b"PCrv" | b"ShpN" | b"Comp" | b"SNEN" | b"SNRR" | b"ShRN" => {
|
||||
if class == Tag::of(b"ShpN") && s.bool(id, b"ABEn") == Some(true) {
|
||||
// Current .af boards carry artboard properties (aprp) in phrp; older
|
||||
// documents use ABEn. Converted curve boards keep those properties too.
|
||||
// Other property classes also use phrp, so the field alone is not a marker.
|
||||
if s.bool(id, b"ABEn") == Some(true) || s.obj(id, b"phrp").is_some_and(|p| s.is(p, b"aprp")) {
|
||||
if class != Tag::of(b"ShpN") {
|
||||
self.warn("a converted artboard outline (imported at its bounding box)");
|
||||
}
|
||||
return Ok(self.artboard(id, world));
|
||||
}
|
||||
let Some((local, even_odd)) = crate::geometry::outline(self, id, class, world) else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
//! End-to-end Iris Blur timings; fixture construction and output drop are untimed.
|
||||
//! `cargo run --release -p photocraft-algo --example bench_iris -- 6000 4000 3`
|
||||
//! An optional fourth argument models the UI's proxy factor (e.g. 1500 1000 5 4).
|
||||
use photocraft_algo::{FilterParams, IrisPin, apply_in};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let number = |i: usize, fallback| args.get(i).and_then(|s| s.parse::<i32>().ok()).unwrap_or(fallback);
|
||||
let (w, h, reps) = (number(1, 6000).clamp(1, 6000), number(2, 4000).clamp(1, 6000), number(3, 3).clamp(1, 20));
|
||||
let proxy_factor = number(4, 1).clamp(1, 8) as f32;
|
||||
let bounds = Rect::new(0, 0, w, h);
|
||||
for (sample, name) in [(SampleType::U8, "U8"), (SampleType::U16, "U16"), (SampleType::F32, "F32")] {
|
||||
let mut source = Surface::new(PixelFormat::new(ColorMode::Rgb, sample, true));
|
||||
// Write rows to avoid a second full-image fixture allocation.
|
||||
for y in 0..h {
|
||||
let row: Vec<f32> = (0..w)
|
||||
.flat_map(|x| {
|
||||
let a = if (x + y) % 31 == 0 { 0.0 } else { 0.7 + (x % 7) as f32 / 30.0 };
|
||||
let hdr = if sample == SampleType::F32 { 3.0 } else { 1.0 };
|
||||
[hdr * (x % 256) as f32 / 255.0, ((x * 3 + y) % 251) as f32 / 250.0, ((x ^ y) % 256) as f32 / 255.0, a]
|
||||
})
|
||||
.collect();
|
||||
source.write_region(Rect::new(0, y, w, y + 1), &row);
|
||||
}
|
||||
let mut cases = vec![("medium", vec![IrisPin { blur: 80.0, angle: 27.0, ..IrisPin::default() }])];
|
||||
if sample == SampleType::U8 {
|
||||
cases.extend([
|
||||
("small", vec![IrisPin::default()]),
|
||||
("large", vec![IrisPin { blur: 300.0, ..IrisPin::default() }]),
|
||||
(
|
||||
"multiple",
|
||||
vec![
|
||||
IrisPin { x: 0.3, blur: 80.0, roundness: 65.0, ..IrisPin::default() },
|
||||
IrisPin { x: 0.8, angle: 73.0, blur: 40.0, ..IrisPin::default() },
|
||||
],
|
||||
),
|
||||
("focused", vec![IrisPin { radius_x: 5.0, radius_y: 5.0, blur: 80.0, ..IrisPin::default() }]),
|
||||
("blurred", vec![IrisPin { x: -5.0, blur: 80.0, ..IrisPin::default() }]),
|
||||
]);
|
||||
}
|
||||
for (label, mut pins) in cases {
|
||||
// Top-level one-pin blur scales in preview_document_with. Its nested
|
||||
// JSON pin list retains supplied amounts; model that existing path.
|
||||
if pins.len() == 1 {
|
||||
for pin in &mut pins {
|
||||
pin.blur /= proxy_factor;
|
||||
}
|
||||
}
|
||||
let p = FilterParams::IrisBlur { pins };
|
||||
let warm = apply_in(&source, &p, bounds, bounds, None, bounds);
|
||||
std::hint::black_box(&warm);
|
||||
drop(warm);
|
||||
let mut times = Vec::new();
|
||||
for _ in 0..reps {
|
||||
let start = std::time::Instant::now();
|
||||
let output = apply_in(&source, &p, bounds, bounds, None, bounds);
|
||||
times.push(start.elapsed().as_secs_f64() * 1000.0);
|
||||
std::hint::black_box(&output);
|
||||
drop(output);
|
||||
}
|
||||
println!("{w}x{h} {name} {label}: {times:?} ms");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
//! Full filter timing, including tile reads, filtering, writeback and pruning.
|
||||
//! RAYON_NUM_THREADS=16 cargo run --release -p photocraft-algo --example bench_pixelate -- facet 6000 4000 u8 3 output.bin
|
||||
//! Optional final argument after output.bin: rgb (default), gray, cmyk or lab.
|
||||
//! Run the same example at the base commit and the PR commit, then compare output.bin with cmp.
|
||||
use photocraft_algo::{FilterParams, apply_in};
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_geom::Rect;
|
||||
use photocraft_raster::Surface;
|
||||
use std::io::Write;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
let case = args.first().map(String::as_str).unwrap_or("facet");
|
||||
let parse = |i: usize, fallback: i32| -> Result<i32, Box<dyn std::error::Error>> {
|
||||
Ok(match args.get(i) {
|
||||
Some(s) => s.parse()?,
|
||||
None => fallback,
|
||||
})
|
||||
};
|
||||
let (w, h, repeats) = (parse(1, 6000)?, parse(2, 4000)?, parse(4, 3)?);
|
||||
if !(1..=10000).contains(&w) || !(1..=10000).contains(&h) || !(1..=9).contains(&repeats) {
|
||||
return Err("dimensions must be 1..10000, repeats 1..9".into());
|
||||
}
|
||||
let depth = match args.get(3).map(String::as_str).unwrap_or("u8") {
|
||||
"u8" => SampleType::U8,
|
||||
"u16" => SampleType::U16,
|
||||
"f32" => SampleType::F32,
|
||||
_ => return Err("depth must be u8, u16 or f32".into()),
|
||||
};
|
||||
let params = match case {
|
||||
"facet" => FilterParams::Facet,
|
||||
"halftone" => FilterParams::ColorHalftone { max_radius: 8.0, angles: [108.0, 162.0, 90.0, 45.0] },
|
||||
"halftone-small" => FilterParams::ColorHalftone { max_radius: 2.0, angles: [108.0, 162.0, 90.0, 45.0] },
|
||||
"halftone-large" => FilterParams::ColorHalftone { max_radius: 64.0, angles: [108.0, 162.0, 90.0, 45.0] },
|
||||
_ => return Err("case must be facet, halftone, halftone-small or halftone-large".into()),
|
||||
};
|
||||
let mode = match args.get(6).map(String::as_str).unwrap_or("rgb") {
|
||||
"rgb" => ColorMode::Rgb,
|
||||
"gray" => ColorMode::Grayscale,
|
||||
"cmyk" => ColorMode::Cmyk,
|
||||
"lab" => ColorMode::Lab,
|
||||
_ => return Err("mode must be rgb, gray, cmyk or lab".into()),
|
||||
};
|
||||
let format = PixelFormat::new(mode, depth, true);
|
||||
let n = format.channels();
|
||||
let bounds = Rect::new(-17, -29, w - 17, h - 29);
|
||||
let mut source = Surface::new(format);
|
||||
for tile in bounds.tiles() {
|
||||
let r = tile.rect().intersect(&bounds);
|
||||
let mut pixels = Vec::with_capacity(r.width() as usize * r.height() as usize * n);
|
||||
for y in r.y0..r.y1 {
|
||||
for x in r.x0..r.x1 {
|
||||
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
|
||||
for c in 0..n {
|
||||
let v = seed.rotate_left(c as u32 * 7).wrapping_add(c as u32 * 71) % 65536;
|
||||
pixels.push(if c + 1 == n && seed.is_multiple_of(5) { 0.0 } else { v as f32 / 65535.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
source.write_region(r, &pixels);
|
||||
}
|
||||
let warm = Rect::new(bounds.x0, bounds.y0, bounds.x0 + w.min(128), bounds.y0 + h.min(128));
|
||||
drop(apply_in(&source, ¶ms, warm, bounds, None, bounds));
|
||||
let mut times = Vec::new();
|
||||
for run in 0..repeats {
|
||||
let start = Instant::now();
|
||||
let result = apply_in(&source, ¶ms, bounds, bounds, None, bounds);
|
||||
let seconds = start.elapsed().as_secs_f64();
|
||||
println!("{case} {w}x{h} {depth:?} {mode:?} run={} seconds={seconds:.6}", run + 1);
|
||||
times.push(seconds);
|
||||
if run + 1 == repeats
|
||||
&& let Some(path) = args.get(5)
|
||||
{
|
||||
let mut file = std::io::BufWriter::new(std::fs::File::create(path)?);
|
||||
for tile in bounds.tiles() {
|
||||
for sample in result.read_region(tile.rect().intersect(&bounds)) {
|
||||
file.write_all(&sample.to_bits().to_le_bytes())?;
|
||||
}
|
||||
}
|
||||
file.flush()?;
|
||||
}
|
||||
}
|
||||
times.sort_by(f64::total_cmp);
|
||||
if let Some(median) = times.get(times.len() / 2) {
|
||||
println!("MEDIAN {case} {w}x{h} {depth:?} {mode:?} seconds={median:.6}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -8,10 +8,14 @@
|
|||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, premul_window, smoothstep, unpremul_px, xy};
|
||||
use crate::image::Image;
|
||||
use crate::fxutil::{MAXC, add_sample_premul, gauss_blur_n, gauss_box_radii, premul_window, smoothstep, unpremul_px, xy};
|
||||
use crate::image::{Image, premultiply};
|
||||
use crate::{BlurPath, Ctx, FieldPin, IrisPin, SpinPin};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_iris.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Gaussian σ for a gallery blur amount in px.
|
||||
fn sigma_of(blur: f32) -> f32 {
|
||||
blur.clamp(0.0, 500.0) * 0.6
|
||||
|
|
@ -25,17 +29,27 @@ pub(crate) fn reach(blur: f32) -> f32 {
|
|||
const LEVELS: usize = 6;
|
||||
|
||||
/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`).
|
||||
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
|
||||
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, trim_levels: bool, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
if n == 0 || out.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if smax < 0.3 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let win = src.rect;
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
let Some(row_len) = ww.checked_mul(n).filter(|&len| len > 0) else { return Vec::new() };
|
||||
let Some(source_len) = row_len.checked_mul(wh) else { return Vec::new() };
|
||||
let Some(pixels) = ow.checked_mul(oh) else { return Vec::new() };
|
||||
let Some(samples) = pixels.checked_mul(n) else { return Vec::new() };
|
||||
if trim_levels && src.data.len() != source_len {
|
||||
return Vec::new();
|
||||
}
|
||||
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
|
||||
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
|
||||
let sig: Vec<f32> = (0..ow * oh)
|
||||
let sig: Vec<f32> = (0..pixels)
|
||||
.map(|i| {
|
||||
let (x, y) = xy(out, i);
|
||||
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
|
||||
|
|
@ -54,20 +68,49 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3
|
|||
0.0
|
||||
}
|
||||
};
|
||||
let p = premul_window(src, win, ctx.alpha);
|
||||
let mut acc = vec![0.0f32; ow * oh * n];
|
||||
let p = if trim_levels {
|
||||
// The window is exactly src.rect: read interleaved channels together,
|
||||
// retaining the same multiplication and alpha order as premul_window.
|
||||
let mut p = src.data.clone();
|
||||
premultiply(&mut p, n, ctx.alpha);
|
||||
p
|
||||
} else {
|
||||
premul_window(src, win, ctx.alpha)
|
||||
};
|
||||
let mut acc = vec![0.0f32; samples];
|
||||
let mut buf = Vec::new();
|
||||
for (k, &lvk) in lv.iter().enumerate() {
|
||||
if !sig.iter().any(|&s| weight(k, s) > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let level: &[f32] = if k == 0 {
|
||||
&p
|
||||
let (level, lw): (&[f32], usize) = if k == 0 {
|
||||
(&p, ww)
|
||||
} else {
|
||||
// Only Iris opts in. Each level needs its own box-cascade reach,
|
||||
// not the maximum blur's trailing halo. Keep the original top/left:
|
||||
// moving the running sums' start changes floating-point cancellation.
|
||||
let trim = trim_levels && win.intersect(&out) == out;
|
||||
let reach = if !trim || lvk < 0.3 { 0 } else { gauss_box_radii(lvk).iter().sum::<usize>() };
|
||||
let reach = i32::try_from(reach).unwrap_or(i32::MAX);
|
||||
let lw = if trim { (i64::from(out.x1.saturating_add(reach).min(win.x1)) - i64::from(win.x0)) as usize } else { ww };
|
||||
let lh = if trim { (i64::from(out.y1.saturating_add(reach).min(win.y1)) - i64::from(win.y0)) as usize } else { wh };
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&p);
|
||||
gauss_blur_n(&mut buf, ww, wh, n, lvk);
|
||||
&buf
|
||||
if buf.capacity() == 0 {
|
||||
// Match the old single full-window allocation. Row appends must
|
||||
// not geometrically grow the capacity beyond that window.
|
||||
buf.reserve_exact(p.len());
|
||||
}
|
||||
if trim {
|
||||
for row in p.chunks_exact(row_len).take(lh) {
|
||||
if let Some(row) = row.get(..lw * n) {
|
||||
buf.extend_from_slice(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
buf.extend_from_slice(&p);
|
||||
}
|
||||
gauss_blur_n(&mut buf, lw, lh, n, lvk);
|
||||
(&buf, lw)
|
||||
};
|
||||
for (i, &s) in sig.iter().enumerate() {
|
||||
let wk = weight(k, s);
|
||||
|
|
@ -75,9 +118,11 @@ fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f3
|
|||
continue;
|
||||
}
|
||||
let (x, y) = xy(out, i);
|
||||
let o = (((y - win.y0) as usize) * ww + (x - win.x0) as usize) * n;
|
||||
for c in 0..n {
|
||||
acc[i * n + c] += level[o + c] * wk;
|
||||
let o = (((y - win.y0) as usize) * lw + (x - win.x0) as usize) * n;
|
||||
if let (Some(dst), Some(px)) = (acc.get_mut(i * n..(i + 1) * n), level.get(o..o + n)) {
|
||||
for (dst, px) in dst.iter_mut().zip(px) {
|
||||
*dst += px * wk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +148,7 @@ pub(crate) fn tilt_shift(src: &Image, out: Rect, ctx: &Ctx, blur: f32, centre: (
|
|||
let (nx, ny) = (s, c);
|
||||
let smax = sigma_of(blur);
|
||||
let (f0, f1) = (focus.max(0.0), focus.max(0.0) + transition.max(1e-3));
|
||||
variable_blur(src, out, ctx, smax, |x, y| {
|
||||
variable_blur(src, out, ctx, smax, false, |x, y| {
|
||||
let d = ((x - cx) * nx + (y - cy) * ny).abs() / ss;
|
||||
smax * smoothstep(f0, f1, d)
|
||||
})
|
||||
|
|
@ -128,20 +173,74 @@ fn pin_distance(x: f32, y: f32, b: Rect, px: f32, py: f32, rx: f32, ry: f32, ang
|
|||
pub(crate) fn iris(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) -> Vec<f32> {
|
||||
let b = ctx.bounds;
|
||||
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
|
||||
if pins.is_empty() {
|
||||
if pins.is_empty() || smax < 0.3 {
|
||||
return src.crop(out);
|
||||
}
|
||||
variable_blur(src, out, ctx, smax, |x, y| {
|
||||
pins.iter()
|
||||
.map(|p| {
|
||||
let m = 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0;
|
||||
let (e, _, _) = pin_distance(x, y, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, m);
|
||||
sigma_of(p.blur) * smoothstep(p.feather.clamp(0.0, 0.999), 1.0, e)
|
||||
})
|
||||
.fold(f32::MAX, f32::min)
|
||||
// Small immutable per-tile preparation; allocation failure retains the
|
||||
// same arithmetic without the table. No additional image-sized cache.
|
||||
let mut prepared = Vec::new();
|
||||
let prepared = if prepared.try_reserve_exact(pins.len()).is_ok() {
|
||||
prepared.extend(pins.iter().map(|p| PreparedIris::new(p, b)));
|
||||
Some(prepared)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
variable_blur(src, out, ctx, smax, true, |x, y| {
|
||||
if let Some(prepared) = &prepared {
|
||||
prepared.iter().map(|p| p.sigma(x, y)).fold(f32::MAX, f32::min)
|
||||
} else {
|
||||
pins.iter().map(|p| PreparedIris::new(p, b).sigma(x, y)).fold(f32::MAX, f32::min)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
struct PreparedIris {
|
||||
cx: f32,
|
||||
cy: f32,
|
||||
rx: f32,
|
||||
ry: f32,
|
||||
sin: f32,
|
||||
cos: f32,
|
||||
exponent: f32,
|
||||
feather: f32,
|
||||
blur: f32,
|
||||
}
|
||||
|
||||
impl PreparedIris {
|
||||
fn new(p: &IrisPin, b: Rect) -> Self {
|
||||
let ss = short_side(b);
|
||||
let angle = if p.angle.is_finite() { p.angle } else { 0.0 };
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
Self {
|
||||
cx: b.x0 as f32 + p.x * b.width() as f32,
|
||||
cy: b.y0 as f32 + p.y * b.height() as f32,
|
||||
rx: p.radius_x.max(1e-3) * ss,
|
||||
ry: p.radius_y.max(1e-3) * ss,
|
||||
sin,
|
||||
cos,
|
||||
exponent: 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0,
|
||||
feather: p.feather.clamp(0.0, 0.999),
|
||||
blur: sigma_of(p.blur),
|
||||
}
|
||||
}
|
||||
|
||||
fn sigma(&self, x: f32, y: f32) -> f32 {
|
||||
let (dx, dy) = (x - self.cx, y - self.cy);
|
||||
let (u, v) = ((dx * self.cos + dy * self.sin) / self.rx, (-dx * self.sin + dy * self.cos) / self.ry);
|
||||
// Outside the normalized bounding square, the superellipse distance
|
||||
// is at least one. The falloff is saturated: no sqrt/powf is needed.
|
||||
let e = if u.abs() >= 1.0 || v.abs() >= 1.0 {
|
||||
f32::INFINITY
|
||||
} else if (self.exponent - 2.0).abs() < 1e-3 {
|
||||
(u * u + v * v).sqrt()
|
||||
} else {
|
||||
(u.abs().powf(self.exponent) + v.abs().powf(self.exponent)).powf(1.0 / self.exponent)
|
||||
};
|
||||
let e = if e.is_finite() { e } else { f32::INFINITY };
|
||||
self.blur * smoothstep(self.feather, 1.0, e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Field Blur: blur amounts at pins, interpolated by inverse squared distance.
|
||||
pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec<f32> {
|
||||
let b = ctx.bounds;
|
||||
|
|
@ -151,7 +250,7 @@ pub(crate) fn field(src: &Image, out: Rect, ctx: &Ctx, pins: &[FieldPin]) -> Vec
|
|||
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
|
||||
let pts: Vec<(f32, f32, f32)> =
|
||||
pins.iter().map(|p| (b.x0 as f32 + p.x * b.width() as f32, b.y0 as f32 + p.y * b.height() as f32, sigma_of(p.blur))).collect();
|
||||
variable_blur(src, out, ctx, smax, |x, y| {
|
||||
variable_blur(src, out, ctx, smax, false, |x, y| {
|
||||
let (mut ws, mut s) = (0.0f32, 0.0f32);
|
||||
for &(px, py, sg) in &pts {
|
||||
let w = 1.0 / ((x - px).powi(2) + (y - py).powi(2) + 1.0);
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@
|
|||
//! ring around the hole (sum of squared differences), i.e. an automatic Healing Brush source.
|
||||
//! * [`synthesize`], create texture: patch-based texture synthesis in the style of A. Efros,
|
||||
//! W. Freeman, *Image Quilting for Texture Synthesis and Transfer*, SIGGRAPH 2001 (blocks picked from
|
||||
//! random candidates by overlap error, feathered overlap instead of a min-cut seam).
|
||||
//! random candidates by overlap error, joined along minimum-error boundary cuts, so the texture is
|
||||
//! copied, never averaged).
|
||||
//!
|
||||
//! All functions take interleaved `w × h × ch` normalised floats (any colour model/depth) and a
|
||||
//! `w × h` hole mask, and return a full buffer equal to the input outside the hole. They are
|
||||
|
|
@ -971,20 +972,34 @@ pub fn synthesize(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], blo
|
|||
}
|
||||
}
|
||||
let c = best.0;
|
||||
for y in (by - ov)..(by + b + ov) {
|
||||
for x in (bx - ov)..(bx + b + ov) {
|
||||
if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
|
||||
continue;
|
||||
}
|
||||
let (x0, y0) = ((bx - ov).max(0), (by - ov).max(0));
|
||||
let (x1, y1) = ((bx + b + ov).min(w as i32), (by + b + ov).min(h as i32));
|
||||
let src = |x: i32, y: i32| ((y - by + c.1) as usize * w + (x - bx + c.0) as usize) * ch;
|
||||
// Squared difference between what is there and the block, where both exist.
|
||||
let err = |x: i32, y: i32| {
|
||||
let i = y as usize * w + x as usize;
|
||||
if !(hole[i] && filled[i]) {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, s) = (i * ch, src(x, y));
|
||||
(0..ch).map(|q| (out[a + q] - img[s + q]).powi(2)).sum::<f32>()
|
||||
};
|
||||
// Minimum-error boundary cuts through the overlaps with the blocks to the left and
|
||||
// above: the block is copied right of and below them, untouched pixels are kept
|
||||
// on the other side. Copying (rather than averaging) keeps the texture's grain.
|
||||
let left = min_cut((x0..(bx + ov).min(x1)).len(), (y0..y1).len(), |u, v| err(x0 + u as i32, y0 + v as i32));
|
||||
let top = min_cut((y0..(by + ov).min(y1)).len(), (x0..x1).len(), |u, v| err(x0 + v as i32, y0 + u as i32));
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let i = y as usize * w + x as usize;
|
||||
if !hole[i] {
|
||||
continue;
|
||||
}
|
||||
let (a, s) = (i * ch, ((y - by + c.1) as usize * w + (x - bx + c.0) as usize) * ch);
|
||||
// Feather into already-synthesised overlap.
|
||||
let k = if filled[i] { 0.5 } else { 1.0 };
|
||||
for q in 0..ch {
|
||||
out[a + q] += (img[s + q] - out[a + q]) * k;
|
||||
let (u, v) = ((x - x0) as usize, (y - y0) as usize);
|
||||
let keep = filled[i] && (left.get(v).is_some_and(|s| u < *s) || top.get(u).is_some_and(|s| v < *s));
|
||||
if !keep {
|
||||
let (a, s) = (i * ch, src(x, y));
|
||||
out[a..a + ch].copy_from_slice(&img[s..s + ch]);
|
||||
}
|
||||
filled[i] = true;
|
||||
}
|
||||
|
|
@ -997,6 +1012,39 @@ pub fn synthesize(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], blo
|
|||
out
|
||||
}
|
||||
|
||||
/// The minimum-error cut through a band `across` wide and `along` long (Efros & Freeman's
|
||||
/// boundary cut, by dynamic programming): for each step along the band, the offset across it
|
||||
/// where the cut runs (`err(across, along)` is the cost of a pixel). Pixels before the cut keep
|
||||
/// what is there. Empty when there is no cost anywhere (nothing to cut through).
|
||||
fn min_cut(across: usize, along: usize, err: impl Fn(usize, usize) -> f32) -> Vec<usize> {
|
||||
if across == 0 || along == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Cumulative cost, step by step along the band; the cut may move by one pixel per step.
|
||||
let mut acc: Vec<f32> = (0..along).flat_map(|v| (0..across).map(move |u| (u, v))).map(|(u, v)| err(u, v)).collect();
|
||||
if acc.iter().all(|x| *x == 0.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
for v in 1..along {
|
||||
for u in 0..across {
|
||||
let lo = u.saturating_sub(1);
|
||||
let hi = (u + 1).min(across - 1);
|
||||
let m = (lo..=hi).map(|k| acc[(v - 1) * across + k]).fold(f32::INFINITY, f32::min);
|
||||
acc[v * across + u] += m;
|
||||
}
|
||||
}
|
||||
let argmin = |v: usize, lo: usize, hi: usize| (lo..=hi).min_by(|a, b| acc[v * across + a].total_cmp(&acc[v * across + b])).unwrap_or(lo);
|
||||
let mut cut = vec![0; along];
|
||||
let mut u = argmin(along - 1, 0, across - 1);
|
||||
for v in (0..along).rev() {
|
||||
if v + 1 < along {
|
||||
u = argmin(v, u.saturating_sub(1), (u + 1).min(across - 1));
|
||||
}
|
||||
cut[v] = u;
|
||||
}
|
||||
cut
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,13 @@
|
|||
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
mod facet;
|
||||
#[cfg(test)]
|
||||
mod reference;
|
||||
mod screen;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use crate::fxutil::{MAXC, cell_point, luma, native, ncol, rgba, set_rgba, subtractive, via_rgb, xy};
|
||||
use crate::image::Image;
|
||||
use crate::noise::hash01;
|
||||
|
|
@ -49,6 +56,14 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
|
|||
};
|
||||
let w = out.width() as usize;
|
||||
let chans = if rgb { 3 } else { cc };
|
||||
// Keep cache metadata on the stack; only the optional, bounded tables allocate.
|
||||
let mut screens: [Option<screen::Screen>; MAXC] = std::array::from_fn(|c| {
|
||||
if c >= chans {
|
||||
return None;
|
||||
}
|
||||
let &(s, co) = rot.get(c)?;
|
||||
screen::Screen::new(out, (ox, oy), (s, co), cell)
|
||||
});
|
||||
let mut vals = [0.0f32; MAXC];
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32);
|
||||
|
|
@ -68,14 +83,21 @@ pub(crate) fn color_halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32,
|
|||
}
|
||||
// Back to document space to read the cell's value.
|
||||
let (dx, dy) = (mu * co - mv * s + ox, mu * s + mv * co + oy);
|
||||
let raw = value(dx, dy, c).clamp(0.0, 1.0);
|
||||
let ink = if sub && !rgb { raw } else { 1.0 - raw };
|
||||
// Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners.
|
||||
let touch = std::f32::consts::FRAC_PI_4;
|
||||
let r = if ink <= touch {
|
||||
cell * (ink / std::f32::consts::PI).sqrt()
|
||||
} else {
|
||||
cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5))
|
||||
// The five source taps and ink-to-radius mapping depend only on
|
||||
// the screen cell, not on the output pixel inside that cell.
|
||||
let radius = || {
|
||||
let raw = value(dx, dy, c).clamp(0.0, 1.0);
|
||||
let ink = if sub && !rgb { raw } else { 1.0 - raw };
|
||||
let touch = std::f32::consts::FRAC_PI_4;
|
||||
if ink <= touch {
|
||||
cell * (ink / std::f32::consts::PI).sqrt()
|
||||
} else {
|
||||
cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5))
|
||||
}
|
||||
};
|
||||
let r = match screens.get_mut(c).and_then(Option::as_mut) {
|
||||
Some(screen) => screen.radius(cu + di as f32, cv + dj as f32, radius),
|
||||
None => radius(),
|
||||
};
|
||||
// Tiny dots cannot cover more than their own area.
|
||||
cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r));
|
||||
|
|
@ -175,6 +197,10 @@ pub(crate) fn crystallize(src: &Image, out: Rect, ctx: &Ctx, cell_size: f32, see
|
|||
/// least-varied of its four overlapping quadrants, clumping similar colours
|
||||
/// into flat facets while keeping edges.
|
||||
pub(crate) fn facet(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
facet::run(src, out, ctx).unwrap_or_else(|| facet_direct(src, out, ctx))
|
||||
}
|
||||
|
||||
fn facet_direct(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let r = 2i32;
|
||||
let mut res = src.crop(out);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
//! Reuse each 3x3 quadrant at its four output pixels. Keep the original
|
||||
//! nine-sample addition order: reassociating the sums can change variance ties.
|
||||
use crate::fxutil::{MAXC, luma};
|
||||
use crate::{Ctx, image::Image};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Quadrant {
|
||||
variance: f32,
|
||||
mean: [f32; MAXC],
|
||||
}
|
||||
|
||||
pub(super) fn run(src: &Image, out: Rect, ctx: &Ctx) -> Option<Vec<f32>> {
|
||||
let n = src.ch;
|
||||
if n == 0 || n > MAXC || out.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let win = Rect::new(out.x0.checked_sub(2)?, out.y0.checked_sub(2)?, out.x1.checked_add(2)?, out.y1.checked_add(2)?);
|
||||
if win.intersect(&src.rect) != win {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (win.width() as usize, win.height() as usize);
|
||||
let len = w.checked_mul(h)?;
|
||||
// A normal 256px tile needs 264 KiB. Bound scratch for explicit tile overrides.
|
||||
if len > 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let stride = (src.rect.width() as usize).checked_mul(n)?;
|
||||
let offset = (win.x0.abs_diff(src.rect.x0) as usize).checked_mul(n)?;
|
||||
let row = |y: i32| -> Option<&[f32]> {
|
||||
let start = (y.abs_diff(src.rect.y0) as usize).checked_mul(stride)?.checked_add(offset)?;
|
||||
src.data.get(start..start.checked_add(w.checked_mul(n)?)?)
|
||||
};
|
||||
let mut lum = Vec::new();
|
||||
lum.try_reserve_exact(len).ok()?;
|
||||
for y in win.y0..win.y1 {
|
||||
lum.extend(row(y)?.chunks_exact(n).map(|px| luma(ctx, px)));
|
||||
}
|
||||
let qw = w.checked_sub(2)?;
|
||||
let mut quadrants: [Vec<Quadrant>; 3] = std::array::from_fn(|_| Vec::new());
|
||||
for line in &mut quadrants {
|
||||
line.try_reserve_exact(qw).ok()?;
|
||||
line.resize(qw, Quadrant::default());
|
||||
}
|
||||
let build_row = |y: usize, line: &mut [Quadrant]| -> Option<()> {
|
||||
let rows = [
|
||||
row(win.y0.checked_add(i32::try_from(y).ok()?)?)?,
|
||||
row(win.y0.checked_add(i32::try_from(y + 1).ok()?)?)?,
|
||||
row(win.y0.checked_add(i32::try_from(y + 2).ok()?)?)?,
|
||||
];
|
||||
for (x, q) in line.iter_mut().enumerate() {
|
||||
let mut sum = [0.0; MAXC];
|
||||
let (mut s, mut s2) = (0.0, 0.0);
|
||||
for (dy, pixels) in rows.iter().enumerate() {
|
||||
let start = x.checked_mul(n)?;
|
||||
let samples = pixels.get(start..start.checked_add(3 * n)?)?;
|
||||
let lights = lum.get((y + dy) * w + x..(y + dy) * w + x + 3)?;
|
||||
for (pixel, &l) in samples.chunks_exact(n).zip(lights) {
|
||||
for (total, value) in sum.iter_mut().zip(pixel) {
|
||||
*total += *value;
|
||||
}
|
||||
s += l;
|
||||
s2 += l * l;
|
||||
}
|
||||
}
|
||||
for v in &mut sum {
|
||||
*v /= 9.0;
|
||||
}
|
||||
*q = Quadrant { variance: s2 / 9.0 - (s / 9.0).powi(2), mean: sum };
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
build_row(0, &mut quadrants[0])?;
|
||||
build_row(1, &mut quadrants[1])?;
|
||||
let width = out.width() as usize;
|
||||
let height = out.height() as usize;
|
||||
let mut result = Vec::new();
|
||||
result.try_reserve_exact(width.checked_mul(height)?.checked_mul(n)?).ok()?;
|
||||
for y in 0..height {
|
||||
build_row(y + 2, &mut quadrants[2])?;
|
||||
for x in 0..width {
|
||||
let mut best = Quadrant { variance: f32::MAX, mean: [0.0; MAXC] };
|
||||
// Preserve quadrant order, including exact ties and non-finite variances.
|
||||
for q in [quadrants[0].get(x)?, quadrants[0].get(x + 2)?, quadrants[2].get(x)?, quadrants[2].get(x + 2)?] {
|
||||
if q.variance < best.variance {
|
||||
best = *q;
|
||||
}
|
||||
}
|
||||
result.extend(best.mean.iter().take(n));
|
||||
}
|
||||
quadrants.rotate_left(1);
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
//! Original scalar kernels, kept as independent test oracles.
|
||||
use super::*;
|
||||
pub(crate) fn halftone(src: &Image, out: Rect, ctx: &Ctx, max_radius: f32, angles: [f32; 4]) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let cc = ncol(ctx, n);
|
||||
let rmax = max_radius.max(1.0);
|
||||
// Cell side such that a full-size dot just covers the cell's corners.
|
||||
let cell = rmax * std::f32::consts::SQRT_2;
|
||||
let sub = subtractive(ctx);
|
||||
let rgb = via_rgb(ctx);
|
||||
let mut res = src.crop(out);
|
||||
let b = ctx.bounds;
|
||||
let (ox, oy) = (b.x0 as f32, b.y0 as f32);
|
||||
let rot: Vec<(f32, f32)> = (0..cc.max(3)).map(|c| angles[c.min(3)].to_radians().sin_cos()).collect();
|
||||
let clip = b.intersect(&src.rect);
|
||||
if clip.is_empty() {
|
||||
return res;
|
||||
}
|
||||
// Channel value at a point averaged over five taps (for Lab, of the RGB conversion).
|
||||
let value = |x: f32, y: f32, c: usize| -> f32 {
|
||||
let d = cell * 0.25;
|
||||
let mut acc = 0.0;
|
||||
let mut px = [0.0f32; MAXC];
|
||||
for (dx, dy) in [(0.0, 0.0), (-d, -d), (d, -d), (-d, d), (d, d)] {
|
||||
// Clamp to the bounds so cells straddling the edge read real pixels.
|
||||
let (ix, iy) = (((x + dx).floor() as i32).clamp(clip.x0, clip.x1 - 1), ((y + dy).floor() as i32).clamp(clip.y0, clip.y1 - 1));
|
||||
if rgb {
|
||||
for (k, p) in px.iter_mut().enumerate().take(n) {
|
||||
*p = src.get(ix, iy, k);
|
||||
}
|
||||
acc += rgba(ctx, &px[..n])[c];
|
||||
} else {
|
||||
acc += src.get(ix, iy, c);
|
||||
}
|
||||
}
|
||||
acc / 5.0
|
||||
};
|
||||
let w = out.width() as usize;
|
||||
let chans = if rgb { 3 } else { cc };
|
||||
let mut vals = [0.0f32; MAXC];
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = (out.x0 + (i % w) as i32, out.y0 + (i / w) as i32);
|
||||
let (fx, fy) = (x as f32 + 0.5 - ox, y as f32 + 0.5 - oy);
|
||||
for (c, v) in vals.iter_mut().enumerate().take(chans) {
|
||||
let (s, co) = rot[c];
|
||||
// Rotate into screen space.
|
||||
let (u, w2) = (fx * co + fy * s, -fx * s + fy * co);
|
||||
let (cu, cv) = ((u / cell).floor(), (w2 / cell).floor());
|
||||
let mut cov: f32 = 0.0;
|
||||
for dj in -1..=1 {
|
||||
for di in -1..=1 {
|
||||
let (mu, mv) = ((cu + di as f32 + 0.5) * cell, (cv + dj as f32 + 0.5) * cell);
|
||||
let d = ((u - mu).powi(2) + (w2 - mv).powi(2)).sqrt();
|
||||
if d >= rmax + 0.5 {
|
||||
continue;
|
||||
}
|
||||
// Back to document space to read the cell's value.
|
||||
let (dx, dy) = (mu * co - mv * s + ox, mu * s + mv * co + oy);
|
||||
let raw = value(dx, dy, c).clamp(0.0, 1.0);
|
||||
let ink = if sub && !rgb { raw } else { 1.0 - raw };
|
||||
// Dot area tracks ink: πr² = ink·cell² until dots touch, then grow to cover the corners.
|
||||
let touch = std::f32::consts::FRAC_PI_4;
|
||||
let r = if ink <= touch {
|
||||
cell * (ink / std::f32::consts::PI).sqrt()
|
||||
} else {
|
||||
cell * (0.5 + (ink - touch) / (1.0 - touch) * (std::f32::consts::FRAC_1_SQRT_2 - 0.5))
|
||||
};
|
||||
// Tiny dots cannot cover more than their own area.
|
||||
cov = cov.max((r - d + 0.5).clamp(0.0, 1.0).min(std::f32::consts::PI * r * r));
|
||||
}
|
||||
}
|
||||
*v = if sub && !rgb { cov } else { 1.0 - cov };
|
||||
}
|
||||
if rgb {
|
||||
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
|
||||
set_rgba(ctx, px, [vals[0], vals[1], vals[2], a]);
|
||||
} else {
|
||||
px[..cc].copy_from_slice(&vals[..cc]);
|
||||
}
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub(crate) fn facet(src: &Image, out: Rect, ctx: &Ctx) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
let r = 2i32;
|
||||
let mut res = src.crop(out);
|
||||
let mut tmp = [0.0f32; MAXC];
|
||||
for (i, px) in res.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(out, i);
|
||||
let mut best = (f32::MAX, [0.0f32; MAXC]);
|
||||
for (qx, qy) in [(-r, -r), (0, -r), (-r, 0), (0, 0)] {
|
||||
let mut sum = [0.0f32; MAXC];
|
||||
let (mut s, mut s2) = (0.0f32, 0.0f32);
|
||||
for yy in y + qy..=y + qy + r {
|
||||
for xx in x + qx..=x + qx + r {
|
||||
for (c, t) in tmp.iter_mut().enumerate().take(n) {
|
||||
*t = src.get(xx, yy, c);
|
||||
sum[c] += *t;
|
||||
}
|
||||
let l = luma(ctx, &tmp[..n]);
|
||||
s += l;
|
||||
s2 += l * l;
|
||||
}
|
||||
}
|
||||
let cnt = ((r + 1) * (r + 1)) as f32;
|
||||
let var = s2 / cnt - (s / cnt).powi(2);
|
||||
if var < best.0 {
|
||||
for v in sum.iter_mut() {
|
||||
*v /= cnt;
|
||||
}
|
||||
best = (var, sum);
|
||||
}
|
||||
}
|
||||
px.copy_from_slice(&best.1[..n]);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
//! Tile-local memoization in rotated screen coordinates. Each cell's radius
|
||||
//! is independent of the output pixel, including the five-tap edge clamping.
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
pub(super) struct Screen {
|
||||
x0: i32,
|
||||
y0: i32,
|
||||
width: usize,
|
||||
height: usize,
|
||||
radii: Vec<Option<f32>>,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
pub(super) fn new(out: Rect, origin: (f32, f32), rotation: (f32, f32), cell: f32) -> Option<Self> {
|
||||
if out.is_empty() || !cell.is_finite() || cell < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let (s, co) = rotation;
|
||||
let (mut lo_x, mut lo_y, mut hi_x, mut hi_y) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
for y in [out.y0, out.y1.checked_sub(1)?] {
|
||||
for x in [out.x0, out.x1.checked_sub(1)?] {
|
||||
let (fx, fy) = (x as f32 + 0.5 - origin.0, y as f32 + 0.5 - origin.1);
|
||||
let (u, v) = ((fx * co + fy * s) / cell, (-fx * s + fy * co) / cell);
|
||||
if !u.is_finite() || !v.is_finite() || u.abs() > 4_000_000.0 || v.abs() > 4_000_000.0 {
|
||||
return None;
|
||||
}
|
||||
lo_x = lo_x.min(u);
|
||||
lo_y = lo_y.min(v);
|
||||
hi_x = hi_x.max(u);
|
||||
hi_y = hi_y.max(v);
|
||||
}
|
||||
}
|
||||
// Include the neighbouring dots and a rounding margin. If a key ever
|
||||
// falls outside this box, radius() computes it directly.
|
||||
let (x0, y0) = (lo_x.floor() as i32 - 2, lo_y.floor() as i32 - 2);
|
||||
let width = usize::try_from(hi_x.floor() as i32 + 3 - x0).ok()?;
|
||||
let height = usize::try_from(hi_y.floor() as i32 + 3 - y0).ok()?;
|
||||
let len = width.checked_mul(height)?;
|
||||
// At most 2 MiB per channel, even for an explicit oversized tile.
|
||||
if len > 256 * 1024 {
|
||||
return None;
|
||||
}
|
||||
let mut radii = Vec::new();
|
||||
radii.try_reserve_exact(len).ok()?;
|
||||
radii.resize(len, None);
|
||||
Some(Self { x0, y0, width, height, radii })
|
||||
}
|
||||
|
||||
pub(super) fn radius(&mut self, x: f32, y: f32, compute: impl FnOnce() -> f32) -> f32 {
|
||||
// Keys must represent exact integers: don't alias non-finite or large
|
||||
// coordinates onto cells whose floating-point arithmetic differs.
|
||||
let slot = if x.is_finite() && y.is_finite() && x.abs() <= 4_000_001.0 && y.abs() <= 4_000_001.0 {
|
||||
usize::try_from(x as i32 - self.x0)
|
||||
.ok()
|
||||
.zip(usize::try_from(y as i32 - self.y0).ok())
|
||||
.filter(|&(cx, cy)| cx < self.width && cy < self.height)
|
||||
.and_then(|(cx, cy)| cy.checked_mul(self.width)?.checked_add(cx))
|
||||
.and_then(|i| self.radii.get_mut(i))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match slot {
|
||||
Some(value) => *value.get_or_insert_with(compute),
|
||||
None => compute(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
fn fixture(rect: Rect, ctx: &Ctx, pattern: u32) -> Image {
|
||||
let n = PixelFormat::new(ctx.mode, SampleType::F32, ctx.alpha).channels();
|
||||
let mut image = Image::new(rect, n);
|
||||
for (i, pixel) in image.data.chunks_exact_mut(n).enumerate() {
|
||||
let (x, y) = xy(rect, i);
|
||||
let seed = (x as u32).wrapping_mul(0x9e37_79b9) ^ (y as u32).wrapping_mul(0x85eb_ca6b);
|
||||
for (c, sample) in pixel.iter_mut().enumerate() {
|
||||
*sample = match pattern {
|
||||
0 => 0.5,
|
||||
1 => {
|
||||
if x + y > -4 {
|
||||
0.9
|
||||
} else {
|
||||
0.1
|
||||
}
|
||||
}
|
||||
2 => (seed.rotate_left(c as u32 * 7) % 65536) as f32 / 65535.0,
|
||||
3 => (seed.rotate_left(c as u32 * 7) % 65536) as f32 / 16384.0 - 1.0,
|
||||
_ => match seed.wrapping_add(c as u32) % 7 {
|
||||
0 => f32::NAN,
|
||||
1 => f32::INFINITY,
|
||||
2 => f32::NEG_INFINITY,
|
||||
_ => 0.5,
|
||||
},
|
||||
};
|
||||
}
|
||||
if ctx.alpha && seed.is_multiple_of(5) {
|
||||
pixel[n - 1] = 0.0;
|
||||
}
|
||||
}
|
||||
image
|
||||
}
|
||||
|
||||
fn assert_bits(got: &[f32], want: &[f32]) {
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (i, (a, b)) in got.iter().zip(want).enumerate() {
|
||||
assert_eq!(a.to_bits(), b.to_bits(), "sample {i}: {a} != {b}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facet_reuse_matches_scalar_in_every_model() {
|
||||
let out = Rect::new(-17, -9, 20, 18);
|
||||
for mode in [
|
||||
ColorMode::Rgb,
|
||||
ColorMode::Grayscale,
|
||||
ColorMode::Cmyk,
|
||||
ColorMode::Lab,
|
||||
ColorMode::Bitmap,
|
||||
ColorMode::Duotone,
|
||||
ColorMode::Indexed,
|
||||
ColorMode::Multichannel,
|
||||
] {
|
||||
for alpha in [false, true] {
|
||||
let ctx = Ctx { bounds: out, mode, alpha };
|
||||
for pattern in 0..5 {
|
||||
let src = fixture(out.inflate(3), &ctx, pattern);
|
||||
assert_bits(&facet(&src, out, &ctx), &reference::facet(&src, out, &ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facet_partial_halo_and_thin_regions_match_scalar() {
|
||||
for out in [Rect::new(-3, 7, -2, 8), Rect::new(-5, 0, 30, 1), Rect::new(9, -8, 10, 29)] {
|
||||
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
|
||||
for halo in [0, 1, 2, 3] {
|
||||
let src = fixture(out.inflate(halo), &ctx, 2);
|
||||
assert_bits(&facet(&src, out, &ctx), &reference::facet(&src, out, &ctx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_cache_matches_scalar_screen_angles_radii_and_models() {
|
||||
let out = Rect::new(-17, -9, 20, 18);
|
||||
for mode in [
|
||||
ColorMode::Rgb,
|
||||
ColorMode::Grayscale,
|
||||
ColorMode::Cmyk,
|
||||
ColorMode::Lab,
|
||||
ColorMode::Bitmap,
|
||||
ColorMode::Duotone,
|
||||
ColorMode::Indexed,
|
||||
ColorMode::Multichannel,
|
||||
] {
|
||||
for alpha in [false, true] {
|
||||
let ctx = Ctx { bounds: Rect::new(-23, -15, 29, 31), mode, alpha };
|
||||
for pattern in 0..5 {
|
||||
let src = fixture(ctx.bounds, &ctx, pattern);
|
||||
for radius in [1.0, 2.0, 3.75, 8.0, 64.0] {
|
||||
for angles in [[108.0, 162.0, 90.0, 45.0], [0.0, -17.5, 359.99, 720.0]] {
|
||||
assert_bits(&color_halftone(&src, out, &ctx, radius, angles), &reference::halftone(&src, out, &ctx, radius, angles));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halftone_unusual_coordinates_and_params_keep_direct_semantics() {
|
||||
for out in [Rect::new(16_777_211, -3, 16_777_220, 4), Rect::new(-3, 4, -2, 5)] {
|
||||
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
|
||||
let src = fixture(out.inflate(2), &ctx, 2);
|
||||
for radius in [f32::NAN, f32::INFINITY, -10.0, 1.0] {
|
||||
for angles in [[f32::NAN, 0.0, f32::INFINITY, 45.0], [0.0; 4]] {
|
||||
assert_bits(&color_halftone(&src, out, &ctx, radius, angles), &reference::halftone(&src, out, &ctx, radius, angles));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(screen::Screen::new(Rect::new(0, 0, 100_000, 100_000), (0.0, 0.0), (0.0, 1.0), 1.0).is_none());
|
||||
assert!(screen::Screen::new(Rect::new(8_000_000, 0, 8_000_010, 10), (0.0, 0.0), (0.0, 1.0), 1.0).is_none());
|
||||
let mut screen = screen::Screen::new(Rect::new(0, 0, 1, 1), (0.0, 0.0), (0.0, 1.0), 1.0).unwrap();
|
||||
assert_eq!(screen.radius(-100.0, 0.0, || 3.0), 3.0);
|
||||
assert_eq!(screen.radius(f32::NAN, 0.0, || 4.0), 4.0);
|
||||
assert_eq!(screen.radius(0.0, 0.0, || 5.0), 5.0);
|
||||
assert_eq!(screen.radius(0.0, 0.0, || panic!("cached dot recomputed")), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiled_filters_match_scalar_at_all_depths_with_soft_selection() {
|
||||
use crate::{FilterParams, apply_tiled};
|
||||
let bounds = Rect::new(-17, -9, 26, 24);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
|
||||
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for alpha in [false, true] {
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
let src = fixture(bounds, &ctx, 2);
|
||||
let mut surface = Surface::new(PixelFormat::new(mode, depth, alpha));
|
||||
surface.write_region(bounds, &src.data);
|
||||
let mut selection = Surface::new(PixelFormat::new(ColorMode::Grayscale, SampleType::U8, false));
|
||||
let coverage: Vec<_> = (0..bounds.width() * bounds.height()).map(|i| (i % 7) as f32 / 6.0).collect();
|
||||
selection.write_region(bounds, &coverage);
|
||||
for params in [FilterParams::Facet, FilterParams::ColorHalftone { max_radius: 3.75, angles: [108.0, 162.0, 90.0, 45.0] }] {
|
||||
for extent in [None, Some(bounds)] {
|
||||
assert!(matches!(params.halo(), crate::Halo::Radius(_)));
|
||||
let halo = match params.halo() {
|
||||
crate::Halo::Radius(r) => r,
|
||||
_ => 0,
|
||||
};
|
||||
let image = match extent {
|
||||
Some(e) => Image::read_clamped(&surface, bounds.inflate(halo), e),
|
||||
None => Image::read(&surface, bounds.inflate(halo)),
|
||||
};
|
||||
let mut expected_data = match params {
|
||||
FilterParams::Facet => reference::facet(&image, bounds, &ctx),
|
||||
FilterParams::ColorHalftone { max_radius, angles } => reference::halftone(&image, bounds, &ctx, max_radius, angles),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
crate::mix_selection(&mut expected_data, bounds, &selection, &image);
|
||||
let mut expected = surface.clone();
|
||||
expected.write_region(bounds, &expected_data);
|
||||
for tile in [1, 7, 17, 256] {
|
||||
let got = apply_tiled(&surface, ¶ms, bounds, bounds, Some(&selection), tile, extent);
|
||||
assert_bits(&got.read_region(bounds), &expected.read_region(bounds));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_channels_and_large_screen_offsets_match_scalar() {
|
||||
let out = Rect::new(16_777_211, -3, 16_777_220, 4);
|
||||
let ctx = Ctx { bounds: Rect::new(0, -10, out.x1 + 10, 10), mode: ColorMode::Multichannel, alpha: true };
|
||||
for n in 5..=MAXC {
|
||||
let mut src = Image::new(out.inflate(3), n);
|
||||
for (i, sample) in src.data.iter_mut().enumerate() {
|
||||
*sample = (i * 73 % 257) as f32 / 256.0;
|
||||
}
|
||||
assert_bits(&facet(&src, out, &ctx), &reference::facet(&src, out, &ctx));
|
||||
assert_bits(&color_halftone(&src, out, &ctx, 1.0, [0.0; 4]), &reference::halftone(&src, out, &ctx, 1.0, [0.0; 4]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facet_scratch_rejects_large_or_incomplete_storage() {
|
||||
let out = Rect::new(0, 0, 10_000, 10_000);
|
||||
let ctx = Ctx { bounds: out, mode: ColorMode::Rgb, alpha: true };
|
||||
let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() };
|
||||
assert!(facet::run(&src, out, &ctx).is_none());
|
||||
let out = Rect::new(0, 0, 2, 2);
|
||||
let src = Image { rect: out.inflate(3), ch: 4, data: Vec::new() };
|
||||
assert!(facet::run(&src, out, &ctx).is_none());
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
//! Source pixels of canonical grid links touching a free pixel, in raster order.
|
||||
//! Merge the free pixels and their left/upper three predecessors. Each stream
|
||||
//! is sorted; a five-way merge deduplicates in O(free pixels), without a dense bitmap.
|
||||
|
||||
pub(super) struct Starts<'a> {
|
||||
free: &'a [usize],
|
||||
w: usize,
|
||||
at: [usize; 5],
|
||||
peek: [Option<usize>; 5],
|
||||
}
|
||||
|
||||
impl<'a> Starts<'a> {
|
||||
pub(super) fn new(free: &'a [usize], w: usize) -> Self {
|
||||
let mut this = Self { free, w, at: [0; 5], peek: [None; 5] };
|
||||
for k in 0..5 {
|
||||
this.advance(k)
|
||||
}
|
||||
this
|
||||
}
|
||||
|
||||
fn advance(&mut self, k: usize) {
|
||||
self.peek[k] = None;
|
||||
while let Some(&i) = self.free.get(self.at[k]) {
|
||||
self.at[k] += 1;
|
||||
let x = i % self.w;
|
||||
let value = match k {
|
||||
0 => Some(i),
|
||||
1 => (x > 0).then(|| i - 1),
|
||||
2 => (i >= self.w).then(|| i - self.w),
|
||||
3 => (i >= self.w && x > 0).then(|| i - self.w - 1),
|
||||
_ => (i >= self.w && x + 1 < self.w).then(|| i - self.w + 1),
|
||||
};
|
||||
if value.is_some() {
|
||||
self.peek[k] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Starts<'_> {
|
||||
type Item = usize;
|
||||
fn next(&mut self) -> Option<usize> {
|
||||
let first = self.peek.iter().flatten().copied().min()?;
|
||||
for k in 0..5 {
|
||||
if self.peek[k] == Some(first) {
|
||||
self.advance(k)
|
||||
}
|
||||
}
|
||||
Some(first)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
use super::*;
|
||||
use crate::segment::{FREE, HARD_BG, HARD_FG, RgbImage, grid_cut_weighted};
|
||||
|
||||
#[test]
|
||||
fn merged_starts_equal_all_live_edge_sources() {
|
||||
for (w, h) in [(1, 1), (1, 7), (7, 1), (2, 3), (4, 3)] {
|
||||
for bits in 0..1usize << (w * h) {
|
||||
let free: Vec<_> = (0..w * h).filter(|i| bits & (1 << i) != 0).collect();
|
||||
let mut expected = Vec::new();
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = y * w + x;
|
||||
let live = free.contains(&i)
|
||||
|| crate::segment::NB.iter().any(|&(dx, dy, _)| {
|
||||
let (xx, yy) = (x as i32 + dx, y as i32 + dy);
|
||||
xx >= 0 && xx < w as i32 && yy < h as i32 && free.contains(&(yy as usize * w + xx as usize))
|
||||
});
|
||||
if live {
|
||||
expected.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(Starts::new(&free, w).collect::<Vec<_>>(), expected, "{w}x{h} bits={bits}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_small_constraint_mask_matches_the_previous_graph() {
|
||||
for (w, h) in [(1, 1), (1, 6), (6, 1), (2, 3)] {
|
||||
let img = RgbImage::from_fn(w, h, |x, y| [(x % 3) as f32 / 2.0, (y % 4) as f32 / 3.0, ((x + y) % 5) as f32 / 4.0]);
|
||||
for code in 0..3usize.pow((w * h) as u32) {
|
||||
let mut state = code;
|
||||
let fixed: Vec<_> = (0..w * h)
|
||||
.map(|_| {
|
||||
let v = state % 3;
|
||||
state /= 3;
|
||||
[FREE, HARD_FG, HARD_BG][v]
|
||||
})
|
||||
.collect();
|
||||
let fg: Vec<_> = (0..w * h).map(|i| (i % 3) as f32 * 0.1).collect();
|
||||
let bg: Vec<_> = (0..w * h).map(|i| (i % 4) as f32 * 0.15).collect();
|
||||
for kind in 0..3 {
|
||||
let weight = |d: f32| match kind {
|
||||
0 => 1.0,
|
||||
1 => 0.3 * (-2.0 * d).exp(),
|
||||
_ => 0.02 + 1.0 / (1.0 + (d.sqrt() / 0.05).powf(1.98)),
|
||||
};
|
||||
assert_eq!(
|
||||
grid_cut_weighted::<true>(&img, &fg, &bg, &fixed, weight),
|
||||
grid_cut_weighted::<false>(&img, &fg, &bg, &fixed, weight),
|
||||
"{w}x{h} constraints={code} weight={kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_graph_keeps_exact_masks_and_avoids_irrelevant_weights() {
|
||||
use std::cell::Cell;
|
||||
let (w, h) = (101usize, 93usize);
|
||||
let img = RgbImage::from_fn(w, h, |x, y| [((x * 13 + y * 7) % 257) as f32 / 256.0; 3]);
|
||||
let cost: Vec<_> = (0..w * h).map(|i| (i % 11) as f32 * 0.01).collect();
|
||||
for kind in 0..4 {
|
||||
let fixed: Vec<_> = (0..w * h)
|
||||
.map(|i| {
|
||||
if (kind == 0 && i % 503 == 0) || (kind == 1 && i % w >= 48 && i % w <= 52) || (kind == 2 && i / w == 0) || (kind == 3 && i % w == w - 1) {
|
||||
FREE
|
||||
} else if i % w < 50 {
|
||||
HARD_FG
|
||||
} else {
|
||||
HARD_BG
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let before = Cell::new(0);
|
||||
let after = Cell::new(0);
|
||||
let old = grid_cut_weighted::<false>(&img, &cost, &cost, &fixed, |d| {
|
||||
before.set(before.get() + 1);
|
||||
0.3 * (-2.0 * d).exp()
|
||||
});
|
||||
let new = grid_cut_weighted::<true>(&img, &cost, &cost, &fixed, |d| {
|
||||
after.set(after.get() + 1);
|
||||
0.3 * (-2.0 * d).exp()
|
||||
});
|
||||
assert_eq!(new, old, "kind={kind}");
|
||||
assert!(after.get() * 5 < before.get(), "kind={kind} counts {} -> {}", before.get(), after.get());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_grid_dimensions_and_short_inputs_return_empty() {
|
||||
for img in [
|
||||
RgbImage { w: usize::MAX, h: 2, px: Vec::new() },
|
||||
RgbImage { w: i32::MAX as usize + 1, h: 0, px: Vec::new() },
|
||||
RgbImage { w: 2, h: 2, px: vec![[0.0; 3]; 3] },
|
||||
] {
|
||||
assert!(crate::segment::grid_cut(&img, &[], &[], &[], 1.0, 1.0).is_empty());
|
||||
}
|
||||
let img = RgbImage::new(2, 2);
|
||||
let cost = [0.0; 4];
|
||||
let fixed = [FREE; 4];
|
||||
for (fg, bg, constraints) in [(&cost[..3], &cost[..], &fixed[..]), (&cost[..], &cost[..3], &fixed[..]), (&cost[..], &cost[..], &fixed[..3])] {
|
||||
assert!(crate::segment::grid_cut_with(&img, fg, bg, constraints, |_| 1.0).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
//! Boykov–Kolmogorov max-flow / min-cut.
|
||||
//! Hybrid max-flow / min-cut: Boykov–Kolmogorov and FIFO push/relabel.
|
||||
//!
|
||||
//! Implemented from Y. Boykov and V. Kolmogorov, "An Experimental Comparison of Min-Cut/Max-Flow
|
||||
//! Algorithms for Energy Minimization in Vision", IEEE PAMI 26(9), 2004: two search trees
|
||||
|
|
@ -6,9 +6,16 @@
|
|||
//! path; saturated tree edges create orphans that are re-adopted (or freed) before growing again.
|
||||
//! Search trees are reused between augmentations, which makes the algorithm very fast on the
|
||||
//! short-path grid graphs of image segmentation.
|
||||
//!
|
||||
//! Large finite graphs with both terminal directions use FIFO push/relabel instead:
|
||||
//! excess is discharged locally, with periodic exact distances to both terminals.
|
||||
//! This avoids repeated path augmentation for the many area-cost terminals in Quick Selection.
|
||||
//! Small graphs and graphs with only one terminal direction retain the BK path.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
mod push_relabel;
|
||||
|
||||
const NONE: u32 = u32::MAX;
|
||||
const TERMINAL: u32 = u32::MAX - 1;
|
||||
const ORPHAN: u32 = u32::MAX - 2;
|
||||
|
|
@ -103,6 +110,24 @@ impl Graph {
|
|||
|
||||
/// Computes the maximum flow (= minimum cut capacity).
|
||||
pub fn maxflow(&mut self) -> f64 {
|
||||
if self.node_count() >= 4096 {
|
||||
let mut source = false;
|
||||
let mut sink = false;
|
||||
for &capacity in &self.tr_cap {
|
||||
if !capacity.is_finite() {
|
||||
return self.maxflow_bk();
|
||||
}
|
||||
source |= capacity > EPS;
|
||||
sink |= capacity < -EPS;
|
||||
}
|
||||
if source && sink && self.r_cap.iter().all(|v| v.is_finite() && *v >= 0.0) {
|
||||
return self.maxflow_push_relabel();
|
||||
}
|
||||
}
|
||||
self.maxflow_bk()
|
||||
}
|
||||
|
||||
pub(super) fn maxflow_bk(&mut self) -> f64 {
|
||||
let n = self.node_count();
|
||||
let mut queue: VecDeque<u32> = VecDeque::new();
|
||||
let mut orphans: VecDeque<u32> = VecDeque::new();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
//! FIFO push/relabel with periodic exact distances to both terminals.
|
||||
//!
|
||||
//! Implemented from Goldberg & Tarjan, "A New Approach to the Maximum-Flow
|
||||
//! Problem", JACM 35(4), 1988, with FIFO scheduling and global relabelling.
|
||||
//! The existing SoA residual graph is reused. Terminal source arcs are saturated
|
||||
//! into a preflow; `back` keeps their reverse capacity, `excess` uses f64 so local
|
||||
//! f32 capacity updates do not accumulate excess-rounding drift.
|
||||
use super::{EPS, Graph, NONE, TERMINAL};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
impl Graph {
|
||||
pub(super) fn maxflow_push_relabel(&mut self) -> f64 {
|
||||
let n = self.node_count();
|
||||
let Some(source_height) = u32::try_from(n).ok().filter(|&n| n < u32::MAX / 2).map(|n| n + 2) else {
|
||||
return self.maxflow_bk();
|
||||
};
|
||||
let mut back = vec![0.0f32; n];
|
||||
let mut excess = vec![0.0f64; n];
|
||||
for i in 0..n {
|
||||
self.active[i] = false;
|
||||
if self.tr_cap[i] > EPS {
|
||||
back[i] = self.tr_cap[i];
|
||||
excess[i] = f64::from(self.tr_cap[i]);
|
||||
self.tr_cap[i] = 0.0;
|
||||
}
|
||||
}
|
||||
self.global_relabel(source_height, &back);
|
||||
let mut queue = VecDeque::new();
|
||||
for (i, &e) in excess.iter().enumerate() {
|
||||
if e > f64::from(EPS) {
|
||||
self.active[i] = true;
|
||||
queue.push_back(i)
|
||||
}
|
||||
}
|
||||
let mut work = 0usize;
|
||||
let frequency = self.r_cap.len().saturating_mul(2).saturating_add(n);
|
||||
while let Some(i) = queue.pop_front() {
|
||||
self.active[i] = false;
|
||||
while excess[i] > f64::from(EPS) {
|
||||
if self.dist[i] == 1 && self.tr_cap[i] < -EPS {
|
||||
let delta = (excess[i] as f32).min(-self.tr_cap[i]);
|
||||
self.tr_cap[i] += delta;
|
||||
excess[i] -= f64::from(delta);
|
||||
self.flow += f64::from(delta);
|
||||
if excess[i] <= f64::from(EPS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.dist[i] == source_height + 1 && back[i] > EPS {
|
||||
let delta = (excess[i] as f32).min(back[i]);
|
||||
back[i] -= delta;
|
||||
self.tr_cap[i] += delta;
|
||||
excess[i] -= f64::from(delta);
|
||||
if excess[i] <= f64::from(EPS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut a = self.parent[i];
|
||||
while a != NONE {
|
||||
let ai = a as usize;
|
||||
let j = self.head[ai] as usize;
|
||||
work += 1;
|
||||
if self.r_cap[ai] > EPS && self.dist[i] == self.dist[j].saturating_add(1) {
|
||||
let delta = (excess[i] as f32).min(self.r_cap[ai]);
|
||||
self.r_cap[ai] -= delta;
|
||||
self.r_cap[ai ^ 1] += delta;
|
||||
excess[i] -= f64::from(delta);
|
||||
excess[j] += f64::from(delta);
|
||||
if !self.active[j] && excess[j] > f64::from(EPS) {
|
||||
self.active[j] = true;
|
||||
queue.push_back(j);
|
||||
}
|
||||
if excess[i] <= f64::from(EPS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
a = self.next[ai];
|
||||
self.parent[i] = a;
|
||||
}
|
||||
if excess[i] <= f64::from(EPS) {
|
||||
break;
|
||||
}
|
||||
let mut height = if self.tr_cap[i] < -EPS { 0 } else { u32::MAX };
|
||||
if back[i] > EPS {
|
||||
height = height.min(source_height)
|
||||
}
|
||||
let mut a = self.first[i];
|
||||
while a != NONE {
|
||||
let ai = a as usize;
|
||||
work += 1;
|
||||
if self.r_cap[ai] > EPS {
|
||||
height = height.min(self.dist[self.head[ai] as usize])
|
||||
}
|
||||
a = self.next[ai];
|
||||
}
|
||||
if height == u32::MAX {
|
||||
break;
|
||||
}
|
||||
self.dist[i] = height.saturating_add(1);
|
||||
self.parent[i] = self.first[i];
|
||||
}
|
||||
if work >= frequency {
|
||||
self.global_relabel(source_height, &back);
|
||||
work = 0;
|
||||
}
|
||||
}
|
||||
// Report the smallest source-side cut, independently of discharge order.
|
||||
self.parent.fill(NONE);
|
||||
self.is_sink.fill(true);
|
||||
for i in 0..n {
|
||||
if self.tr_cap[i] > EPS {
|
||||
self.parent[i] = TERMINAL;
|
||||
self.is_sink[i] = false;
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let mut a = self.first[i];
|
||||
while a != NONE {
|
||||
let ai = a as usize;
|
||||
let j = self.head[ai] as usize;
|
||||
if self.r_cap[ai] > EPS && self.parent[j] == NONE {
|
||||
self.parent[j] = TERMINAL;
|
||||
self.is_sink[j] = false;
|
||||
queue.push_back(j);
|
||||
}
|
||||
a = self.next[ai];
|
||||
}
|
||||
}
|
||||
self.flow
|
||||
}
|
||||
|
||||
fn global_relabel(&mut self, source_height: u32, back: &[f32]) {
|
||||
self.dist.fill(u32::MAX);
|
||||
self.parent.clone_from(&self.first);
|
||||
let mut queue = VecDeque::new();
|
||||
for i in 0..self.node_count() {
|
||||
if self.tr_cap[i] < -EPS {
|
||||
self.dist[i] = 1;
|
||||
queue.push_back(i)
|
||||
}
|
||||
}
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let mut a = self.first[i];
|
||||
while a != NONE {
|
||||
let ai = a as usize;
|
||||
let j = self.head[ai] as usize;
|
||||
if self.r_cap[ai ^ 1] > EPS && self.dist[j] == u32::MAX {
|
||||
self.dist[j] = self.dist[i].saturating_add(1);
|
||||
queue.push_back(j);
|
||||
}
|
||||
a = self.next[ai];
|
||||
}
|
||||
}
|
||||
// Give sink-unreachable vertices exact distances back to the source.
|
||||
// Resetting all of them to the same height can undo discharge progress.
|
||||
for (i, &capacity) in back.iter().enumerate() {
|
||||
if capacity > EPS && self.dist[i] == u32::MAX {
|
||||
self.dist[i] = source_height + 1;
|
||||
queue.push_back(i);
|
||||
}
|
||||
}
|
||||
while let Some(i) = queue.pop_front() {
|
||||
let mut a = self.first[i];
|
||||
while a != NONE {
|
||||
let ai = a as usize;
|
||||
let j = self.head[ai] as usize;
|
||||
if self.r_cap[ai ^ 1] > EPS && self.dist[j] == u32::MAX {
|
||||
self.dist[j] = self.dist[i].saturating_add(1);
|
||||
queue.push_back(j);
|
||||
}
|
||||
a = self.next[ai];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
use super::*;
|
||||
#[test]
|
||||
fn preflow_matches_bk_on_integer_graphs_and_returns_unused_source() {
|
||||
let mut rng = 7u32;
|
||||
let mut next = || {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
rng
|
||||
};
|
||||
for n in 1..40 {
|
||||
for _ in 0..30 {
|
||||
let mut g = Graph::with_capacity(n, n * 4);
|
||||
for i in 0..n {
|
||||
g.add_tweights(i, (next() % 10) as f32, (next() % 10) as f32)
|
||||
}
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
if next() % 7 == 0 {
|
||||
g.add_edge(i, j, (next() % 8) as f32, (next() % 8) as f32)
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut old = g.clone();
|
||||
assert_eq!(g.maxflow_push_relabel(), old.maxflow_bk());
|
||||
assert_eq!((0..n).map(|i| g.in_source(i)).collect::<Vec<_>>(), (0..n).map(|i| old.in_source(i)).collect::<Vec<_>>());
|
||||
assert_eq!(g.residual_out_of_source_side(), 0.0);
|
||||
let flow = g.flow();
|
||||
assert_eq!(g.maxflow_push_relabel(), flow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn energy(g: &Graph, labels: &[bool]) -> f64 {
|
||||
let mut sum = g.flow;
|
||||
for (i, &source) in labels.iter().enumerate() {
|
||||
sum += f64::from(if source { (-g.tr_cap[i]).max(0.0) } else { g.tr_cap[i].max(0.0) });
|
||||
for (j, capacity, _) in g.arcs(i) {
|
||||
if source && !labels[j] {
|
||||
sum += f64::from(capacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_flows_match_brute_force_minimum_cuts() {
|
||||
let mut rng = crate::segment::Rng::new(911);
|
||||
for n in 1..=12 {
|
||||
for _ in 0..20 {
|
||||
let mut g = Graph::with_capacity(n, n * n);
|
||||
for i in 0..n {
|
||||
g.add_tweights(i, (rng.next_u64() % 31) as f32 / 32.0, (rng.next_u64() % 31) as f32 / 32.0);
|
||||
for j in 0..i {
|
||||
if rng.next_u64().is_multiple_of(5) {
|
||||
g.add_edge(i, j, (rng.next_u64() % 31) as f32 / 32.0, (rng.next_u64() % 31) as f32 / 32.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let input = g.clone();
|
||||
let mut optimum = f64::INFINITY;
|
||||
let mut smallest = vec![true; n];
|
||||
for code in 0..1usize << n {
|
||||
let labels: Vec<_> = (0..n).map(|i| code & (1 << i) != 0).collect();
|
||||
let value = energy(&input, &labels);
|
||||
if value < optimum {
|
||||
optimum = value;
|
||||
smallest.clone_from(&labels);
|
||||
} else if value == optimum {
|
||||
for (a, b) in smallest.iter_mut().zip(labels) {
|
||||
*a &= b;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(g.maxflow_push_relabel(), optimum, "n={n}");
|
||||
let labels: Vec<_> = (0..n).map(|i| g.in_source(i)).collect();
|
||||
assert_eq!(energy(&input, &labels), optimum);
|
||||
assert_eq!(labels, smallest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_capacity_graphs_match_bk() {
|
||||
let mut rng = crate::segment::Rng::new(819);
|
||||
for n in [1, 2, 7, 16, 39, 64] {
|
||||
for case in 0..100 {
|
||||
let mut g = Graph::with_capacity(n, n * 4);
|
||||
for i in 0..n {
|
||||
g.add_tweights(i, (rng.next_u64() % 1001) as f32 / 97.0, (rng.next_u64() % 1001) as f32 / 97.0);
|
||||
for j in 0..i {
|
||||
if rng.next_u64().is_multiple_of(7) {
|
||||
g.add_edge(i, j, (rng.next_u64() % 1001) as f32 / 113.0, (rng.next_u64() % 1001) as f32 / 113.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
let input = g.clone();
|
||||
let mut old = g.clone();
|
||||
let a = g.maxflow_push_relabel();
|
||||
let b = old.maxflow_bk();
|
||||
assert!((a - b).abs() < 0.001, "n={n} case={case}: flow {a} vs {b}");
|
||||
let labels: Vec<_> = (0..n).map(|i| g.in_source(i)).collect();
|
||||
let expected: Vec<_> = (0..n).map(|i| old.in_source(i)).collect();
|
||||
assert_eq!(labels, expected, "n={n} case={case}: energy {} vs {}", energy(&input, &labels), energy(&input, &expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_return_survives_long_disconnected_chains() {
|
||||
let n = 5000;
|
||||
let mut g = Graph::with_capacity(n, n);
|
||||
g.add_tweights(0, 200.0, 0.0);
|
||||
g.add_tweights(n - 1, 0.0, 100.0);
|
||||
for i in 1..n - 1 {
|
||||
g.add_edge(i - 1, i, 100.0, 100.0);
|
||||
}
|
||||
assert_eq!(g.maxflow_push_relabel(), 0.0);
|
||||
for i in 0..n {
|
||||
assert_eq!(g.in_source(i), i != n - 1);
|
||||
}
|
||||
assert_eq!(g.maxflow_push_relabel(), 0.0);
|
||||
g.add_edge(n - 2, n - 1, 30.0, 30.0);
|
||||
assert_eq!(g.maxflow_push_relabel(), 30.0);
|
||||
assert_eq!(g.residual_out_of_source_side(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release solver comparison across sparse and dense terminal graphs"]
|
||||
fn solver_workload_release_comparison() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
for width in [32usize, 64, 128, 256] {
|
||||
let n = width * width;
|
||||
for kind in ["chain", "all-source", "dense-grid"] {
|
||||
let mut input = Graph::with_capacity(n, n * 4);
|
||||
if kind == "chain" {
|
||||
input.add_tweights(0, 100.0, 0.0);
|
||||
input.add_tweights(n - 1, 0.0, 100.0);
|
||||
for i in 1..n {
|
||||
input.add_edge(i - 1, i, 1.0, 1.0);
|
||||
}
|
||||
} else {
|
||||
for y in 0..width {
|
||||
for x in 0..width {
|
||||
let i = y * width + x;
|
||||
if kind == "all-source" {
|
||||
input.add_tweights(i, 1.0, 0.0);
|
||||
} else {
|
||||
input.add_tweights(i, if x < width / 4 { 3.0 } else { 0.0 }, 0.01 + (i % 17) as f32 * 0.001);
|
||||
}
|
||||
if x > 0 {
|
||||
input.add_edge(i, i - 1, 1.0, 1.0);
|
||||
}
|
||||
if y > 0 {
|
||||
input.add_edge(i, i - width, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
|
||||
let mut reference = input.clone();
|
||||
let flow = reference.maxflow_bk();
|
||||
for run in 0..3 {
|
||||
for fast in [run % 2 == 0, run % 2 != 0] {
|
||||
let mut g = input.clone();
|
||||
let start = Instant::now();
|
||||
let value = if fast { g.maxflow() } else { g.maxflow_bk() };
|
||||
black_box(value);
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
assert!((value - flow).abs() < 0.01);
|
||||
assert_eq!((0..n).map(|i| g.in_source(i)).collect::<Vec<_>>(), (0..n).map(|i| reference.in_source(i)).collect::<Vec<_>>());
|
||||
if fast { new_ms.push(ms) } else { old_ms.push(ms) }
|
||||
}
|
||||
}
|
||||
old_ms.sort_by(f64::total_cmp);
|
||||
new_ms.sort_by(f64::total_cmp);
|
||||
println!("SOLVER width={width} n={n} case={kind} old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2}", old_ms[1] / new_ms[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@
|
|||
//!
|
||||
//! Everything here is classical (no learned models) and implemented from the published papers:
|
||||
//!
|
||||
//! * [`maxflow`]: Boykov–Kolmogorov min-cut / max-flow (Boykov & Kolmogorov, "An Experimental
|
||||
//! * [`maxflow`]: hybrid push/relabel (Goldberg & Tarjan, JACM 1988) and Boykov–Kolmogorov
|
||||
//! min-cut / max-flow (Boykov & Kolmogorov, "An Experimental
|
||||
//! Comparison of Min-Cut/Max-Flow Algorithms for Energy Minimization in Vision", PAMI 2004).
|
||||
//! * [`gmm`]: Gaussian mixture colour models (k-means++ initialisation, Arthur & Vassilvitskii
|
||||
//! 2007; EM refinement).
|
||||
|
|
@ -232,60 +233,25 @@ pub const HARD_BG: u8 = 2;
|
|||
/// become graph nodes (links to fixed neighbours fold into terminal capacities). Returns
|
||||
/// `true` for foreground.
|
||||
pub fn grid_cut(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], gamma: f32, beta: f32) -> Vec<bool> {
|
||||
let (w, h) = (img.w as i32, img.h as i32);
|
||||
let n = img.w * img.h;
|
||||
let mut idx = vec![u32::MAX; n];
|
||||
let mut count = 0u32;
|
||||
for i in 0..n {
|
||||
if fixed[i] == FREE {
|
||||
idx[i] = count;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
let mut out: Vec<bool> = fixed.iter().map(|f| *f == HARD_FG).collect();
|
||||
if count == 0 {
|
||||
return out;
|
||||
}
|
||||
let mut g = Graph::with_capacity(count as usize, count as usize * 4);
|
||||
for i in 0..n {
|
||||
if idx[i] != u32::MAX {
|
||||
g.add_tweights(idx[i] as usize, cost_bg[i], cost_fg[i]);
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
let a = img.px[i];
|
||||
for (dx, dy, len) in NB {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || nx >= w || ny >= h {
|
||||
continue;
|
||||
}
|
||||
let j = (ny * w + nx) as usize;
|
||||
let wt = gamma * (-beta * d2(a, img.px[j])).exp() / len;
|
||||
match (idx[i] != u32::MAX, idx[j] != u32::MAX) {
|
||||
(true, true) => g.add_edge(idx[i] as usize, idx[j] as usize, wt, wt),
|
||||
// Neighbour fixed: disagreeing with it costs `wt`.
|
||||
(true, false) => add_fixed_link(&mut g, idx[i] as usize, fixed[j], wt),
|
||||
(false, true) => add_fixed_link(&mut g, idx[j] as usize, fixed[i], wt),
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g.maxflow();
|
||||
for i in 0..n {
|
||||
if idx[i] != u32::MAX {
|
||||
out[i] = g.in_source(idx[i] as usize);
|
||||
}
|
||||
}
|
||||
out
|
||||
grid_cut_weighted::<true>(img, cost_fg, cost_bg, fixed, |d2| gamma * (-beta * d2).exp())
|
||||
}
|
||||
|
||||
/// [`grid_cut`] with any edge weight: separating neighbours costs `weight(‖z_p−z_q‖²) / dist(p,q)`.
|
||||
/// Only links touching a free pixel need a weight; fixed/fixed links cannot change the cut.
|
||||
pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
|
||||
grid_cut_weighted::<true>(img, cost_fg, cost_bg, fixed, weight)
|
||||
}
|
||||
|
||||
mod edge_starts;
|
||||
|
||||
// The const parameter also runs the previous full scan in before/after workflow tests.
|
||||
fn grid_cut_weighted<const OPTIMIZED: bool>(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[u8], weight: impl Fn(f32) -> f32) -> Vec<bool> {
|
||||
let (w, h) = (img.w as i32, img.h as i32);
|
||||
let n = img.w * img.h;
|
||||
let Some(n) = img.w.checked_mul(img.h).filter(|&n| n < u32::MAX as usize - 2) else { return Vec::new() };
|
||||
if img.w > i32::MAX as usize || img.h > i32::MAX as usize || [img.px.len(), cost_fg.len(), cost_bg.len(), fixed.len()].iter().any(|&len| len < n) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut free = Vec::new();
|
||||
let mut idx = vec![u32::MAX; n];
|
||||
let mut count = 0u32;
|
||||
for i in 0..n {
|
||||
|
|
@ -304,27 +270,47 @@ pub fn grid_cut_with(img: &RgbImage, cost_fg: &[f32], cost_bg: &[f32], fixed: &[
|
|||
g.add_tweights(idx[i] as usize, cost_bg[i], cost_fg[i]);
|
||||
}
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let i = (y * w + x) as usize;
|
||||
let a = img.px[i];
|
||||
for (dx, dy, len) in NB {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || nx >= w || ny >= h {
|
||||
continue;
|
||||
}
|
||||
let j = (ny * w + nx) as usize;
|
||||
let wt = weight(d2(a, img.px[j])) / len;
|
||||
match (idx[i] != u32::MAX, idx[j] != u32::MAX) {
|
||||
(true, true) => g.add_edge(idx[i] as usize, idx[j] as usize, wt, wt),
|
||||
(true, false) => add_fixed_link(&mut g, idx[i] as usize, fixed[j], wt),
|
||||
(false, true) => add_fixed_link(&mut g, idx[j] as usize, fixed[i], wt),
|
||||
(false, false) => {}
|
||||
}
|
||||
let use_sparse = OPTIMIZED && (count as usize) < n / 8;
|
||||
if use_sparse {
|
||||
free.extend((0..n).filter(|&i| idx[i] != u32::MAX))
|
||||
}
|
||||
let mut visit = |i: usize, x: i32, y: i32| {
|
||||
let a = img.px[i];
|
||||
for (dx, dy, len) in NB {
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
if nx < 0 || nx >= w || ny >= h {
|
||||
continue;
|
||||
}
|
||||
let j = (ny as usize) * img.w + nx as usize;
|
||||
if OPTIMIZED && idx[i] == u32::MAX && idx[j] == u32::MAX {
|
||||
continue;
|
||||
}
|
||||
let wt = weight(d2(a, img.px[j])) / len;
|
||||
match (idx[i] != u32::MAX, idx[j] != u32::MAX) {
|
||||
(true, true) => g.add_edge(idx[i] as usize, idx[j] as usize, wt, wt),
|
||||
(true, false) => add_fixed_link(&mut g, idx[i] as usize, fixed[j], wt),
|
||||
(false, true) => add_fixed_link(&mut g, idx[j] as usize, fixed[i], wt),
|
||||
(false, false) => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
if use_sparse {
|
||||
for i in edge_starts::Starts::new(&free, img.w) {
|
||||
visit(i, (i % img.w) as i32, (i / img.w) as i32);
|
||||
}
|
||||
} else {
|
||||
// Keep the original dense raster traversal, including its arithmetic order.
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
visit((y as usize) * img.w + x as usize, x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.maxflow();
|
||||
if OPTIMIZED {
|
||||
g.maxflow();
|
||||
} else {
|
||||
g.maxflow_bk();
|
||||
}
|
||||
for i in 0..n {
|
||||
if idx[i] != u32::MAX {
|
||||
out[i] = g.in_source(idx[i] as usize);
|
||||
|
|
@ -686,3 +672,6 @@ pub fn subsample<T: Copy>(v: &[T], max: usize) -> Vec<T> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod workflow_tests;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ pub fn quick_select(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)],
|
|||
|
||||
/// [`quick_select`] with explicit model constants.
|
||||
pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option<Region> {
|
||||
quick_select_using::<true>(sampler, canvas, points, size, t)
|
||||
}
|
||||
|
||||
pub(super) fn quick_select_using<const OPTIMIZED: bool>(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f32)], size: f32, t: &Tuning) -> Option<Region> {
|
||||
if points.is_empty() || canvas.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -120,7 +124,7 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3
|
|||
// them is the best over the whole canvas too (outside the window it could only add cost).
|
||||
// One that reaches a side inside the canvas is recomputed in a larger window.
|
||||
let inner = [window.x0 > canvas.x0, window.y0 > canvas.y0, window.x1 < canvas.x1, window.y1 < canvas.y1];
|
||||
let low = select_working(&img, &seeds, rw, t);
|
||||
let low = select_working_using::<OPTIMIZED>(&img, &seeds, rw, t);
|
||||
let touches = |side: usize| match side {
|
||||
0 => (0..lh).any(|y| low[y * lw]),
|
||||
1 => (0..lw).any(|x| low[x]),
|
||||
|
|
@ -139,6 +143,10 @@ pub fn quick_select_with(sampler: &dyn Sampler, canvas: Rect, points: &[(f32, f3
|
|||
/// The model at working resolution: `seeds` (the brush footprint, `radius` working pixels) are
|
||||
/// selected; returns the selection.
|
||||
pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
|
||||
select_working_using::<true>(img, seeds, radius, t)
|
||||
}
|
||||
|
||||
fn select_working_using<const OPTIMIZED: bool>(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -> Vec<bool> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let d = crate::selection::edt(seeds, w, h);
|
||||
let grow = (t.grow + t.grow_frac * radius).max(0.0);
|
||||
|
|
@ -146,7 +154,7 @@ pub fn select_working(img: &RgbImage, seeds: &[bool], radius: f32, t: &Tuning) -
|
|||
let fixed: Vec<u8> = d.iter().map(|d| if *d <= grow { HARD_FG } else { FREE }).collect();
|
||||
let (floor, sigma, k) = (t.floor, t.sigma.max(1e-4), t.k);
|
||||
let weight = move |d2: f32| floor + 1.0 / (1.0 + (d2.sqrt() / sigma).powf(k));
|
||||
cut_levels(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024))
|
||||
cut_levels::<OPTIMIZED>(img, &fixed, &cost_fg, &weight, 1.0, t.direct_px.max(1024))
|
||||
}
|
||||
|
||||
/// Band half-width (pixels) re-cut at each finer level.
|
||||
|
|
@ -156,11 +164,11 @@ const BAND: f32 = 3.0;
|
|||
/// problem (colours averaged, pixel costs summed, cut weights doubled since a coarse side spans two
|
||||
/// fine ones) gives a boundary, and the full-size cut is redone only within `BAND` of it. Seeds
|
||||
/// stay foreground.
|
||||
fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec<bool> {
|
||||
fn cut_levels<const OPTIMIZED: bool>(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32) -> f32, scale: f32, direct_px: usize) -> Vec<bool> {
|
||||
let (w, h) = (img.w, img.h);
|
||||
let zeros = vec![0.0f32; w * h];
|
||||
if w * h <= direct_px || w < 8 || h < 8 {
|
||||
return super::grid_cut_with(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2));
|
||||
return super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, fixed, |d2| scale * weight(d2));
|
||||
}
|
||||
let (cw, ch) = (w.div_ceil(2), h.div_ceil(2));
|
||||
let coarse_img = img.downsample(2);
|
||||
|
|
@ -175,7 +183,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32
|
|||
}
|
||||
}
|
||||
}
|
||||
let coarse = cut_levels(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px);
|
||||
let coarse = cut_levels::<OPTIMIZED>(&coarse_img, &cfix, &ccost, weight, scale * 2.0, direct_px);
|
||||
let proj: Vec<bool> = (0..w * h).map(|i| coarse[(i / w / 2) * cw + (i % w) / 2]).collect();
|
||||
// Band: within BAND of the projected boundary.
|
||||
let edge: Vec<bool> = (0..w * h)
|
||||
|
|
@ -202,7 +210,7 @@ fn cut_levels(img: &RgbImage, fixed: &[u8], cost_fg: &[f32], weight: &dyn Fn(f32
|
|||
}
|
||||
})
|
||||
.collect();
|
||||
super::grid_cut_with(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2))
|
||||
super::grid_cut_weighted::<OPTIMIZED>(img, cost_fg, &zeros, &banded, |d2| scale * weight(d2))
|
||||
}
|
||||
|
||||
/// Pixels within `radius` (working pixels) of the polyline `pts`, plus each point's own pixel.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
use super::*;
|
||||
#[test]
|
||||
#[ignore = "release comparison of actual Quick Selection strokes on 24-36 MP documents"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
fn quick_selection_workflow_release_comparison() {
|
||||
use std::{hint::black_box, time::Instant};
|
||||
assert!(!cfg!(debug_assertions), "run with --release");
|
||||
for (w, h) in [(6000usize, 4000usize), (7200, 5000)] {
|
||||
for (name, size, points, ow, oh) in [
|
||||
("click", 30.0f32, vec![(0.0, 0.0)], 220.0f32, 220.0f32),
|
||||
("long-stroke", 256.0, vec![(-1000.0, 0.0), (1000.0, 0.0)], 2400.0, 1000.0),
|
||||
(
|
||||
"paint-object",
|
||||
180.0,
|
||||
vec![(-1000.0, -400.0), (1000.0, -400.0), (1000.0, 0.0), (-1000.0, 0.0), (-1000.0, 400.0), (1000.0, 400.0)],
|
||||
2400.0,
|
||||
1200.0,
|
||||
),
|
||||
] {
|
||||
let (cx, cy) = (w as f32 * 0.5, h as f32 * 0.5);
|
||||
let points: Vec<_> = points.iter().map(|&(x, y)| (x + cx, y + cy)).collect();
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let inside = (x as f32 - cx).abs() < ow * 0.5 && (y as f32 - cy).abs() < oh * 0.5;
|
||||
let noise = ((x * 13 + y * 7) % 31) as f32 / 620.0;
|
||||
if inside { [0.8 + noise, 0.1 + noise, 0.1] } else { [0.1, 0.1 + noise, 0.7 + noise] }
|
||||
});
|
||||
let sampler = ImageSampler { img: &img, origin: (0, 0) };
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let tune = quick::Tuning::default();
|
||||
let old = quick::quick_select_using::<false>(&sampler, canvas, &points, size, &tune).unwrap();
|
||||
let new = quick::quick_select_using::<true>(&sampler, canvas, &points, size, &tune).unwrap();
|
||||
assert_eq!(new.bbox, old.bbox);
|
||||
assert_eq!(new.mask, old.mask);
|
||||
let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new());
|
||||
for run in 0..3 {
|
||||
let mut measure = |fast: bool| {
|
||||
let start = Instant::now();
|
||||
let out = if fast {
|
||||
quick::quick_select_using::<true>(&sampler, canvas, black_box(&points), size, &tune)
|
||||
} else {
|
||||
quick::quick_select_using::<false>(&sampler, canvas, black_box(&points), size, &tune)
|
||||
}
|
||||
.unwrap();
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(out.bbox, old.bbox);
|
||||
assert_eq!(out.mask, old.mask);
|
||||
if fast { new_ms.push(ms) } else { old_ms.push(ms) }
|
||||
};
|
||||
measure(run % 2 == 0);
|
||||
measure(run % 2 != 0);
|
||||
}
|
||||
old_ms.sort_by(f64::total_cmp);
|
||||
new_ms.sort_by(f64::total_cmp);
|
||||
println!("QUICK {w}x{h} case={name} brush={size} old_ms={old_ms:?} new_ms={new_ms:?} speedup={:.2} bbox={:?}", old_ms[1] / new_ms[1], old.bbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_selection_preserves_textures_low_contrast_and_thin_features() {
|
||||
let (w, h) = (360usize, 280usize);
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
for kind in 0..6 {
|
||||
let img = RgbImage::from_fn(w, h, |x, y| {
|
||||
let inside = if kind == 2 {
|
||||
(x as f32 - 180.0).hypot(y as f32 - 140.0) < 72.0
|
||||
} else if kind == 3 {
|
||||
y > 130 && y < 145
|
||||
} else {
|
||||
x > 60 && x < 300 && y > 60 && y < 220
|
||||
};
|
||||
let t = ((x * 23 + y * 19) % 101) as f32 / 100.0;
|
||||
match kind {
|
||||
0 => {
|
||||
if inside {
|
||||
[0.6 + t * 0.1, 0.3, 0.2]
|
||||
} else {
|
||||
[0.4, 0.3 + t * 0.1, 0.2]
|
||||
}
|
||||
}
|
||||
1 => [0.4 + x as f32 / 1800.0, 0.3 + y as f32 / 1400.0, t * 0.02],
|
||||
4 => [t, ((x * 7 + y * 31) % 79) as f32 / 78.0, 0.4],
|
||||
5 => [0.4; 3],
|
||||
_ => {
|
||||
if inside {
|
||||
[0.8 + t * 0.1, 0.3, 0.2]
|
||||
} else {
|
||||
[0.2, 0.3 + t * 0.1, 0.8]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let sampler = ImageSampler { img: &img, origin: (0, 0) };
|
||||
for (points, brush) in [(vec![(180.0, 140.0)], 12.0), (vec![(90.0, 140.0), (270.0, 140.0)], 40.0), (vec![(1.0, 1.0), (160.0, 130.0)], 64.0)] {
|
||||
for direct_px in [10_000, 300_000] {
|
||||
let tune = quick::Tuning { direct_px, ..quick::Tuning::default() };
|
||||
let old = quick::quick_select_using::<false>(&sampler, canvas, &points, brush, &tune);
|
||||
let new = quick::quick_select_using::<true>(&sampler, canvas, &points, brush, &tune);
|
||||
match (old, new) {
|
||||
(Some(a), Some(b)) => {
|
||||
assert_eq!(a.bbox, b.bbox, "kind={kind}");
|
||||
assert_eq!(a.mask, b.mask, "kind={kind} brush={brush} direct={direct_px}");
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("different empty result kind={kind}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release comparison through editor raster surfaces on 24-36 MP documents"]
|
||||
#[allow(clippy::assertions_on_constants)]
|
||||
fn quick_selection_surface_release_comparison() {
|
||||
use photocraft_color::PixelFormat;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
assert!(!cfg!(debug_assertions), "run with --release");
|
||||
for (w, h) in [(6000usize, 4000usize), (7200, 5000)] {
|
||||
for format in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
|
||||
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
|
||||
let canvas = Rect::new(0, 0, w as i32, h as i32);
|
||||
let mut surface = Surface::new(format);
|
||||
// Fill the document in bounded row strips, outside the measured strokes.
|
||||
for y0 in (0..h).step_by(256) {
|
||||
let y1 = (y0 + 256).min(h);
|
||||
let data: Vec<_> = (y0..y1)
|
||||
.flat_map(|y| {
|
||||
(0..w).flat_map(move |x| {
|
||||
let noise = ((x * 13 + y * 7) % 31) as f32 / 620.0;
|
||||
if (x as f32 - cx).abs() < 1200.0 && (y as f32 - cy).abs() < 600.0 {
|
||||
[0.8 + noise, 0.1 + noise, 0.1, 1.0]
|
||||
} else {
|
||||
[0.1, 0.1 + noise, 0.7 + noise, 1.0]
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
surface.write_region(Rect::new(0, y0 as i32, w as i32, y1 as i32), &data);
|
||||
}
|
||||
let sampler = SurfaceSampler(&surface);
|
||||
let points = [
|
||||
(cx - 1000.0, cy - 400.0),
|
||||
(cx + 1000.0, cy - 400.0),
|
||||
(cx + 1000.0, cy),
|
||||
(cx - 1000.0, cy),
|
||||
(cx - 1000.0, cy + 400.0),
|
||||
(cx + 1000.0, cy + 400.0),
|
||||
];
|
||||
let tune = quick::Tuning::default();
|
||||
let old = quick::quick_select_using::<false>(&sampler, canvas, &points, 180.0, &tune).unwrap();
|
||||
let new = quick::quick_select_using::<true>(&sampler, canvas, &points, 180.0, &tune).unwrap();
|
||||
assert_eq!(new.bbox, old.bbox);
|
||||
assert_eq!(new.mask, old.mask);
|
||||
let (mut before, mut after) = (Vec::new(), Vec::new());
|
||||
for run in 0..5 {
|
||||
for fast in [run % 2 == 0, run % 2 != 0] {
|
||||
let start = Instant::now();
|
||||
let out = if fast {
|
||||
quick::quick_select_using::<true>(&sampler, canvas, black_box(&points), 180.0, &tune)
|
||||
} else {
|
||||
quick::quick_select_using::<false>(&sampler, canvas, black_box(&points), 180.0, &tune)
|
||||
}
|
||||
.unwrap();
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
assert_eq!(out.bbox, old.bbox);
|
||||
assert_eq!(out.mask, old.mask);
|
||||
if fast { after.push(ms) } else { before.push(ms) }
|
||||
}
|
||||
}
|
||||
before.sort_by(f64::total_cmp);
|
||||
after.sort_by(f64::total_cmp);
|
||||
println!("SURFACE {w}x{h} format={format:?} old_ms={before:?} new_ms={after:?} speedup={:.2}", before[2] / after[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -728,11 +728,20 @@ pub fn contract(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
|||
expand(&inv, w, h, r).into_iter().map(|v| 1.0 - v).collect()
|
||||
}
|
||||
|
||||
/// A band of width `r` straddling the selection edge.
|
||||
/// Select › Modify › Border: a soft band `2r` wide centred on the selection edge, as in
|
||||
/// Photoshop. A pixel `D` away (centre to centre) from the nearest pixel on the other side of the
|
||||
/// edge is selected `1 − (D − 1) / r`: fully at the edge, fading to nothing `r` pixels out on
|
||||
/// either side (measured on Photoshop 27.11, Border 4: 63, 127, 191, 255 | 255, 191, 127, 63
|
||||
/// across a straight edge, the same along the inside of a corner). It used to be a hard band
|
||||
/// `r` wide.
|
||||
pub fn border(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
let outer = expand(m, w, h, r / 2.0);
|
||||
let inner = contract(m, w, h, r / 2.0);
|
||||
outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect()
|
||||
let inside: Vec<bool> = m.iter().map(|v| *v >= 0.5).collect();
|
||||
let outside: Vec<bool> = inside.iter().map(|v| !v).collect();
|
||||
let (to_inside, to_outside) = (edt(&inside, w, h), edt(&outside, w, h));
|
||||
if r <= 0.0 || to_inside.len() != m.len() || to_outside.len() != m.len() {
|
||||
return vec![0.0; m.len()];
|
||||
}
|
||||
inside.iter().zip(to_inside.iter().zip(&to_outside)).map(|(&sel, (&di, &dout))| (1.0 - ((if sel { dout } else { di }) - 1.0) / r).clamp(0.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// Smooth: removes specks and rounds corners (box average of radius `round(r)`, edges
|
||||
|
|
@ -741,7 +750,7 @@ pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
|||
crate::selection_blur::smooth(m, w, h, r)
|
||||
}
|
||||
|
||||
/// Feather: Gaussian blur of the mask with sigma = radius / 2 (kernel truncated at 3σ, zero
|
||||
/// Feather: Gaussian blur of the mask with sigma = radius, as in Photoshop (kernel truncated at 3σ, zero
|
||||
/// beyond the canvas). Parallel, confined to the selection's bounds, and independent of the
|
||||
/// radius (#211).
|
||||
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
|
|
@ -1098,6 +1107,22 @@ mod tests {
|
|||
assert!(b[10 * 20 + 5] > 0.5 && b[10 * 20 + 4] > 0.5);
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: a 32×32 rectangle at (16,16) in 64×64, Select › Modify ›
|
||||
/// Border 4 px, filled white. Row y = 32 for x = 8..=24, and the diagonal (16,16)..(24,24).
|
||||
#[test]
|
||||
fn border_matches_photoshop() {
|
||||
let (w, h) = (64, 64);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if (16..48).contains(&(i % w)) && (16..48).contains(&(i / w)) { 1.0 } else { 0.0 }).collect();
|
||||
let b = border(&m, w, h, 4.0);
|
||||
let v = |x: usize, y: usize| (b[y * w + x] * 255.0).round() as i32;
|
||||
let row: Vec<i32> = (8..=24).map(|x| v(x, 32)).collect();
|
||||
let diag: Vec<i32> = (16..=24).map(|d| v(d, d)).collect();
|
||||
let near = |got: &[i32], want: &[i32]| got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1);
|
||||
let want_row = [0, 0, 0, 0, 63, 127, 191, 255, 255, 191, 127, 63, 0, 0, 0, 0, 0];
|
||||
assert!(near(&row, &want_row), "{row:?}");
|
||||
assert!(near(&diag, &[255, 191, 127, 63, 0, 0, 0, 0, 0]), "{diag:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_removes_specks_and_feather_softens() {
|
||||
let mut m = square(20, 20);
|
||||
|
|
@ -1105,7 +1130,8 @@ mod tests {
|
|||
let s = smooth(&m, 20, 20, 1.0);
|
||||
assert_eq!(s[2 * 20 + 2], 0.0);
|
||||
assert_eq!(s[10 * 20 + 10], 1.0);
|
||||
let f = feather(&square(20, 20), 20, 20, 4.0);
|
||||
// Radius 2 (σ 2) keeps the blur inside the 20×20 canvas, so no coverage is lost.
|
||||
let f = feather(&square(20, 20), 20, 20, 2.0);
|
||||
let edge = f[10 * 20 + 5];
|
||||
assert!(edge > 0.3 && edge < 0.8, "{edge}");
|
||||
let sum: f32 = f.iter().sum();
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ fn inconsistent_and_empty_dimensions_do_not_panic() {
|
|||
|
||||
#[test]
|
||||
fn selection_modifiers_keep_fractional_coverage() {
|
||||
use crate::selection::{border, contract, expand};
|
||||
// Border is no longer expand − contract (it is Photoshop's soft band, checked against
|
||||
// measurements in `selection::tests::border_matches_photoshop`), so only these two remain.
|
||||
use crate::selection::{contract, expand};
|
||||
let (w, h) = (47, 39);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if (8..31).contains(&(i % w)) && (5..27).contains(&(i / w)) { 0.8 } else { 0.2 }).collect();
|
||||
let reference_expand = |m: &[f32], r: f32| {
|
||||
|
|
@ -92,9 +94,6 @@ fn selection_modifiers_keep_fractional_coverage() {
|
|||
let inverse: Vec<_> = m.iter().map(|v| 1.0 - v).collect();
|
||||
let contracted: Vec<_> = reference_expand(&inverse, r).iter().map(|v| 1.0 - v).collect();
|
||||
assert_eq!(contract(&m, w, h, r), contracted);
|
||||
let outer = reference_expand(&m, r / 2.0);
|
||||
let inner: Vec<_> = reference_expand(&inverse, r / 2.0).iter().map(|v| 1.0 - v).collect();
|
||||
assert_eq!(border(&m, w, h, r), outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
//!
|
||||
//! * **Smooth** is a box average with edge-replicated borders: a running sum per line, `O(1)` per
|
||||
//! pixel whatever the radius.
|
||||
//! * **Feather** is a Gaussian (`σ = radius / 2`, kernel truncated at `⌈3σ⌉` and normalized, zero
|
||||
//! * **Feather** is a Gaussian (`σ = radius`, as Photoshop's, kernel truncated at `⌈3σ⌉` and normalized, zero
|
||||
//! outside the canvas). Small kernels are convolved directly. Wide ones are written as a short
|
||||
//! cosine series over the kernel window, `k(j) ≈ a₀ + Σ aₖ cos(ωₖ j)`, and every term is a
|
||||
//! sliding windowed sum `Σ m[x+j]·e^{iωₖj}` updated in `O(1)` per pixel, so the cost doesn't grow
|
||||
|
|
@ -155,10 +155,13 @@ fn box_line(src: &[f32], dst: &mut [f32], r: usize) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then the
|
||||
/// contrast curve `(v − ½)·4 + ½` that re-hardens the edge and drops specks.
|
||||
/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then a hard
|
||||
/// threshold at ½, as in Photoshop: straight edges stay exactly where they were, corners round
|
||||
/// off and specks drop (measured on Photoshop 27.11, Smooth 8 on a 32 px square: every sampled
|
||||
/// pixel 0 or 255, corner cut where the 17×17 average crosses ½). The contrast curve
|
||||
/// `(v − ½)·4 + ½` used before left grey, half-selected pixels along every edge.
|
||||
pub(crate) fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
let curve = |v: f32| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0);
|
||||
let curve = |v: f32| if v >= 0.5 { 1.0 } else { 0.0 };
|
||||
let n = w.saturating_mul(h);
|
||||
if m.len() != n || n == 0 {
|
||||
return m.iter().map(|v| curve(*v)).collect();
|
||||
|
|
@ -539,12 +542,15 @@ impl CosKernel {
|
|||
}
|
||||
}
|
||||
|
||||
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius / 2`, the kernel
|
||||
/// truncated at `⌈3σ⌉` and normalized, zero beyond the canvas.
|
||||
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius`, the kernel truncated
|
||||
/// at `⌈3σ⌉` and normalized, zero beyond the canvas. Photoshop's feather radius is the Gaussian's
|
||||
/// σ (measured on Photoshop 27.11; the same mapping the mask feather uses, fitted on the
|
||||
/// psd-tools corpus in `photocraft_compose::masks::feather_sigma`). It was `radius / 2`: half
|
||||
/// as wide as Photoshop's.
|
||||
pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let n = w.saturating_mul(h);
|
||||
let sigma = if radius.is_finite() {
|
||||
radius.max(0.0) / 2.0
|
||||
radius.max(0.0)
|
||||
} else if radius > 0.0 {
|
||||
MAX_SIGMA
|
||||
} else {
|
||||
|
|
@ -614,11 +620,11 @@ mod tests {
|
|||
}
|
||||
|
||||
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
|
||||
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
|
||||
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| if v >= 0.5 { 1.0 } else { 0.0 }).collect()
|
||||
}
|
||||
|
||||
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
|
||||
let sigma = radius.max(0.0) / 2.0;
|
||||
let sigma = radius.max(0.0);
|
||||
if sigma < 0.1 {
|
||||
return m.to_vec();
|
||||
}
|
||||
|
|
@ -680,6 +686,27 @@ mod tests {
|
|||
vec![("binary", binary), ("soft", soft), ("blob", blob), ("sparse", sparse), ("rect", rect)]
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: a 32×32 rectangle at (16,16) in 64×64, Select › Modify ›
|
||||
/// Feather 4 px, filled white. Row y = 32 for x = 8..=24, then (16,16), (18,18), (20,20).
|
||||
#[test]
|
||||
fn feather_matches_photoshop() {
|
||||
const ROW: [u8; 17] = [7, 13, 22, 34, 50, 70, 92, 115, 140, 163, 185, 205, 221, 233, 242, 248, 252];
|
||||
const DIAG: [(usize, u8); 3] = [(16, 76), (18, 134), (20, 190)];
|
||||
let (w, h) = (64, 64);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if (16..48).contains(&(i % w)) && (16..48).contains(&(i / w)) { 1.0 } else { 0.0 }).collect();
|
||||
let f = feather(&m, w, h, 4.0);
|
||||
let v = |x: usize, y: usize| (f[y * w + x] * 255.0).round() as i32;
|
||||
for (i, want) in ROW.iter().enumerate() {
|
||||
let x = 8 + i;
|
||||
assert!((v(x, 32) - i32::from(*want)).abs() <= 3, "x {x}: {} vs Photoshop {want}", v(x, 32));
|
||||
}
|
||||
// Photoshop's corners come out a few levels darker than a true 2-D Gaussian's; the row
|
||||
// is the defining measurement.
|
||||
for (d, want) in DIAG {
|
||||
assert!((v(d, d) - i32::from(want)).abs() <= 4, "({d},{d}): {} vs Photoshop {want}", v(d, d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feather_matches_direct_gaussian() {
|
||||
for (w, h, seed) in [(97, 61, 1), (40, 150, 2), (13, 7, 3)] {
|
||||
|
|
@ -692,13 +719,32 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: a 32×32 rectangle at (16,16) in 64×64, Select › Modify ›
|
||||
/// Smooth 8 px, filled white. The straight edge stays hard at x = 16; only the corner rounds.
|
||||
#[test]
|
||||
fn smooth_matches_photoshop() {
|
||||
let (w, h) = (64, 64);
|
||||
let m: Vec<f32> = (0..w * h).map(|i| if (16..48).contains(&(i % w)) && (16..48).contains(&(i / w)) { 1.0 } else { 0.0 }).collect();
|
||||
let s = smooth(&m, w, h, 8.0);
|
||||
let row: Vec<f32> = (8..=24).map(|x| s[32 * w + x]).collect();
|
||||
let want_row: Vec<f32> = (8..=24).map(|x| if x >= 16 { 1.0 } else { 0.0 }).collect();
|
||||
assert_eq!(row, want_row);
|
||||
let diag: Vec<f32> = (16..=24).map(|d| s[d * w + d]).collect();
|
||||
assert_eq!(diag, [0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_matches_direct_box() {
|
||||
for (w, h, seed) in [(97, 61, 4), (40, 150, 5), (13, 7, 6)] {
|
||||
for (kind, m) in masks(w, h, seed) {
|
||||
for radius in [0.0, 0.4, 1.0, 2.0, 5.0, 16.0, 50.0, 500.0] {
|
||||
let d = max_diff(&smooth(&m, w, h, radius), &oracle::smooth(&m, w, h, radius));
|
||||
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
|
||||
// The threshold is hard, so a pixel may only differ where the box average sits
|
||||
// within float rounding of ½ (the running sum adds in another order).
|
||||
let (fast, slow) = (smooth(&m, w, h, radius), oracle::smooth(&m, w, h, radius));
|
||||
let avg = oracle::box_blur(&m, w, h, radius.max(0.0).round() as usize);
|
||||
for (i, (a, b)) in fast.iter().zip(&slow).enumerate() {
|
||||
assert!(a == b || (avg[i] - 0.5).abs() < 1e-4, "{kind} {w}x{h} r {radius} pixel {i}: {a} vs {b} (average {})", avg[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
use super::*;
|
||||
use photocraft_color::{ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_raster::{Interrupt, Surface};
|
||||
|
||||
#[path = "tests_iris_reference.rs"]
|
||||
mod reference;
|
||||
|
||||
fn image(rect: Rect, ch: usize) -> Image {
|
||||
let data = (rect.y0..rect.y1)
|
||||
.flat_map(|y| {
|
||||
(rect.x0..rect.x1).flat_map(move |x| {
|
||||
(0..ch).map(move |c| match c {
|
||||
0 => ((x * 17 + y * 31).rem_euclid(113)) as f32 / 37.0 - 0.5,
|
||||
1 => {
|
||||
if (x + y).rem_euclid(3) == 0 {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
_ => ((x ^ y).unsigned_abs() as usize + c) as f32 % 23.0 / 22.0,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Image { rect, ch, data }
|
||||
}
|
||||
|
||||
fn check(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) {
|
||||
let want = reference::iris(src, out, ctx, pins);
|
||||
let got = iris(src, out, ctx, pins);
|
||||
assert_eq!(got.len(), want.len());
|
||||
for (i, (&got, &want)) in got.iter().zip(&want).enumerate() {
|
||||
assert!(got.to_bits() == want.to_bits() || (got.is_nan() && want.is_nan()), "sample {i}: {got} != {want}, delta {}", (got - want).abs());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_matches_frozen_kernel_at_transitions_and_edges() {
|
||||
let bounds = Rect::new(-19, 7, 54, 66);
|
||||
let mut cases = vec![vec![], vec![IrisPin::default()]];
|
||||
for blur in [0.0, 0.49, 0.5, 1.0, 15.0, 80.0, 500.0] {
|
||||
for roundness in [0.0, 0.01, 37.0, 100.0] {
|
||||
cases.push(vec![IrisPin { x: 0.18, y: 0.91, radius_x: 0.45, radius_y: 0.13, angle: 71.0, feather: 0.998, roundness, blur }]);
|
||||
}
|
||||
}
|
||||
cases.push(vec![
|
||||
IrisPin { x: 0.2, blur: 30.0, angle: -43.0, ..IrisPin::default() },
|
||||
IrisPin { x: 0.7, y: 0.1, blur: 11.0, roundness: 80.0, ..IrisPin::default() },
|
||||
]);
|
||||
cases.push(vec![IrisPin { radius_x: 8.0, radius_y: 9.0, ..IrisPin::default() }]);
|
||||
cases.push(vec![IrisPin { x: -5.0, ..IrisPin::default() }]);
|
||||
for ch in 1..=6 {
|
||||
let src = image(bounds.inflate(12), ch);
|
||||
for alpha in [false, true] {
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha };
|
||||
for pins in &cases {
|
||||
check(&src, bounds, &ctx, pins);
|
||||
check(&src, Rect::new(-7, 17, 15, 39), &ctx, pins);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_preserves_degenerate_and_nonfinite_pin_behavior() {
|
||||
for bounds in [Rect::EMPTY, Rect::new(1, -1, 2, 0), Rect::new(-5, 3, 14, 4)] {
|
||||
let src = image(bounds, 4);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
for value in [0.0, -1.0, f32::MIN, f32::MAX, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
for field in 0..9 {
|
||||
let mut p = IrisPin::default();
|
||||
match field {
|
||||
0 => p.x = value,
|
||||
1 => p.y = value,
|
||||
2 => p.radius_x = value,
|
||||
3 => p.radius_y = value,
|
||||
4 => p.angle = value,
|
||||
5 => p.feather = value,
|
||||
6 => p.blur = value,
|
||||
7 => p.roundness = value,
|
||||
_ => {
|
||||
p.radius_x = value;
|
||||
p.radius_y = value;
|
||||
}
|
||||
}
|
||||
check(&src, bounds, &ctx, &[p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_keeps_running_sum_order_for_extreme_hdr_and_signed_zero() {
|
||||
let bounds = Rect::new(-13, -5, 24, 28);
|
||||
let mut src = image(bounds.inflate(15), 4);
|
||||
let values = [0.0, -0.0, 1e30, -1e30, 1e-30, -1e-30, 0.8];
|
||||
for (i, px) in src.data.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
px[0] = values[i % values.len()];
|
||||
px[1] = values[(i + 3) % values.len()];
|
||||
px[3] = [0.0, 1e-8, 0.5, 1.0][i % 4];
|
||||
}
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
for blur in [0.5, 15.0, 80.0, 300.0] {
|
||||
let pins = [IrisPin { angle: 37.0, roundness: 55.0, blur, ..IrisPin::default() }];
|
||||
check(&src, bounds, &ctx, &pins);
|
||||
check(&src, Rect::new(0, 0, 10, 17), &ctx, &pins);
|
||||
}
|
||||
for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
let mut invalid_samples = src.clone();
|
||||
invalid_samples.data[19] = value;
|
||||
invalid_samples.data[47] = value;
|
||||
check(&invalid_samples, bounds, &ctx, &[IrisPin::default()]);
|
||||
}
|
||||
let pins = vec![IrisPin::default(); 4096];
|
||||
check(&src, Rect::new(0, 0, 1, 1), &ctx, &pins);
|
||||
let malformed = Image { rect: bounds, ch: 4, data: vec![] };
|
||||
assert!(iris(&malformed, bounds, &ctx, &[IrisPin::default()]).is_empty());
|
||||
let no_channels = Image { rect: bounds, ch: 0, data: vec![] };
|
||||
assert!(iris(&no_channels, bounds, &ctx, &[IrisPin::default()]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_cancellation_after_progress_preserves_source() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
let bounds = Rect::new(0, 0, 512, 256);
|
||||
let mut source = Surface::new(PixelFormat::RGBA8);
|
||||
source.write_region(bounds, &image(bounds, 4).data);
|
||||
let original = source.clone();
|
||||
let cancelled = AtomicBool::new(false);
|
||||
let cancel = || cancelled.load(Ordering::Relaxed);
|
||||
let progress = |p| {
|
||||
if p > 0.0 {
|
||||
cancelled.store(true, Ordering::Relaxed);
|
||||
}
|
||||
};
|
||||
let ctl = Interrupt::new(&cancel, &progress);
|
||||
let params = crate::FilterParams::IrisBlur { pins: vec![IrisPin::default()] };
|
||||
assert!(crate::apply_in_with(&source, ¶ms, bounds, bounds, None, bounds, &ctl).is_none());
|
||||
assert!(cancelled.load(Ordering::Relaxed));
|
||||
assert_eq!(source, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iris_depths_models_selection_tiles_and_cancellation() {
|
||||
let bounds = Rect::new(-17, 11, 96, 84);
|
||||
let area = Rect::new(-9, 17, 79, 77);
|
||||
let pins = vec![IrisPin { angle: 29.0, roundness: 35.0, blur: 17.0, ..IrisPin::default() }];
|
||||
let params = crate::FilterParams::IrisBlur { pins: pins.clone() };
|
||||
let mut selection = Surface::new(PixelFormat::GRAY8);
|
||||
selection.fill_rect(bounds, &[0.4]);
|
||||
selection.fill_rect(Rect::new(3, 19, 35, 44), &[1.0]);
|
||||
for mode in [ColorMode::Rgb, ColorMode::Grayscale, ColorMode::Cmyk, ColorMode::Lab] {
|
||||
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
|
||||
for alpha in [false, true] {
|
||||
let fmt = PixelFormat::new(mode, sample, alpha);
|
||||
let mut surface = Surface::new(fmt);
|
||||
surface.write_region(bounds, &image(bounds, fmt.channels()).data);
|
||||
let ctl = Interrupt::cancel_only(&|| true);
|
||||
assert!(crate::apply_in_with(&surface, ¶ms, area, bounds, Some(&selection), bounds, &ctl).is_none());
|
||||
let original = surface.clone();
|
||||
let ctx = Ctx { bounds, mode, alpha };
|
||||
let radius = reach(17.0).ceil() as i32 + 1;
|
||||
assert_eq!(params.halo_for(bounds), crate::Halo::Radius(radius));
|
||||
for tile in [16, 47, 128] {
|
||||
let got = crate::apply_tiled(&surface, ¶ms, area, bounds, Some(&selection), tile, Some(bounds));
|
||||
let mut want = surface.clone();
|
||||
for y in (area.y0..area.y1).step_by(tile as usize) {
|
||||
for x in (area.x0..area.x1).step_by(tile as usize) {
|
||||
let t = Rect::new(x, y, (x + tile).min(area.x1), (y + tile).min(area.y1));
|
||||
let src = Image::read_clamped(&surface, t.inflate(radius), bounds);
|
||||
let mut data = reference::iris(&src, t, &ctx, &pins);
|
||||
crate::mix_selection(&mut data, t, &selection, &src);
|
||||
want.write_region(t, &data);
|
||||
}
|
||||
}
|
||||
assert_eq!(got.read_region(bounds), want.read_region(bounds), "{fmt:?}, tile {tile}");
|
||||
}
|
||||
assert_eq!(surface, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "release profiling; run alone with --nocapture"]
|
||||
fn iris_profile_baseline_stages() {
|
||||
for blur in [15.0, 80.0, 300.0] {
|
||||
let out = Rect::new(1500, 1000, 2012, 1512);
|
||||
let bounds = Rect::new(0, 0, 6000, 4000);
|
||||
let src = image(out.inflate(reach(blur).ceil() as i32), 4);
|
||||
let ctx = Ctx { bounds, mode: ColorMode::Rgb, alpha: true };
|
||||
let pin = IrisPin { blur, angle: 27.0, roundness: 60.0, ..IrisPin::default() };
|
||||
let start = std::time::Instant::now();
|
||||
let sig: Vec<f32> = (0..out.width() as usize * out.height() as usize)
|
||||
.map(|i| {
|
||||
let (x, y) = xy(out, i);
|
||||
let (e, _, _) = pin_distance(x as f32 + 0.5, y as f32 + 0.5, bounds, pin.x, pin.y, pin.radius_x, pin.radius_y, pin.angle, 5.6);
|
||||
sigma_of(blur) * smoothstep(pin.feather, 1.0, e)
|
||||
})
|
||||
.collect();
|
||||
println!("blur {blur}: geometry {:?}", start.elapsed());
|
||||
std::hint::black_box(sig);
|
||||
let start = std::time::Instant::now();
|
||||
let p = premul_window(&src, src.rect, ctx.alpha);
|
||||
println!("blur {blur}: premul {:?}", start.elapsed());
|
||||
for k in 0..LEVELS {
|
||||
let mut buf = p.clone();
|
||||
let sigma = sigma_of(blur) / 2f32.powi((LEVELS - 1 - k) as i32);
|
||||
let start = std::time::Instant::now();
|
||||
gauss_blur_n(&mut buf, src.rect.width() as usize, src.rect.height() as usize, src.ch, sigma);
|
||||
println!("blur {blur}: level {sigma} {:?}", start.elapsed());
|
||||
std::hint::black_box(buf);
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
std::hint::black_box(reference::iris(&src, out, &ctx, std::slice::from_ref(&pin)));
|
||||
println!("blur {blur}: reference whole kernel {:?}", start.elapsed());
|
||||
let start = std::time::Instant::now();
|
||||
std::hint::black_box(iris(&src, out, &ctx, &[pin]));
|
||||
println!("blur {blur}: candidate whole kernel {:?}", start.elapsed());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
//! Frozen Iris Blur kernel from base 7722172585a01cbdb93c06f0f5ff2634fcb17999.
|
||||
use super::*;
|
||||
fn sigma_of(blur: f32) -> f32 {
|
||||
blur.clamp(0.0, 500.0) * 0.6
|
||||
}
|
||||
|
||||
const LEVELS: usize = 6;
|
||||
|
||||
/// Blurs `src` over `out` with a per-pixel σ (`sigma(x, y)`, ≤ `smax`).
|
||||
fn variable_blur(src: &Image, out: Rect, ctx: &Ctx, smax: f32, sigma: impl Fn(f32, f32) -> f32) -> Vec<f32> {
|
||||
let n = src.ch;
|
||||
if smax < 0.3 {
|
||||
return src.crop(out);
|
||||
}
|
||||
let win = src.rect;
|
||||
let (ww, wh) = (win.width() as usize, win.height() as usize);
|
||||
let (ow, oh) = (out.width() as usize, out.height() as usize);
|
||||
// Level σs: 0, then smax / 2^(LEVELS-1) … smax.
|
||||
let lv: Vec<f32> = std::iter::once(0.0).chain((0..LEVELS).map(|k| smax / 2f32.powi((LEVELS - 1 - k) as i32))).collect();
|
||||
let sig: Vec<f32> = (0..ow * oh)
|
||||
.map(|i| {
|
||||
let (x, y) = xy(out, i);
|
||||
sigma(x as f32 + 0.5, y as f32 + 0.5).clamp(0.0, smax)
|
||||
})
|
||||
.collect();
|
||||
// Hat-function weight of level k for σ s.
|
||||
let weight = |k: usize, s: f32| -> f32 {
|
||||
let c = lv[k];
|
||||
if k > 0 && s >= lv[k - 1] && s <= c {
|
||||
(s - lv[k - 1]) / (c - lv[k - 1]).max(1e-6)
|
||||
} else if k + 1 < lv.len() && s >= c && s <= lv[k + 1] {
|
||||
(lv[k + 1] - s) / (lv[k + 1] - c).max(1e-6)
|
||||
} else if k + 1 == lv.len() && s >= c {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
let p = premul_window(src, win, ctx.alpha);
|
||||
let mut acc = vec![0.0f32; ow * oh * n];
|
||||
let mut buf = Vec::new();
|
||||
for (k, &lvk) in lv.iter().enumerate() {
|
||||
if !sig.iter().any(|&s| weight(k, s) > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let level: &[f32] = if k == 0 {
|
||||
&p
|
||||
} else {
|
||||
buf.clear();
|
||||
buf.extend_from_slice(&p);
|
||||
gauss_blur_n(&mut buf, ww, wh, n, lvk);
|
||||
&buf
|
||||
};
|
||||
for (i, &s) in sig.iter().enumerate() {
|
||||
let wk = weight(k, s);
|
||||
if wk <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = xy(out, i);
|
||||
let o = (((y - win.y0) as usize) * ww + (x - win.x0) as usize) * n;
|
||||
for c in 0..n {
|
||||
acc[i * n + c] += level[o + c] * wk;
|
||||
}
|
||||
}
|
||||
}
|
||||
for px in acc.chunks_exact_mut(n) {
|
||||
unpremul_px(px, ctx.alpha);
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
fn short_side(b: Rect) -> f32 {
|
||||
(b.width().min(b.height()) as f32).max(1.0)
|
||||
}
|
||||
|
||||
/// Normalized (super)elliptical distance of a point from an iris/spin pin.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pin_distance(x: f32, y: f32, b: Rect, px: f32, py: f32, rx: f32, ry: f32, angle: f32, m: f32) -> (f32, f32, f32) {
|
||||
let ss = short_side(b);
|
||||
let (cx, cy) = (b.x0 as f32 + px * b.width() as f32, b.y0 as f32 + py * b.height() as f32);
|
||||
let angle_deg = if angle.is_finite() { angle } else { 0.0 };
|
||||
let (s, c) = angle_deg.to_radians().sin_cos();
|
||||
let (dx, dy) = (x - cx, y - cy);
|
||||
let (u, v) = (dx * c + dy * s, -dx * s + dy * c);
|
||||
let (u, v) = (u / (rx.max(1e-3) * ss), v / (ry.max(1e-3) * ss));
|
||||
let e = if (m - 2.0).abs() < 1e-3 { (u * u + v * v).sqrt() } else { (u.abs().powf(m) + v.abs().powf(m)).powf(1.0 / m) };
|
||||
if !e.is_finite() { (f32::INFINITY, 0.0, 0.0) } else { (e, u, v) }
|
||||
}
|
||||
|
||||
/// Iris Blur: everything outside each pin's ellipse is blurred; the sharp
|
||||
/// core is `feather` of the ellipse. Sharpness from any pin wins.
|
||||
pub(crate) fn iris(src: &Image, out: Rect, ctx: &Ctx, pins: &[IrisPin]) -> Vec<f32> {
|
||||
let b = ctx.bounds;
|
||||
let smax = pins.iter().map(|p| sigma_of(p.blur)).fold(0.0, f32::max);
|
||||
if pins.is_empty() {
|
||||
return src.crop(out);
|
||||
}
|
||||
variable_blur(src, out, ctx, smax, |x, y| {
|
||||
pins.iter()
|
||||
.map(|p| {
|
||||
let m = 2.0 + p.roundness.clamp(0.0, 100.0) / 100.0 * 6.0;
|
||||
let (e, _, _) = pin_distance(x, y, b, p.x, p.y, p.radius_x, p.radius_y, p.angle, m);
|
||||
sigma_of(p.blur) * smoothstep(p.feather.clamp(0.0, 0.999), 1.0, e)
|
||||
})
|
||||
.fold(f32::MAX, f32::min)
|
||||
})
|
||||
}
|
||||
|
|
@ -126,19 +126,19 @@ fn catmull_rom(t: f64) -> [f64; 4] {
|
|||
/// Warp `src` (its content inside `src_rect`) by `h` (source → destination document space).
|
||||
/// The result has the same format as `src`, with alpha added if `src` had none; pixels outside
|
||||
/// the warped quad are transparent.
|
||||
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Surface {
|
||||
pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Interp) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
let mut fmt = src.format();
|
||||
let converted;
|
||||
let mut src = if fmt.alpha {
|
||||
src
|
||||
} else {
|
||||
fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
converted = src.convert(fmt);
|
||||
converted = src.try_convert(fmt)?;
|
||||
&converted
|
||||
};
|
||||
let mut out = Surface::new(fmt);
|
||||
if src_rect.is_empty() {
|
||||
return out;
|
||||
return Ok(out);
|
||||
}
|
||||
let mut h = *h;
|
||||
let mut src_rect = src_rect;
|
||||
|
|
@ -153,12 +153,12 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
|||
h = h.mul(&Homography([1.0 / f, 0.0, 0.0, 0.0, 1.0 / f, 0.0, 0.0, 0.0, 1.0]));
|
||||
src_rect = crate::resample::scaled_rect(src_rect, f, f);
|
||||
}
|
||||
let Some(inv) = h.inverse() else { return out };
|
||||
let Some(inv) = h.inverse() else { return Ok(out) };
|
||||
// Destination bounds: the warped corners (plus a pixel for filter support).
|
||||
let corners = [(src_rect.x0, src_rect.y0), (src_rect.x1, src_rect.y0), (src_rect.x1, src_rect.y1), (src_rect.x0, src_rect.y1)]
|
||||
.map(|(x, y)| h.apply(x as f64, y as f64));
|
||||
if corners.iter().any(|c| !c.0.is_finite() || !c.1.is_finite()) {
|
||||
return out;
|
||||
return Ok(out);
|
||||
}
|
||||
let lim = 1 << 20;
|
||||
let bx0 = corners.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor().max(-(lim as f64)) as i32 - 1;
|
||||
|
|
@ -168,21 +168,58 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
|||
let dst = Rect::new(bx0, by0, bx1, by1);
|
||||
let n = fmt.channels();
|
||||
let a = n - 1;
|
||||
let tiles: Vec<Rect> = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect();
|
||||
let tile_count = dst.tiles().count();
|
||||
let tile_bytes = tile_count.checked_mul(std::mem::size_of::<Rect>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
let mut tiles = Vec::new();
|
||||
tiles.try_reserve_exact(tile_count).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: tile_bytes })?;
|
||||
tiles.extend(dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()));
|
||||
let src_ref: &Surface = src;
|
||||
let work = |t: &Rect| -> Option<(Rect, Vec<f32>)> {
|
||||
// Source footprint of this tile (inverse-mapped corners), padded for the filter.
|
||||
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| inv.apply(x as f64, y as f64));
|
||||
let fx0 = tc.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
|
||||
let fy0 = tc.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
|
||||
let fx1 = tc.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
|
||||
let fy1 = tc.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
|
||||
let foot = Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect);
|
||||
// A sampled pixel's inverse w is 1 / (forward w at its source point). While the forward w keeps one sign over the source
|
||||
// (plus the margin the sampler reads), every sampled pixel lies at least `w_lo` from the inverse horizon, so clipping a tile
|
||||
// there keeps its footprint exact and bounded even when a steep Distort brings that horizon into the tile (#2271).
|
||||
let (sx0, sy0, sx1, sy1) = (src_rect.x0 as f64 - 1.0, src_rect.y0 as f64 - 1.0, src_rect.x1 as f64 + 1.0, src_rect.y1 as f64 + 1.0);
|
||||
let fwd_w = [(sx0, sy0), (sx1, sy0), (sx1, sy1), (sx0, sy1)].map(|(x, y)| h.0[6] * x + h.0[7] * y + h.0[8]);
|
||||
let side = [1.0, -1.0].into_iter().find(|&s| fwd_w.iter().all(|&c| s * c > 0.0));
|
||||
let w_lo = 1.0 / fwd_w.iter().fold(0.0f64, |m, c| m.max(c.abs()));
|
||||
let work = |t: &Rect| -> Result<Option<(Rect, Vec<f32>)>, photocraft_raster::AllocationError> {
|
||||
// Source footprint of this tile (inverse-mapped corners of its sampled part), padded for the filter.
|
||||
let tc = [(t.x0, t.y0), (t.x1, t.y0), (t.x1, t.y1), (t.x0, t.y1)].map(|(x, y)| (x as f64, y as f64));
|
||||
let wc = tc.map(|(x, y)| inv.0[6] * x + inv.0[7] * y + inv.0[8]);
|
||||
let poly = match side {
|
||||
// Sutherland-Hodgman against `s * w >= w_lo`.
|
||||
Some(s) => {
|
||||
let d = wc.map(|w| s * w - w_lo);
|
||||
let mut poly = Vec::with_capacity(5);
|
||||
for (i, (&p, &dp)) in tc.iter().zip(&d).enumerate() {
|
||||
let (q, dq) = (tc[(i + 1) % 4], d[(i + 1) % 4]);
|
||||
if dp >= 0.0 {
|
||||
poly.push(p);
|
||||
}
|
||||
if (dp >= 0.0) != (dq >= 0.0) {
|
||||
let k = dp / (dp - dq);
|
||||
poly.push((p.0 + (q.0 - p.0) * k, p.1 + (q.1 - p.1) * k));
|
||||
}
|
||||
}
|
||||
poly
|
||||
}
|
||||
None => tc.to_vec(),
|
||||
};
|
||||
if poly.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let fp: Vec<(f64, f64)> = poly.iter().map(|&(x, y)| inv.apply(x, y)).collect();
|
||||
let fx0 = fp.iter().map(|c| c.0).fold(f64::MAX, f64::min).floor() as i32 - 3;
|
||||
let fy0 = fp.iter().map(|c| c.1).fold(f64::MAX, f64::min).floor() as i32 - 3;
|
||||
let fx1 = fp.iter().map(|c| c.0).fold(f64::MIN, f64::max).ceil() as i32 + 3;
|
||||
let fy1 = fp.iter().map(|c| c.1).fold(f64::MIN, f64::max).ceil() as i32 + 3;
|
||||
// Without a one-signed forward w, a tile straddling the inverse horizon has no bounded footprint.
|
||||
let straddles = side.is_none() && wc.iter().any(|w| *w <= 0.0) && wc.iter().any(|w| *w >= 0.0);
|
||||
let foot = if straddles { src_rect } else { Rect::new(fx0, fy0, fx1, fy1).intersect(&src_rect) };
|
||||
if foot.is_empty() || !src_ref.has_tiles_in(foot) {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
// Premultiplied source window.
|
||||
let mut px = src_ref.read_region(foot);
|
||||
let mut px = src_ref.try_read_region(foot)?;
|
||||
for p in px.chunks_exact_mut(n) {
|
||||
let al = p[a];
|
||||
for v in &mut p[..a] {
|
||||
|
|
@ -198,7 +235,11 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
|||
}
|
||||
};
|
||||
let w = t.width() as usize;
|
||||
let mut outp = vec![0.0f32; w * t.height() as usize * n];
|
||||
let len = w.checked_mul(t.height() as usize).and_then(|v| v.checked_mul(n)).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
let bytes = len.checked_mul(std::mem::size_of::<f32>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
let mut outp = Vec::new();
|
||||
outp.try_reserve_exact(len).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes })?;
|
||||
outp.resize(len, 0.0f32);
|
||||
let mut any = false;
|
||||
let mut acc = [0.0f64; 8];
|
||||
for y in t.y0..t.y1 {
|
||||
|
|
@ -252,20 +293,34 @@ pub fn warp_surface(src: &Surface, src_rect: Rect, h: &Homography, interp: Inter
|
|||
outp[o + a] = al as f32;
|
||||
}
|
||||
}
|
||||
any.then_some((*t, outp))
|
||||
Ok(any.then_some((*t, outp)))
|
||||
};
|
||||
let result_bytes = tiles.len().checked_mul(std::mem::size_of::<(Rect, Vec<f32>)>()).ok_or(photocraft_raster::AllocationError::SizeOverflow)?;
|
||||
let mut done = Vec::new();
|
||||
done.try_reserve_exact(tiles.len()).map_err(|_| photocraft_raster::AllocationError::NotEnoughMemory { bytes: result_bytes })?;
|
||||
let done = std::sync::Mutex::new(done);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let done: Vec<(Rect, Vec<f32>)> = {
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
tiles.par_iter().filter_map(work).collect()
|
||||
};
|
||||
tiles.into_par_iter().try_for_each(|tile| -> Result<(), photocraft_raster::AllocationError> {
|
||||
if let Some(result) = work(&tile)? {
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let done: Vec<(Rect, Vec<f32>)> = tiles.iter().filter_map(work).collect();
|
||||
for tile in tiles {
|
||||
if let Some(result) = work(&tile)? {
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(result);
|
||||
}
|
||||
}
|
||||
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for (r, v) in done {
|
||||
out.write_region(r, &v);
|
||||
out.try_write_region(r, &v)?;
|
||||
}
|
||||
out.prune();
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -292,6 +347,24 @@ mod tests {
|
|||
assert!(Homography::rect_to_quad(r, [[0.0, 0.0]; 4]).is_none() || Homography::rect_to_quad(r, [[0.0, 0.0]; 4]).unwrap().inverse().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steep_distort_keeps_every_pixel_inside_the_quad() {
|
||||
// Corner C is almost flat, so the horizon of the inverse map crosses the 256-px tiles that
|
||||
// also hold quad pixels; those tiles must still read every source pixel that lands in them.
|
||||
let mut s = rgba();
|
||||
s.fill_rect(Rect::new(0, 0, 100, 100), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let r = s.content_bounds();
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 100.0, 100.0], [[0.0, 0.0], [300.0, 0.0], [155.0, 155.0], [0.0, 300.0]]).unwrap();
|
||||
let w = warp_surface(&s, r, &h, Interp::Nearest).unwrap();
|
||||
for (x, y) in [(20, 20), (252, 14), (252, 42), (14, 252), (42, 252)] {
|
||||
let a = w.read_region(Rect::new(x, y, x + 1, y + 1))[3];
|
||||
assert!(a > 0.99, "({x},{y}) inside the quad is transparent: {a}");
|
||||
}
|
||||
for (x, y) in [(200, 200), (280, 100), (100, 280)] {
|
||||
assert_eq!(w.read_region(Rect::new(x, y, x + 1, y + 1))[3], 0.0, "({x},{y}) outside the quad is painted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_translation_are_exact() {
|
||||
let mut s = rgba();
|
||||
|
|
@ -299,7 +372,7 @@ mod tests {
|
|||
let r = s.content_bounds();
|
||||
for interp in [Interp::Nearest, Interp::Bilinear, Interp::Bicubic] {
|
||||
let h = Homography([1.0, 0.0, 7.0, 0.0, 1.0, -3.0, 0.0, 0.0, 1.0]);
|
||||
let o = warp_surface(&s, r, &h, interp);
|
||||
let o = warp_surface(&s, r, &h, interp).unwrap();
|
||||
assert_eq!(o.content_bounds(), Rect::new(11, 1, 27, 9), "{interp:?}");
|
||||
assert_eq!(o.pixel(15, 5), vec![1.0, 128.0 / 255.0, 0.0, 1.0], "{interp:?}");
|
||||
}
|
||||
|
|
@ -315,12 +388,13 @@ mod tests {
|
|||
r,
|
||||
&Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], [[0.0, 0.0], [80.0, 0.0], [80.0, 40.0], [0.0, 40.0]]).unwrap(),
|
||||
Interp::Bicubic,
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
let b = up.content_bounds();
|
||||
assert!(b.width() >= 80 && b.width() <= 82 && b.height() >= 40 && b.height() <= 42, "{b:?}");
|
||||
// 90° rotation about (20, 10): a 40×20 box becomes 20×40.
|
||||
let q = [[30.0, -10.0], [30.0, 30.0], [10.0, 30.0], [10.0, -10.0]];
|
||||
let rot = warp_surface(&s, r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], q).unwrap(), Interp::Bilinear);
|
||||
let rot = warp_surface(&s, r, &Homography::rect_to_quad([0.0, 0.0, 40.0, 20.0], q).unwrap(), Interp::Bilinear).unwrap();
|
||||
let b = rot.content_bounds();
|
||||
assert!(b.width().abs_diff(20) <= 2 && b.height().abs_diff(40) <= 2, "{b:?}");
|
||||
let p = rot.pixel(20, 10);
|
||||
|
|
@ -338,7 +412,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
let h = Homography::rect_to_quad([0.0, 0.0, 64.0, 64.0], [[0.0, 0.0], [8.0, 0.0], [8.0, 8.0], [0.0, 8.0]]).unwrap();
|
||||
let o = warp_surface(&s, s.content_bounds(), &h, Interp::Bicubic);
|
||||
let o = warp_surface(&s, s.content_bounds(), &h, Interp::Bicubic).unwrap();
|
||||
let p = o.pixel(4, 4);
|
||||
assert!((p[0] - 0.5).abs() < 0.1, "{p:?}");
|
||||
}
|
||||
|
|
@ -348,7 +422,7 @@ mod tests {
|
|||
let mut s = Surface::new(PixelFormat::new(photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8, false));
|
||||
s.fill_rect(Rect::new(0, 0, 10, 10), &[1.0, 0.0, 0.0]);
|
||||
let h = Homography([1.0, 0.0, 5.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let o = warp_surface(&s, Rect::new(0, 0, 10, 10), &h, Interp::Nearest);
|
||||
let o = warp_surface(&s, Rect::new(0, 0, 10, 10), &h, Interp::Nearest).unwrap();
|
||||
assert!(o.format().alpha);
|
||||
assert_eq!(o.pixel(2, 2)[3], 0.0);
|
||||
assert_eq!(o.pixel(7, 2), vec![1.0, 0.0, 0.0, 1.0]);
|
||||
|
|
|
|||
|
|
@ -228,22 +228,22 @@ fn quad_mask(h: &Homography, area: Rect) -> Vec<f32> {
|
|||
/// Pastes `src` (content in `src_rect`) onto plane `i` with its top-left at plane coordinates
|
||||
/// `at` (0..1) and its width `width` (fraction of the plane's width), aspect preserved in the
|
||||
/// plane's metric. Returns the warped image clipped to the plane (transparent elsewhere).
|
||||
pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2], width: f64) -> Option<Surface> {
|
||||
let (h, lu, lv) = scene.frame(i)?;
|
||||
pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2], width: f64) -> Result<Option<Surface>, photocraft_raster::AllocationError> {
|
||||
let Some((h, lu, lv)) = scene.frame(i) else { return Ok(None) };
|
||||
let (sw, sh) = (src_rect.width() as f64, src_rect.height() as f64);
|
||||
if sw <= 0.0 || sh <= 0.0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
// Source px → plane (u, v): u spans `width`, v keeps the metric aspect.
|
||||
let su = width / sw;
|
||||
let sv = width * lu / lv / sw;
|
||||
let to_plane = Homography([su, 0.0, at[0] - src_rect.x0 as f64 * su, 0.0, sv, at[1] - src_rect.y0 as f64 * sv, 0.0, 0.0, 1.0]);
|
||||
let full = h.mul(&to_plane);
|
||||
let mut out = warp_surface(src, src_rect, &full, Interp::Bicubic);
|
||||
let mut out = warp_surface(src, src_rect, &full, Interp::Bicubic)?;
|
||||
// Clip to the plane.
|
||||
let b = out.content_bounds();
|
||||
if b.is_empty() {
|
||||
return Some(out);
|
||||
return Ok(Some(out));
|
||||
}
|
||||
let mask = quad_mask(&h, b);
|
||||
let fmt = out.format();
|
||||
|
|
@ -254,7 +254,7 @@ pub fn paste(scene: &Scene, i: usize, src: &Surface, src_rect: Rect, at: [f64; 2
|
|||
}
|
||||
out.write_region(b, &data);
|
||||
out.prune();
|
||||
Some(out)
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Perspective clone stamp: one stroke of dabs at image points `points` on the destination
|
||||
|
|
@ -384,7 +384,7 @@ mod tests {
|
|||
let sc = Scene::new(floor_quad(500.0, c), c, None, 800.0);
|
||||
let mut img = Surface::new(PixelFormat::RGBA8);
|
||||
img.fill_rect(Rect::new(0, 0, 100, 50), &[1.0, 0.0, 0.0, 1.0]);
|
||||
let out = paste(&sc, 0, &img, Rect::new(0, 0, 100, 50), [0.25, 0.25], 0.5).unwrap();
|
||||
let out = paste(&sc, 0, &img, Rect::new(0, 0, 100, 50), [0.25, 0.25], 0.5).unwrap().unwrap();
|
||||
// The pasted rectangle lands inside the plane, around plane (0.5, 0.5).
|
||||
let h = sc.planes[0].homography().unwrap();
|
||||
let (x, y) = h.apply(0.5, 0.5);
|
||||
|
|
|
|||
|
|
@ -30,31 +30,41 @@ const MAX_CELLS: usize = 512;
|
|||
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
|
||||
/// pass, an exact shift for whole-pixel translations (so conversions and re-renders are
|
||||
/// lossless), bicubic otherwise. Shared by the engine's re-render and PSD export's filter cache.
|
||||
pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
|
||||
pub fn place_source(
|
||||
src: &Surface,
|
||||
src_rect: Rect,
|
||||
t: &photocraft_geom::Affine,
|
||||
warp: Option<&photocraft_geom::warp::Warp>,
|
||||
) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
let [a, b, c, d, e, f] = t.m;
|
||||
if let Some(w) = warp.filter(|w| !w.is_identity()) {
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
(a * u + c * v + e, b * u + d * v + f)
|
||||
};
|
||||
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
|
||||
return Ok(warp_mesh_surface(src, src_rect, &map, Interp::Bicubic));
|
||||
}
|
||||
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
|
||||
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
|
||||
return crate::resample::translate_surface(src, e.round() as i32, f.round() as i32);
|
||||
return Ok(crate::resample::translate_surface(src, e.round() as i32, f.round() as i32));
|
||||
}
|
||||
crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic)
|
||||
}
|
||||
|
||||
/// [`place_source`] through a projective map `h` (source → document; Distort, Perspective): the
|
||||
/// warp first (in source space), then `h`.
|
||||
pub fn place_source_projective(src: &Surface, src_rect: Rect, h: &crate::transform::Homography, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
|
||||
pub fn place_source_projective(
|
||||
src: &Surface,
|
||||
src_rect: Rect,
|
||||
h: &crate::transform::Homography,
|
||||
warp: Option<&photocraft_geom::warp::Warp>,
|
||||
) -> Result<Surface, photocraft_raster::AllocationError> {
|
||||
if let Some(w) = warp.filter(|w| !w.is_identity()) {
|
||||
let map = |x: f64, y: f64| {
|
||||
let (u, v) = w.map(x, y);
|
||||
h.apply(u, v)
|
||||
};
|
||||
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
|
||||
return Ok(warp_mesh_surface(src, src_rect, &map, Interp::Bicubic));
|
||||
}
|
||||
crate::transform::warp_surface(src, src_rect, h, Interp::Bicubic)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,8 +151,7 @@ impl Headless {
|
|||
// alone (#1547).
|
||||
let ext =
|
||||
format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| target.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()));
|
||||
let layered = matches!(ext.as_deref(), Some("psd" | "psb" | "ora")) || ext.as_deref() == Some(photocraft_format::EXTENSION);
|
||||
if layered {
|
||||
if ext.as_deref().is_some_and(file_cmds::layered_extension) {
|
||||
// Saving by index must not retarget the next automation command.
|
||||
let previously_active = self.session.active_index();
|
||||
self.session.set_active(i);
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ pub struct UiSetParams {
|
|||
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel
|
||||
/// ({background: bool} picks which swatch the Color panel edits), maskTarget,
|
||||
/// vectorMaskTarget, selectionMode, zoom, center, rotation (view angle in degrees), fit, theme (pro, proMedium, studio,
|
||||
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark), brushSection, brushTab,
|
||||
/// studioLight, classic, solarizedDark, adwaita, adwaitaDark, breezeLight, breezeDark), brushSection, brushTab,
|
||||
/// brushesView, brushPicker ([x, y] opens the Brush Preset picker there, null closes it),
|
||||
/// brushPickerName, brushPickerStroke, brushPickerTip (the picker card's parts, at least one
|
||||
/// on), brushPickerScale (the picker footer's card-scale slider, 0.15 to 2, 1 the standard),
|
||||
|
|
@ -239,8 +239,8 @@ pub struct UiSetParams {
|
|||
/// allNoAdjustments, currentAndBelowNoAdjustments), eyedropperRing (bool), cropOverlay (thirds,
|
||||
/// grid, diagonal, triangle, goldenRatio, goldenSpiral), cropOverlayShow (auto, always, never),
|
||||
/// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled,
|
||||
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100) and
|
||||
/// auto_adjust), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100),
|
||||
/// auto_adjust, classic_mode and auto_center_preview), shapeStroke (an object patching width, align, cap, join, miterLimit,
|
||||
/// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use
|
||||
/// `shape.edit` names and units; colour comes from `tools.setColors`. Other fields are an error.
|
||||
pub fields: Value,
|
||||
|
|
|
|||
|
|
@ -127,6 +127,13 @@ pub fn srgb() -> &'static Profile {
|
|||
Builtin::Srgb.profile()
|
||||
}
|
||||
|
||||
/// Gray with a linear tone curve: what a grayscale OpenEXR holds. Not a [`Builtin`], as it is
|
||||
/// only assigned to such files, never offered as a working space.
|
||||
pub fn linear_gray() -> &'static Profile {
|
||||
static PROFILE: OnceLock<Profile> = OnceLock::new();
|
||||
PROFILE.get_or_init(|| gray("Linear Gray (Photocraft)", Curve::Gamma(1.0)))
|
||||
}
|
||||
|
||||
/// The ICC D65 used for the RGB spaces (chromaticity 0.3127, 0.3290).
|
||||
fn d65() -> [f64; 3] {
|
||||
math::xy_to_xyz(math::D65_XY)
|
||||
|
|
|
|||
|
|
@ -448,7 +448,8 @@ fn parse_profile(bytes: &[u8]) -> Result<Profile, CmsError> {
|
|||
let version = (r.u8(8)?, r.u8(9)?);
|
||||
let class = ProfileClass::from_sig(r.u32(12)?);
|
||||
let color_space = ColorSpace::from_sig(r.u32(16)?);
|
||||
let pcs = match &r.u32(20)?.to_be_bytes() {
|
||||
let pcs_sig = r.u32(20)?;
|
||||
let pcs = match &pcs_sig.to_be_bytes() {
|
||||
b"Lab " => Pcs::Lab,
|
||||
_ => Pcs::Xyz,
|
||||
};
|
||||
|
|
@ -539,15 +540,18 @@ fn parse_profile(bytes: &[u8]) -> Result<Profile, CmsError> {
|
|||
bytes: None,
|
||||
hash: Default::default(),
|
||||
};
|
||||
// Validate channel counts of LUTs against the header.
|
||||
// Validate channel counts of LUTs against the header. A DeviceLink connects two device
|
||||
// spaces: its header PCS field holds the output colour space (ICC.1:2010 §7.2.7), so a
|
||||
// CMYK→CMYK link has a 4→4 AToB0.
|
||||
let dev = p.color_space.channels();
|
||||
let conn = if p.class == ProfileClass::DeviceLink { ColorSpace::from_sig(pcs_sig).channels() } else { 3 };
|
||||
for l in p.a2b.iter().flatten() {
|
||||
if dev != 0 && l.inputs != dev || l.outputs != 3 {
|
||||
if dev != 0 && l.inputs != dev || conn != 0 && l.outputs != conn {
|
||||
return Err(CmsError::Invalid(format!("AToB tag has {}→{} channels for a {:?} profile", l.inputs, l.outputs, p.color_space)));
|
||||
}
|
||||
}
|
||||
for l in p.b2a.iter().flatten() {
|
||||
if l.inputs != 3 || dev != 0 && l.outputs != dev {
|
||||
if conn != 0 && l.inputs != conn || dev != 0 && l.outputs != dev {
|
||||
return Err(CmsError::Invalid(format!("BToA tag has {}→{} channels for a {:?} profile", l.inputs, l.outputs, p.color_space)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -510,7 +510,9 @@ fn apply_matrix(m: &Option<(usize, usize, Vec<f32>, Vec<f32>)>, v: &[f32; 16], o
|
|||
|
||||
// ------------------------------------------------------------ parallel helpers
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PAR_MIN_PIXELS: usize = 16 * 1024;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const PAR_CHUNK_PIXELS: usize = 8 * 1024;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
|
@ -549,7 +551,9 @@ fn par_inplace(buf: &mut [f32], _stride: usize, f: impl Fn(&mut [f32]) + Sync) {
|
|||
/// Stages from `src` device values to `dst` device values.
|
||||
pub(crate) fn link_stages(src: &Profile, dst: &Profile, intent: Intent, bpc: bool) -> Result<Vec<Stage>, CmsError> {
|
||||
for p in [src, dst] {
|
||||
if matches!(p.class, ProfileClass::DeviceLink | ProfileClass::NamedColor | ProfileClass::Abstract) && p.color_space != ColorSpace::Lab {
|
||||
// A Lab DeviceLink stands in for an abstract profile only when it also ends in Lab.
|
||||
let lab_to_lab = p.color_space == ColorSpace::Lab && p.a2b.iter().flatten().all(|l| l.outputs == 3) && p.b2a.iter().flatten().all(|l| l.inputs == 3);
|
||||
if matches!(p.class, ProfileClass::DeviceLink | ProfileClass::NamedColor | ProfileClass::Abstract) && !lab_to_lab {
|
||||
return Err(CmsError::Unsupported(format!("{:?} profiles cannot be used as a source or destination", p.class)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -699,3 +699,56 @@ fn windows_srgb_is_the_builtin_srgb() {
|
|||
assert_ne!(p.content_hash(), srgb().content_hash());
|
||||
assert!(p.same_colors(srgb()));
|
||||
}
|
||||
|
||||
/// A minimal DeviceLink: header plus one `A2B0` (`mft2`, 2-point grid) mapping `inputs` channels
|
||||
/// of `space` to `outputs` channels of `out_space`, which the header stores in the PCS field.
|
||||
fn device_link(space: &[u8; 4], out_space: &[u8; 4], inputs: usize, outputs: usize) -> Vec<u8> {
|
||||
let mut lut = b"mft2\0\0\0\0".to_vec();
|
||||
lut.extend_from_slice(&[inputs as u8, outputs as u8, 2, 0]);
|
||||
for k in 0..9 {
|
||||
let v: i32 = if k % 4 == 0 { 0x10000 } else { 0 };
|
||||
lut.extend_from_slice(&v.to_be_bytes()); // identity matrix
|
||||
}
|
||||
lut.extend_from_slice(&[0, 2, 0, 2]); // 2-entry input and output tables
|
||||
for _ in 0..inputs {
|
||||
lut.extend_from_slice(&[0, 0, 0xff, 0xff]);
|
||||
}
|
||||
for _ in 0..(1 << inputs) * outputs {
|
||||
lut.extend_from_slice(&[0x80, 0]);
|
||||
}
|
||||
for _ in 0..outputs {
|
||||
lut.extend_from_slice(&[0, 0, 0xff, 0xff]);
|
||||
}
|
||||
let mut icc = vec![0u8; 128];
|
||||
icc[8] = 4;
|
||||
icc[9] = 0x30;
|
||||
icc[12..16].copy_from_slice(b"link");
|
||||
icc[16..20].copy_from_slice(space);
|
||||
icc[20..24].copy_from_slice(out_space);
|
||||
icc[36..40].copy_from_slice(b"acsp");
|
||||
icc.extend_from_slice(&1u32.to_be_bytes());
|
||||
icc.extend_from_slice(b"A2B0");
|
||||
icc.extend_from_slice(&144u32.to_be_bytes());
|
||||
icc.extend_from_slice(&(lut.len() as u32).to_be_bytes());
|
||||
icc.extend_from_slice(&lut);
|
||||
let size = icc.len() as u32;
|
||||
icc[..4].copy_from_slice(&size.to_be_bytes());
|
||||
icc
|
||||
}
|
||||
|
||||
/// Regression (#2337): DeviceLinks whose output isn't a 3-channel space were rejected as
|
||||
/// invalid, because the LUT check assumed every AToB tag ends in the PCS. They parse now and
|
||||
/// get the clear "unsupported" error when used for a conversion, including a Lab→CMYK link,
|
||||
/// which must not pass for a Lab-to-Lab one.
|
||||
#[test]
|
||||
fn device_links_parse_and_are_reported_unsupported() {
|
||||
for (space, out_space, inputs, outputs) in [(b"CMYK", b"CMYK", 4, 4), (b"RGB ", b"CMYK", 3, 4), (b"Lab ", b"CMYK", 3, 4), (b"RGB ", b"RGB ", 3, 3)] {
|
||||
let p = Profile::parse(&device_link(space, out_space, inputs, outputs)).unwrap();
|
||||
assert_eq!(p.color_space.channels(), inputs);
|
||||
for err in [Transform::new(&p, srgb(), Intent::Perceptual, false).err(), Transform::new(srgb(), &p, Intent::Perceptual, false).err()] {
|
||||
assert!(matches!(err, Some(photocraft_cms::CmsError::Unsupported(_))), "{err:?}");
|
||||
}
|
||||
}
|
||||
// The output space still bounds the tag: a CMYK→CMYK link with a 4→3 table is malformed.
|
||||
assert!(matches!(Profile::parse(&device_link(b"CMYK", b"CMYK", 4, 3)), Err(photocraft_cms::CmsError::Invalid(_))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,6 +181,77 @@ pub(crate) fn decode_part(bytes: &[u8], index: usize, limits: &Limits) -> Result
|
|||
layer_to_image(&read_layer(bytes, index)?)
|
||||
}
|
||||
|
||||
/// Decodes the named channels of one flat part (exact names as the file stores them, e.g.
|
||||
/// `diffuse.R`) into an image: R/G/B (plus A when named) give Rgba, a lone Y gives Gray. This
|
||||
/// is how a channel group renders without the part's other channels in the way.
|
||||
pub(crate) fn decode_channels(bytes: &[u8], index: usize, names: &[&str], limits: &Limits) -> Result<Image, CodecError> {
|
||||
let meta = read_meta(bytes, limits)?;
|
||||
if meta.requirements.has_deep_data {
|
||||
return Err(CodecError::unsupported(F, "this file has deep parts; the deep decoder composites those (mixing flat and deep parts is not supported)"));
|
||||
}
|
||||
if index >= meta.headers.len() {
|
||||
return Err(err(format!("part index {index} is out of range ({} parts)", meta.headers.len())));
|
||||
}
|
||||
let layer = read_layer(bytes, index)?;
|
||||
let (w, h) = (layer.size.0, layer.size.1);
|
||||
let channels = &layer.channel_data.list;
|
||||
let npx = w * h;
|
||||
let mut picked: Vec<&AnyChannel<FlatSamples>> = Vec::with_capacity(names.len());
|
||||
for name in names {
|
||||
let c = channels.iter().find(|c| c.name.to_string() == *name).ok_or_else(|| err(format!("the part has no channel {name}")))?;
|
||||
if c.sample_data.len() != npx {
|
||||
return Err(err("channel sample count mismatch (subsampled channels are unsupported)"));
|
||||
}
|
||||
picked.push(c);
|
||||
}
|
||||
let base = |c: &AnyChannel<FlatSamples>| -> String { base_channel_name(&c.name.to_string()).to_string() };
|
||||
let has = |n: &str| picked.iter().any(|c| base(c) == n);
|
||||
let (layout, order): (ChannelLayout, Vec<Option<usize>>) = if has("R") && has("G") && has("B") {
|
||||
let at = |n: &str| picked.iter().position(|c| base(c) == n);
|
||||
match has("A").then(|| at("A")) {
|
||||
Some(a) => (ChannelLayout::Rgba, vec![at("R"), at("G"), at("B"), a]),
|
||||
None => (ChannelLayout::Rgb, vec![at("R"), at("G"), at("B"), None]),
|
||||
}
|
||||
} else if has("Y") {
|
||||
let y = picked.iter().position(|c| base(c) == "Y");
|
||||
match picked.iter().position(|c| base(c) == "A") {
|
||||
Some(a) => (ChannelLayout::GrayA, vec![y, Some(a)]),
|
||||
None => (ChannelLayout::Gray, vec![y]),
|
||||
}
|
||||
} else if picked.len() == 1 {
|
||||
(ChannelLayout::Gray, vec![Some(0)])
|
||||
} else {
|
||||
return Err(CodecError::unsupported(F, "the named channels are no R/G/B or Y set"));
|
||||
};
|
||||
let all_f16 = picked.iter().all(|c| matches!(c.sample_data, FlatSamples::F16(_)));
|
||||
let nc = layout.channels();
|
||||
let (w32, h32) = (w as u32, h as u32);
|
||||
// The None slots sit past the layout's own channels (the absent A of an RGB pick) and
|
||||
// are skipped, so the buffer keeps its pixel stride.
|
||||
let img = if all_f16 {
|
||||
let mut out = vec![f16::ZERO; npx * nc];
|
||||
for (ci, pick) in order.iter().enumerate() {
|
||||
let Some(idx) = pick else { continue };
|
||||
let Some(FlatSamples::F16(v)) = picked.get(*idx).map(|c| &c.sample_data) else { continue };
|
||||
for (i, s) in v.iter().enumerate() {
|
||||
out[i * nc + ci] = *s;
|
||||
}
|
||||
}
|
||||
Image::from_f16(w32, h32, layout, &out)
|
||||
} else {
|
||||
let mut out = vec![0f32; npx * nc];
|
||||
for (ci, pick) in order.iter().enumerate() {
|
||||
let Some(idx) = pick else { continue };
|
||||
let Some(c) = picked.get(*idx) else { continue };
|
||||
for (i, s) in c.sample_data.values_as_f32().enumerate() {
|
||||
out[i * nc + ci] = s;
|
||||
}
|
||||
}
|
||||
Image::from_f32(w32, h32, layout, &out)
|
||||
};
|
||||
img.map_err(|e| CodecError::encode(F, e))
|
||||
}
|
||||
|
||||
pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Vec<u8>, CodecError> {
|
||||
let img = src.converted(plan.layout, plan.sample);
|
||||
let (w, h) = (img.width() as usize, img.height() as usize);
|
||||
|
|
|
|||
|
|
@ -163,6 +163,14 @@ pub fn decode_cryptomatte(bytes: &[u8], layer_name: &str, limits: &Limits) -> Re
|
|||
codecs::exr_cryptomatte::decode(bytes, layer_name, limits)
|
||||
}
|
||||
|
||||
/// Decodes the named channels of one part (exact names as the file stores them, e.g.
|
||||
/// `diffuse.R`) into an image: R/G/B (plus A when named) give Rgba, a lone Y gives Gray.
|
||||
/// This renders a channel group without the part's other channels in the way.
|
||||
pub fn decode_exr_channels(bytes: &[u8], part: usize, names: &[&str], opts: &DecodeOptions) -> Result<Image, CodecError> {
|
||||
let img = exr::decode_channels(bytes, part, names, &opts.limits)?;
|
||||
finish_decode(img, 1, opts)
|
||||
}
|
||||
|
||||
/// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists
|
||||
/// (colour, alpha and Z per sample), for callers that composite deep data themselves —
|
||||
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
|
||||
|
|
|
|||
|
|
@ -382,10 +382,36 @@ pub fn xmp_with_resolution(xmp: &str, ppi: (f32, f32)) -> Cow<'_, str> {
|
|||
if out == xmp { Cow::Borrowed(xmp) } else { Cow::Owned(out) }
|
||||
}
|
||||
|
||||
/// `exif` without its embedded thumbnail, or `None` when it has none (or doesn't parse).
|
||||
///
|
||||
/// EXIF keeps the thumbnail in IFD1, the IFD IFD0's next-IFD pointer names. Finder, Android's
|
||||
/// gallery and upload pickers show that thumbnail instead of decoding the image, so a copied one
|
||||
/// keeps showing the photo as it was before it was edited (#2147, #2373). The pointer is cleared;
|
||||
/// nothing else moves, so every other offset in the block stays valid.
|
||||
fn exif_without_thumbnail(exif: &[u8]) -> Option<Vec<u8>> {
|
||||
let body = tiff_body(exif);
|
||||
let prefix = exif.len() - body.len();
|
||||
let (_, first, count, entry_bytes, big_tiff) = ifd0_entries(body)?;
|
||||
let next = prefix.checked_add(first)?.checked_add(count.checked_mul(entry_bytes)?)?;
|
||||
let width = if big_tiff { 8 } else { 4 };
|
||||
// In bounds: `ifd0_entries` checked that the whole pointer is present.
|
||||
if exif.get(next..next.checked_add(width)?)?.iter().all(|b| *b == 0) {
|
||||
return None;
|
||||
}
|
||||
let mut out = exif.to_vec();
|
||||
out.get_mut(next..next + width)?.fill(0);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The EXIF to write next to upright pixels at resolution `ppi`: Orientation 1 (see
|
||||
/// [`upright_exif`]) and, when `ppi` is known, the resolution (see [`exif_with_resolution`]).
|
||||
/// [`upright_exif`]), no thumbnail of the source image (see [`exif_without_thumbnail`]) and,
|
||||
/// when `ppi` is known, the resolution (see [`exif_with_resolution`]).
|
||||
pub fn export_exif(exif: &[u8], ppi: Option<(f32, f32)>) -> Cow<'_, [u8]> {
|
||||
let up = upright_exif(exif);
|
||||
let up = match exif_without_thumbnail(&up) {
|
||||
Some(v) => Cow::Owned(v),
|
||||
None => up,
|
||||
};
|
||||
let Some(ppi) = ppi else { return up };
|
||||
if let Cow::Owned(v) = exif_with_resolution(&up, ppi) {
|
||||
return Cow::Owned(v);
|
||||
|
|
|
|||
|
|
@ -436,3 +436,78 @@ fn png_phys_outranks_its_exif() {
|
|||
let with_phys = [&png[..ihdr_end], &chunk, &png[ihdr_end..]].concat();
|
||||
near(decode(&with_phys).unwrap().meta.dpi, 150.0114);
|
||||
}
|
||||
|
||||
/// `camera_exif` plus IFD1, the embedded thumbnail a camera (or the last editor) wrote:
|
||||
/// JPEGInterchangeFormat / JPEGInterchangeFormatLength naming a fake JPEG after the IFD.
|
||||
fn exif_with_thumbnail(e: &Exif) -> Vec<u8> {
|
||||
let mut v = camera_exif(e);
|
||||
let ifd1 = v.len() as u32;
|
||||
// IFD0 (4 entries) ends with its next-IFD pointer.
|
||||
v[8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4].copy_from_slice(&e.u32(ifd1));
|
||||
let thumb = b"\xFF\xD8stale thumbnail\xFF\xD9";
|
||||
let thumb_at = ifd1 + 2 + 2 * 12 + 4;
|
||||
v.extend_from_slice(&e.u16(2));
|
||||
for (tag, value) in [(0x0201u16, thumb_at), (0x0202, thumb.len() as u32)] {
|
||||
v.extend_from_slice(&e.u16(tag));
|
||||
v.extend_from_slice(&e.u16(LONG));
|
||||
v.extend_from_slice(&e.u32(1));
|
||||
v.extend_from_slice(&e.u32(value));
|
||||
}
|
||||
v.extend_from_slice(&e.u32(0));
|
||||
v.extend_from_slice(thumb);
|
||||
v
|
||||
}
|
||||
|
||||
fn next_ifd(e: &Exif, exif: &[u8]) -> u32 {
|
||||
let at = 8 + 2 + 4 * 12;
|
||||
let b: [u8; 4] = exif[at..at + 4].try_into().unwrap();
|
||||
if e.big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) }
|
||||
}
|
||||
|
||||
/// #2147 / #2373: an export must not carry the source's EXIF thumbnail (IFD1). Finder, Android
|
||||
/// and upload pickers show it instead of the image, so an edited photo kept its old thumbnail.
|
||||
#[test]
|
||||
fn exports_drop_the_source_exif_thumbnail() {
|
||||
for e in [&LE, &BE] {
|
||||
let exif = exif_with_thumbnail(e);
|
||||
assert_ne!(next_ifd(e, &exif), 0);
|
||||
for ppi in [None, Some((300.0, 300.0)), Some((240.0, 240.0))] {
|
||||
let out = export_exif(&exif, ppi);
|
||||
assert_eq!(next_ifd(e, &out), 0, "IFD1 is unlinked ({ppi:?})");
|
||||
assert_eq!(out.len(), exif.len(), "nothing moves");
|
||||
assert!(out.windows(6).any(|w| w == b"NIKON\0"), "the rest of the EXIF is kept");
|
||||
near(exif_resolution(&out), ppi.map_or(300.0, |p| p.0));
|
||||
}
|
||||
// Only the pointer changes.
|
||||
let out = export_exif(&exif, None);
|
||||
let differing: Vec<usize> = (0..exif.len()).filter(|&i| exif[i] != out[i]).collect();
|
||||
assert!(differing.iter().all(|&i| (8 + 2 + 4 * 12..8 + 2 + 4 * 12 + 4).contains(&i)), "{differing:?}");
|
||||
// With a JPEG-style prefix too.
|
||||
let prefixed = [b"Exif\0\0".as_slice(), &exif].concat();
|
||||
assert_eq!(next_ifd(e, &export_exif(&prefixed, None)[6..]), 0);
|
||||
// An EXIF without a thumbnail comes through untouched.
|
||||
assert!(matches!(export_exif(&camera_exif(e), None), Cow::Borrowed(_)));
|
||||
// Through every encoder that writes EXIF.
|
||||
let mut img = Image::from_raw(4, 4, ChannelLayout::Rgb, SampleType::U8, vec![90; 48]).unwrap();
|
||||
img.meta.exif = Some(exif.clone());
|
||||
let jpeg = encode(&img, Format::Jpeg, &EncodeOptions::default()).unwrap();
|
||||
assert_eq!(next_ifd(e, &exif_of(&jpeg)), 0, "JPEG");
|
||||
for format in [Format::Png, Format::WebP] {
|
||||
let back = decode(&encode(&img, format, &EncodeOptions::default()).unwrap()).unwrap();
|
||||
let got = back.meta.exif.unwrap_or_else(|| panic!("{format:?} kept no EXIF"));
|
||||
let body = got.strip_prefix(b"Exif\0\0").unwrap_or(&got);
|
||||
assert_eq!(next_ifd(e, body), 0, "{format:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_removal_never_panics_on_truncated_exif() {
|
||||
for e in [&LE, &BE] {
|
||||
let exif = exif_with_thumbnail(e);
|
||||
for n in 0..exif.len() {
|
||||
let _ = export_exif(&exif[..n], None);
|
||||
let _ = export_exif(&exif[..n], Some((300.0, 300.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,7 +336,17 @@ pub fn lum(c: [f32; 3]) -> f32 {
|
|||
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
|
||||
}
|
||||
|
||||
fn clip_color(c: [f32; 3]) -> [f32; 3] {
|
||||
/// ClipColor: moves `c` towards its grey ([`lum`]) until every channel is within `0..=top`,
|
||||
/// keeping the luminosity. `top` is the brightest in-gamut value: 1, or `f32::MAX` in 32-bit
|
||||
/// documents (values above 1 are valid light there, so only negatives are pulled in).
|
||||
///
|
||||
/// The scale factors `l / (l − min)` and `(top − l) / (max − l)` lie in 0..1 only while the
|
||||
/// luminosity itself is in range. Outside it no colour of that luminosity is in gamut: the plain
|
||||
/// formula turned colours inside out (a grey of 2 as the Luminosity of (0.5, 0.25, 0.125) gave
|
||||
/// (1.0, 2.32, 2.99)) and, for greys, divided by the rounding noise of `max − l`, so results were
|
||||
/// arbitrary and differed between the CPU and the GPU. Such colours become their grey instead
|
||||
/// (the formula's value as the luminosity reaches the bound).
|
||||
fn clip_color(c: [f32; 3], top: f32) -> [f32; 3] {
|
||||
let l = lum(c);
|
||||
let n = c[0].min(c[1]).min(c[2]);
|
||||
let x = c[0].max(c[1]).max(c[2]);
|
||||
|
|
@ -344,13 +354,13 @@ fn clip_color(c: [f32; 3]) -> [f32; 3] {
|
|||
if n < 0.0 {
|
||||
let d = l - n;
|
||||
for v in &mut out {
|
||||
*v = if d.abs() < 1e-12 { l } else { l + (*v - l) * l / d };
|
||||
*v = if l > 0.0 && d > 0.0 { l + (*v - l) * l / d } else { l };
|
||||
}
|
||||
}
|
||||
if x > 1.0 {
|
||||
if x > top {
|
||||
let d = x - l;
|
||||
for v in &mut out {
|
||||
*v = if d.abs() < 1e-12 { l } else { l + (*v - l) * (1.0 - l) / d };
|
||||
*v = if l < top && d > 0.0 { l + (*v - l) * (top - l) / d } else { l };
|
||||
}
|
||||
}
|
||||
out
|
||||
|
|
@ -359,8 +369,13 @@ fn clip_color(c: [f32; 3]) -> [f32; 3] {
|
|||
/// `c` with its luminosity ([`lum`]) moved to `l`, clipped back into gamut towards grey (the
|
||||
/// Luminosity blend mode's SetLum + ClipColor).
|
||||
pub fn set_lum(c: [f32; 3], l: f32) -> [f32; 3] {
|
||||
set_lum_within(c, l, 1.0)
|
||||
}
|
||||
|
||||
/// [`set_lum`] clipping at `top` instead of 1 (see [`blend_rgb_within`]).
|
||||
fn set_lum_within(c: [f32; 3], l: f32, top: f32) -> [f32; 3] {
|
||||
let d = l - lum(c);
|
||||
clip_color([c[0] + d, c[1] + d, c[2] + d])
|
||||
clip_color([c[0] + d, c[1] + d, c[2] + d], top)
|
||||
}
|
||||
|
||||
fn sat(c: [f32; 3]) -> f32 {
|
||||
|
|
@ -382,11 +397,17 @@ fn set_sat(c: [f32; 3], s: f32) -> [f32; 3] {
|
|||
|
||||
/// Blend a whole RGB triple: `B(Cb, Cs)` including non-separable modes.
|
||||
pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
blend_rgb_within(mode, cb, cs, 1.0)
|
||||
}
|
||||
|
||||
/// [`blend_rgb`] with Hue / Saturation / Color / Luminosity clipped at `top` instead of 1:
|
||||
/// `f32::MAX` keeps the values above 1 of 32-bit documents (their gamut has no upper bound).
|
||||
pub fn blend_rgb_within(mode: BlendMode, cb: [f32; 3], cs: [f32; 3], top: f32) -> [f32; 3] {
|
||||
match mode {
|
||||
BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
|
||||
BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
|
||||
BlendMode::Color => set_lum(cs, lum(cb)),
|
||||
BlendMode::Luminosity => set_lum(cb, lum(cs)),
|
||||
BlendMode::Hue => set_lum_within(set_sat(cs, sat(cb)), lum(cb), top),
|
||||
BlendMode::Saturation => set_lum_within(set_sat(cb, sat(cs)), lum(cb), top),
|
||||
BlendMode::Color => set_lum_within(cs, lum(cb), top),
|
||||
BlendMode::Luminosity => set_lum_within(cb, lum(cs), top),
|
||||
BlendMode::DarkerColor => {
|
||||
if lum(cs) < lum(cb) {
|
||||
cs
|
||||
|
|
@ -405,9 +426,10 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
|||
}
|
||||
}
|
||||
|
||||
/// [`blend_rgb`] for 32-bit float documents: separable modes use [`blend_channel_hdr`].
|
||||
/// [`blend_rgb`] for 32-bit float documents: separable modes use [`blend_channel_hdr`], and the
|
||||
/// non-separable ones keep values above 1 ([`blend_rgb_within`] at `f32::MAX`).
|
||||
pub fn blend_rgb_hdr(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
if mode.is_separable() { std::array::from_fn(|i| blend_channel_hdr(mode, cb[i], cs[i])) } else { blend_rgb(mode, cb, cs) }
|
||||
if mode.is_separable() { std::array::from_fn(|i| blend_channel_hdr(mode, cb[i], cs[i])) } else { blend_rgb_within(mode, cb, cs, f32::MAX) }
|
||||
}
|
||||
|
||||
/// Composite a straight-alpha source over a straight-alpha backdrop with blend mode and opacity.
|
||||
|
|
@ -598,8 +620,9 @@ mod tests {
|
|||
assert_eq!(blend_channel_hdr(BlendMode::Divide, f32::MAX, 1e-30), f32::MAX);
|
||||
assert_eq!(blend_channel_hdr(BlendMode::LinearDodge, f32::MAX, f32::MAX), f32::MAX);
|
||||
assert!(blend_channel_hdr(BlendMode::LinearDodge, f32::INFINITY, 1.0).is_finite());
|
||||
// Every other mode is unchanged, and the in-range results agree.
|
||||
for m in BlendMode::LAYER_MODES.into_iter().filter(|m| !matches!(m, BlendMode::LinearDodge | BlendMode::Divide)) {
|
||||
// Every other separable mode is unchanged (the non-separable ones keep values above 1:
|
||||
// blend_rgb_within), and the in-range results agree.
|
||||
for m in BlendMode::LAYER_MODES.into_iter().filter(|m| m.is_separable() && !matches!(m, BlendMode::LinearDodge | BlendMode::Divide)) {
|
||||
for (cb, cs) in [(0.2, 0.3), (0.9, 0.05), (2.0, 0.5), (0.4, 1.5)] {
|
||||
assert_eq!(blend_rgb(m, [cb, cs, 0.5], [cs, cb, 0.25]), blend_rgb_hdr(m, [cb, cs, 0.5], [cs, cb, 0.25]), "{m:?} cb={cb} cs={cs}");
|
||||
}
|
||||
|
|
@ -624,6 +647,149 @@ mod tests {
|
|||
assert!(close(s[0], s[1]) && close(s[1], s[2]));
|
||||
}
|
||||
|
||||
/// ClipColor exactly as ISO 32000 / W3C write it (what `clip_color` was before it guarded
|
||||
/// out-of-range luminosities).
|
||||
fn spec_clip_color(c: [f32; 3]) -> [f32; 3] {
|
||||
let l = lum(c);
|
||||
let n = c[0].min(c[1]).min(c[2]);
|
||||
let x = c[0].max(c[1]).max(c[2]);
|
||||
let mut out = c;
|
||||
if n < 0.0 {
|
||||
let d = l - n;
|
||||
for v in &mut out {
|
||||
*v = if d.abs() < 1e-12 { l } else { l + (*v - l) * l / d };
|
||||
}
|
||||
}
|
||||
if x > 1.0 {
|
||||
let d = x - l;
|
||||
for v in &mut out {
|
||||
*v = if d.abs() < 1e-12 { l } else { l + (*v - l) * (1.0 - l) / d };
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn spec_blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
||||
let set_lum = |c: [f32; 3], l: f32| {
|
||||
let d = l - lum(c);
|
||||
spec_clip_color([c[0] + d, c[1] + d, c[2] + d])
|
||||
};
|
||||
match mode {
|
||||
BlendMode::Hue => set_lum(set_sat(cs, sat(cb)), lum(cb)),
|
||||
BlendMode::Saturation => set_lum(set_sat(cb, sat(cs)), lum(cb)),
|
||||
BlendMode::Color => set_lum(cs, lum(cb)),
|
||||
BlendMode::Luminosity => set_lum(cb, lum(cs)),
|
||||
m => blend_rgb(m, cb, cs),
|
||||
}
|
||||
}
|
||||
|
||||
const NON_SEPARABLE: [BlendMode; 4] = [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity];
|
||||
|
||||
#[test]
|
||||
fn non_separable_modes_unchanged_on_integer_inputs() {
|
||||
// 8/16-bit documents only hold 0..1, where the luminosity stays in range (up to a
|
||||
// rounding step at white) and ClipColor is the plain ISO 32000 formula.
|
||||
let mut edges: Vec<f32> = vec![0.0, 1.0 / 255.0, 0.5, 254.0 / 255.0, 65534.0 / 65535.0, 1.0];
|
||||
edges.extend((0..=8).map(|i| i as f32 / 8.0));
|
||||
let mut colors = Vec::new();
|
||||
for &r in &edges {
|
||||
for &g in &edges {
|
||||
for &b in &edges {
|
||||
colors.push([r, g, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seed = 0x1234_5678u32;
|
||||
let mut next = |levels: u32| {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
((seed >> 8) % (levels + 1)) as f32 / levels as f32
|
||||
};
|
||||
for _ in 0..2000 {
|
||||
let levels = if next(1) > 0.5 { 255 } else { 65535 };
|
||||
colors.push([next(levels), next(levels), next(levels)]);
|
||||
}
|
||||
let mut worst = 0.0f32;
|
||||
for (i, &cb) in colors.iter().enumerate() {
|
||||
for &cs in colors.iter().skip(i % 7).step_by(7) {
|
||||
for m in NON_SEPARABLE {
|
||||
let (got, want) = (blend_rgb(m, cb, cs), spec_blend_rgb(m, cb, cs));
|
||||
for k in 0..3 {
|
||||
worst = worst.max((got[k] - want[k]).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only white-ish colours whose luminosity rounds a step above 1 can differ, by a rounding step.
|
||||
assert!(worst <= 1e-6, "{worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_separable_modes_stable_when_luminosity_is_out_of_range() {
|
||||
// The spec formula divided by `max − lum`, which is rounding noise for a grey above 1 and
|
||||
// turns colours inside out when the luminosity is above 1: Luminosity of a grey of 2 over
|
||||
// (0.5, 0.25, 0.125) gave (1.0, 2.32, 2.99).
|
||||
let spec = spec_blend_rgb(BlendMode::Luminosity, [0.5, 0.25, 0.125], [2.0; 3]);
|
||||
assert!(spec[0] < spec[2], "{spec:?}");
|
||||
let colors: [[f32; 3]; 10] = [
|
||||
[1.0, 1.0, 1.0],
|
||||
[1.5, 1.5, 1.5],
|
||||
[2.0, 2.0, 2.0],
|
||||
[4.0, 4.0, 4.0],
|
||||
[1000.0, 1000.0, 1000.0],
|
||||
[2.0, 2.0, 1.999_999],
|
||||
[4.0, 3.999_9, 4.0],
|
||||
[3.0, 0.25, 0.125],
|
||||
[1.25, 0.875, 3.75],
|
||||
[-0.25, 0.5, 1.375],
|
||||
];
|
||||
for top in [1.0, f32::MAX] {
|
||||
for m in NON_SEPARABLE {
|
||||
for cb in colors {
|
||||
for cs in colors {
|
||||
let r = blend_rgb_within(m, cb, cs, top);
|
||||
assert!(r.iter().all(|v| v.is_finite()), "{m:?} {cb:?} {cs:?} {top}: {r:?}");
|
||||
// The target luminosity is kept, at any depth.
|
||||
let want = if m == BlendMode::Luminosity { lum(cs) } else { lum(cb) };
|
||||
if want >= 0.0 {
|
||||
assert!((lum(r) - want).abs() <= 1e-4 * want.max(1.0), "{m:?} {cb:?} {cs:?} {top}: {r:?}");
|
||||
}
|
||||
let (lo, hi) = (r[0].min(r[1]).min(r[2]), r[0].max(r[1]).max(r[2]));
|
||||
if top == 1.0 {
|
||||
// In gamut, or (a luminosity above 1 has no in-gamut colour) its grey.
|
||||
assert!((lo >= 0.0 && hi <= 1.0 + E) || hi - lo <= 1e-6 * hi, "{m:?} {cb:?} {cs:?}: {r:?}");
|
||||
} else {
|
||||
assert!(lo >= -1e-6 * hi.max(1.0), "{m:?} {cb:?} {cs:?}: {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 32-bit: a grey backdrop gives a grey of its own luminosity in Hue and Saturation, a
|
||||
// grey source a grey in Color, and bright colours keep their values above 1.
|
||||
for g in [1.5f32, 2.0, 4.0, 1000.0] {
|
||||
let grey = [g; 3];
|
||||
for cs in colors {
|
||||
for m in [BlendMode::Hue, BlendMode::Saturation] {
|
||||
let r = blend_rgb_within(m, grey, cs, f32::MAX);
|
||||
assert!(r.iter().all(|&v| (v - lum(grey)).abs() <= 1e-5 * g), "{m:?} {g} {cs:?}: {r:?}");
|
||||
}
|
||||
}
|
||||
let r = blend_rgb_within(BlendMode::Luminosity, [0.5, 0.25, 0.125], grey, f32::MAX);
|
||||
assert!(r[0] > 1.0 && r[0] > r[1] && r[1] > r[2], "{g}: {r:?}");
|
||||
let r = blend_rgb_within(BlendMode::Color, grey, [3.0, 0.25, 0.125], f32::MAX);
|
||||
assert!(r[0] > 1.0 && r[0] > r[1] && r[1] > r[2], "{g}: {r:?}");
|
||||
// Integer documents: no colour of that luminosity fits in 0..1, so it is its grey.
|
||||
let r = blend_rgb_within(BlendMode::Luminosity, [0.5, 0.25, 0.125], grey, 1.0);
|
||||
assert!(r.iter().all(|&v| (v - lum(grey)).abs() <= 1e-5 * g), "{g}: {r:?}");
|
||||
}
|
||||
// Continuous across near-greys: the result barely moves from the grey's.
|
||||
for m in NON_SEPARABLE {
|
||||
let a = blend_rgb_within(m, [2.0, 2.0, 2.0], [4.0, 4.0, 4.0], f32::MAX);
|
||||
let b = blend_rgb_within(m, [2.0, 2.0, 1.999_999], [4.0, 3.999_999, 4.0], f32::MAX);
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1e-4), "{m:?}: {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn darker_lighter_color_pick_whole_pixel() {
|
||||
let a = [0.9, 0.1, 0.1];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
//! Reproduce tall-document TSL-off stroke-effect compositing from issue #2102.
|
||||
//! Run in release mode: `cargo run --release -p photocraft-compose --example bench_tsly_stroke`.
|
||||
|
||||
use std::error::Error;
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_compose::render_bands;
|
||||
use photocraft_doc::{Document, Effect, FxCommon, FxPaint, Layer, Size, StrokeFx, StrokePosition};
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
const WIDTH: u32 = 1920;
|
||||
const HEIGHT: u32 = 103_675;
|
||||
const BAND_ROWS: i32 = 4352;
|
||||
|
||||
fn document_with_strokes(layer_count: usize) -> Result<Document, Box<dyn Error>> {
|
||||
let mut document = Document::new("#2102 tall TSL-off strokes", Size::new(WIDTH, HEIGHT), ColorMode::Rgb, SampleType::U8);
|
||||
let step = HEIGHT / (layer_count as u32 + 1);
|
||||
for i in 0..layer_count {
|
||||
let y = step.saturating_mul(i as u32 + 1);
|
||||
let mut layer = Layer::raster("Outside stroke", PixelFormat::RGBA8);
|
||||
let surface = layer.surface_mut().ok_or("raster layer has no surface")?;
|
||||
surface.fill_rect(Rect::new(360, y as i32, 1560, y as i32 + 120), &[0.2, 0.5, 0.9, 1.0]);
|
||||
layer.effects.items.push(Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 6.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(0.8, 0.1, 0.2)),
|
||||
}));
|
||||
layer.advanced.transparency_shapes = false;
|
||||
document.layers.push(layer);
|
||||
}
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
for layer_count in [3, 10] {
|
||||
let document = document_with_strokes(layer_count)?;
|
||||
let start = Instant::now();
|
||||
render_bands(&document, document.bounds(), BAND_ROWS, |_| -> Result<(), &'static str> { Ok(()) })?;
|
||||
println!("{WIDTH}×{HEIGHT}, {layer_count} TSL-off 6 px outside strokes: {:.3} s", start.elapsed().as_secs_f64());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -85,12 +85,16 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth
|
|||
}
|
||||
Adjustment::Posterize { levels } => map_rgb(buf, |c| c.map(|v| posterize(v, *levels))),
|
||||
Adjustment::BrightnessContrast { brightness, contrast, legacy: true } => {
|
||||
// Legacy: contrast scales around mid-grey, then brightness is added
|
||||
// (fitted to Photoshop's rendering, exact on the corpus).
|
||||
// Legacy: contrast scales around 128/255, with brightness added before it when
|
||||
// contrast is raised and after it when contrast is lowered. Measured on Photoshop
|
||||
// 27.11, brightness +40 with contrast +30 maps 128 to 185 (brightness first), while
|
||||
// the corpus's brightness -23 with contrast -26 (psd-tools) needs contrast first;
|
||||
// contrast 80 keeps 128 at 128 (a 0.5 pivot moved it to 130).
|
||||
let b = brightness / 255.0;
|
||||
let c = contrast.clamp(-100.0, 99.0);
|
||||
let k = if c >= 0.0 { 1.0 / (1.0 - c / 100.0) } else { 1.0 + c / 100.0 };
|
||||
map_rgb(buf, |px| px.map(|v| ((v - 0.5) * k + 0.5 + b).clamp(0.0, 1.0)))
|
||||
let (pre, post) = if c >= 0.0 { (b, 0.0) } else { (0.0, b) };
|
||||
map_rgb(buf, |px| px.map(|v| ((v + pre - LEGACY_PIVOT) * k + LEGACY_PIVOT + post).clamp(0.0, 1.0)))
|
||||
}
|
||||
Adjustment::BrightnessContrast { brightness, contrast, .. } => {
|
||||
// Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth
|
||||
|
|
@ -303,12 +307,13 @@ pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
|
|||
}
|
||||
|
||||
/// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1.
|
||||
/// Colorize sets the hue and saturation instead of shifting them.
|
||||
/// Colorize sets the hue and saturation instead of shifting them; at 0% it leaves only the
|
||||
/// lightness, as in Photoshop (#2326). Ticking Colorize in the dialog starts saturation at 25%.
|
||||
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
|
||||
let (mut hh, mut ss, ll) = rgb_to_hsl(c);
|
||||
if colorize {
|
||||
hh = hue.rem_euclid(360.0) / 360.0;
|
||||
ss = s.abs().max(0.25);
|
||||
ss = s.abs().min(1.0);
|
||||
} else {
|
||||
hh = (hh + hue / 360.0).rem_euclid(1.0);
|
||||
ss = (ss * (1.0 + s)).clamp(0.0, 1.0);
|
||||
|
|
@ -820,6 +825,32 @@ mod tone_tests {
|
|||
Buffer { rect, px }
|
||||
}
|
||||
|
||||
/// Photoshop 27.11, measured: Brightness/Contrast with Use Legacy on, over a gray ramp
|
||||
/// (x = 0, 16, …, 240, 255), one row per (brightness, contrast).
|
||||
#[test]
|
||||
fn legacy_brightness_contrast_matches_photoshop() {
|
||||
const X: [u8; 17] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255];
|
||||
let measured: [(f32, f32, [u8; 17]); 7] = [
|
||||
(40.0, 0.0, [40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 216, 232, 248, 255, 255, 255]),
|
||||
(-40.0, 0.0, [0, 0, 0, 8, 24, 40, 56, 72, 88, 104, 120, 136, 152, 168, 184, 200, 215]),
|
||||
(0.0, 30.0, [0, 0, 0, 14, 37, 60, 82, 105, 128, 151, 174, 196, 219, 242, 255, 255, 255]),
|
||||
(0.0, -30.0, [38, 49, 61, 72, 83, 94, 106, 117, 128, 139, 150, 162, 173, 184, 195, 207, 217]),
|
||||
(40.0, 30.0, [3, 25, 48, 71, 94, 117, 139, 162, 185, 208, 231, 253, 255, 255, 255, 255, 255]),
|
||||
(100.0, 0.0, [100, 116, 132, 148, 164, 180, 196, 212, 228, 244, 255, 255, 255, 255, 255, 255, 255]),
|
||||
(0.0, 80.0, [0, 0, 0, 0, 0, 0, 0, 48, 128, 208, 255, 255, 255, 255, 255, 255, 255]),
|
||||
];
|
||||
for (brightness, contrast, want) in measured {
|
||||
let rect = Rect::new(0, 0, 17, 1);
|
||||
let px = X.iter().map(|x| [f32::from(*x) / 255.0; 4]).map(|p| [p[0], p[1], p[2], 1.0]).collect();
|
||||
let mut b = Buffer { rect, px };
|
||||
apply(&Adjustment::BrightnessContrast { brightness, contrast, legacy: true }, &mut b);
|
||||
for (i, (p, w)) in b.px.iter().zip(want).enumerate() {
|
||||
let got = (p[0] * 255.0).round() as i32;
|
||||
assert!((got - i32::from(w)).abs() <= 1, "b {brightness} c {contrast} at {}: {got} vs Photoshop {w}", X[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_lookup_with_an_overflowing_stored_size_is_the_identity() {
|
||||
// A stored document's size is untrusted: (2²²)³ × 3 overflows, which once wrapped past
|
||||
|
|
@ -940,6 +971,30 @@ mod tone_tests {
|
|||
assert_eq!(b.px[2], [0.5, 0.5, 0.5, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_at_zero_saturation_is_grey() {
|
||||
// Photoshop: Colorize at 0% saturation leaves no colour, only the pixel's lightness (#2326).
|
||||
let px = vec![[0.9, 0.2, 0.1, 1.0], [0.1, 0.6, 0.8, 1.0], [0.5, 0.5, 0.5, 1.0]];
|
||||
for depth in [None, Some(SampleType::U8), Some(SampleType::U16), Some(SampleType::F32)] {
|
||||
for lightness in [0.0, 30.0, -30.0] {
|
||||
let a = Adjustment::HueSaturation { hue: 200.0, saturation: 0.0, lightness, colorize: true, ranges: HueRange::defaults() };
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 3, 1), px: px.clone() };
|
||||
apply_depth(&a, &mut b, Transfer::Srgb, depth);
|
||||
for p in &b.px {
|
||||
assert!((p[0] - p[1]).abs() < 1e-4 && (p[1] - p[2]).abs() < 1e-4, "{depth:?} lightness {lightness}: {p:?}");
|
||||
}
|
||||
}
|
||||
// Low saturations still tint: the old 25% floor no longer hides them.
|
||||
let tint = |saturation: f32| {
|
||||
let a = Adjustment::HueSaturation { hue: 0.0, saturation, lightness: 0.0, colorize: true, ranges: HueRange::defaults() };
|
||||
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.5, 0.5, 0.5, 1.0]] };
|
||||
apply_depth(&a, &mut b, Transfer::Srgb, depth);
|
||||
b.px[0][0] - b.px[0][2]
|
||||
};
|
||||
assert!(tint(10.0) > 0.05 && tint(10.0) < tint(25.0), "{depth:?}: 10% {} vs 25% {}", tint(10.0), tint(25.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_white_weights() {
|
||||
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
|
||||
|
|
@ -962,3 +1017,7 @@ mod tone_tests {
|
|||
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Brightness/Contrast scales contrast around 128 of 255 (contrast 80 keeps 128 at 128,
|
||||
/// measured on Photoshop 27.11; a 0.5 pivot moved it to 130).
|
||||
pub const LEGACY_PIVOT: f32 = 128.0 / 255.0;
|
||||
|
|
|
|||
|
|
@ -272,6 +272,94 @@ fn inside_stroke() -> Effect {
|
|||
})
|
||||
}
|
||||
|
||||
fn outside_stroke() -> Effect {
|
||||
Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 6.0,
|
||||
position: StrokePosition::Outside,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapeless_stroke_tile_skips_match_full_region_across_blending_settings() {
|
||||
use photocraft_doc::BlendRange;
|
||||
|
||||
let modes = [BlendMode::Normal, BlendMode::Multiply, BlendMode::Screen, BlendMode::Difference];
|
||||
for depth in DEPTHS {
|
||||
for position in [StrokePosition::Outside, StrokePosition::Inside, StrokePosition::Center] {
|
||||
for blend in modes {
|
||||
for fill in [0.0, 0.5, 1.0] {
|
||||
for blend_if in [false, true] {
|
||||
let mut d = Document::with_background("t", Size::new(80, 768), ColorMode::Rgb, depth, Color::WHITE);
|
||||
let mut l = solid(&d, "stroke", Rect::new(28, 318, 52, 342), BLUE);
|
||||
l.effects.items = vec![Effect::Stroke(StrokeFx {
|
||||
common: FxCommon::new(BlendMode::Normal, 1.0),
|
||||
size: 6.0,
|
||||
position,
|
||||
paint: FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)),
|
||||
})];
|
||||
l.blend = blend;
|
||||
l.fill_opacity = fill;
|
||||
l.advanced.transparency_shapes = false;
|
||||
if blend_if {
|
||||
l.blend_if.set(0, [BlendRange::FULL, BlendRange { black: [128, 128], white: [255, 255] }]);
|
||||
}
|
||||
d.layers.push(l);
|
||||
|
||||
let rect = Rect::new(0, 0, 80, 768);
|
||||
let whole = render_tiled(&d, rect, 1024);
|
||||
let tiled = render_tiled(&d, rect, 64);
|
||||
assert_eq!(whole.px, tiled.px, "{depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}");
|
||||
|
||||
let mut bands = Vec::new();
|
||||
render_bands(&d, rect, 256, |band| -> Result<(), ()> {
|
||||
bands.push(band);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mut band_pixels = Vec::with_capacity(whole.px.len());
|
||||
for band in bands {
|
||||
band_pixels.extend(band.px);
|
||||
}
|
||||
assert_eq!(whole.px, band_pixels, "banded: {depth:?}, {position:?}, {blend:?}, fill={fill}, blend_if={blend_if}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapeless_stroke_skip_rejects_advanced_blending_and_masks() {
|
||||
use photocraft_doc::{BlendRange, Path, Subpath, VectorMask};
|
||||
|
||||
let d = doc(SampleType::U8, true);
|
||||
let mut l = solid(&d, "stroke", Rect::new(2, 2, 8, 8), BLUE);
|
||||
l.effects.items = vec![outside_stroke()];
|
||||
l.advanced.transparency_shapes = false;
|
||||
assert!(shapeless_stroke_bounds(&l).is_some());
|
||||
|
||||
l.advanced.knockout = Knockout::Deep;
|
||||
assert!(shapeless_stroke_bounds(&l).is_none(), "knockout changes the backdrop outside the content");
|
||||
l.advanced.knockout = Knockout::None;
|
||||
|
||||
l.advanced.blend_interior = true;
|
||||
assert!(shapeless_stroke_bounds(&l).is_none(), "Blend Interior effects remain on the uncropped path");
|
||||
l.advanced.blend_interior = false;
|
||||
|
||||
l.blend_if.set(0, [BlendRange::FULL, BlendRange { black: [128, 128], white: [255, 255] }]);
|
||||
assert!(shapeless_stroke_bounds(&l).is_none(), "Blend If remains on the uncropped path");
|
||||
l.blend_if = Default::default();
|
||||
|
||||
l.mask = Some(LayerMask::hide_all());
|
||||
assert!(shapeless_stroke_bounds(&l).is_none(), "enabled masks remain on the uncropped path");
|
||||
l.mask = None;
|
||||
|
||||
l.vector_mask = Some(VectorMask::new(Path::new(vec![Subpath::polygon(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)])])));
|
||||
assert!(shapeless_stroke_bounds(&l).is_none(), "enabled vector masks remain on the uncropped path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_mask_hides_effects() {
|
||||
for depth in DEPTHS {
|
||||
|
|
|
|||
|
|
@ -915,6 +915,16 @@ pub fn ranged_lut(contour: &Contour, range: f32) -> Option<Vec<f32>> {
|
|||
)
|
||||
}
|
||||
|
||||
/// The bevel Contour element's height transfer: the contour alone. Unlike a glow's, its Range is
|
||||
/// not a `v / range` stretch: that flattened a Linear contour at the default 50 % to a bevel half
|
||||
/// as wide as Photoshop's (a real 18 px smooth inner bevel at 136 % depth: top-edge highlight gone
|
||||
/// 15 px in, Photoshop's still 0.19 at 16 px), and dropping it lowers the bad pixels of every
|
||||
/// contour oracle (bevel-inner-contour-ramp-peak 25.9 → 21.8 %, -double-ring-aa-r70 25.2 → 21.0 %,
|
||||
/// bevel-pillow-chisel-hard-gloss-and-contour 11.4 → 7.2 %). What Range does instead is still open.
|
||||
pub fn bevel_contour_lut(c: &photocraft_doc::BevelContour) -> Vec<f32> {
|
||||
contour_lut(&c.contour).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect())
|
||||
}
|
||||
|
||||
fn apply_lut(m: Map, l: Option<Vec<f32>>) -> Map {
|
||||
match l {
|
||||
None => m,
|
||||
|
|
@ -1480,9 +1490,9 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern
|
|||
}
|
||||
h
|
||||
};
|
||||
// Contour element: the height profile through the contour over its range.
|
||||
// Contour element: the height profile through the contour.
|
||||
if let Some(c) = &b.contour {
|
||||
h = apply_lut(h, Some(ranged_lut(&c.contour, c.range).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect())));
|
||||
h = apply_lut(h, Some(bevel_contour_lut(c)));
|
||||
}
|
||||
// Texture element: the pattern's luminance as extra height, scaled so a full-contrast step
|
||||
// at 100 % depth slopes like the bevel at its depth.
|
||||
|
|
@ -2799,6 +2809,16 @@ mod tests {
|
|||
assert!(at(24, 12) > 0.1 && (at(24, 12) - at(24, 13)).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_bevel_contour_leaves_the_height_alone_at_any_range() {
|
||||
for range in [0.5, 0.7, 1.0] {
|
||||
let lut = bevel_contour_lut(&photocraft_doc::BevelContour { contour: Contour::Linear, range, anti_alias: false });
|
||||
for v in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert!((lut_at(&lut, v) - v).abs() < 1e-3, "range {range}: {v} -> {}", lut_at(&lut, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bevel_contour_and_texture_shape_the_height() {
|
||||
let shape = square(40, 6, 34);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
//! The mapping matches the Gradient tool's destructive drag (`photocraft_algo::paint`): a
|
||||
//! Linear gradient runs from the start (t = 0) to the end point (t = 1); Radial, Angle,
|
||||
//! Reflected and Diamond gradients are centred on the start point and reach t = 1 at the end
|
||||
//! point's distance. A fill made from a drag therefore renders the same pixels as painting that
|
||||
//! drag (see the tests).
|
||||
//! point's distance. Fill and painted gradients share this geometry (see the tests).
|
||||
|
||||
use photocraft_doc::{Fill, GradientStyle};
|
||||
use photocraft_geom::Rect;
|
||||
|
|
@ -19,8 +18,8 @@ const MIN_LEN: f32 = 0.5;
|
|||
/// A gradient fill's colour ramp, prepared for one render (colours converted once, while the
|
||||
/// document's CMYK profile is active).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ramp {
|
||||
color: Vec<(f32, [f32; 4])>,
|
||||
pub struct Ramp<const N: usize = 4> {
|
||||
color: Vec<(f32, [f32; N])>,
|
||||
mids: Vec<f32>,
|
||||
opacity: Vec<(f32, f32)>,
|
||||
}
|
||||
|
|
@ -28,14 +27,17 @@ pub struct Ramp {
|
|||
impl Ramp {
|
||||
/// The ramp of a gradient fill (`None` for other fills).
|
||||
pub fn new(f: &Fill) -> Option<Ramp> {
|
||||
Self::with_colors(f, |c| {
|
||||
let rgb = c.to_rgb();
|
||||
[rgb[0], rgb[1], rgb[2], c.alpha]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<const N: usize> Ramp<N> {
|
||||
fn with_colors(f: &Fill, convert: impl Fn(photocraft_doc::Color) -> [f32; N]) -> Option<Self> {
|
||||
let Fill::Gradient { stops, opacity_stops, midpoints, .. } = f else { return None };
|
||||
let mut color: Vec<(f32, [f32; 4])> = stops
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let rgb = c.to_rgb();
|
||||
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
})
|
||||
.collect();
|
||||
let mut color: Vec<_> = stops.iter().map(|(p, c)| (*p, convert(*c))).collect();
|
||||
color.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut opacity = opacity_stops.clone();
|
||||
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
|
@ -43,10 +45,10 @@ impl Ramp {
|
|||
}
|
||||
|
||||
/// Colour and alpha at `t` (`0..=1`).
|
||||
pub fn sample(&self, t: f32) -> [f32; 4] {
|
||||
pub fn sample(&self, t: f32) -> [f32; N] {
|
||||
let mut c = sample_color(&self.color, &self.mids, t);
|
||||
if !self.opacity.is_empty() {
|
||||
c[3] *= sample_opacity(&self.opacity, t);
|
||||
if let Some(alpha) = c.last_mut() {
|
||||
*alpha *= sample_opacity(&self.opacity, t);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
|
@ -65,8 +67,8 @@ pub fn midpoint_remap(u: f32, m: f32) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] };
|
||||
fn sample_color<const N: usize>(stops: &[(f32, [f32; N])], mids: &[f32], t: f32) -> [f32; N] {
|
||||
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; N] };
|
||||
if t <= first.0 {
|
||||
return first.1;
|
||||
}
|
||||
|
|
@ -97,29 +99,74 @@ fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 {
|
|||
|
||||
/// Adds one level of the shared dither noise to a colour (as the Gradient tool does).
|
||||
pub fn dither(c: &mut [f32; 4], x: i32, y: i32) {
|
||||
dither_channels(&mut c[..3], x, y);
|
||||
}
|
||||
|
||||
fn dither_channels(c: &mut [f32], x: i32, y: i32) {
|
||||
dither_channels_to(c, x, y, None);
|
||||
}
|
||||
|
||||
/// [`dither_channels`], then rounded to `quantum` steps when given. The noise is a fraction of a
|
||||
/// level that only means something once the value lands on a level, as it does in a painted
|
||||
/// gradient: left in float, a dark channel lifted 0.002 above black is still 0.002 to the next
|
||||
/// blend, and Color Dodge under a bright layer turns that into visible speckle (#2755).
|
||||
fn dither_channels_to(c: &mut [f32], x: i32, y: i32, quantum: Option<f32>) {
|
||||
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
|
||||
for ch in c.iter_mut().take(3) {
|
||||
for ch in c {
|
||||
*ch = (*ch + n).clamp(0.0, 1.0);
|
||||
if let Some(q) = quantum {
|
||||
*ch = (*ch * q + 0.5).floor() / q;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a gradient fill over `rect`, laid out in `frame` (row-major RGBA).
|
||||
pub fn render(f: &Fill, rect: Rect, frame: Rect) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize];
|
||||
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (Ramp::new(f), f) else { return px };
|
||||
render_quantized(f, rect, frame, None)
|
||||
}
|
||||
|
||||
/// [`render`] with dithered pixels rounded to `quantum` steps (the document depth's levels, see
|
||||
/// [`crate::adjustment_quantum`]), as a gradient painted into a layer of that depth stores them.
|
||||
pub fn render_quantized(f: &Fill, rect: Rect, frame: Rect, quantum: Option<f32>) -> Vec<[f32; 4]> {
|
||||
render_ramp(f, rect, frame, Ramp::new(f), quantum)
|
||||
}
|
||||
|
||||
/// Native CMYK solid/gradient pixels at document depth; patterns return `None`.
|
||||
/// Use the same samples for rendering and PSD caches to avoid a lossy RGB separation.
|
||||
pub fn render_cmyk_fill(f: &Fill, rect: Rect, frame: Rect, depth: photocraft_color::SampleType) -> Option<photocraft_raster::Surface> {
|
||||
let native = |c: photocraft_doc::Color| {
|
||||
let c = c.in_mode(photocraft_color::ColorMode::Cmyk);
|
||||
[c.c[0], c.c[1], c.c[2], c.c[3], c.alpha]
|
||||
};
|
||||
let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Cmyk, depth, true));
|
||||
match f {
|
||||
Fill::Solid(c) => surface.fill_rect(rect, &native(*c)),
|
||||
Fill::Gradient { .. } => {
|
||||
let px = render_ramp(f, rect, frame, Ramp::with_colors(f, native), None);
|
||||
surface.write_region(rect, px.as_flattened());
|
||||
}
|
||||
Fill::Pattern { .. } => return None,
|
||||
}
|
||||
surface.prune();
|
||||
Some(surface)
|
||||
}
|
||||
|
||||
fn render_ramp<const N: usize>(f: &Fill, rect: Rect, frame: Rect, ramp: Option<Ramp<N>>, quantum: Option<f32>) -> Vec<[f32; N]> {
|
||||
let mut px = vec![[0.0f32; N]; rect.width() as usize * rect.height() as usize];
|
||||
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (ramp, f) else { return px };
|
||||
let w = rect.width() as usize;
|
||||
if w == 0 {
|
||||
return px;
|
||||
}
|
||||
// Linear and Reflected end points on whole pixels, as Photoshop (crate::fill_layout).
|
||||
let (angle, scale, offset) = &crate::fill_layout::gradient_layout(*style, *angle, *scale, *offset, frame);
|
||||
let row = |y: i32, out: &mut [[f32; 4]]| {
|
||||
let row = |y: i32, out: &mut [[f32; N]]| {
|
||||
for (i, p) in out.iter_mut().enumerate() {
|
||||
let x = rect.x0 + i as i32;
|
||||
let t = crate::effects::gradient_t(*style, *angle, *scale, *reverse, *offset, frame, x as f32 + 0.5, y as f32 + 0.5);
|
||||
*p = ramp.sample(t);
|
||||
if *dith {
|
||||
dither(p, x, y);
|
||||
dither_channels_to(p.get_mut(..N.saturating_sub(1)).unwrap_or_default(), x, y, quantum);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -205,6 +252,29 @@ mod tests {
|
|||
|
||||
const STYLES: [GradientStyle; 5] = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
|
||||
|
||||
#[test]
|
||||
fn native_cmyk_ramp_interpolates_black_and_alpha_separately() {
|
||||
let a = Color { mode: photocraft_color::ColorMode::Cmyk, c: [0.0, 0.2, 0.4, 0.6], alpha: 0.5 };
|
||||
let b = Color { c: [0.4, 0.6, 0.8, 1.0], alpha: 1.0, ..a };
|
||||
let mut f = Fill::gradient(vec![(1.0, b), (0.0, a)], 0.0, 1.0, GradientStyle::Linear, false);
|
||||
if let Fill::Gradient { midpoints, opacity_stops, .. } = &mut f {
|
||||
*midpoints = vec![0.25];
|
||||
*opacity_stops = vec![(1.0, 0.0), (0.0, 1.0)];
|
||||
}
|
||||
let ramp = Ramp::with_colors(&f, |c| [c.c[0], c.c[1], c.c[2], c.c[3], c.alpha]).unwrap();
|
||||
let expected = [0.2, 0.4, 0.6, 0.8, 0.5625];
|
||||
for (v, expected) in ramp.sample(0.25).into_iter().zip(expected) {
|
||||
assert!((v - expected).abs() < 1e-6);
|
||||
}
|
||||
let mut dithered = ramp.sample(0.25);
|
||||
dither_channels(&mut dithered[..4], 3, 7);
|
||||
assert_eq!(dithered[4], expected[4]);
|
||||
let noise = (photocraft_color::dither_noise(3, 7) - 0.5) / 255.0;
|
||||
for i in 0..4 {
|
||||
assert!((dithered[i] - expected[i] - noise).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_round_trip() {
|
||||
let frame = Rect::new(10, -4, 210, 96);
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer {
|
|||
fn render_tiled_with(doc: &Document, rect: Rect, tile: i32, cx: &Ctx) -> Buffer {
|
||||
let tile = tile.max(1);
|
||||
// Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX); 32-bit
|
||||
// documents don't clip Add / Divide at 1 (psblend::HDR).
|
||||
// documents don't clip Add / Divide or Hue / Saturation / Color / Luminosity at 1 (psblend::HDR).
|
||||
let lab = doc.mode == photocraft_color::ColorMode::Lab;
|
||||
let hdr = doc.depth == photocraft_color::SampleType::F32;
|
||||
// CMYK layers are read through the document's own CMYK profile (thread-local scope).
|
||||
|
|
@ -734,7 +734,16 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
|
|||
LayerContent::Fill(f) => match &layer.fill_cache {
|
||||
// Photoshop's own rendering, valid while the fill is unchanged.
|
||||
Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect),
|
||||
_ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns),
|
||||
_ if cx.mode == photocraft_color::ColorMode::Cmyk => {
|
||||
let frame = fill_frame(layer, cx.canvas);
|
||||
let native_rect = if matches!(f, Fill::Solid(_)) { Rect::new(0, 0, 1, 1) } else { rect };
|
||||
match gradient_fill::render_cmyk_fill(f, native_rect, frame, cx.depth) {
|
||||
Some(s) if matches!(f, Fill::Solid(_)) => Buffer::filled(rect, s.rgba(0, 0)),
|
||||
Some(s) => surface_to_buffer(&s, rect),
|
||||
None => render_fill(f, rect, frame, cx.patterns, adjustment_quantum(cx.depth)),
|
||||
}
|
||||
}
|
||||
_ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns, adjustment_quantum(cx.depth)),
|
||||
},
|
||||
LayerContent::Adjustment(_) => return None,
|
||||
_ => match layer.surface() {
|
||||
|
|
@ -814,17 +823,19 @@ pub fn fill_frame(layer: &Layer, canvas: Rect) -> Rect {
|
|||
/// compositor does) but without applying the masks: the pixels a PSD fill layer stores.
|
||||
pub fn render_fill_content(layer: &Layer, f: &Fill, canvas: Rect, patterns: &[Pattern]) -> Buffer {
|
||||
let prepared = pattern::PreparedPatterns::new(patterns, pattern::PREPARED_PATTERN_BYTES);
|
||||
render_fill(f, canvas, fill_frame(layer, canvas), &prepared)
|
||||
render_fill(f, canvas, fill_frame(layer, canvas), &prepared, None)
|
||||
}
|
||||
|
||||
fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>) -> Buffer {
|
||||
/// `quantum`: the document depth's levels, which a dithered gradient is rounded to
|
||||
/// ([`gradient_fill::render_quantized`]); `None` keeps it in float.
|
||||
fn render_fill(f: &Fill, rect: Rect, canvas: Rect, patterns: &pattern::PreparedPatterns<'_>, quantum: Option<f32>) -> Buffer {
|
||||
match f {
|
||||
Fill::Solid(c) => {
|
||||
let rgb = c.to_rgb();
|
||||
Buffer::filled(rect, [rgb[0], rgb[1], rgb[2], c.alpha])
|
||||
}
|
||||
// Gradient geometry relative to the layer's frame, independent of the render rect.
|
||||
Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render(f, rect, canvas) },
|
||||
Fill::Gradient { .. } => Buffer { rect, px: gradient_fill::render_quantized(f, rect, canvas, quantum) },
|
||||
// Laid out from the layer's frame when linked; transparent if the pattern is missing.
|
||||
Fill::Pattern { name, scale, id, angle, link, phase } => match patterns.get(id, name) {
|
||||
Some(tile) => Buffer { rect, px: pattern::render(&tile, &pattern::Placement::new(canvas, *link, *phase, *scale, *angle), rect) },
|
||||
|
|
@ -842,7 +853,8 @@ fn empty_in(layer: &Layer, rect: Rect) -> bool {
|
|||
// Effects reach at most `margin` beyond the layer's pixels (when it is transparent
|
||||
// outside them): render tiles away from a small text layer skip it entirely.
|
||||
let canvas = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4);
|
||||
return transparent_outside(layer) && layer_bounds(layer, canvas).inflate(effects::margin(layer)).intersect(&rect).is_empty();
|
||||
let bounds = shapeless_stroke_bounds(layer).unwrap_or_else(|| layer_bounds(layer, canvas));
|
||||
return transparent_outside(layer) && bounds.inflate(effects::margin(layer)).intersect(&rect).is_empty();
|
||||
}
|
||||
match &layer.content {
|
||||
LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => match layer.surface() {
|
||||
|
|
@ -853,6 +865,47 @@ fn empty_in(layer: &Layer, rect: Rect) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// For the narrow `tsly=0` case where only local stroke effects are enabled, the effects cannot
|
||||
/// contribute outside the stroke reach from the layer's pixels. Other `tsly=0` effects can cover
|
||||
/// the full layer, and advanced blending can change the backdrop independently of those pixels.
|
||||
fn shapeless_stroke_bounds(layer: &Layer) -> Option<Rect> {
|
||||
use photocraft_doc::Knockout;
|
||||
|
||||
if layer.advanced.transparency_shapes
|
||||
|| layer.advanced.knockout != Knockout::None
|
||||
|| layer.advanced.blend_interior
|
||||
|| layer.clipped
|
||||
|| !layer.blend_if.is_default()
|
||||
|| layer.mask.as_ref().is_some_and(|m| m.enabled)
|
||||
|| layer.vector_mask.as_ref().is_some_and(|m| m.enabled)
|
||||
|| !transparent_outside(layer)
|
||||
|| !effects::maps_are_local(layer)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if !layer.effects.enabled
|
||||
|| !layer.effects.items.iter().any(|e| e.enabled())
|
||||
|| layer.effects.items.iter().any(|e| e.enabled() && !matches!(e, photocraft_doc::Effect::Stroke(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
match &layer.content {
|
||||
LayerContent::Raster(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_) => {
|
||||
let b = layer.surface().map_or(Rect::EMPTY, bounds::content_bounds);
|
||||
// A filled shape's effects follow its outline, also where its fill is transparent (as
|
||||
// in `layer_bounds`).
|
||||
Some(match effect_outline(layer).and_then(|_| paint_bounds(layer)) {
|
||||
Some(p) if !b.is_empty() => b.union(&p),
|
||||
Some(p) => p,
|
||||
None => b,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Blending Options › Channels as per-channel weights over display RGB (1 = the layer's result,
|
||||
/// 0 = the backdrop's value kept), or `None` when every channel blends. RGB documents map R, G, B
|
||||
/// directly and a grayscale document's single channel covers all three; other modes composite in
|
||||
|
|
@ -1105,7 +1158,10 @@ fn composite_artboard(layer: &Layer, ab: &photocraft_doc::Artboard, clipped: &[L
|
|||
|
||||
fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer, cx: &Ctx, scope: advanced::Scope) {
|
||||
let rect = backdrop.rect;
|
||||
if empty_in(layer, rect) {
|
||||
// A TSL-off stroke base that is transparent here can still have visible clipped siblings;
|
||||
// keep the full clipping path for it instead of the stroke-bounds shortcut. Other bases keep
|
||||
// the usual skip (their clipped layers vanish with them).
|
||||
if (clipped.is_empty() || shapeless_stroke_bounds(layer).is_none()) && empty_in(layer, rect) {
|
||||
return;
|
||||
}
|
||||
let opacity = layer.opacity * layer.fill_opacity;
|
||||
|
|
@ -1209,12 +1265,12 @@ fn composite_layer_plain(layer: &Layer, clipped: &[Layer], backdrop: &mut Buffer
|
|||
*p = psblend::composite(BlendMode::Normal, *p, *s, 1.0);
|
||||
p[3] *= mask_k(&mask, i);
|
||||
}
|
||||
blend_into(backdrop, &content, layer.blend, opacity);
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, 1.0);
|
||||
return;
|
||||
}
|
||||
let Some(mut content) = render_content(layer, rect, cx) else { return };
|
||||
advanced::composite_clipped(clipped, &mut content, cx);
|
||||
blend_into_g(backdrop, &content, layer.blend, opacity, text_gamma(layer));
|
||||
blend_into_fill(backdrop, &content, layer.blend, layer.opacity, layer.fill_opacity, text_gamma(layer));
|
||||
}
|
||||
|
||||
/// A layer with effects (and its clipping group) onto `backdrop`.
|
||||
|
|
@ -1581,6 +1637,19 @@ fn blend_into(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32
|
|||
blend_into_g(backdrop, src, mode, opacity, 1.0);
|
||||
}
|
||||
|
||||
/// [`blend_into_g`] for a layer's own `opacity` and `fill`: Fill is part of the blend for
|
||||
/// Photoshop's special eight ([`psblend::composite_fill`]), plain coverage otherwise.
|
||||
fn blend_into_fill(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, fill: f32, gamma: f32) {
|
||||
if fill >= 1.0 || !psblend::fill_is_special(mode) {
|
||||
return blend_into_g(backdrop, src, mode, opacity * fill, gamma);
|
||||
}
|
||||
for (b, s) in backdrop.px.iter_mut().zip(&src.px) {
|
||||
if s[3] > 0.0 {
|
||||
*b = psblend::composite_fill(mode, *b, *s, opacity, fill, gamma);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`blend_into`] mixing coverage in a `gamma` space (type layers).
|
||||
fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f32, gamma: f32) {
|
||||
let rect = backdrop.rect;
|
||||
|
|
|
|||
|
|
@ -76,8 +76,9 @@ thread_local! {
|
|||
/// (Photoshop composites Lab documents in Lab; an anti-aliased edge between two colours
|
||||
/// differs by up to 14 / 255 from an sRGB mix: psd-tools stroke-color-descriptors-lab).
|
||||
pub static LAB_MIX: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Set while rendering a 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1
|
||||
/// ([`generic::blend_rgb_hdr`]), as in Photoshop's 32-bit mode; integer depths clip.
|
||||
/// Set while rendering a 32-bit float document: Linear Dodge (Add) and Divide don't clip at 1,
|
||||
/// and Hue, Saturation, Color and Luminosity keep values above 1 ([`generic::blend_rgb_hdr`]),
|
||||
/// as in Photoshop's 32-bit mode; integer depths clip.
|
||||
pub static HDR: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +121,76 @@ pub fn blend_rgb(mode: BlendMode, cb: [f32; 3], cs: [f32; 3]) -> [f32; 3] {
|
|||
}
|
||||
}
|
||||
|
||||
/// Photoshop's "special eight": the modes for which a layer's Fill isn't just less coverage.
|
||||
/// Below 100% Fill, Photoshop blends at full strength a source pulled toward the mode's neutral
|
||||
/// colour, the one that leaves the backdrop unchanged (Hard Mix has its own formula); Opacity
|
||||
/// still mixes the result in. Measured on Photoshop 27.11 (Fill 50%, eight modes, ±1 level).
|
||||
pub fn fill_is_special(mode: BlendMode) -> bool {
|
||||
matches!(
|
||||
mode,
|
||||
BlendMode::ColorBurn
|
||||
| BlendMode::LinearBurn
|
||||
| BlendMode::ColorDodge
|
||||
| BlendMode::LinearDodge
|
||||
| BlendMode::VividLight
|
||||
| BlendMode::LinearLight
|
||||
| BlendMode::HardMix
|
||||
| BlendMode::Difference
|
||||
)
|
||||
}
|
||||
|
||||
/// [`blend_rgb`] for a layer at `fill` (0..1): the special eight ([`fill_is_special`]) change
|
||||
/// their blend below 100%; every other mode, or `fill >= 1`, is plain [`blend_rgb`].
|
||||
pub fn blend_rgb_fill(mode: BlendMode, cb: [f32; 3], cs: [f32; 3], fill: f32) -> [f32; 3] {
|
||||
let f = fill.clamp(0.0, 1.0);
|
||||
if f >= 1.0 || !fill_is_special(mode) {
|
||||
return blend_rgb(mode, cb, cs);
|
||||
}
|
||||
let toward = |neutral: f32| -> [f32; 3] { std::array::from_fn(|i| neutral + (cs[i] - neutral) * f) };
|
||||
match mode {
|
||||
// Hard Mix at Fill f: (cb − f·(1 − cs)) / (1 − f), clamped. At f = 1 it is the hard
|
||||
// threshold (cb + cs ≥ 1), at f = 0 the backdrop.
|
||||
BlendMode::HardMix => std::array::from_fn(|i| ((cb[i] - f * (1.0 - cs[i])) / (1.0 - f)).clamp(0.0, 1.0)),
|
||||
BlendMode::ColorBurn | BlendMode::LinearBurn => blend_rgb(mode, cb, toward(1.0)),
|
||||
BlendMode::VividLight | BlendMode::LinearLight => blend_rgb(mode, cb, toward(0.5)),
|
||||
_ => blend_rgb(mode, cb, toward(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`composite_gamma`] for a layer with separate `opacity` and `fill`. Normal modes take both
|
||||
/// as coverage, as before. For the special eight below 100% Fill, coverage over the backdrop is
|
||||
/// `opacity` and the blend is [`blend_rgb_fill`]; over transparency, Fill stays coverage, so a
|
||||
/// layer over nothing looks as it did.
|
||||
pub fn composite_fill(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32, fill: f32, gamma: f32) -> [f32; 4] {
|
||||
if fill >= 1.0 || !fill_is_special(mode) {
|
||||
return composite_gamma(mode, backdrop, source, opacity * fill.clamp(0.0, 1.0), gamma);
|
||||
}
|
||||
let ab = backdrop[3];
|
||||
let over = source[3] * opacity;
|
||||
let alone = over * fill.clamp(0.0, 1.0);
|
||||
if over <= 0.0 {
|
||||
return backdrop;
|
||||
}
|
||||
let ao = ab + (1.0 - ab) * alone;
|
||||
if ao <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let cb = [backdrop[0], backdrop[1], backdrop[2]];
|
||||
let cs = [source[0], source[1], source[2]];
|
||||
let b = blend_rgb_fill(mode, cb, cs, fill);
|
||||
let (wb, wbs, ws) = (ab * (1.0 - over) / ao, ab * over / ao, (1.0 - ab) * alone / ao);
|
||||
let mut out = [0.0f32; 4];
|
||||
for i in 0..3 {
|
||||
out[i] = if gamma == 1.0 {
|
||||
wb * cb[i] + wbs * b[i] + ws * cs[i]
|
||||
} else {
|
||||
text_decode(wb * text_encode(cb[i], gamma) + wbs * text_encode(b[i], gamma) + ws * text_encode(cs[i], gamma), gamma)
|
||||
};
|
||||
}
|
||||
out[3] = ao;
|
||||
out
|
||||
}
|
||||
|
||||
/// Straight-alpha source over straight-alpha backdrop (W3C/PDF general
|
||||
/// formula) using [`blend_rgb`].
|
||||
pub fn composite(mode: BlendMode, backdrop: [f32; 4], source: [f32; 4], opacity: f32) -> [f32; 4] {
|
||||
|
|
@ -250,6 +321,67 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
|
||||
/// Photoshop 27.11, measured: Background #c89664, a layer #64b4dc at Fill 50% (128/255),
|
||||
/// Opacity 100%, in each of the special eight. Values are the composite at 8 bits.
|
||||
#[test]
|
||||
fn special_eight_fill_matches_photoshop() {
|
||||
let measured: [(BlendMode, [u8; 3]); 8] = [
|
||||
(BlendMode::ColorBurn, [176, 132, 88]),
|
||||
(BlendMode::LinearBurn, [122, 112, 82]),
|
||||
(BlendMode::ColorDodge, [249, 232, 176]),
|
||||
(BlendMode::LinearDodge, [250, 240, 210]),
|
||||
(BlendMode::VividLight, [193, 188, 157]),
|
||||
(BlendMode::LinearLight, [171, 202, 192]),
|
||||
(BlendMode::HardMix, [245, 225, 165]),
|
||||
(BlendMode::Difference, [150, 60, 10]),
|
||||
];
|
||||
let px = |c: [u8; 3]| [f32::from(c[0]) / 255.0, f32::from(c[1]) / 255.0, f32::from(c[2]) / 255.0, 1.0];
|
||||
let (b, t) = (px([200, 150, 100]), px([100, 180, 220]));
|
||||
for (mode, want) in measured {
|
||||
let got = composite_fill(mode, b, t, 1.0, 128.0 / 255.0, 1.0);
|
||||
for i in 0..3 {
|
||||
assert!((got[i] * 255.0 - f32::from(want[i])).abs() <= 1.5, "{mode:?} channel {i}: {} vs Photoshop {}", got[i] * 255.0, want[i]);
|
||||
}
|
||||
// At 100% Fill it is the plain blend; at 0% the backdrop.
|
||||
assert_eq!(composite_fill(mode, b, t, 1.0, 1.0, 1.0), composite(mode, b, t, 1.0), "{mode:?}");
|
||||
let none = composite_fill(mode, b, t, 1.0, 0.0, 1.0);
|
||||
assert!((0..3).all(|i| (none[i] - b[i]).abs() < 1e-5), "{mode:?} at Fill 0: {none:?}");
|
||||
}
|
||||
// A second measurement: Background #3c78b4, layer #d25a1e at Fill 25% (64/255) and 75%
|
||||
// (191/255). Hard Mix at 75% is 3 levels off in red: dividing by 1 − f = 0.25 magnifies
|
||||
// Photoshop's 8-bit rounding.
|
||||
let confirm: [(BlendMode, [u8; 3], [u8; 3]); 8] = [
|
||||
(BlendMode::ColorBurn, [51, 94, 159], [30, 0, 33]),
|
||||
(BlendMode::LinearBurn, [49, 79, 124], [26, 0, 11]),
|
||||
(BlendMode::ColorDodge, [76, 132, 186], [156, 163, 197]),
|
||||
(BlendMode::LinearDodge, [113, 143, 188], [217, 187, 202]),
|
||||
(BlendMode::VividLight, [71, 109, 162], [117, 81, 80]),
|
||||
(BlendMode::LinearLight, [100, 100, 130], [183, 63, 33]),
|
||||
(BlendMode::HardMix, [65, 105, 166], [102, 0, 47]),
|
||||
(BlendMode::Difference, [7, 97, 172], [97, 53, 158]),
|
||||
];
|
||||
let (b2, t2) = (px([60, 120, 180]), px([210, 90, 30]));
|
||||
for (mode, q, tq) in confirm {
|
||||
for (fill, want) in [(64.0 / 255.0, q), (191.0 / 255.0, tq)] {
|
||||
let got = composite_fill(mode, b2, t2, 1.0, fill, 1.0);
|
||||
let tol = if mode == BlendMode::HardMix { 3.5 } else { 1.5 };
|
||||
for i in 0..3 {
|
||||
assert!(
|
||||
(got[i] * 255.0 - f32::from(want[i])).abs() <= tol,
|
||||
"{mode:?} fill {fill} channel {i}: {} vs Photoshop {}",
|
||||
got[i] * 255.0,
|
||||
want[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Other modes keep Fill as coverage, like Opacity.
|
||||
assert_eq!(composite_fill(BlendMode::Multiply, b, t, 0.8, 0.5, 1.0), composite(BlendMode::Multiply, b, t, 0.4));
|
||||
// Over transparency Fill is still coverage for the special eight.
|
||||
let r = composite_fill(BlendMode::LinearDodge, [0.0; 4], t, 1.0, 0.5, 1.0);
|
||||
assert!((r[3] - 0.5).abs() < 1e-6 && (r[0] - t[0]).abs() < 1e-6, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vivid_light_source_extremes_win() {
|
||||
assert_eq!(vivid_light_ps(0.0, 1.0), 1.0);
|
||||
|
|
@ -274,6 +406,20 @@ mod tests {
|
|||
assert!((color_dodge(0.25, 0.5) - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_nests_and_restores() {
|
||||
let bright = |m| blend_rgb(m, [0.5, 0.5, 0.5], [1.0, 0.0, 0.0])[0];
|
||||
with_hdr(true, || {
|
||||
// A 32-bit document keeps Color's red above 1.
|
||||
assert!(bright(BlendMode::Color) > 1.1);
|
||||
// Another tile's (or document's) scope run inside this one doesn't reset it.
|
||||
with_hdr(false, || assert!(bright(BlendMode::Color) <= 1.0));
|
||||
assert!(HDR.get());
|
||||
});
|
||||
assert!(!HDR.get());
|
||||
assert!(bright(BlendMode::Color) <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_modes_delegate() {
|
||||
for m in BlendMode::LAYER_MODES {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,57 @@ fn every_blend_mode_matches_reference_on_opaque_pixels() {
|
|||
}
|
||||
}
|
||||
|
||||
/// One opaque pixel: `top` in `mode` over `bottom`, in a document of `depth`.
|
||||
fn blend_px(depth: SampleType, mode: BlendMode, bottom: [f32; 3], top: [f32; 3]) -> [f32; 4] {
|
||||
let mut d = Document::new("b", Size::new(1, 1), ColorMode::Rgb, depth);
|
||||
let fmt = d.pixel_format();
|
||||
let mut b = Layer::raster("b", fmt);
|
||||
b.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [bottom[0], bottom[1], bottom[2], 1.0]));
|
||||
let mut t = Layer::raster("t", fmt);
|
||||
t.surface_mut().unwrap().fill_rect(Rect::new(0, 0, 1, 1), &photocraft_raster::from_rgba(&fmt, [top[0], top[1], top[2], 1.0]));
|
||||
t.blend = mode;
|
||||
d.layers = vec![b, t];
|
||||
px(&d, 0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_separable_modes_keep_values_above_one_in_32_bit() {
|
||||
use photocraft_color::blend::{blend_rgb_within, lum};
|
||||
let modes = [BlendMode::Hue, BlendMode::Saturation, BlendMode::Color, BlendMode::Luminosity];
|
||||
// Hue over a grey of 2: the grey (the backdrop has no saturation), not ClipColor dividing
|
||||
// by the rounding noise of `max − lum` (Hue over (4, 3.9999, 4) gave 27.7 in blue).
|
||||
for bg in [[2.0, 2.0, 2.0], [2.0, 2.0, 1.999_99], [4.0, 3.999_9, 4.0]] {
|
||||
for m in modes {
|
||||
for top in [[3.0, 0.25, 0.125], [2.0, 2.0, 2.0], [4.0, 3.999_9, 4.0]] {
|
||||
let got = blend_px(SampleType::F32, m, bg, top);
|
||||
let want = blend_rgb_within(m, bg, top, f32::MAX);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-4 * w.abs().max(1.0)), "{m:?} {bg:?} {top:?}: {got:?} vs {want:?}");
|
||||
assert!(got.iter().all(|v| v.is_finite() && *v >= -1e-5), "{m:?} {bg:?} {top:?}: {got:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A saturated colour keeps its brightness above 1 in 32-bit, and is clipped into 0..1
|
||||
// (exactly as before) in 8/16-bit.
|
||||
let (bg, top) = ([0.5, 0.5, 0.5], [1.0, 0.0, 0.0]);
|
||||
let hdr = blend_px(SampleType::F32, BlendMode::Color, bg, top);
|
||||
assert!((hdr[0] - 1.2).abs() < 1e-4 && (lum([hdr[0], hdr[1], hdr[2]]) - 0.5).abs() < 1e-4, "{hdr:?}");
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
for m in modes {
|
||||
let got = blend_px(depth, m, bg, top);
|
||||
let want = photocraft_color::blend::blend_rgb(m, bg, top);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1.0 / 255.0), "{m:?} {depth:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
let got = blend_px(depth, BlendMode::Color, bg, top);
|
||||
assert!(got[0] <= 1.0 && got[0] > 0.9, "{depth:?}: {got:?}");
|
||||
}
|
||||
// A 32-bit document's in-range colours that stay in range are unchanged.
|
||||
for m in modes {
|
||||
let got = blend_px(SampleType::F32, m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
|
||||
let want = photocraft_color::blend::blend_rgb(m, [0.6, 0.3, 0.2], [0.2, 0.7, 0.5]);
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() <= 1e-5), "{m:?}: {got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_mask_hides_pixels() {
|
||||
let mut d = doc_white(4, 4);
|
||||
|
|
@ -316,13 +367,60 @@ fn solid_and_gradient_fill_layers() {
|
|||
|
||||
let g = Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
let patterns = pattern::PreparedPatterns::new(&[], pattern::PREPARED_PATTERN_BYTES);
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
let buf = render_fill(&g, Rect::new(0, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
|
||||
// tile independence: a 1px render of the right edge equals the full render
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns);
|
||||
let one = render_fill(&g, Rect::new(9, 0, 10, 1), Rect::new(0, 0, 10, 1), &patterns, None);
|
||||
assert_eq!(one.px[0], buf.px[9]);
|
||||
assert!(buf.px[0][0] < buf.px[9][0], "left dark, right light");
|
||||
}
|
||||
|
||||
fn dithered_gradient(stops: Vec<(f32, Color)>, angle: f32) -> Fill {
|
||||
let mut f = Fill::gradient(stops, angle, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
if let Fill::Gradient { dither, .. } = &mut f {
|
||||
*dither = true;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// #2755: a dithered gradient fill under a bright Color Dodge gradient. Dodge divides by
|
||||
/// `1 − cs`, so the dither's fraction of a level, left in float, came out as speckle several
|
||||
/// levels high (a black channel lifted to 4/255: coloured fringes). On an 8-bit document the
|
||||
/// dithered pixels sit on levels, as a painted gradient's do, so a channel at 0 stays 0.
|
||||
#[test]
|
||||
fn dithered_gradient_fills_under_color_dodge_keep_black_channels() {
|
||||
let mut d = doc_white(128, 32);
|
||||
// Red to blue: green is 0 everywhere.
|
||||
let bottom = dithered_gradient(vec![(0.0, Color::rgb(1.0, 0.0, 0.0)), (1.0, Color::rgb(0.0, 0.0, 1.0))], 0.0);
|
||||
d.layers.push(Layer::new("bottom", LayerContent::Fill(bottom)));
|
||||
let top = dithered_gradient(vec![(0.0, Color::rgb(0.88, 0.88, 0.88)), (1.0, Color::rgb(0.97, 0.97, 0.97))], 90.0);
|
||||
let mut l = Layer::new("top", LayerContent::Fill(top));
|
||||
l.blend = BlendMode::ColorDodge;
|
||||
d.layers.push(l);
|
||||
for (i, p) in flatten(&d).px.iter().enumerate() {
|
||||
assert_eq!(p[1], 0.0, "pixel {i}: green lifted off black to {} levels", p[1] * 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dithered gradient pixels land on the depth's levels; float documents and undithered
|
||||
/// gradients keep their float values.
|
||||
#[test]
|
||||
fn dithered_gradient_fill_is_rounded_to_the_document_depth() {
|
||||
let stops = vec![(0.0, Color::rgb(0.1, 0.4, 0.0)), (1.0, Color::rgb(0.9, 0.2, 1.0))];
|
||||
let rect = Rect::new(0, 0, 64, 8);
|
||||
let f = dithered_gradient(stops.clone(), 0.0);
|
||||
for depth in [SampleType::U8, SampleType::U16] {
|
||||
let q = adjustment_quantum(depth).unwrap();
|
||||
for p in gradient_fill::render_quantized(&f, rect, rect, Some(q)) {
|
||||
for v in &p[..3] {
|
||||
assert_eq!((v * q).round() / q, *v, "{depth:?}: {v} is between levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(gradient_fill::render_quantized(&f, rect, rect, None), gradient_fill::render(&f, rect, rect));
|
||||
let plain = Fill::gradient(stops, 0.0, 1.0, photocraft_doc::GradientStyle::Linear, false);
|
||||
assert_eq!(gradient_fill::render_quantized(&plain, rect, rect, Some(255.0)), gradient_fill::render(&plain, rect, rect));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dissolve_coverage_matches_opacity() {
|
||||
let mut d = Document::new("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8);
|
||||
|
|
|
|||
|
|
@ -937,7 +937,18 @@ impl Document {
|
|||
}
|
||||
|
||||
pub fn path_of(&self, id: LayerId) -> Option<LayerPath> {
|
||||
self.walk().into_iter().find(|(_, _, l)| l.id == id).map(|(p, _, _)| p)
|
||||
fn rec(layers: &[Layer], id: LayerId, path: &mut LayerPath) -> bool {
|
||||
for (i, l) in layers.iter().enumerate() {
|
||||
path.push(i);
|
||||
if l.id == id || l.children().is_some_and(|ch| rec(ch, id, path)) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
rec(&self.layers, id, &mut path).then_some(path)
|
||||
}
|
||||
|
||||
/// The locks in force on the layer at `path`: its own and those of every group around it,
|
||||
|
|
@ -952,8 +963,10 @@ impl Document {
|
|||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
|
||||
let path = self.path_of(id)?;
|
||||
self.layer_at(&path)
|
||||
fn rec(layers: &[Layer], id: LayerId) -> Option<&Layer> {
|
||||
layers.iter().find_map(|l| if l.id == id { Some(l) } else { l.children().and_then(|ch| rec(ch, id)) })
|
||||
}
|
||||
rec(&self.layers, id)
|
||||
}
|
||||
|
||||
pub fn layer_mut(&mut self, id: LayerId) -> Option<&mut Layer> {
|
||||
|
|
@ -1198,6 +1211,27 @@ mod tests {
|
|||
assert_eq!(d.layer_count(), 4);
|
||||
}
|
||||
|
||||
/// `layer` and `path_of` search the tree directly (no `walk`): they must agree with it at
|
||||
/// every depth, and miss cleanly.
|
||||
#[test]
|
||||
fn layer_lookup_agrees_with_walk() {
|
||||
let mut d = doc();
|
||||
let format = d.pixel_format();
|
||||
let leaf = |n: &str| Layer::raster(n, format);
|
||||
let deep = Layer::group("A", vec![leaf("a0"), Layer::group("B", vec![leaf("b0"), Layer::group("C", vec![leaf("c0")]), leaf("b2")]), leaf("a2")]);
|
||||
d.insert_above(None, deep);
|
||||
d.insert_above(None, leaf("top"));
|
||||
let walk: Vec<(LayerPath, LayerId)> = d.walk().into_iter().map(|(p, _, l)| (p, l.id)).collect();
|
||||
assert_eq!(walk.len(), 10);
|
||||
for (path, id) in &walk {
|
||||
assert_eq!(d.path_of(*id).as_ref(), Some(path));
|
||||
assert_eq!(d.layer(*id).map(|l| l.id), Some(*id));
|
||||
assert_eq!(d.layer_mut(*id).map(|l| l.id), Some(*id));
|
||||
}
|
||||
let missing = Layer::raster("elsewhere", d.pixel_format()).id;
|
||||
assert!(d.path_of(missing).is_none() && d.layer(missing).is_none() && d.layer_mut(missing).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_group_locks_its_contents() {
|
||||
let mut d = doc();
|
||||
|
|
|
|||
|
|
@ -44,3 +44,5 @@ photocraft-affinity = { workspace = true, features = ["synth"] }
|
|||
photocraft-raw = { workspace = true, features = ["testgen"] }
|
||||
# WebAssembly text -> binary for plug-in test modules (Apache-2.0 WITH LLVM-exception).
|
||||
wat = "1.228"
|
||||
# Synthetic multi-part EXR fixtures for the parts-commands tests.
|
||||
exr = { version = "1.73", default-features = false }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Window › Actions, as engine commands (`actions.record` / `stop` / `play` / `list` / `get` /
|
||||
//! `delete`).
|
||||
//! `delete` / `move` / `rename`).
|
||||
//!
|
||||
//! The list lives on [`Session`] so the panel, the CLI, the control channel and MCP share one
|
||||
//! copy. Recording copies replayable journal entries (commands whose [`CommandSpec::journal`] is
|
||||
|
|
@ -67,11 +67,19 @@ impl ActionState {
|
|||
pub fn replayable(id: &str) -> bool {
|
||||
// Again journals its concrete transform too; recording the wrapper would replay it twice.
|
||||
id == "actions.play"
|
||||
|| (!id.starts_with("actions.") && id != "edit.transform.again" && (shell_view_command(id) || crate::commands::find(id).is_some_and(|c| c.journal)))
|
||||
|| (!id.starts_with("actions.")
|
||||
&& id != "edit.transform.again"
|
||||
&& (shell_view_command(id) || shell_save_command(id) || crate::commands::find(id).is_some_and(|c| c.journal)))
|
||||
}
|
||||
|
||||
/// File › Save and Save As, recorded by the desktop shell once the save has a path (#2032).
|
||||
/// The shell replays them; batch and droplets write to their destination folder instead.
|
||||
pub fn shell_save_command(id: &str) -> bool {
|
||||
matches!(id, "file.save" | "file.saveAs")
|
||||
}
|
||||
|
||||
/// View-only menu commands recorded by the desktop shell. Headless playback reports
|
||||
/// them as unsupported instead of silently dropping them.
|
||||
/// them as unsupported instead of silently dropping them; Batch and droplets skip them.
|
||||
pub fn shell_view_command(id: &str) -> bool {
|
||||
matches!(id, "view.fitOnScreen" | "view.actualPixels" | "view.zoomIn" | "view.zoomOut")
|
||||
}
|
||||
|
|
@ -380,6 +388,49 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
/// Prefix of the persisted shortcut override that plays a named action (`actions.play:<name>`).
|
||||
pub const PLAY_SHORTCUT_PREFIX: &str = "actions.play:";
|
||||
|
||||
/// Rename an action in place. Nested `actions.play` steps that call it by name, and its
|
||||
/// function-key binding, follow the new name so nothing silently stops working.
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
not_playing(s, "actions.rename")?;
|
||||
let idx = resolve(&s.actions.list, p, "actions.rename")?;
|
||||
let name = match p.get("name") {
|
||||
Some(Value::String(n)) if !n.trim().is_empty() => n.clone(),
|
||||
Some(Value::String(_)) => return Err(bad("actions.rename", "\"name\" is empty")),
|
||||
Some(_) => return Err(bad("actions.rename", "\"name\" must be a string")),
|
||||
None => return Err(bad("actions.rename", "needs \"name\"")),
|
||||
};
|
||||
let old = s.actions.list.get(idx).map(|a| a.name.clone()).ok_or_else(|| bad("actions.rename", "no such action"))?;
|
||||
if old == name {
|
||||
return Ok(json!({"action": name, "index": idx}));
|
||||
}
|
||||
if s.actions.list.iter().any(|a| a.name == name) {
|
||||
return Err(bad("actions.rename", "an action with this name already exists"));
|
||||
}
|
||||
let pending = s.actions.recording.map_or(s.journal.len(), |(start, _)| start);
|
||||
let calls = s.actions.list.iter_mut().flat_map(|a| a.steps.iter_mut()).chain(s.journal.iter_mut().skip(pending));
|
||||
for (id, params) in calls {
|
||||
if id == "actions.play" && params.get("action").and_then(Value::as_str) == Some(old.as_str()) {
|
||||
params["action"] = json!(name);
|
||||
}
|
||||
}
|
||||
if let Some(action) = s.actions.list.get_mut(idx) {
|
||||
action.name = name.clone();
|
||||
}
|
||||
s.actions.touch();
|
||||
let (from, to) = (format!("{PLAY_SHORTCUT_PREFIX}{old}"), format!("{PLAY_SHORTCUT_PREFIX}{name}"));
|
||||
if s.prefs().shortcuts.contains_key(&from) {
|
||||
s.edit_prefs(|prefs| {
|
||||
if let Some(key) = prefs.shortcuts.remove(&from) {
|
||||
prefs.shortcuts.insert(to, key);
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({"action": name, "index": idx}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec {
|
||||
|
|
@ -452,6 +503,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
run: delete,
|
||||
journal: false,
|
||||
},
|
||||
CommandSpec {
|
||||
id: "actions.rename",
|
||||
label: "Rename Action",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"action":name|index, "name":str} → {action, index}. The name must not be empty or whitespace-only and must be unique. Nested Play Action steps and the action's function key follow the rename."##,
|
||||
enabled: always,
|
||||
run: rename,
|
||||
journal: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -464,6 +525,30 @@ mod tests {
|
|||
s.active().unwrap().doc.layers[layer].surface().unwrap().pixel(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_calls_and_function_key_and_rejects_bad_names() {
|
||||
let mut s = Session::new();
|
||||
s.actions.list.push(Action { name: "Action 1".into(), steps: vec![("layer.new.layer".into(), json!({}))] });
|
||||
s.actions.list.push(Action { name: "Caller".into(), steps: vec![("actions.play".into(), json!({"action": "Action 1"}))] });
|
||||
s.edit_prefs(|p| p.shortcuts.insert(format!("{PLAY_SHORTCUT_PREFIX}Action 1"), "F2".into()));
|
||||
assert_eq!(s.execute("actions.rename", json!({"action": 0, "name": "Sepia"})).unwrap()["action"], "Sepia");
|
||||
assert_eq!(s.execute("actions.list", json!({})).unwrap()["actions"][0]["name"], "Sepia");
|
||||
assert_eq!(s.actions.list[1].steps[0].1["action"], "Sepia");
|
||||
assert_eq!(s.prefs().shortcuts.get("actions.play:Sepia").map(String::as_str), Some("F2"));
|
||||
assert!(!s.prefs().shortcuts.contains_key("actions.play:Action 1"));
|
||||
for bad in [
|
||||
json!({"action": 0, "name": ""}),
|
||||
json!({"action": 0, "name": " "}),
|
||||
json!({"action": 0, "name": "Caller"}),
|
||||
json!({"action": 0, "name": 7}),
|
||||
json!({"action": 9, "name": "X"}),
|
||||
json!({"action": 0}),
|
||||
] {
|
||||
assert!(s.execute("actions.rename", bad.clone()).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.actions.list[0].name, "Sepia");
|
||||
}
|
||||
|
||||
fn record_red(s: &mut Session, depth: u32) {
|
||||
s.execute("file.new", json!({"width": 40, "height": 40, "depth": depth})).unwrap();
|
||||
s.execute("actions.record", json!({"name": "Red"})).unwrap();
|
||||
|
|
|
|||
|
|
@ -329,6 +329,22 @@ fn mask_at(s: &Session, x: i32, y: i32) -> f32 {
|
|||
d.doc.layer(d.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0)
|
||||
}
|
||||
|
||||
/// #2456: Edit > Fill (and the fill keys) fill the targeted mask of an adjustment layer.
|
||||
#[test]
|
||||
fn fill_works_on_an_adjustment_layers_mask() {
|
||||
let mut s = session(8, "rgb");
|
||||
s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
let mask = json!({"target": "mask"});
|
||||
assert!(!s.is_enabled("edit.fill"), "no pixels to fill without the mask target");
|
||||
assert!(s.is_enabled_with("edit.fill", &mask));
|
||||
assert!(s.is_enabled_with("edit.fillForeground", &mask));
|
||||
s.execute("edit.fill", json!({"contents": "black", "target": "mask"})).unwrap();
|
||||
assert_eq!(mask_at(&s, 3, 3), 0.0, "the mask is filled black");
|
||||
s.execute("edit.fill", json!({"contents": "white", "target": "mask"})).unwrap();
|
||||
assert_eq!(mask_at(&s, 3, 3), 1.0);
|
||||
}
|
||||
|
||||
/// #780: with the layer mask targeted, Invert (⌘I) inverts the mask, past the canvas too, and
|
||||
/// leaves the layer's pixels alone; one history step.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -391,6 +391,9 @@ fn hue_saturation(r: &Reader<'_>, base: Option<&Adjustment>) -> Result<Adjustmen
|
|||
let colorize = r.boolean("colorize", c)?;
|
||||
// Colorize hue is 0..360, a shift is -180..180.
|
||||
let hue = if colorize { r.num("hue", h.rem_euclid(360.0), 0.0, 360.0)? } else { r.num("hue", h, -180.0, 180.0)? };
|
||||
// Turning Colorize on starts at 25% saturation unless one is given, like the dialog's
|
||||
// checkbox and Photoshop; an explicit 0 stays 0, which colorizes to grey (#2326).
|
||||
let s = if colorize && !c { s.max(25.0) } else { s };
|
||||
let (saturation, lightness) = (r.num("saturation", s, if colorize { 0.0 } else { -100.0 }, 100.0)?, r.num("lightness", l, -100.0, 100.0)?);
|
||||
for (i, key) in HUE_RANGES.iter().enumerate() {
|
||||
let Some(o) = r.object(key)? else { continue };
|
||||
|
|
@ -880,6 +883,13 @@ mod tests {
|
|||
assert!(matches!(a, Adjustment::BlackWhite { weights, tint: Some(_) } if weights[0] == 120.0 && weights[1] == 60.0));
|
||||
let a = from_params("hueSaturation", &json!({"blues": {"hue": 40}}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(&a, Adjustment::HueSaturation { ranges, .. } if ranges[4].hue == 40.0 && ranges[0].is_neutral()));
|
||||
// Colorize without a saturation starts at 25%; an explicit 0 and an already colorized base are kept.
|
||||
let a = from_params("hueSaturation", &json!({"colorize": true, "hue": 30}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::HueSaturation { colorize: true, saturation: 25.0, .. }), "{a:?}");
|
||||
let a = from_params("hueSaturation", &json!({"colorize": true, "saturation": 0}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::HueSaturation { colorize: true, saturation: 0.0, .. }), "{a:?}");
|
||||
let a = from_params("hueSaturation", &json!({"hue": 120}), Some(&a), ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(a, Adjustment::HueSaturation { colorize: true, saturation: 0.0, hue: 120.0, .. }), "{a:?}");
|
||||
let a = from_params("gradientMap", &json!({"stops": [[1, "#ffffff"], [0, "#000000"]], "dither": true}), None, ColorMode::Rgb).unwrap();
|
||||
assert!(matches!(&a, Adjustment::GradientMap { stops, dither: true, .. } if stops[0].0 == 0.0));
|
||||
// CMYK curves: ink channels; Lab: lightness without a composite.
|
||||
|
|
|
|||
|
|
@ -84,11 +84,14 @@ fn auto_align(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let l = doc.layer(ids[i]).ok_or(EngineError::NoLayer(ids[i]))?;
|
||||
let surf = l.surface().ok_or(EngineError::NoLayer(ids[i]))?;
|
||||
let b = surf.content_bounds();
|
||||
let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp) };
|
||||
let mask = l.mask.as_ref().map(|m| {
|
||||
let mb = m.surface.content_bounds();
|
||||
if mb.is_empty() { m.surface.clone() } else { warp_placed(&m.surface, mb, (0.0, 0.0), pl, (0.0, 0.0), Interp::Bilinear) }
|
||||
});
|
||||
let px = if b.is_empty() { surf.clone() } else { warp_placed(surf, b, (0.0, 0.0), pl, (0.0, 0.0), interp)? };
|
||||
let mask = match l.mask.as_ref() {
|
||||
Some(m) => {
|
||||
let mb = m.surface.content_bounds();
|
||||
Some(if mb.is_empty() { m.surface.clone() } else { warp_placed(&m.surface, mb, (0.0, 0.0), pl, (0.0, 0.0), Interp::Bilinear)? })
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok((ids[i], px, mask))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
//! Fallible buffers used by memory-intensive document operations.
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static FAIL_NEXT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Reserve and initialize a buffer without invoking the allocator's abort-on-OOM path.
|
||||
pub(crate) fn filled<T: Clone>(len: usize, value: T, purpose: &str) -> Result<Vec<T>> {
|
||||
let mut out = capacity(len, purpose)?;
|
||||
out.resize(len, value);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reserve a vector for a known number of operation results.
|
||||
pub(crate) fn capacity<T>(len: usize, purpose: &str) -> Result<Vec<T>> {
|
||||
let bytes = len.checked_mul(std::mem::size_of::<T>()).ok_or_else(|| error(purpose, "requested size overflowed"))?;
|
||||
#[cfg(test)]
|
||||
if FAIL_NEXT.with(|flag| flag.replace(false)) {
|
||||
return Err(error(purpose, "not enough memory"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(len).map_err(|_| error(purpose, &format!("not enough memory for {bytes} bytes")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Check an operation boundary where downstream encoders/decoders allocate internally.
|
||||
/// The test-only failpoint lets command tests prove their transaction boundary.
|
||||
pub(crate) fn checkpoint(purpose: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
if FAIL_NEXT.with(|flag| flag.replace(false)) {
|
||||
return Err(error(purpose, "not enough memory"));
|
||||
}
|
||||
let _ = purpose;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy a region into an explicitly reserved buffer.
|
||||
pub(crate) fn read_region(surface: &photocraft_raster::Surface, area: photocraft_geom::Rect, purpose: &str) -> Result<Vec<f32>> {
|
||||
let len = (area.width() as usize)
|
||||
.checked_mul(area.height() as usize)
|
||||
.and_then(|n| n.checked_mul(surface.channels()))
|
||||
.ok_or_else(|| error(purpose, "requested size overflowed"))?;
|
||||
let mut out = filled(len, 0.0, purpose)?;
|
||||
surface.read_region_into(area, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn error(purpose: &str, detail: &str) -> EngineError {
|
||||
EngineError::Other(format!("not enough memory for {purpose} ({detail}); the document was not changed"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fail_next_for_test() {
|
||||
FAIL_NEXT.with(|flag| flag.set(true));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allocation_failure_is_reported_without_panicking() {
|
||||
fail_next_for_test();
|
||||
let result = filled::<u8>(16, 0, "test image");
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for test image")));
|
||||
}
|
||||
}
|
||||
|
|
@ -188,7 +188,13 @@ pub(crate) fn fit_canvas(doc: &mut Document) {
|
|||
w = w.max(i64::from(a.rect.x1));
|
||||
h = h.max(i64::from(a.rect.y1));
|
||||
}
|
||||
doc.size = Size::new(w.clamp(1, 300_000) as u32, h.clamp(1, 300_000) as u32);
|
||||
let size = Size::new(w.clamp(1, 300_000) as u32, h.clamp(1, 300_000) as u32);
|
||||
if size != doc.size {
|
||||
doc.size = size;
|
||||
// Shape pixels are cut at the canvas: a board moved past the old edge rendered its
|
||||
// shapes against it, leaving them missing or clipped (#2389).
|
||||
crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes);
|
||||
}
|
||||
}
|
||||
|
||||
fn next_artboard_name(doc: &Document) -> String {
|
||||
|
|
|
|||
|
|
@ -385,3 +385,44 @@ fn moving_an_artboard_past_the_canvas_grows_it() {
|
|||
s.undo();
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), (100, 80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_artboard_past_the_canvas_keeps_its_shapes_whole() {
|
||||
// #2389: a shape's pixels are cut at the canvas; the board moved before the canvas grew,
|
||||
// so its shape rendered against the old edge and stayed missing or clipped.
|
||||
let shape_board = |s: &mut Session| {
|
||||
s.execute("file.new", json!({"width": 100, "height": 100, "background": "transparent"})).unwrap();
|
||||
let sh = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 20, 30, 30], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.new.artboardFromLayers", json!({})).unwrap();
|
||||
sh
|
||||
};
|
||||
for (cmd, p, size, red, not_red) in [
|
||||
("layer.artboard.set", json!({"x": 120}), (150, 100), [(121, 21), (130, 30), (149, 49)], None),
|
||||
("layer.translate", json!({"dx": 110, "dy": 0}), (150, 100), [(121, 21), (130, 30), (149, 49)], None),
|
||||
("layer.artboard.set", json!({"y": 120}), (100, 150), [(11, 121), (20, 130), (39, 149)], None),
|
||||
// Partly past the edge: the whole shape, not just the part inside the old canvas.
|
||||
("layer.artboard.set", json!({"x": 90}), (120, 100), [(91, 21), (95, 30), (110, 30)], Some((15, 30))),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let sh = shape_board(&mut s);
|
||||
s.execute(cmd, p.clone()).unwrap();
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), size, "{cmd} {p}");
|
||||
for (x, y) in red {
|
||||
assert!(red_at(&s, x, y), "{cmd} {p}: ({x}, {y})");
|
||||
}
|
||||
if let Some((x, y)) = not_red {
|
||||
assert!(!red_at(&s, x, y), "{cmd} {p}: ({x}, {y}) left behind");
|
||||
}
|
||||
let info = s.execute("shape.info", json!({"layer": sh})).unwrap();
|
||||
assert_eq!(info["bounds"], json!([red[0].0 - 1, red[0].1 - 1, 30, 30]), "{cmd} {p}");
|
||||
// One undo step back to the original board; redo brings the whole shape back.
|
||||
s.undo();
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), (100, 100));
|
||||
assert!(red_at(&s, 20, 30), "{cmd} {p}: undo");
|
||||
s.redo();
|
||||
assert_eq!((doc(&s).size.width, doc(&s).size.height), size);
|
||||
for (x, y) in red {
|
||||
assert!(red_at(&s, x, y), "{cmd} {p}: redo ({x}, {y})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,7 +107,14 @@ fn run_steps(s: &mut Session, steps: &[(String, Value)]) -> Vec<Value> {
|
|||
let mut results = Vec::new();
|
||||
for (id, params) in steps {
|
||||
match s.execute(id, params.clone()) {
|
||||
Ok(r) => results.push(json!({"command": id, "result": r})),
|
||||
// A called action reports a failed step inside an `Ok`; it stops the script too.
|
||||
Ok(r) => match crate::actions_cmds::nested_failure(id, &r) {
|
||||
Some(error) => {
|
||||
results.push(json!({"command": id, "result": r, "error": error}));
|
||||
break;
|
||||
}
|
||||
None => results.push(json!({"command": id, "result": r})),
|
||||
},
|
||||
Err(e) => {
|
||||
results.push(json!({"command": id, "error": e.to_string()}));
|
||||
break;
|
||||
|
|
@ -284,7 +291,9 @@ fn create_droplet(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let cmd = "file.automate.createDroplet";
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|v| !v.is_empty()).ok_or_else(|| bad(cmd, "missing \"path\" (where to save the droplet)"))?;
|
||||
let steps_v = p.get("steps").or_else(|| p.get("action")).ok_or_else(|| bad(cmd, "missing \"steps\" (the action to run)"))?;
|
||||
let steps = parse_steps(steps_v, cmd)?;
|
||||
let mut steps = parse_steps(steps_v, cmd)?;
|
||||
// The droplet's output folder replaces recorded saves, and view steps don't apply, as in batch.
|
||||
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id));
|
||||
check_known(&steps, cmd)?;
|
||||
let path = if path.ends_with(".pcdroplet") || path.ends_with(".json") { path.to_string() } else { format!("{path}.pcdroplet") };
|
||||
let name = p.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| stem(&path));
|
||||
|
|
@ -379,9 +388,8 @@ fn statistics(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.execute("edit.autoAlignLayers", json!({"projection": "auto"}))?;
|
||||
let name = s.active().map(|d| d.doc.name.clone()).unwrap_or_default();
|
||||
s.edit("Convert to Smart Object", |doc, active| {
|
||||
let children = std::mem::take(&mut doc.layers);
|
||||
let group = Layer::new(name.clone(), LayerContent::Group(photocraft_doc::Group { children, expanded: true, artboard: None }));
|
||||
let smart = crate::smart_cmds::layer_to_smart(doc, &group)?;
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
let smart = crate::smart_cmds::layers_to_smart(doc, &name, layers)?;
|
||||
*active = Some(smart.id);
|
||||
doc.layers = vec![smart];
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -113,6 +113,21 @@ fn droplets_are_written_and_run() {
|
|||
assert!(s.execute("file.automate.createDroplet", json!({"path": path, "steps": [["bogus", {}]]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_and_droplets_skip_recorded_view_steps() {
|
||||
let dir = tmp("view-steps");
|
||||
images(&dir, 1, 40, 20);
|
||||
let steps = json!([["view.zoomIn", {}], ["image.imageRotation.90cw", {}], ["view.fitOnScreen", {}]]);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.automate.batch", json!({"steps": steps, "input": dir.clone(), "output": format!("{dir}/batch"), "format": "png"})).unwrap();
|
||||
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
|
||||
let out = photocraft_codecs::decode(&std::fs::read(format!("{dir}/batch/img0.png")).unwrap()).unwrap();
|
||||
assert_eq!(out.dimensions(), (20, 40));
|
||||
let r = s.execute("file.automate.createDroplet", json!({"path": format!("{dir}/Rotate"), "steps": steps})).unwrap();
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(r["path"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert_eq!(v["action"]["steps"], json!([["image.imageRotation.90cw", {}]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statistics_makes_a_stack_mode_smart_object() {
|
||||
let dir = tmp("stats");
|
||||
|
|
@ -130,6 +145,43 @@ fn statistics_makes_a_stack_mode_smart_object() {
|
|||
assert!(s.execute("file.scripts.statistics", json!({"mode": "average", "input": dir})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_smart_object_holds_the_loaded_layers_at_its_top_level() {
|
||||
// Like Photoshop, Load Files into Stack › Create Smart Object (Statistics without alignment) and
|
||||
// Statistics with alignment embed one layer per file, bottom to top, with no wrapper group.
|
||||
let dir = tmp("stack-top");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 96, "height": 72, "background": "#606060"})).unwrap();
|
||||
s.edit("texture", |doc, active| {
|
||||
let surf = doc.layer_mut(active.ok_or(EngineError::NoDocument)?).and_then(Layer::surface_mut).ok_or(EngineError::NoDocument)?;
|
||||
for k in 0..40 {
|
||||
let (x, y, v) = ((k * 37) % 88, (k * 23) % 66, (k % 7) as f32 / 6.0);
|
||||
surf.fill_rect(photocraft_geom::Rect::new(x, y, x + 3 + k % 5, y + 2 + k % 4), &[v, 1.0 - v, 0.5, 1.0]);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let source = photocraft_compose::flatten(&s.active().unwrap().doc);
|
||||
let files: Vec<String> = (0..3)
|
||||
.map(|i| {
|
||||
let path = format!("{dir}/img{i}.png");
|
||||
crate::file_cmds::save_doc(&s.active().unwrap().doc, &path, None).unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
for align in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.scripts.statistics", json!({"mode": "median", "input": files, "align": align})).unwrap();
|
||||
let d = &s.active().unwrap().doc;
|
||||
let LayerContent::Smart(so) = &d.layers[0].content else { panic!("not a smart object") };
|
||||
let photocraft_doc::SmartSource::Embedded { file_name, bytes } = &so.source else { panic!("not embedded") };
|
||||
let inner = crate::smart_cmds::decode_source(file_name, bytes).unwrap();
|
||||
let names: Vec<&str> = inner.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["img0.png", "img1.png", "img2.png"], "align {align}");
|
||||
assert_eq!(photocraft_compose::flatten(d).px, source.px, "align {align}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_sheet_places_thumbnails_with_captions() {
|
||||
let dir = tmp("contact");
|
||||
|
|
@ -162,3 +214,32 @@ fn contact_sheet_places_thumbnails_with_captions() {
|
|||
assert_eq!(d.layers.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_step_inside_a_called_action_fails_scripts_and_batch() {
|
||||
use crate::actions_cmds::Action;
|
||||
let dir = tmp("nested-failure");
|
||||
images(&dir, 1, 8, 4);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 8, "height": 6})).unwrap();
|
||||
s.actions.list.push(Action { name: "Rotate".into(), steps: vec![("image.imageRotation.90cw".into(), json!({}))] });
|
||||
s.actions.list.push(Action { name: "Bad".into(), steps: vec![("layer.delete".into(), json!({"layer": 999_999}))] });
|
||||
// A script stops at the failed call and reports it.
|
||||
let r = s.execute("file.scripts.browse", json!({"steps": [["actions.play", {"action": "Bad"}], ["image.imageRotation.90cw", {}]]})).unwrap();
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert_eq!(r["results"].as_array().unwrap().len(), 1);
|
||||
// Batch plays called actions, and a failure inside one is a file error, not a saved result.
|
||||
let batch = |s: &mut Session, action: &str| {
|
||||
let p = json!({"steps": [["actions.play", {"action": action}]], "input": dir.clone(), "output": format!("{dir}/{action}"), "format": "png"});
|
||||
s.execute("file.automate.batch", p).unwrap()
|
||||
};
|
||||
let r = batch(&mut s, "Rotate");
|
||||
assert!(r["errors"].as_array().unwrap().is_empty(), "{r}");
|
||||
let r = batch(&mut s, "Bad");
|
||||
assert_eq!(r["files"], json!([]), "{r}");
|
||||
assert!(r["errors"][0]["error"].as_str().unwrap().contains("no such layer"), "{r}");
|
||||
// An action that batches itself stops as a recursive call instead of overflowing the stack.
|
||||
let inner = json!({"steps": [["actions.play", {"action": "Loop"}]], "input": dir.clone(), "output": format!("{dir}/inner"), "format": "png"});
|
||||
s.actions.list.push(Action { name: "Loop".into(), steps: vec![("file.automate.batch".into(), inner)] });
|
||||
batch(&mut s, "Loop");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
//! Layer › New › Background from Layer, and the rule that keeps the Background at the bottom.
|
||||
//!
|
||||
//! Photoshop's Background is the locked, opaque layer at the bottom of the stack. Background from
|
||||
//! Layer turns the selected layer into it: transparent pixels take the background colour and the
|
||||
//! layer drops to the bottom. While a document has a Background nothing reorders it and no layer
|
||||
//! can go below it (Layer › Arrange, Reverse, dragging in the Layers panel); Layer from Background
|
||||
//! (`layer.new.layerFromBackground`) turns it back into a normal layer that moves freely.
|
||||
|
||||
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::extra_cmds::is_background;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const CMD: &str = "layer.new.backgroundFromLayer";
|
||||
|
||||
/// The document's Background layer, if it has one (always the bottom layer).
|
||||
pub(crate) fn background_id(doc: &Document) -> Option<LayerId> {
|
||||
doc.layers.first().filter(|l| is_background(l)).map(|l| l.id)
|
||||
}
|
||||
|
||||
/// Refuses an edit that moved the Background `bg` (taken before the edit) off the bottom of the
|
||||
/// stack, or put another layer below it. Called at the end of a `Session::edit` closure, so an
|
||||
/// `Err` leaves the document and history untouched.
|
||||
pub(crate) fn keep_background_at_bottom(bg: Option<LayerId>, doc: &Document) -> Result<()> {
|
||||
match bg {
|
||||
Some(id) if doc.layers.first().map(|l| l.id) != Some(id) => Err(EngineError::Other(
|
||||
"the Background layer stays at the bottom of the stack (Layer › New › Layer from Background makes it a normal layer)".into(),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Can `l` become the Background? Groups and adjustment layers have no pixels of their own.
|
||||
fn convertible(l: &Layer) -> std::result::Result<(), String> {
|
||||
match l.content {
|
||||
LayerContent::Group(_) | LayerContent::Adjustment(_) => {
|
||||
Err(format!("{} {} layer can't become the Background", l.content.article(), l.content.kind_name()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn can_convert(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
if background_id(&d.doc).is_some() {
|
||||
return Err("the document already has a Background layer".into());
|
||||
}
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
convertible(l)
|
||||
}
|
||||
|
||||
fn background_from_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = match p.get("layer") {
|
||||
None | Some(Value::Null) => crate::commands::layer_param(s, p)?,
|
||||
Some(v) => LayerId(v.as_u64().ok_or_else(|| EngineError::BadParams { cmd: CMD.into(), msg: "`layer` must be a layer id".into() })?),
|
||||
};
|
||||
let bg = s.tools.background;
|
||||
s.edit("Background From Layer", |doc, active| {
|
||||
if background_id(doc).is_some() {
|
||||
return Err(EngineError::Other("the document already has a Background layer".into()));
|
||||
}
|
||||
let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?;
|
||||
convertible(l).map_err(EngineError::Other)?;
|
||||
// Render the layer alone, as it looks (mask, opacity and effects applied), over the
|
||||
// background colour: the Background is opaque and plain.
|
||||
let mut only = l.clone();
|
||||
only.visible = true;
|
||||
only.clipped = false;
|
||||
let visible = l.visible;
|
||||
let fmt = doc.pixel_format();
|
||||
let rest = std::mem::replace(&mut doc.layers, vec![only]);
|
||||
let pixels = photocraft_compose::flatten_to_surface(doc, fmt, Some([bg[0], bg[1], bg[2]]));
|
||||
doc.layers = rest;
|
||||
let mut out = Layer::raster("Background", fmt);
|
||||
out.id = id;
|
||||
out.visible = visible;
|
||||
out.locks.transparency = true;
|
||||
out.locks.position = true;
|
||||
*crate::pixels_mut(&mut out)? = pixels;
|
||||
doc.remove(id).ok_or(EngineError::NoLayer(id))?;
|
||||
doc.layers.insert(0, out);
|
||||
*active = Some(id);
|
||||
Ok(())
|
||||
})?;
|
||||
// The Background is the one targeted layer now.
|
||||
s.select_layer(id)?;
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![CommandSpec {
|
||||
id: CMD,
|
||||
label: "Background From Layer",
|
||||
menu: &["Layer", "New"],
|
||||
shortcut: None,
|
||||
params: r##"{"layer":id?} (the active layer by default; transparent pixels take the background colour and the layer drops to the bottom as the locked Background)"##,
|
||||
enabled: can_convert,
|
||||
journal: true,
|
||||
run: background_from_layer,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
/// Bottom to top: Layer 0 (the old Background, now a normal layer), A, B. A has a 4×4 red
|
||||
/// square at (2, 2); the rest of it is transparent.
|
||||
fn session(depth: u32) -> (Session, [LayerId; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 12, "depth": depth})).unwrap();
|
||||
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
|
||||
let l0 = doc(&s).layers[0].id;
|
||||
let id = |s: &mut Session, name: &str| LayerId(s.execute("layer.new.layer", json!({"name": name})).unwrap()["layer"].as_u64().unwrap());
|
||||
let a = id(&mut s, "A");
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 4, "height": 4})).unwrap();
|
||||
s.execute("edit.fill", json!({"contents": "color", "color": "#ff0000"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let b = id(&mut s, "B");
|
||||
(s, [l0, a, b])
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<LayerId> {
|
||||
doc(s).layers.iter().map(|l| l.id).collect()
|
||||
}
|
||||
|
||||
fn pixel(s: &Session, x: i32, y: i32) -> Vec<f32> {
|
||||
doc(s).layers[0].surface().unwrap().read_region(Rect::new(x, y, x + 1, y + 1))
|
||||
}
|
||||
|
||||
/// A Background (the white one `file.new` makes) with A and B above it.
|
||||
fn with_background() -> (Session, [LayerId; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": 16, "height": 12})).unwrap();
|
||||
let bg = doc(&s).layers[0].id;
|
||||
let a = LayerId(s.execute("layer.new.layer", json!({"name": "A"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let b = LayerId(s.execute("layer.new.layer", json!({"name": "B"})).unwrap()["layer"].as_u64().unwrap());
|
||||
(s, [bg, a, b])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn makes_the_layer_the_background_at_the_bottom() {
|
||||
for depth in [8, 16, 32] {
|
||||
let (mut s, [l0, a, b]) = session(depth);
|
||||
s.tools.background = [0.0, 0.0, 1.0, 1.0];
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
assert!(s.is_enabled(CMD), "{depth}");
|
||||
assert_eq!(s.execute(CMD, json!({})).unwrap()["layer"], a.0);
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}: drops to the bottom");
|
||||
let l = &doc(&s).layers[0];
|
||||
assert!(is_background(l) && l.name == "Background", "{depth}");
|
||||
assert_eq!(s.active().unwrap().active_layer, Some(a));
|
||||
// Its pixels stay; transparency takes the background colour (blue).
|
||||
let red = pixel(&s, 3, 3);
|
||||
let blue = pixel(&s, 10, 10);
|
||||
assert!((red[0] - 1.0).abs() < 1e-3 && red[2].abs() < 1e-3 && (red[3] - 1.0).abs() < 1e-3, "{depth}: {red:?}");
|
||||
assert!(blue[0].abs() < 1e-3 && (blue[2] - 1.0).abs() < 1e-3 && (blue[3] - 1.0).abs() < 1e-3, "{depth}: {blue:?}");
|
||||
// Now there is a Background: the item greys, Layer from Background is back.
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({"layer": b.0})).is_err(), "{depth}: one Background per document");
|
||||
assert!(s.is_enabled("layer.new.layerFromBackground"));
|
||||
// One step: undo restores the layer where it was, transparent.
|
||||
assert!(s.undo());
|
||||
assert_eq!(order(&s), [l0, a, b], "{depth}");
|
||||
let l = doc(&s).layer(a).unwrap();
|
||||
assert!(!is_background(l) && l.name == "A");
|
||||
assert!(s.redo());
|
||||
assert_eq!(order(&s), [a, l0, b], "{depth}");
|
||||
assert!(is_background(&doc(&s).layers[0]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_in_a_group_leaves_it_and_keeps_its_look() {
|
||||
let (mut s, [l0, a, b]) = session(8);
|
||||
s.execute("layer.select", json!({"layer": b.0})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
s.execute("layer.setProps", json!({"layer": b.0, "opacity": 0.5, "visible": false})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0})).unwrap();
|
||||
s.execute(CMD, json!({})).unwrap();
|
||||
let d = doc(&s);
|
||||
assert_eq!(d.layers[0].id, b);
|
||||
assert!(d.layers[1..].iter().all(|l| l.id != b));
|
||||
assert_eq!(d.layers[1].id, l0);
|
||||
assert_eq!(d.layers[2].id, a);
|
||||
let bg = &d.layers[0];
|
||||
assert!(is_background(bg) && !bg.visible, "visibility is kept");
|
||||
assert_eq!(bg.opacity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_groups_adjustments_missing_layers_and_bad_params() {
|
||||
// No document.
|
||||
let mut s = Session::new();
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({})).is_err());
|
||||
// Already a Background: unavailable, for any layer.
|
||||
let (mut s, [bg, a, _]) = with_background();
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({"layer": a.0})).is_err());
|
||||
assert!(s.execute(CMD, json!({"layer": bg.0})).is_err());
|
||||
// Bad params.
|
||||
let (mut s, [_, a, _]) = session(8);
|
||||
for p in [json!({"layer": "A"}), json!({"layer": -1}), json!({"layer": 1.5}), json!({"layer": 99999}), json!({"layer": [a.0]})] {
|
||||
assert!(s.execute(CMD, p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
// A group or an adjustment layer can't become the Background.
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
assert!(!s.is_enabled(CMD), "the group is active");
|
||||
assert!(s.execute(CMD, json!({})).is_err());
|
||||
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
|
||||
assert!(!s.is_enabled(CMD));
|
||||
assert!(s.execute(CMD, json!({})).is_err());
|
||||
assert!(!doc(&s).layers.iter().any(is_background));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_background_stays_at_the_bottom() {
|
||||
let (mut s, [bg, a, b]) = with_background();
|
||||
// Arrange: the Background doesn't move, and nothing goes below it. (The menu greys these
|
||||
// for the Background in the shell's enable rules; here an agent asks anyway.)
|
||||
for cmd in ["layer.arrange.bringForward", "layer.arrange.bringToFront", "layer.arrange.sendBackward", "layer.arrange.sendToBack"] {
|
||||
assert!(s.execute(cmd, json!({"layer": bg.0})).is_err(), "{cmd}");
|
||||
assert_eq!(order(&s), [bg, a, b], "{cmd}");
|
||||
}
|
||||
assert!(s.execute("layer.arrange.sendBackward", json!({"layer": a.0})).is_err());
|
||||
s.execute("layer.arrange.sendToBack", json!({"layer": b.0})).unwrap();
|
||||
assert_eq!(order(&s), [bg, b, a], "Send to Back stops above the Background");
|
||||
// Dragging in the Layers panel (layer.moveTo).
|
||||
assert!(s.execute("layer.moveTo", json!({"layer": bg.0, "target": a.0, "position": "above"})).is_err());
|
||||
assert!(s.execute("layer.moveTo", json!({"layer": a.0, "target": bg.0, "position": "below"})).is_err());
|
||||
assert!(s.execute("layer.moveTo", json!({"layer": a.0, "target": bg.0, "position": "below", "copy": true})).is_err());
|
||||
assert!(s.execute("layer.moveTo", json!({"layers": [bg.0, a.0], "target": b.0, "position": "above"})).is_err());
|
||||
assert_eq!(order(&s), [bg, b, a]);
|
||||
s.execute("layer.moveTo", json!({"layer": a.0, "target": bg.0, "position": "above"})).unwrap();
|
||||
assert_eq!(order(&s), [bg, a, b], "above the Background is fine");
|
||||
// ⌥-dragging the Background copies it above; the copy is a normal layer.
|
||||
s.execute("layer.moveTo", json!({"layer": bg.0, "target": b.0, "position": "above", "copy": true})).unwrap();
|
||||
assert_eq!(doc(&s).layers.len(), 4);
|
||||
assert_eq!(doc(&s).layers[0].id, bg);
|
||||
assert!(s.undo());
|
||||
// Reverse with the Background among the selected layers.
|
||||
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "add"})).unwrap();
|
||||
assert!(s.execute("layer.arrange.reverse", json!({})).is_err());
|
||||
assert_eq!(order(&s), [bg, a, b]);
|
||||
// As a normal layer it moves freely again.
|
||||
s.execute("layer.new.layerFromBackground", json!({})).unwrap();
|
||||
s.execute("layer.arrange.bringToFront", json!({"layer": bg.0})).unwrap();
|
||||
assert_eq!(order(&s), [a, b, bg]);
|
||||
}
|
||||
}
|
||||
|
|
@ -312,6 +312,9 @@ fn apply_auto_erase(brush: &mut BrushSettings, surf: &Surface, start: Option<&St
|
|||
/// dabs on the pixel grid), hard and at full flow, with the options-bar mode.
|
||||
fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
|
||||
let mut brush = with_blend_mode(resolve_brush(s, p, "paint.pencil")?, p);
|
||||
if flag(p, "block", false) {
|
||||
return block_brush(brush, p);
|
||||
}
|
||||
brush.aliased = true;
|
||||
if num(p, "hardness").is_none() {
|
||||
brush.hardness = 1.0;
|
||||
|
|
@ -322,6 +325,42 @@ fn pencil_brush(s: &Session, p: &Value) -> Result<BrushSettings> {
|
|||
Ok(brush)
|
||||
}
|
||||
|
||||
/// Edge of the Eraser's Block in screen pixels (Photoshop): constant on screen, so it covers
|
||||
/// `BLOCK_SCREEN_PX / zoom` document pixels.
|
||||
pub const BLOCK_SCREEN_PX: f64 = 16.0;
|
||||
|
||||
/// The Eraser's Block edge in document pixels at view `zoom`: `16 / zoom`, rounded to whole
|
||||
/// pixels (a zoom that is not a positive number counts as 100 %).
|
||||
pub fn block_size(zoom: f64) -> f32 {
|
||||
let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };
|
||||
(BLOCK_SCREEN_PX / zoom).round().clamp(1.0, f64::from(photocraft_paint::brush::MAX_BRUSH_SIZE)) as f32
|
||||
}
|
||||
|
||||
/// The Eraser's Block mode (`"block": true`): a hard, aliased square of
|
||||
/// [`BLOCK_SCREEN_PX`] screen pixels at the stroke's `"zoom"`, i.e. `16 / zoom` document
|
||||
/// pixels rounded to whole pixels. Size, opacity, flow, hardness and the brush's dynamics don't
|
||||
/// apply (Photoshop); colour, erase, smoothing and the seed are the resolved brush's.
|
||||
fn block_brush(b: BrushSettings, p: &Value) -> Result<BrushSettings> {
|
||||
let zoom = match p.get("zoom") {
|
||||
None => 1.0,
|
||||
Some(v) => v.as_f64().filter(|z| z.is_finite() && *z > 0.0).ok_or_else(|| bad("paint.pencil", "`zoom` must be a positive number"))?,
|
||||
};
|
||||
Ok(BrushSettings {
|
||||
size: block_size(zoom),
|
||||
aliased: true,
|
||||
square: true,
|
||||
pressure_size: false,
|
||||
pressure_opacity: false,
|
||||
spacing: 0.25,
|
||||
color: b.color,
|
||||
background: b.background,
|
||||
erase: b.erase,
|
||||
smoothing: b.smoothing,
|
||||
seed: b.seed,
|
||||
..BrushSettings::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies the options-bar blend `mode` to a brush. `"mode"` accepts any blend-mode name
|
||||
/// (normal|multiply|screen|…). The Eraser has no blend mode in Photoshop, so it is forced to Normal.
|
||||
fn with_blend_mode(mut b: BrushSettings, p: &Value) -> BrushSettings {
|
||||
|
|
@ -882,7 +921,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"paint.pencil",
|
||||
"Pencil",
|
||||
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
r##"{"points":[[x,y,pressure?,tiltX?,tiltY?,rotation?,timeMs?,wheel?],…],"brush":{…}?,"preset":name?,"size":0.5..5000 px?,"opacity":0..1?,"color":"#rrggbb"?=foreground,"mode":"normal|multiply|screen|…"="normal","erase":bool?,"block":bool=false (a hard 16×16 screen-px square, 16/zoom doc px; size, opacity, flow, hardness and dynamics ignored: the Eraser's Block mode),"zoom":view zoom=1,"autoErase":bool=false,"seed":u64?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target}"##,
|
||||
has_paintable,
|
||||
pencil,
|
||||
true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
//! Copy and Paste with one channel targeted in the Channels panel (#2307), as in Photoshop.
|
||||
//!
|
||||
//! - Copy with a colour channel (Red, Green, Blue, …) or an alpha channel targeted copies that
|
||||
//! channel's values as a grayscale clip, instead of the whole layer.
|
||||
//! - Paste with a colour channel targeted writes the clip's luminosity into that channel of the
|
||||
//! active layer, instead of making a new layer. The other channels and the layer's transparency
|
||||
//! stay as they are. Pasting into an alpha channel, a layer mask or the Quick Mask goes through
|
||||
//! [`crate::edit_cmds::paste_to_target`].
|
||||
//! - Cut with a colour channel targeted copies that channel and fills the cut area of that channel
|
||||
//! only with the background colour (#2698).
|
||||
|
||||
use photocraft_color::{ColorMode, PixelFormat};
|
||||
use photocraft_doc::{Layer, LayerId};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::channel_cmds::ChannelTarget;
|
||||
use crate::edit_cmds::Clip;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The colour channel a paste writes into: the Channels panel targets one and the params name no
|
||||
/// other target.
|
||||
pub(crate) fn paste_color_target(s: &Session, p: &Value) -> Option<usize> {
|
||||
if p.get("target").is_some() {
|
||||
return None;
|
||||
}
|
||||
match s.active()?.channel_view.target {
|
||||
ChannelTarget::Color(k) => Some(k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the targeted colour or alpha channel as a grayscale clip, inside the selection (or the
|
||||
/// whole canvas). `Ok(None)` when no single channel is targeted: Copy copies the layer.
|
||||
pub(crate) fn copy(s: &mut Session) -> Result<Option<Value>> {
|
||||
let Some(d) = s.active() else { return Ok(None) };
|
||||
let doc = &d.doc;
|
||||
let canvas = doc.bounds();
|
||||
// The surface the values come from, and which of its samples is the channel.
|
||||
let (src, k) = match d.channel_view.target {
|
||||
ChannelTarget::Composite => return Ok(None),
|
||||
ChannelTarget::Color(k) => {
|
||||
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let surf = doc.layer(id).and_then(Layer::surface).ok_or_else(|| EngineError::Other("the active layer has no pixels".into()))?;
|
||||
(surf, k)
|
||||
}
|
||||
ChannelTarget::Alpha(i) => {
|
||||
let c = doc.channels.get(i).ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len())))?;
|
||||
(&c.surface, 0)
|
||||
}
|
||||
};
|
||||
let n = src.format().channels();
|
||||
if k >= n {
|
||||
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
|
||||
}
|
||||
let sel = doc.selection.as_ref();
|
||||
let area = sel.map_or(canvas, |m| m.content_bounds().intersect(&canvas));
|
||||
if area.is_empty() {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
let fmt = PixelFormat::new(ColorMode::Grayscale, doc.pixel_format().sample, true);
|
||||
let values = src.read_region(area);
|
||||
let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1)));
|
||||
let mut px = Vec::with_capacity(values.len() / n * 2);
|
||||
for (i, p) in values.chunks_exact(n).enumerate() {
|
||||
px.push(p.get(k).copied().unwrap_or(0.0));
|
||||
px.push(cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0)));
|
||||
}
|
||||
let mut surface = Surface::new(fmt);
|
||||
surface.write_region(area, &px);
|
||||
surface.prune();
|
||||
let bounds = surface.content_bounds();
|
||||
if bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
}
|
||||
s.clipboard = Some(Clip { surface, bounds, layers: None });
|
||||
Ok(Some(json!({"bounds": [bounds.x0, bounds.y0, bounds.width(), bounds.height()], "channel": k})))
|
||||
}
|
||||
|
||||
/// Paste the clip's luminosity into colour channel `k` of the active layer, placed as a paste
|
||||
/// places it. The clip's transparency, and `limit` (Paste Into / Outside), let the old values show
|
||||
/// through. One history step.
|
||||
pub(crate) fn paste(s: &mut Session, p: &Value, k: usize, in_place: bool, limit: Option<&Surface>, label: &str) -> Result<Value> {
|
||||
let clip = s.clipboard.clone().ok_or_else(|| EngineError::Other("the clipboard is empty".into()))?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let id = d.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let (dx, dy) = crate::edit_cmds::paste_offset(&clip, canvas, p, in_place);
|
||||
let src = crate::edit_cmds::shifted(&clip.surface, dx, dy);
|
||||
let area = src.content_bounds().intersect(&canvas);
|
||||
s.edit(label, |doc, _| {
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
let n = surf.format().channels();
|
||||
if k >= n {
|
||||
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
|
||||
}
|
||||
if !area.is_empty() {
|
||||
let mut rgba = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
|
||||
src.read_rgba_into(area, &mut rgba);
|
||||
let weight = limit.map(|m| (m.read_region(area), m.format().channels().max(1)));
|
||||
let mut px = surf.read_region(area);
|
||||
for (i, (dst, s)) in px.chunks_exact_mut(n).zip(&rgba).enumerate() {
|
||||
let w = s[3] * weight.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
|
||||
let v = photocraft_color::convert::rgb_to_gray([s[0], s[1], s[2]]);
|
||||
if let Some(c) = dst.get_mut(k) {
|
||||
*c += (v - *c) * w.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
surf.write_region(area, &px);
|
||||
surf.prune();
|
||||
}
|
||||
doc.selection = None;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "channel": k, "offset": [dx, dy]}))
|
||||
}
|
||||
|
||||
/// Cut with colour channel `k` of layer `id` targeted (#2698): copy the channel as [`copy`] does,
|
||||
/// then fill the selected area (or the canvas) of that channel only with the background colour's
|
||||
/// value for it, as Photoshop does. The other channels and the layer's transparency stay as they
|
||||
/// are. One history step.
|
||||
pub(crate) fn cut(s: &mut Session, id: LayerId, k: usize) -> Result<Value> {
|
||||
let r = copy(s)?.ok_or_else(|| EngineError::Other("no colour channel is targeted".into()))?;
|
||||
let bg = s.tools.background;
|
||||
s.edit("Cut Pixels", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, k, bg))?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Copy reads only: with an alpha channel targeted it needs no pixel layer.
|
||||
pub(crate) fn copies_alpha_channel(s: &Session) -> bool {
|
||||
s.active().is_some_and(|st| matches!(st.channel_view.target, ChannelTarget::Alpha(i) if i < st.doc.channels.len()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "channel_clip/tests.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
use super::*;
|
||||
use photocraft_color::ColorMode;
|
||||
use photocraft_geom::Rect;
|
||||
|
||||
const W: i32 = 40;
|
||||
const H: i32 = 20;
|
||||
|
||||
/// A white document whose left half is #336699: red 0.2, green 0.4, blue 0.6 there.
|
||||
fn session(depth: u32) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": W / 2, "height": H})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn px(s: &Session, x: i32, y: i32) -> Vec<f32> {
|
||||
let d = s.active().unwrap();
|
||||
d.doc.layer(d.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.active().unwrap().doc.walk().len()
|
||||
}
|
||||
|
||||
fn near(a: &[f32], b: &[f32]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_red_paste_into_blue() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = session(depth);
|
||||
let (left, right) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.execute("channel.target", json!({"channel": "red"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let clip = s.clipboard.as_ref().unwrap();
|
||||
assert_eq!(clip.surface.format().mode, ColorMode::Grayscale, "depth {depth}: a channel copies as grayscale");
|
||||
assert_eq!(clip.bounds, Rect::new(0, 0, W, H), "no selection: the whole channel");
|
||||
let n = layers(&s);
|
||||
s.execute("channel.target", json!({"channel": "blue"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(layers(&s), n, "depth {depth}: no new layer");
|
||||
assert!(near(&px(&s, 5, 5), &[left[0], left[1], left[0], 1.0]), "depth {depth}: blue = old red, {:?}", px(&s, 5, 5));
|
||||
assert!(near(&px(&s, 30, 5), &right));
|
||||
assert!(s.active().unwrap().doc.selection.is_none());
|
||||
s.undo();
|
||||
assert!(near(&px(&s, 5, 5), &left), "depth {depth}: one undoable step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_channels_copy_and_paste_into_colour_channels() {
|
||||
let mut s = session(8);
|
||||
// Alpha 1: white on the top half.
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": W, "height": H / 2})).unwrap();
|
||||
s.execute("channel.new", json!({"fill": "selection"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let clip = &s.clipboard.as_ref().unwrap().surface;
|
||||
assert_eq!((clip.pixel(30, 2), clip.pixel(30, 15)), (vec![1.0, 1.0], vec![0.0, 1.0]), "the alpha channel's values");
|
||||
s.execute("channel.target", json!({"channel": "green"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(near(&px(&s, 30, 2), &[1.0, 1.0, 1.0, 1.0]));
|
||||
assert!(near(&px(&s, 30, 15), &[1.0, 0.0, 1.0, 1.0]), "green = Alpha 1, {:?}", px(&s, 30, 15));
|
||||
// With the composite targeted, Paste still makes a layer (grey, from the grayscale clip).
|
||||
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
|
||||
let n = layers(&s);
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(layers(&s), n + 1);
|
||||
assert!(near(&px(&s, 30, 15), &[0.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_an_alpha_channel_without_a_pixel_layer() {
|
||||
let mut s = session(8);
|
||||
s.execute("channel.new", json!({"fill": "white"})).unwrap();
|
||||
s.edit("no pixel layer", |_, active| {
|
||||
*active = None;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.is_enabled("edit.copy"), "an alpha channel needs no pixel layer");
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
assert_eq!(s.clipboard.as_ref().unwrap().surface.pixel(3, 3), vec![1.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_into_a_colour_channel_keeps_to_the_selection() {
|
||||
let mut s = session(16);
|
||||
// Paste Into centres the paste on the selection: copy the same area so it lands in place.
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": H})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "red"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("channel.target", json!({"channel": "green"})).unwrap();
|
||||
let n = layers(&s);
|
||||
s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap();
|
||||
assert_eq!(layers(&s), n, "no new layer or mask");
|
||||
let (inside, outside) = (px(&s, 5, 5), px(&s, 15, 5));
|
||||
assert!(near(&inside, &[0.2, 0.2, 0.6, 1.0]), "green = red inside, {inside:?}");
|
||||
assert!(near(&outside, &[0.2, 0.4, 0.6, 1.0]), "untouched outside, {outside:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_and_pasting_channels_fail_cleanly() {
|
||||
let mut s = session(8);
|
||||
s.execute("channel.target", json!({"channel": "red"})).unwrap();
|
||||
// An empty selection copies nothing and leaves the clipboard alone.
|
||||
s.edit("empty selection", |doc, _| {
|
||||
doc.selection = Some(Surface::new(photocraft_color::PixelFormat::GRAY8));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(s.execute("edit.copy", json!({})).is_err());
|
||||
assert!(s.clipboard.is_none());
|
||||
// A pixel-locked layer refuses the paste and keeps its pixels.
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let id = s.active().unwrap().active_layer.unwrap();
|
||||
s.edit("lock", |doc, _| {
|
||||
doc.layer_mut(id).unwrap().locks.pixels = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = px(&s, 5, 5);
|
||||
s.execute("channel.target", json!({"channel": "blue"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({})).is_err());
|
||||
assert_eq!(px(&s, 5, 5), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_a_colour_channel_clears_only_that_channel() {
|
||||
let mut s = session(8);
|
||||
let (left, right) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.tools.background = [0.8, 0.1, 0.9, 1.0];
|
||||
s.execute("channel.target", json!({"channel": "red"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 2, "y": 2, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.cut", json!({})).unwrap();
|
||||
let clip = s.clipboard.as_ref().unwrap();
|
||||
assert_eq!(clip.surface.format().mode, ColorMode::Grayscale, "the red channel copies as grayscale");
|
||||
assert!(near(&clip.surface.pixel(5, 5), &[left[0], 1.0]), "the clip holds the old red, {:?}", clip.surface.pixel(5, 5));
|
||||
// Red takes the background colour's red; green, blue and transparency stay.
|
||||
assert!(near(&px(&s, 5, 5), &[0.8, left[1], left[2], 1.0]), "{:?}", px(&s, 5, 5));
|
||||
assert!(near(&px(&s, 15, 5), &left), "outside the selection");
|
||||
assert!(near(&px(&s, 30, 5), &right));
|
||||
s.undo();
|
||||
assert!(near(&px(&s, 5, 5), &left), "one undoable step");
|
||||
}
|
||||
|
|
@ -533,12 +533,15 @@ pub(crate) fn channel_surface_for_filter<'a>(doc: &'a mut Document, layer: Optio
|
|||
Ok(Some(surf))
|
||||
}
|
||||
|
||||
/// Commands that edit a targeted layer mask in place of the layer: the Image › Adjustments table
|
||||
/// and the filters. A mask target enables them on any layer with a mask, so ⌘I inverts an
|
||||
/// adjustment layer's mask as in Photoshop (#780).
|
||||
/// Commands that edit a targeted layer mask in place of the layer: the Image › Adjustments table,
|
||||
/// the filters and the fills (Edit › Fill and its keys). A mask target enables them on any layer
|
||||
/// with a mask, so ⌘I inverts an adjustment layer's mask (#780) and Fill fills it (#2456) as in
|
||||
/// Photoshop.
|
||||
fn edits_mask(id: &str) -> bool {
|
||||
id.strip_prefix("image.adjustments.").is_some_and(|kind| crate::adjust_params::default_for(kind, ColorMode::Rgb).is_ok())
|
||||
|| crate::filters::params_for(id, &Value::Null).is_some()
|
||||
|| id == "edit.fill"
|
||||
|| crate::fill_key_cmds::IDS.contains(&id)
|
||||
}
|
||||
|
||||
/// The precondition of `id` when `p` targets the active layer's mask (`"target":"mask"`): the
|
||||
|
|
@ -553,7 +556,8 @@ pub(crate) fn mask_target_enabled(s: &Session, id: &str, p: &Value) -> Option<st
|
|||
/// Whether command `id` edits the targeted channel or mask when its params name no `"target"`
|
||||
/// (filters, adjustments, paint and fill commands); the shell adds its mask target to these.
|
||||
pub fn follows_target(id: &str) -> bool {
|
||||
routed(id)
|
||||
// Moving the selected pixels (⌘-drag, Move tool) moves the targeted mask's, not the layer's.
|
||||
routed(id) || id == "select.float"
|
||||
}
|
||||
|
||||
/// Commands whose target follows the Channels panel.
|
||||
|
|
@ -643,6 +647,71 @@ pub(crate) fn restrict_to_color(before: &Document, after: &mut Document, id: Lay
|
|||
new.prune();
|
||||
}
|
||||
|
||||
/// The colour channel Clear is limited to: the Channels panel targets a single one.
|
||||
pub(crate) fn targeted_color(s: &Session) -> Option<usize> {
|
||||
match s.active()?.channel_view.target {
|
||||
ChannelTarget::Color(k) => Some(k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear (Delete) with colour channel `k` of layer `id` targeted (#2720): fill the selected area
|
||||
/// (or the canvas) of that channel only with the background colour's value for it, as Photoshop
|
||||
/// does. A channel has no transparency of its own, so the other channels and the layer's alpha
|
||||
/// stay; partly selected pixels blend.
|
||||
pub(crate) fn clear_color_channel(doc: &mut Document, id: LayerId, k: usize, background: [f32; 4]) -> Result<()> {
|
||||
let canvas = doc.bounds();
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
let fmt = surf.format();
|
||||
let n = fmt.channels();
|
||||
// The background colour in the layer's colour model, so `k` picks the right component.
|
||||
let Some(v) = from_rgba(&fmt, [background[0], background[1], background[2], 1.0]).get(k).copied().filter(|_| k < n) else {
|
||||
return Err(EngineError::Other(format!("no colour channel {k} in this layer")));
|
||||
};
|
||||
if area.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let cover = sel.map(|m| (m.read_region(area), m.format().channels().max(1)));
|
||||
let mut px = surf.read_region(area);
|
||||
for (i, p) in px.chunks_exact_mut(n).enumerate() {
|
||||
let w = cover.as_ref().map_or(1.0, |(m, mk)| m.get(i * mk).copied().unwrap_or(0.0));
|
||||
if let Some(c) = p.get_mut(k) {
|
||||
*c += (v - *c) * w.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
surf.write_region(area, &px);
|
||||
surf.prune();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The alpha channel or Quick Mask the Channels panel targets, as `"target"` params: Clear
|
||||
/// (Delete) fills it instead of the layer (#2734).
|
||||
pub(crate) fn targeted_channel(s: &Session) -> Option<Value> {
|
||||
let st = s.active()?;
|
||||
let t = match st.channel_view.target {
|
||||
ChannelTarget::Alpha(i) if i < st.doc.channels.len() => json!({ "channel": i }),
|
||||
ChannelTarget::Composite if st.doc.quick_mask.is_some() => json!("quickMask"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(json!({ "target": t }))
|
||||
}
|
||||
|
||||
/// Clear (Delete) with an alpha channel or the Quick Mask targeted (`target` from
|
||||
/// [`targeted_channel`]): fill the selected area (or the canvas) of it with the background colour,
|
||||
/// as gray, as Photoshop does. Partly selected pixels blend.
|
||||
pub(crate) fn clear_channel(doc: &mut Document, target: &Value, background: [f32; 4]) -> Result<()> {
|
||||
let canvas = doc.bounds();
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
|
||||
if area.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (surf, _) = target_surface(doc, None, target)?;
|
||||
crate::fill_cmds::fill_color(surf, area, [background[0], background[1], background[2], 1.0], sel.as_ref())
|
||||
}
|
||||
|
||||
/// The active document targets an alpha channel or is in Quick Mask mode, so pixel commands
|
||||
/// work without a pixel layer.
|
||||
pub(crate) fn edits_channel(s: &Session) -> bool {
|
||||
|
|
|
|||
|
|
@ -521,3 +521,53 @@ fn apply_image_and_calculations_fail_gracefully_never_panic() {
|
|||
// A well-formed self-apply still works.
|
||||
assert!(s.execute("image.applyImage", json!({"source": {"channel": "composite"}})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_with_a_colour_channel_targeted_clears_only_that_channel() {
|
||||
let near = |a: &[f32], b: &[f32]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-3);
|
||||
// The Background, then a normal layer (Delete there can't make one channel transparent).
|
||||
for duplicate in [false, true] {
|
||||
let mut s = session_depth(16);
|
||||
rect(&mut s, 0, 0, 20, 20, "replace");
|
||||
s.execute("edit.fill", json!({"color": "#336699"})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
if duplicate {
|
||||
s.execute("layer.duplicate", json!({})).unwrap();
|
||||
}
|
||||
let (left, right) = (px(&s, 5, 5), px(&s, 30, 5));
|
||||
s.tools.background = [0.8, 0.1, 0.9, 1.0];
|
||||
s.execute("channel.target", json!({"channel": "red"})).unwrap();
|
||||
rect(&mut s, 2, 2, 10, 10, "replace");
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
// Red takes the background colour's red; green, blue and transparency stay.
|
||||
assert!(near(&px(&s, 5, 5), &[0.8, left[1], left[2], 1.0]), "duplicate {duplicate}: {:?}", px(&s, 5, 5));
|
||||
assert!(near(&px(&s, 15, 5), &left), "outside the selection");
|
||||
assert!(near(&px(&s, 30, 5), &right));
|
||||
s.undo();
|
||||
assert!(near(&px(&s, 5, 5), &left), "one undoable step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_with_an_alpha_channel_or_quick_mask_targeted_fills_it_not_the_layer() {
|
||||
let mut s = session_depth(16);
|
||||
let layer = px(&s, 5, 5);
|
||||
s.tools.background = [0.0, 0.0, 0.0, 1.0];
|
||||
// A white Alpha 1, targeted.
|
||||
s.execute("channel.new", json!({"fill": "white"})).unwrap();
|
||||
rect(&mut s, 2, 2, 10, 10, "replace");
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
// The selected part of the channel takes the background colour; the layer stays.
|
||||
assert_eq!(chan(&s, 0, 5, 5), 0.0);
|
||||
assert_eq!(chan(&s, 0, 15, 5), 1.0, "outside the selection");
|
||||
assert_eq!(px(&s, 5, 5), layer, "the layer is untouched");
|
||||
s.undo();
|
||||
assert_eq!(chan(&s, 0, 5, 5), 1.0, "one undoable step");
|
||||
// In Quick Mask mode Delete fills the mask (black = masked), not the layer.
|
||||
s.execute("channel.target", json!({"channel": "composite"})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap();
|
||||
s.execute("edit.clear", json!({})).unwrap();
|
||||
let q = doc(&s).quick_mask.as_ref().unwrap().surface.sample_channel(5, 5, 0);
|
||||
assert_eq!(q, 0.0);
|
||||
assert_eq!(px(&s, 5, 5), layer, "the layer is untouched in Quick Mask mode");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use std::sync::{Arc, Mutex, Weak};
|
|||
|
||||
use photocraft_cms::{Builtin, ColorSpace, GamutCheck, Intent, Lut3d, Profile, SampleKind, Transform};
|
||||
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
||||
use photocraft_doc::{DocId, Document, Effect, Fill, FxPaint, Layer, LayerContent};
|
||||
use photocraft_doc::{DocId, Document, Duotone, Effect, Fill, FxPaint, Layer, LayerContent};
|
||||
use photocraft_raster::Surface;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -251,9 +251,11 @@ impl ColorState {
|
|||
// Compared by colour, not bytes: Photoshop's sRGB IEC61966-2.1 is our working sRGB.
|
||||
let mismatch = !emb.same_colors(&working);
|
||||
let base = json!({"embedded": emb.description, "working": working.description, "mismatch": mismatch, "policy": policy.id()});
|
||||
// 32-bit linear images (EXR/HDR are tagged linear sRGB on import) stay linear, as
|
||||
// Photoshop keeps 32-bit documents in a linear version of the working space.
|
||||
let linear_hdr = doc.depth == SampleType::F32 && emb.same_colors(Builtin::LinearSrgb.profile());
|
||||
// 32-bit linear images (EXR/HDR are tagged linear sRGB or linear gray on import)
|
||||
// stay linear, as Photoshop keeps 32-bit documents in a linear version of the
|
||||
// working space.
|
||||
let linear_hdr =
|
||||
doc.depth == SampleType::F32 && (emb.same_colors(Builtin::LinearSrgb.profile()) || emb.same_colors(photocraft_cms::builtin::linear_gray()));
|
||||
if !mismatch || linear_hdr {
|
||||
return merge(base, json!({"action": "kept"}));
|
||||
}
|
||||
|
|
@ -574,23 +576,24 @@ fn convert_color(c: &mut Color, from: ColorMode, to: ColorMode, t: &Transform) {
|
|||
c.c = nc;
|
||||
}
|
||||
|
||||
fn convert_fill(f: &mut Fill, from: ColorMode, to: ColorMode, t: &Transform) {
|
||||
fn convert_fill(f: &mut Fill, cc: &mut dyn FnMut(&mut Color)) {
|
||||
match f {
|
||||
Fill::Solid(c) => convert_color(c, from, to, t),
|
||||
Fill::Gradient { stops, .. } => stops.iter_mut().for_each(|s| convert_color(&mut s.1, from, to, t)),
|
||||
Fill::Solid(c) => cc(c),
|
||||
Fill::Gradient { stops, .. } => stops.iter_mut().for_each(|s| cc(&mut s.1)),
|
||||
Fill::Pattern { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_layer_colors(l: &mut Layer, from: ColorMode, to: ColorMode, t: &Transform) {
|
||||
let mut cc = |c: &mut Color| convert_color(c, from, to, t);
|
||||
/// Applies `cc` to every colour value of `l` (fill, text, shape and effect colours), recursing
|
||||
/// into groups.
|
||||
fn convert_layer_colors(l: &mut Layer, cc: &mut dyn FnMut(&mut Color)) {
|
||||
let mut touched_fx = false;
|
||||
for e in &mut l.effects.items {
|
||||
touched_fx = true;
|
||||
match e {
|
||||
Effect::DropShadow(s) | Effect::InnerShadow(s) => cc(&mut s.color),
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint_colors(&mut g.paint, &mut cc),
|
||||
Effect::Stroke(s) => paint_colors(&mut s.paint, &mut cc),
|
||||
Effect::OuterGlow(g) | Effect::InnerGlow(g) => paint_colors(&mut g.paint, cc),
|
||||
Effect::Stroke(s) => paint_colors(&mut s.paint, cc),
|
||||
Effect::ColorOverlay { color, .. } => cc(color),
|
||||
Effect::GradientOverlay { gradient, .. } => gradient.stops.iter_mut().for_each(|s| cc(&mut s.1)),
|
||||
Effect::Satin(s) => cc(&mut s.color),
|
||||
|
|
@ -603,7 +606,7 @@ fn convert_layer_colors(l: &mut Layer, from: ColorMode, to: ColorMode, t: &Trans
|
|||
}
|
||||
let _ = touched_fx;
|
||||
match &mut l.content {
|
||||
LayerContent::Fill(f) => convert_fill(f, from, to, t),
|
||||
LayerContent::Fill(f) => convert_fill(f, cc),
|
||||
LayerContent::Text(tx) => {
|
||||
cc(&mut tx.color);
|
||||
for r in &mut tx.runs {
|
||||
|
|
@ -612,22 +615,22 @@ fn convert_layer_colors(l: &mut Layer, from: ColorMode, to: ColorMode, t: &Trans
|
|||
}
|
||||
LayerContent::Shape(sh) => {
|
||||
if let Some(f) = &mut sh.fill {
|
||||
convert_fill(f, from, to, t);
|
||||
convert_fill(f, cc);
|
||||
}
|
||||
if let Some(s) = &mut sh.stroke {
|
||||
convert_fill(&mut s.paint, from, to, t);
|
||||
convert_fill(&mut s.paint, cc);
|
||||
}
|
||||
}
|
||||
LayerContent::Group(g) => {
|
||||
for c in &mut g.children {
|
||||
convert_layer_colors(c, from, to, t);
|
||||
convert_layer_colors(c, cc);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_colors(p: &mut FxPaint, cc: &mut impl FnMut(&mut Color)) {
|
||||
fn paint_colors(p: &mut FxPaint, cc: &mut dyn FnMut(&mut Color)) {
|
||||
match p {
|
||||
FxPaint::Color(c) => cc(c),
|
||||
FxPaint::Gradient(g) => g.stops.iter_mut().for_each(|s| cc(&mut s.1)),
|
||||
|
|
@ -638,9 +641,13 @@ fn paint_colors(p: &mut FxPaint, cc: &mut impl FnMut(&mut Color)) {
|
|||
/// Converts every pixel layer (and cached text/shape/smart pixels and fill caches) of `doc`
|
||||
/// from its profile to `dst`, switching the mode to `dst`'s colour space. Masks, alpha
|
||||
/// channels and the selection are untouched; fill, text, shape and effect colours are
|
||||
/// converted. The document is tagged with `dst`.
|
||||
/// converted. A Duotone document going to a colour space starts from its ink colours
|
||||
/// ([`ink_duotone`]). The document is tagged with `dst`.
|
||||
pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc: bool) -> Result<()> {
|
||||
let to_mode = space_mode(dst.color_space).ok_or_else(|| EngineError::Other(format!("cannot convert to a {:?} profile", dst.color_space)))?;
|
||||
if to_mode != ColorMode::Grayscale {
|
||||
ink_duotone(doc);
|
||||
}
|
||||
let src = document_profile(doc);
|
||||
let from_mode = doc.pixel_format().mode;
|
||||
let t = Transform::new(&src, dst, intent, bpc).map_err(cms_err)?;
|
||||
|
|
@ -650,7 +657,7 @@ pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc:
|
|||
*surf = convert_surface(surf, from_mode, f, &t);
|
||||
});
|
||||
for l in &mut doc.layers {
|
||||
convert_layer_colors(l, from_mode, to_mode, &t);
|
||||
convert_layer_colors(l, &mut |c| convert_color(c, from_mode, to_mode, &t));
|
||||
// Preserved PSD blocks describing colours in the old mode no longer apply.
|
||||
}
|
||||
doc.mode = to_mode;
|
||||
|
|
@ -661,6 +668,76 @@ pub fn convert_document(doc: &mut Document, dst: &Profile, intent: Intent, bpc:
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Turns a Duotone document into the sRGB document it displays as: every gray pixel and colour
|
||||
/// value becomes its printed ink colour ([`Duotone::render`], sRGB like the inks), so converting
|
||||
/// to a colour mode keeps the inks' look, as Photoshop converts them to their equivalents.
|
||||
/// Other documents (and Duotone without inks) are left alone.
|
||||
fn ink_duotone(doc: &mut Document) {
|
||||
if doc.mode != ColorMode::Duotone {
|
||||
return;
|
||||
}
|
||||
let Some(d) = doc.duotone.take_if(|d| !d.inks.is_empty()) else { return };
|
||||
crate::image_cmds::for_each_surface(&mut doc.layers, false, &mut |surf, _| *surf = ink_surface(surf, &d));
|
||||
for l in &mut doc.layers {
|
||||
convert_layer_colors(l, &mut |c| {
|
||||
if matches!(c.mode, ColorMode::Grayscale | ColorMode::Duotone) {
|
||||
let [r, g, b] = d.render(c.c[0]);
|
||||
*c = Color::rgba(r, g, b, c.alpha);
|
||||
}
|
||||
});
|
||||
}
|
||||
doc.mode = ColorMode::Rgb;
|
||||
doc.icc_profile = Some(Builtin::Srgb.profile().to_bytes());
|
||||
}
|
||||
|
||||
/// The RGB surface of a gray surface's ink colours (same depth and alpha), one parallel pass
|
||||
/// over the tiles. Non-gray surfaces are returned unchanged.
|
||||
fn ink_surface(s: &Surface, d: &Duotone) -> Surface {
|
||||
use rayon::prelude::*;
|
||||
let sf = s.format();
|
||||
if sf.mode != ColorMode::Grayscale {
|
||||
return s.clone();
|
||||
}
|
||||
let to = PixelFormat { mode: ColorMode::Rgb, ..sf };
|
||||
let (ss, ds) = (sf.channels(), to.channels());
|
||||
let ink = |src: &[f32], out: &mut [f32]| {
|
||||
let [r, g, b] = d.render(src.first().copied().unwrap_or(0.0));
|
||||
for (o, v) in out.iter_mut().zip([r, g, b].into_iter().chain(src.get(1).copied())) {
|
||||
*o = v;
|
||||
}
|
||||
};
|
||||
let mut dp = vec![0.0f32; ds];
|
||||
ink(&s.default_pixel(), &mut dp);
|
||||
let mut out = Surface::with_default(to, &dp);
|
||||
let tiles: Vec<_> = s.tiles().map(|(c, t)| (*c, t.clone())).collect();
|
||||
let inked: Vec<Vec<u8>> = tiles
|
||||
.par_iter()
|
||||
.map(|(_, t)| {
|
||||
let src = t.bytes();
|
||||
let n = src.len() / sf.bytes_per_pixel().max(1);
|
||||
let mut dst = vec![0u8; n * to.bytes_per_pixel()];
|
||||
let (mut px, mut o) = ([0.0f32; 2], [0.0f32; 4]);
|
||||
for i in 0..n {
|
||||
for (k, v) in px.iter_mut().enumerate().take(ss) {
|
||||
*v = photocraft_color::read_sample(src, sf.sample, i * ss + k);
|
||||
}
|
||||
ink(&px[..ss], &mut o[..ds]);
|
||||
for (k, v) in o.iter().enumerate().take(ds) {
|
||||
photocraft_color::write_sample(&mut dst, to.sample, i * ds + k, *v);
|
||||
}
|
||||
}
|
||||
dst
|
||||
})
|
||||
.collect();
|
||||
for ((c, _), bytes) in tiles.iter().zip(inked) {
|
||||
let tile = out.tile_mut(*c).bytes_mut();
|
||||
if tile.len() == bytes.len() {
|
||||
tile.copy_from_slice(&bytes);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Image › Mode › RGB/Grayscale/CMYK/Lab through the CMS. Params: `profile` (destination,
|
||||
/// default the mode's working profile), `intent` (default relative), `bpc` (default true).
|
||||
pub fn convert_mode(s: &mut Session, mode: ColorMode, p: &Value) -> Result<Value> {
|
||||
|
|
|
|||
|
|
@ -393,13 +393,23 @@ fn build() -> Vec<CommandSpec> {
|
|||
"Fill…",
|
||||
["Edit"],
|
||||
Some("Shift+F5"),
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|{"channel":i}|"quickMask"?}"##,
|
||||
r##"{"contents":"foreground|background|color|contentAware|pattern|history|black|gray|white"="color","color":"#rrggbb|[r,g,b,a]"=foreground (contents=color),"pattern":id|name (contents=pattern),"scale":%=100,"angle":deg,"state":index? (contents=history; default the oldest state),"colorAdaptation":bool=true (contents=contentAware),"mode":"normal|multiply|…"="normal","opacity":0..100=100,"preserveTransparency":bool=false,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##,
|
||||
has_pixel_or_channel,
|
||||
crate::fill_cmds::fill
|
||||
),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{}", has_pixel_layer, |s, p| {
|
||||
let id = layer_param(s, p)?;
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{} (a targeted channel or Quick Mask is cleared alone)", has_pixel_or_channel, |s, p| {
|
||||
let bg = s.tools.background;
|
||||
// A targeted alpha channel or Quick Mask is filled instead (#2734).
|
||||
if let Some(t) = crate::channel_cmds::targeted_channel(s) {
|
||||
s.edit("Clear", |doc, _| crate::channel_cmds::clear_channel(doc, &t, bg))?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let id = layer_param(s, p)?;
|
||||
// A targeted colour channel clears that channel only (#2720).
|
||||
if let Some(k) = crate::channel_cmds::targeted_color(s) {
|
||||
s.edit("Clear", |doc, _| crate::channel_cmds::clear_color_channel(doc, id, k, bg))?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
s.edit("Clear", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let area = sel.as_ref().map(|m| m.content_bounds()).unwrap_or(doc.bounds());
|
||||
|
|
@ -547,7 +557,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
|s, p| {
|
||||
let in_place = p.get("inPlace").and_then(Value::as_bool).unwrap_or(false);
|
||||
if crate::layer_multi_cmds::multi(s, p) {
|
||||
return crate::layer_multi_cmds::duplicate_selected(s, in_place);
|
||||
return crate::layer_multi_cmds::duplicate_selected(s, in_place, "Duplicate Layers");
|
||||
}
|
||||
let id = layer_param(s, p)?;
|
||||
let nid = s.edit("Duplicate Layer", |doc, active| {
|
||||
|
|
@ -891,8 +901,13 @@ fn build() -> Vec<CommandSpec> {
|
|||
Ok(Value::Null)
|
||||
}),
|
||||
cmd!("tools.defaultColors", "Default Foreground and Background Colors", [], Some("D"), "{}", always, |s, _| {
|
||||
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
s.tools.background = [1.0, 1.0, 1.0, 1.0];
|
||||
if s.tools.mask_targeted {
|
||||
s.tools.foreground = crate::ToolState::MASK_COLORS[0];
|
||||
s.tools.background = crate::ToolState::MASK_COLORS[1];
|
||||
} else {
|
||||
s.tools.foreground = [0.0, 0.0, 0.0, 1.0];
|
||||
s.tools.background = [1.0, 1.0, 1.0, 1.0];
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
// Queries (not journaled)
|
||||
|
|
@ -1142,7 +1157,9 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::select_extra_cmds::specs());
|
||||
v.extend(crate::paint_cmds::specs());
|
||||
v.extend(crate::extra_cmds::specs());
|
||||
v.extend(crate::background_cmds::specs());
|
||||
v.extend(crate::file_cmds::specs());
|
||||
v.extend(crate::exr_cmds::specs());
|
||||
v.extend(crate::type_extra_cmds::specs());
|
||||
v.extend(crate::type_styles_cmds::specs());
|
||||
v.extend(crate::type_spell_cmds::specs());
|
||||
|
|
@ -1191,6 +1208,7 @@ fn build() -> Vec<CommandSpec> {
|
|||
v.extend(crate::video_cmds::specs());
|
||||
v.extend(crate::jobs::specs());
|
||||
v.extend(crate::wia_cmds::specs());
|
||||
v.extend(crate::rasterize_style_cmds::specs());
|
||||
v.extend(crate::variables_cmds::specs());
|
||||
v.extend(crate::plugin_cmds::specs());
|
||||
v.extend(crate::group_view_cmds::specs());
|
||||
|
|
@ -1253,6 +1271,11 @@ pub fn adjustment_kind(a: &Adjustment) -> &'static str {
|
|||
fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
|
||||
let id = layer_param(s, p)?;
|
||||
s.edit("Arrange", |doc, _| {
|
||||
// The Background stays at the bottom: it doesn't move and nothing goes below it.
|
||||
let bg = crate::background_cmds::background_id(doc);
|
||||
if bg == Some(id) {
|
||||
return Err(EngineError::Other("the Background layer can't be moved".into()));
|
||||
}
|
||||
let steps = match delta {
|
||||
i32::MAX => 10_000,
|
||||
i32::MIN => -10_000,
|
||||
|
|
@ -1264,6 +1287,10 @@ fn arrange(s: &mut Session, p: &Value, delta: i32) -> Result<Value> {
|
|||
if !doc.shift(id, dir) {
|
||||
break;
|
||||
}
|
||||
if crate::background_cmds::keep_background_at_bottom(bg, doc).is_err() {
|
||||
doc.shift(id, -dir);
|
||||
break;
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
if moved { Ok(()) } else { Err(EngineError::Other("layer can't move further".into())) }
|
||||
|
|
|
|||
|
|
@ -190,6 +190,25 @@ fn exr_round_trip_is_linear() {
|
|||
assert!(report.get("ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_exr_round_trip_is_linear() {
|
||||
let mut s = Session::new();
|
||||
// An sGray document exported to EXR is linearised (#2386); re-opened it is tagged linear gray
|
||||
// and displays as before.
|
||||
let d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(128.0 / 255.0));
|
||||
let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap();
|
||||
let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document;
|
||||
let icc = back.icc_profile.clone().expect("tagged");
|
||||
assert_eq!(Profile::parse(&icc).unwrap().content_hash(), photocraft_cms::builtin::linear_gray().content_hash());
|
||||
let v = back.layers[0].surface().unwrap().pixel(1, 1);
|
||||
assert!((v[0] - 0.2159).abs() < 1e-3, "stored linear: {v:?}");
|
||||
assert!(close(shown(&s, &back), shown(&s, &d), 1.0), "{:?} vs {:?}", shown(&s, &back), shown(&s, &d));
|
||||
// Opening keeps it linear without a mismatch prompt.
|
||||
let (_, report) = s.open_document(back, None);
|
||||
assert_eq!(report["action"], "kept");
|
||||
assert!(report.get("ask").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_profile_setting() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -309,7 +309,7 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn deform_selected(surf: &Surface, sel: Option<&Surface>, f: &dyn Fn(&Surface) -> Result<Surface>) -> Result<Surface> {
|
||||
match sel {
|
||||
Some(sel) => {
|
||||
let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel);
|
||||
let (lifted, mut rest) = crate::transform_cmds::split_selected(surf, sel)?;
|
||||
let moved = f(&lifted)?;
|
||||
crate::transform_cmds::composite_over(&mut rest, &moved);
|
||||
rest.prune();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,36 @@ pub(crate) use paste_target;
|
|||
pub struct Clip {
|
||||
pub surface: Surface,
|
||||
pub bounds: Rect,
|
||||
/// The copied layer itself, when Copy took a whole layer that Paste should recreate rather
|
||||
/// than rasterize (see [`whole_layer`]): a scratch document in the source's colour holding it.
|
||||
/// `surface` still holds its pixels, for Paste Into, masks, channels and other apps.
|
||||
pub layers: Option<Box<Document>>,
|
||||
}
|
||||
|
||||
/// Photoshop's layer-based copy (CC 2018 on): with no selection, Copy takes the active layer
|
||||
/// whole when it is a smart object (#2428), a type or shape layer, or a group (its whole subtree)
|
||||
/// (#2670), so Paste recreates it instead of its pixels. Pixel layers, and several selected
|
||||
/// layers, still copy the active layer's pixels.
|
||||
fn takes_whole(d: &crate::DocState, id: LayerId) -> bool {
|
||||
d.doc.selection.is_none()
|
||||
&& d.selected_layers().len() <= 1
|
||||
&& crate::layer_copy_cmds::layered(d.doc.mode)
|
||||
&& d.doc
|
||||
.layer(id)
|
||||
.is_some_and(|l| matches!(l.content, LayerContent::Smart(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Group(_)))
|
||||
}
|
||||
|
||||
/// The layer Copy takes whole ([`takes_whole`]): a scratch document in the source's colour
|
||||
/// holding it.
|
||||
fn whole_layer(d: &crate::DocState, id: LayerId) -> Option<Box<Document>> {
|
||||
let l = d.doc.layer(id).filter(|_| takes_whole(d, id))?;
|
||||
let doc = &d.doc;
|
||||
let mut copies = Document::new("", doc.size, doc.mode, doc.depth);
|
||||
copies.icc_profile = doc.icc_profile.clone();
|
||||
copies.resolution_dpi = doc.resolution_dpi;
|
||||
copies.patterns = doc.patterns.clone();
|
||||
copies.layers.push(l.clone());
|
||||
Some(Box::new(copies))
|
||||
}
|
||||
|
||||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||||
|
|
@ -55,6 +85,15 @@ fn has_layer_pixels(s: &Session) -> std::result::Result<(), String> {
|
|||
active_layer(s)?.surface().map(|_| ()).ok_or_else(|| "the active layer has no pixels".into())
|
||||
}
|
||||
|
||||
/// Copy: a targeted alpha channel (#2307), a layer with pixels, or one taken whole (a group with
|
||||
/// no selection, #2670).
|
||||
fn can_copy(s: &Session) -> std::result::Result<(), String> {
|
||||
if crate::channel_clip::copies_alpha_channel(s) {
|
||||
return Ok(());
|
||||
}
|
||||
has_layer_pixels(s).or_else(|e| s.active().and_then(|d| d.active_layer.filter(|id| takes_whole(d, *id))).map(|_| ()).ok_or(e))
|
||||
}
|
||||
|
||||
fn has_clip(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
s.clipboard.as_ref().map(|_| ()).ok_or_else(|| "the clipboard is empty".into())
|
||||
|
|
@ -65,7 +104,7 @@ fn active_id(s: &Session) -> Result<LayerId> {
|
|||
}
|
||||
|
||||
/// The pixels of `src` inside the selection (or all of them), as a straight-alpha surface.
|
||||
fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip {
|
||||
fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Result<Clip> {
|
||||
let fmt = src.format();
|
||||
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let area = match sel {
|
||||
|
|
@ -74,34 +113,45 @@ fn lift(src: &Surface, sel: Option<&Surface>, canvas: Rect) -> Clip {
|
|||
};
|
||||
let mut out = Surface::new(with_alpha);
|
||||
if area.is_empty() {
|
||||
return Clip { surface: out, bounds: Rect::EMPTY };
|
||||
return Ok(Clip { surface: out, bounds: Rect::EMPTY, layers: None });
|
||||
}
|
||||
// Only the selected area is read: converting the whole layer (a 24 MP Background gaining
|
||||
// alpha) cost about a second per Layer via Copy (#668).
|
||||
let n = with_alpha.channels();
|
||||
let mut px = if fmt.alpha {
|
||||
src.read_region(area)
|
||||
crate::allocation::read_region(src, area, "copying pixels")?
|
||||
} else {
|
||||
let opaque = src.read_region(area);
|
||||
let opaque = crate::allocation::read_region(src, area, "copying pixels")?;
|
||||
let k = fmt.channels();
|
||||
let mut v = Vec::with_capacity(opaque.len() / k.max(1) * n);
|
||||
let len = opaque
|
||||
.len()
|
||||
.checked_div(k.max(1))
|
||||
.and_then(|pixels| pixels.checked_mul(n))
|
||||
.ok_or_else(|| EngineError::Other("not enough memory for copying pixels (requested size overflowed); the document was not changed".into()))?;
|
||||
let mut v = crate::allocation::filled(len, 0.0, "copying pixels")?;
|
||||
let mut at = 0;
|
||||
for p in opaque.chunks_exact(k.max(1)) {
|
||||
v.extend_from_slice(p);
|
||||
v.push(1.0);
|
||||
let Some(dst) = v.get_mut(at..at + k.max(1)) else { break };
|
||||
dst.copy_from_slice(p);
|
||||
at += k.max(1);
|
||||
if let Some(alpha) = v.get_mut(at) {
|
||||
*alpha = 1.0;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
v
|
||||
};
|
||||
if let Some(m) = sel {
|
||||
let mask = m.read_region(area);
|
||||
let mask = crate::allocation::read_region(m, area, "applying the selection to copied pixels")?;
|
||||
let mk = m.format().channels().max(1);
|
||||
for (p, a) in px.chunks_exact_mut(n).zip(mask.chunks_exact(mk)) {
|
||||
p[n - 1] *= a[0];
|
||||
}
|
||||
}
|
||||
out.write_region(area, &px);
|
||||
out.try_write_region(area, &px).map_err(|e| EngineError::Other(format!("not enough memory while copying pixels ({e}); the document was not changed")))?;
|
||||
out.prune();
|
||||
let bounds = out.content_bounds();
|
||||
Clip { surface: out, bounds }
|
||||
Ok(Clip { surface: out, bounds, layers: None })
|
||||
}
|
||||
|
||||
/// Merged composite of the visible document as a surface in the document's format.
|
||||
|
|
@ -114,11 +164,22 @@ fn copy(s: &mut Session, merged: bool) -> Result<Value> {
|
|||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
let clip = if merged {
|
||||
lift(&merged_surface(&d.doc), d.doc.selection.as_ref(), canvas)
|
||||
lift(&merged_surface(&d.doc), d.doc.selection.as_ref(), canvas)?
|
||||
} else {
|
||||
let id = d.active_layer.ok_or(EngineError::Other("no active layer".into()))?;
|
||||
let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::Other("the active layer has no pixels".into()))?;
|
||||
lift(surf, d.doc.selection.as_ref(), canvas)
|
||||
let layers = whole_layer(d, id);
|
||||
match (d.doc.layer(id).and_then(Layer::surface), &layers) {
|
||||
(Some(surf), _) => Clip { layers, ..lift(surf, d.doc.selection.as_ref(), canvas)? },
|
||||
// A group has no pixels of its own: the clipboard pixels are what it shows.
|
||||
(None, Some(copies)) => {
|
||||
let mut shown = (**copies).clone();
|
||||
for l in &mut shown.layers {
|
||||
l.visible = true;
|
||||
}
|
||||
Clip { layers, ..lift(&merged_surface(&shown), None, canvas)? }
|
||||
}
|
||||
(None, None) => return Err(EngineError::Other("the active layer has no pixels".into())),
|
||||
}
|
||||
};
|
||||
if clip.bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not copy: the selected area is empty".into()));
|
||||
|
|
@ -141,9 +202,9 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio
|
|||
let bg = crate::extra_cmds::is_background(doc.layer(id).ok_or(EngineError::NoLayer(id))?);
|
||||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
if bg {
|
||||
crate::pixels::fill_surface(surf, area, background, sel, true);
|
||||
crate::pixels::try_fill_surface(surf, area, background, sel, true)?;
|
||||
} else {
|
||||
crate::pixels::clear_surface(surf, area, sel);
|
||||
crate::pixels::try_clear_surface(surf, area, sel)?;
|
||||
}
|
||||
surf.prune();
|
||||
Ok(())
|
||||
|
|
@ -152,6 +213,7 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio
|
|||
/// Paste as a new layer (or into the targeted mask or channel: [`paste_to_target`]). `in_place` keeps the original position; otherwise the pixels are centred
|
||||
/// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas.
|
||||
fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
|
||||
crate::allocation::checkpoint("pasting pixels")?;
|
||||
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
|
||||
let Some(d) = s.active() else {
|
||||
// Nothing open to paste into: the clipboard becomes a document of its own (#368).
|
||||
|
|
@ -160,9 +222,16 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
|
|||
if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels {
|
||||
return paste_to_target(s, p, in_place, None, "Paste");
|
||||
}
|
||||
// A targeted colour channel takes the paste instead of a new layer (#2307).
|
||||
if let Some(k) = crate::channel_clip::paste_color_target(s, p) {
|
||||
return crate::channel_clip::paste(s, p, k, in_place, None, "Paste");
|
||||
}
|
||||
let canvas = d.doc.bounds();
|
||||
let fmt = d.doc.pixel_format();
|
||||
let (dx, dy) = paste_offset(&clip, canvas, p, in_place);
|
||||
if let Some(layers) = clip.layers.as_deref().filter(|_| crate::layer_copy_cmds::layered(d.doc.mode)) {
|
||||
return paste_layers(s, layers, dx, dy);
|
||||
}
|
||||
let moved = shifted(&clip.surface, dx, dy);
|
||||
let target = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let surf = if moved.format() == target { moved } else { moved.convert(target) };
|
||||
|
|
@ -177,10 +246,33 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
|
|||
Ok(json!({"layer": id.0, "offset": [dx, dy]}))
|
||||
}
|
||||
|
||||
/// Paste of a whole copied layer ([`whole_layer`]): fresh copies (a group's children too),
|
||||
/// converted to the document's colour, moved as the pixels would be, above the active layer. A pasted smart object gets its
|
||||
/// own contents, independent of the copied one (as across documents).
|
||||
fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let mut copies = layers.clone();
|
||||
for l in &mut copies.layers {
|
||||
*l = l.duplicate();
|
||||
}
|
||||
crate::layer_copy_cmds::adapt_copies(s, &mut copies, &d.doc)?;
|
||||
crate::layer_copy_cmds::translate_copies(&mut copies, dx, dy);
|
||||
let patterns: Vec<_> = copies.patterns.iter().filter(|pat| !d.doc.patterns.iter().any(|q| q.id == pat.id)).cloned().collect();
|
||||
let id = s.edit("Paste", |doc, active| {
|
||||
doc.patterns.extend(patterns);
|
||||
for l in copies.layers {
|
||||
*active = Some(doc.insert_above(*active, l));
|
||||
}
|
||||
doc.selection = None;
|
||||
active.ok_or(EngineError::Other("nothing to paste".into()))
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "offset": [dx, dy]}))
|
||||
}
|
||||
|
||||
/// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the
|
||||
/// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the
|
||||
/// canvas centre.
|
||||
fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
|
||||
pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) {
|
||||
if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
|
||||
return (0, 0);
|
||||
}
|
||||
|
|
@ -190,7 +282,7 @@ fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i
|
|||
((cx - (b.x0 + b.x1) as f64 / 2.0).round() as i32, (cy - (b.y0 + b.y1) as f64 / 2.0).round() as i32)
|
||||
}
|
||||
|
||||
fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
|
||||
pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
|
||||
if dx == 0 && dy == 0 { surface.clone() } else { photocraft_algo::resample::translate_surface(surface, dx, dy) }
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +291,7 @@ fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface {
|
|||
/// over what was there. Their transparency, and `limit` (Paste Into / Outside), let it show
|
||||
/// through. One history step.
|
||||
pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit: Option<&Surface>, label: &str) -> Result<Value> {
|
||||
crate::allocation::checkpoint("pasting pixels")?;
|
||||
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let canvas = d.doc.bounds();
|
||||
|
|
@ -208,7 +301,7 @@ pub(crate) fn paste_to_target(s: &mut Session, p: &Value, in_place: bool, limit:
|
|||
let area = src.content_bounds().intersect(&canvas);
|
||||
s.edit(label, |doc, _| {
|
||||
let (surf, _) = crate::channel_cmds::target_surface(doc, layer, p)?;
|
||||
crate::fill_cmds::composite_over(surf, &src, area, limit);
|
||||
crate::fill_cmds::composite_over(surf, &src, area, limit)?;
|
||||
surf.prune();
|
||||
doc.selection = None;
|
||||
Ok(())
|
||||
|
|
@ -240,6 +333,10 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
|
|||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = active_id(s)?;
|
||||
let has_sel = d.doc.selection.is_some();
|
||||
if !has_sel && !cut && crate::layer_multi_cmds::multi(s, &Value::Null) {
|
||||
// No selection, several layers selected: copy every one of them (Photoshop, #2777).
|
||||
return crate::layer_multi_cmds::duplicate_selected(s, true, "Layer Via Copy");
|
||||
}
|
||||
if !has_sel && !cut {
|
||||
// No selection: Layer via Copy duplicates the whole layer (Photoshop).
|
||||
let nid = s.edit("Layer Via Copy", |doc, active| {
|
||||
|
|
@ -254,7 +351,7 @@ fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
|
|||
return Ok(json!({"layer": nid.0}));
|
||||
}
|
||||
let surf = d.doc.layer(id).and_then(Layer::surface).ok_or(EngineError::Other("the active layer has no pixels".into()))?;
|
||||
let clip = lift(surf, d.doc.selection.as_ref(), d.doc.bounds());
|
||||
let clip = lift(surf, d.doc.selection.as_ref(), d.doc.bounds())?;
|
||||
if clip.bounds.is_empty() {
|
||||
return Err(EngineError::Other("Could not complete the command: the selected area is empty".into()));
|
||||
}
|
||||
|
|
@ -287,16 +384,11 @@ fn merge_visible(s: &mut Session) -> Result<Value> {
|
|||
// Composite of visible layers only (hidden ones stay where they are).
|
||||
let mut solo = doc.clone();
|
||||
solo.layers.retain(|l| l.visible);
|
||||
let buf = photocraft_compose::flatten(&solo);
|
||||
let buf = if is_background { buf.over_background([1.0, 1.0, 1.0]) } else { buf };
|
||||
let fmt = doc.pixel_format();
|
||||
let fmt = if is_background { fmt } else { PixelFormat::new(fmt.mode, fmt.sample, true) };
|
||||
let data: Vec<f32> = buf.px.iter().flat_map(|p| photocraft_raster::from_rgba(&fmt, *p)).collect();
|
||||
let mut merged = Layer::raster(base.name.clone(), fmt);
|
||||
merged.locks = base.locks;
|
||||
let surf = crate::pixels_mut(&mut merged)?;
|
||||
surf.write_region(doc.bounds(), &data);
|
||||
surf.prune();
|
||||
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers(&solo, fmt, is_background.then_some([1.0, 1.0, 1.0]));
|
||||
let mid = merged.id;
|
||||
let mut out = Vec::with_capacity(doc.layers.len());
|
||||
for (i, l) in doc.layers.drain(..).enumerate() {
|
||||
|
|
@ -537,16 +629,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
};
|
||||
}
|
||||
vec![
|
||||
spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{}", has_pixels, |s, _| {
|
||||
spec!("edit.cut", "Cut", &["Edit"], Some("Cmd+X"), "{} (a targeted colour channel cuts that channel only)", has_pixels, |s, p| {
|
||||
// Refuse a locked layer before copying, so a refused Cut leaves the clipboard alone.
|
||||
let id = active_id(s)?;
|
||||
crate::commands::check_pixels_unlocked(&s.active().ok_or(EngineError::NoDocument)?.doc, id)?;
|
||||
// A targeted colour channel cuts that channel only (#2698).
|
||||
if let Some(k) = crate::channel_clip::paste_color_target(s, p) {
|
||||
return crate::channel_clip::cut(s, id, k);
|
||||
}
|
||||
let r = copy(s, false)?;
|
||||
let bg = s.tools.background;
|
||||
s.edit("Cut Pixels", |doc, _| clear_selected(doc, id, bg))?;
|
||||
Ok(r)
|
||||
}),
|
||||
spec!("edit.copy", "Copy", &["Edit"], Some("Cmd+C"), "{}", has_layer_pixels, |s, _| copy(s, false)),
|
||||
spec!("edit.copy", "Copy", &["Edit"], Some("Cmd+C"), "{} (a targeted colour or alpha channel copies that channel, as grayscale)", can_copy, |s, _| {
|
||||
match crate::channel_clip::copy(s)? {
|
||||
Some(r) => Ok(r),
|
||||
None => copy(s, false),
|
||||
}
|
||||
}),
|
||||
spec!("edit.copyMerged", "Copy Merged", &["Edit"], Some("Cmd+Shift+C"), "{}", has_doc, |s, _| copy(s, true)),
|
||||
spec!(
|
||||
"edit.paste",
|
||||
|
|
@ -623,6 +724,104 @@ mod tests {
|
|||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_clear_buffer_failure_leaves_the_document_unchanged() {
|
||||
let mut s = session();
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let revision = st.revision;
|
||||
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("edit.clear", json!({}));
|
||||
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for erasing pixels")));
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
|
||||
assert_eq!(st.revision, revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_copy_buffer_failure_does_not_change_document_or_clipboard() {
|
||||
let mut s = session();
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let revision = st.revision;
|
||||
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("edit.copy", json!({}));
|
||||
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for copying pixels")));
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
|
||||
assert_eq!(st.revision, revision);
|
||||
assert!(s.clipboard.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_fill_result_reservation_failure_keeps_pixels_and_history() {
|
||||
let mut s = session();
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let revision = st.revision;
|
||||
let history_len = st.history.past_len();
|
||||
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("edit.fill", json!({"color": "#00ff00"}));
|
||||
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for blending pixels")));
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(st.history.past_len(), history_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_paste_failure_does_not_change_document_or_clipboard() {
|
||||
let mut s = session();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 8, "height": 8})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let before_clip = s.clipboard.clone();
|
||||
let st = s.active().unwrap();
|
||||
let before_doc = (*st.doc).clone();
|
||||
let revision = st.revision;
|
||||
let history_len = st.history.past_len();
|
||||
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("edit.paste", json!({}));
|
||||
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for pasting pixels")));
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(*st.doc, before_doc);
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(st.history.past_len(), history_len);
|
||||
let after_clip = s.clipboard.as_ref().expect("clipboard remains available");
|
||||
let before_clip = before_clip.as_ref().expect("clipboard was populated");
|
||||
assert_eq!(after_clip.bounds, before_clip.bounds);
|
||||
assert_eq!(after_clip.surface, before_clip.surface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_transform_failure_keeps_pixels_and_history() {
|
||||
let mut s = session();
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
let before = st.doc.layer(id).unwrap().surface().unwrap().clone();
|
||||
let revision = st.revision;
|
||||
let history_len = st.history.past_len();
|
||||
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 20, 30]}));
|
||||
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for transforming pixels")));
|
||||
let st = s.active().unwrap();
|
||||
assert_eq!(st.doc.layer(id).unwrap().surface(), Some(&before));
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!(st.history.past_len(), history_len);
|
||||
}
|
||||
|
||||
fn active_bounds(s: &Session) -> Rect {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
|
||||
|
|
@ -938,30 +1137,66 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// #2777: with several layers selected and no pixel selection, Layer via Copy (Cmd+J) copies
|
||||
/// every selected layer in one step, each copy above its original and the copies selected.
|
||||
/// With a pixel selection it still copies the active layer's selected pixels only.
|
||||
#[test]
|
||||
fn layer_via_copy_duplicates_every_selected_layer() {
|
||||
let mut s = session();
|
||||
let a = s.active().unwrap().active_layer.unwrap();
|
||||
let b = LayerId(s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layer": a.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": b.0, "mode": "toggle"})).unwrap();
|
||||
let steps = s.active().unwrap().history.past_len();
|
||||
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
|
||||
let st = s.active().unwrap();
|
||||
let ids: Vec<LayerId> = st.doc.layers.iter().map(|l| l.id).collect();
|
||||
assert_eq!(ids.len(), 5, "background, a, a copy, b, b copy");
|
||||
assert_eq!((ids[1], ids[3]), (a, b), "each copy sits above its original");
|
||||
let mut selected = st.selected_layers();
|
||||
selected.sort_by_key(|l| l.0);
|
||||
assert_eq!(selected, vec![ids[2], ids[4]], "the copies become the selection");
|
||||
assert_eq!(st.history.past_len(), steps + 1, "one undo step");
|
||||
assert!(s.undo());
|
||||
s.execute("layer.select", json!({"layer": b.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layer": a.0, "mode": "toggle"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("layer.new.layerViaCopy", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.layers.len(), 4, "a pixel selection copies one layer's pixels");
|
||||
}
|
||||
|
||||
fn active_content(s: &Session) -> LayerContent {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().content.clone()
|
||||
}
|
||||
|
||||
/// #541: a smart object copies what it shows, with or without a selection, and pastes as
|
||||
/// #541, #2428: with no selection, Copy takes a smart object whole and Paste makes a smart
|
||||
/// object again, with contents of its own; with a selection, the selected pixels paste as
|
||||
/// pixels. Cut edits pixels, so it's refused; Layer via Copy and Duplicate Layer copy the layer.
|
||||
#[test]
|
||||
fn copy_a_smart_object_layer() {
|
||||
let mut s = session();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
let LayerContent::Smart(original) = active_content(&s) else { panic!("smart object") };
|
||||
assert!(s.is_enabled("edit.copy"));
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 40, 20]), "no selection: the whole layer");
|
||||
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
|
||||
assert!(matches!(active_content(&s), LayerContent::Raster(_)));
|
||||
let LayerContent::Smart(pasted) = active_content(&s) else { panic!("the paste is a smart object (#2428)") };
|
||||
assert_eq!(pasted.source, original.source, "the same contents");
|
||||
assert_ne!(pasted.contents_id, original.contents_id, "but not linked to the copied one");
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30));
|
||||
assert_eq!(
|
||||
s.active().unwrap().doc.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap().pixel(20, 20),
|
||||
vec![1.0, 0.0, 0.0, 1.0]
|
||||
);
|
||||
s.execute("edit.paste", json!({"center": [70, 70]})).unwrap();
|
||||
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
|
||||
assert_eq!(active_bounds(&s), Rect::new(50, 60, 90, 80), "placed as pasted pixels are");
|
||||
s.undo();
|
||||
s.undo();
|
||||
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(s.execute("edit.copy", json!({})).unwrap()["bounds"], json!([10, 10, 10, 10]), "only the selected part");
|
||||
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
|
||||
assert!(matches!(active_content(&s), LayerContent::Raster(_)), "a selection copies pixels");
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 20, 20));
|
||||
s.undo();
|
||||
let cut = s.execute("edit.cut", json!({})).unwrap_err().to_string();
|
||||
assert!(cut.contains("not directly editable"), "{cut}");
|
||||
assert!(matches!(active_content(&s), LayerContent::Smart(_)));
|
||||
|
|
@ -973,6 +1208,20 @@ mod tests {
|
|||
assert_eq!(s.active().unwrap().doc.layers.len(), 4);
|
||||
}
|
||||
|
||||
/// #2428: a smart object copied in one document pastes into another as a smart object, in
|
||||
/// that document's bit depth.
|
||||
#[test]
|
||||
fn paste_a_smart_object_into_another_document() {
|
||||
let mut s = session();
|
||||
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 60, "height": 40, "depth": 16})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let LayerContent::Smart(sm) = active_content(&s) else { panic!("the paste is a smart object") };
|
||||
assert_eq!(sm.cache.map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
|
||||
assert_eq!(active_bounds(&s), Rect::new(10, 10, 50, 30), "on the canvas: in place");
|
||||
}
|
||||
|
||||
/// Type and shape layers copy their rendered pixels too; groups and adjustments have none.
|
||||
#[test]
|
||||
fn copy_reads_any_layer_that_shows_pixels() {
|
||||
|
|
@ -981,9 +1230,39 @@ mod tests {
|
|||
assert!(!s.is_enabled("edit.cut"));
|
||||
assert!(s.execute("edit.copy", json!({})).is_ok());
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
assert!(s.is_enabled("edit.copy"), "no selection: the group is copied whole (#2670)");
|
||||
s.execute("select.all", json!({})).unwrap();
|
||||
assert!(!s.is_enabled("edit.copy"));
|
||||
}
|
||||
|
||||
/// #2670: with no selection, Copy takes a type layer, a shape layer or a group whole and
|
||||
/// Paste recreates it, into another document too (with fresh ids, in its bit depth); with a
|
||||
/// selection, Copy takes pixels.
|
||||
#[test]
|
||||
fn copy_paste_keeps_type_shape_and_group_layers() {
|
||||
let mut s = session();
|
||||
s.execute("type.create", json!({"text": "Hi", "size": 30, "x": 20, "y": 60})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("edit.pasteSpecial.pasteInPlace", json!({})).unwrap();
|
||||
let LayerContent::Text(t) = active_content(&s) else { panic!("the paste is a type layer") };
|
||||
assert_eq!(t.text, "Hi");
|
||||
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(matches!(active_content(&s), LayerContent::Raster(_)), "a selection copies pixels");
|
||||
s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 40, 20], "fill": "#ff0000"})).unwrap();
|
||||
s.execute("layer.groupLayers", json!({})).unwrap();
|
||||
let LayerContent::Group(src) = active_content(&s) else { panic!("a group") };
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.execute("file.new", json!({"width": 60, "height": 40, "depth": 16})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
let LayerContent::Group(g) = active_content(&s) else { panic!("the paste is a group") };
|
||||
let [shape] = g.children.as_slice() else { panic!("one child") };
|
||||
assert!(matches!(shape.content, LayerContent::Shape(_)), "the shape stays a shape");
|
||||
assert_ne!(shape.id, src.children[0].id, "fresh ids");
|
||||
assert_eq!(shape.surface().map(|c| c.format().sample), Some(s.active().unwrap().doc.depth), "converted to 16 bits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_visible_keeps_hidden_layers() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -269,6 +269,11 @@ fn purge(s: &mut Session, what: &str) -> Result<Value> {
|
|||
freed = freed.saturating_add(masks);
|
||||
items.push("mask cache");
|
||||
}
|
||||
let stages = crate::smart_cmds::purge_stack_cache();
|
||||
if stages > 0 {
|
||||
freed = freed.saturating_add(stages);
|
||||
items.push("smart filter cache");
|
||||
}
|
||||
}
|
||||
s.edit_state.fade = None;
|
||||
let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) };
|
||||
|
|
@ -286,7 +291,11 @@ fn can_purge_histories(s: &Session) -> std::result::Result<(), String> {
|
|||
}
|
||||
fn can_purge_all(s: &Session) -> std::result::Result<(), String> {
|
||||
can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| {
|
||||
if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 { Ok(()) } else { Err("nothing to purge".into()) }
|
||||
if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 || crate::smart_cmds::stack_cache_bytes() > 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("nothing to purge".into())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -572,7 +581,7 @@ fn content_aware_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
move |s, out| {
|
||||
s.edit(label, |doc, _| {
|
||||
let surf = writable_surface(doc, id)?;
|
||||
crate::pixels::clear_surface(surf, region, None);
|
||||
crate::pixels::try_clear_surface(surf, region, None)?;
|
||||
surf.write_region(dst, &out);
|
||||
surf.prune();
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
//! EXR parts: what a multi-part or channel-grouped EXR file holds, and opening the chosen
|
||||
//! parts/groups as the layers of one document (a Maya/Arnold render writes one part per
|
||||
//! AOV). The query command feeds the File › Open EXR Parts… dialog, the CLI, MCP and the
|
||||
//! control channel alike.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::CommandSpec;
|
||||
use crate::file_cmds::{native, read_file, stem};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
fn entries_json(path: &str) -> Result<Value> {
|
||||
let bytes = read_file(path)?;
|
||||
let entries = photocraft_io::exr_parts::entries(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!(entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"kind": match e.kind { photocraft_io::exr_parts::EntryKind::Part => "part", photocraft_io::exr_parts::EntryKind::Group => "group", photocraft_io::exr_parts::EntryKind::Cryptomatte => "cryptomatte" },
|
||||
"name": e.name,
|
||||
"view": e.view,
|
||||
"width": e.width,
|
||||
"height": e.height,
|
||||
"channels": e.channels,
|
||||
"objects": e.objects,
|
||||
}))
|
||||
.collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn exr_parts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let _ = s;
|
||||
let path = crate::file_cmds::str_param(p, "path", "file.exrParts")?.to_string();
|
||||
entries_json(&path)
|
||||
}
|
||||
|
||||
/// Collects `part/<name>` and `group/<name>` boolean params into the selection names.
|
||||
fn selected(p: &Value, prefix: &str) -> Vec<String> {
|
||||
p.as_object()
|
||||
.map(|m| m.iter().filter(|(k, v)| k.starts_with(prefix) && v.as_bool() == Some(true)).map(|(k, _)| k[prefix.len()..].to_string()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn open_exr_parts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = crate::file_cmds::str_param(p, "path", "file.openExrParts")?.to_string();
|
||||
let view = p.get("view").and_then(Value::as_str).unwrap_or("both");
|
||||
if !matches!(view, "both" | "left" | "right") {
|
||||
return Err(EngineError::BadParams { cmd: "file.openExrParts".into(), msg: format!("view is \"{view}\"; it is \"both\", \"left\" or \"right\"") });
|
||||
}
|
||||
let sel = photocraft_io::exr_parts::Selection {
|
||||
parts: selected(p, "part/"),
|
||||
groups: selected(p, "group/"),
|
||||
view: view.to_string(),
|
||||
precomp: p.get("precomp").and_then(Value::as_bool).unwrap_or(false),
|
||||
};
|
||||
let bytes = read_file(&path)?;
|
||||
let r = photocraft_io::exr_parts::import(&stem(&path), &bytes, &sel).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (i, color) = s.open_document(r.document, Some(path.clone()));
|
||||
Ok(json!({"document": i, "color": color, "warnings": r.warnings}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
macro_rules! spec {
|
||||
($id:expr, $label:expr, $menu:expr, $sc:expr, $params:expr, $enabled:expr, $run:expr) => {
|
||||
CommandSpec { id: $id, label: $label, menu: $menu, shortcut: $sc, params: $params, enabled: $enabled, journal: true, run: $run }
|
||||
};
|
||||
}
|
||||
vec![
|
||||
spec!("file.exrParts", "EXR Parts Info", &[], None, r##"{"path":str (the EXR file)}"##, native, exr_parts),
|
||||
spec!(
|
||||
"file.openExrParts",
|
||||
"Open EXR Parts…",
|
||||
&["File"],
|
||||
None,
|
||||
r##"{"path":str (the EXR file),"view":"both|left|right"="both","part/<name>":bool (import this part as a layer),"group/<name>":bool (import this channel group as a layer),"precomp":bool=false (add the additive beauty precomp from the AOVs)}"##,
|
||||
native,
|
||||
open_exr_parts
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use photocraft_doc::ColorMode;
|
||||
|
||||
fn session() -> Session {
|
||||
Session::new()
|
||||
}
|
||||
|
||||
// The fixture writer mirrors the codecs tests: a small multi-part EXR via the `exr`
|
||||
// crate, one part per AOV plus a single-part file with prefixed channel groups.
|
||||
|
||||
fn write_multipart(path: &std::path::Path) {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h]));
|
||||
let beauty = AnyChannels::sort(vec![mk("R".into(), 0.8), mk("G".into(), 0.4), mk("B".into(), 0.2), mk("A".into(), 1.0)].into_iter().collect());
|
||||
let z = AnyChannels::sort(vec![mk("Z".into(), 5.0)].into_iter().collect());
|
||||
let layer = |name: &str, chans: AnyChannels<FlatSamples>| {
|
||||
Layer::new(
|
||||
(w, h),
|
||||
LayerAttributes::named(name),
|
||||
Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing },
|
||||
chans,
|
||||
)
|
||||
};
|
||||
let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer("Z", z), layer("beauty", beauty)]);
|
||||
image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture");
|
||||
}
|
||||
|
||||
fn write_grouped(path: &std::path::Path) {
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h]));
|
||||
// Two AOV groups (each with alpha) plus the beauty RGBA, in ONE part.
|
||||
let list = vec![
|
||||
mk("diffuse.R".into(), 0.3),
|
||||
mk("diffuse.G".into(), 0.3),
|
||||
mk("diffuse.B".into(), 0.3),
|
||||
mk("diffuse.A".into(), 0.5),
|
||||
mk("specular.R".into(), 0.6),
|
||||
mk("specular.G".into(), 0.6),
|
||||
mk("specular.B".into(), 0.6),
|
||||
mk("specular.A".into(), 0.25),
|
||||
mk("R".into(), 0.9),
|
||||
mk("G".into(), 0.9),
|
||||
mk("B".into(), 0.9),
|
||||
mk("A".into(), 1.0),
|
||||
];
|
||||
let layer = Layer::new(
|
||||
(w, h),
|
||||
LayerAttributes::named("main"),
|
||||
Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing },
|
||||
AnyChannels::sort(list.into_iter().collect()),
|
||||
);
|
||||
let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]);
|
||||
image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(path).expect("create fixture"))).expect("write fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exr_parts_lists_parts_groups_and_views() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-parts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("aov.exr");
|
||||
write_multipart(&path);
|
||||
let info = entries_json(path.to_str().unwrap()).unwrap();
|
||||
let kinds: Vec<&str> = info.as_array().unwrap().iter().map(|e| e["kind"].as_str().unwrap()).collect();
|
||||
assert_eq!(kinds, ["part", "part"], "{info}");
|
||||
assert_eq!(info[0]["name"], "Z");
|
||||
assert_eq!(info[1]["name"], "beauty");
|
||||
assert_eq!(info[1]["channels"], 4);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exr_parts_imports_the_chosen_part_as_a_layer() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("aov.exr");
|
||||
write_multipart(&path);
|
||||
let mut s = session();
|
||||
let r = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/beauty": true, "part/Z": false})).unwrap();
|
||||
assert_eq!(r["warnings"].as_array().unwrap().len(), 0, "{r}");
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
assert_eq!(doc.layers[0].name, "beauty");
|
||||
// The beauty pixels survived (R 0.8 at pixel 0).
|
||||
if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content {
|
||||
let px = surface.pixel(0, 0);
|
||||
assert!((px[0] - 0.8).abs() < 1e-4, "{:?}", px);
|
||||
} else {
|
||||
panic!("expected a raster layer");
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_exr_parts_refuses_an_empty_selection_and_unknown_names() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-empty-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("aov.exr");
|
||||
write_multipart(&path);
|
||||
let mut s = session();
|
||||
let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap()})).unwrap_err();
|
||||
assert!(e.to_string().contains("no parts or groups selected"), "{e}");
|
||||
let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "part/nope": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("no flat part named nope"), "{e}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_groups_import_from_their_own_channels() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-groups-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("grouped.exr");
|
||||
write_grouped(&path);
|
||||
let info = entries_json(path.to_str().unwrap()).unwrap();
|
||||
let names: Vec<&str> = info.as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["main", "diffuse", "specular"], "{info}");
|
||||
let mut s = session();
|
||||
s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "group/diffuse": true})).unwrap();
|
||||
let doc = &s.active().unwrap().doc;
|
||||
assert_eq!(doc.layers.len(), 1);
|
||||
assert_eq!(doc.layers[0].name, "diffuse");
|
||||
if let photocraft_doc::LayerContent::Raster(surface) = &doc.layers[0].content {
|
||||
let px = surface.pixel(0, 0);
|
||||
// The channels import as stored (straight here): values are untouched.
|
||||
assert!((px[0] - 0.3).abs() < 1e-4, "{:?}", px);
|
||||
assert!((px[3] - 0.5).abs() < 1e-4, "{:?}", px);
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomp_sums_the_light_path_set_and_refuses_an_incomplete_one() {
|
||||
let dir = std::env::temp_dir().join(format!("pcraft-exr-precomp-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("grouped.exr");
|
||||
write_grouped(&path);
|
||||
let mut s = session();
|
||||
// diffuse + specular make the set incomplete: emission is missing.
|
||||
let e = s.execute("file.openExrParts", json!({"path": path.to_str().unwrap(), "precomp": true, "group/diffuse": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("missing") && e.to_string().contains("emission"), "{e}");
|
||||
|
||||
// A complete set: write a file holding direct/indirect diffuse+specular and emission.
|
||||
let full = dir.join("full.exr");
|
||||
use exr::prelude::*;
|
||||
let (w, h) = (4usize, 4usize);
|
||||
let mk = |n: String, v: f32| AnyChannel::new(n.as_str(), FlatSamples::F32(vec![v; w * h]));
|
||||
let aovs = [
|
||||
("direct_diffuse", 0.10, 1.0),
|
||||
("indirect_diffuse", 0.20, 1.0),
|
||||
("direct_specular", 0.05, 1.0),
|
||||
("indirect_specular", 0.15, 1.0),
|
||||
("emission", 0.30, 1.0),
|
||||
];
|
||||
let mut list: Vec<AnyChannel<FlatSamples>> = Vec::new();
|
||||
let mut expect = 0.0f32;
|
||||
for (name, v, a) in aovs {
|
||||
list.push(mk(format!("{name}.R"), v));
|
||||
list.push(mk(format!("{name}.G"), v));
|
||||
list.push(mk(format!("{name}.B"), v));
|
||||
list.push(mk(format!("{name}.A"), a));
|
||||
expect += v * a;
|
||||
}
|
||||
let layer = Layer::new(
|
||||
(w, h),
|
||||
LayerAttributes::named("main"),
|
||||
Encoding { compression: Compression::Uncompressed, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing },
|
||||
AnyChannels::sort(list.into_iter().collect()),
|
||||
);
|
||||
let image = Image::from_layers(ImageAttributes::new(IntegerBounds::new((0, 0), (w, h))), vec![layer]);
|
||||
image.write().to_buffered(&mut std::io::BufWriter::new(std::fs::File::create(&full).expect("create fixture"))).expect("write fixture");
|
||||
let mut s = session();
|
||||
let r = s.execute("file.openExrParts", json!({"path": full.to_str().unwrap(), "precomp": true})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("precomp")), "{r}");
|
||||
let doc = &s.active().unwrap().doc;
|
||||
let precomp = doc.layers.iter().find(|l| l.name == "beauty (precomp)").expect("the precomp layer");
|
||||
if let photocraft_doc::LayerContent::Raster(surface) = &precomp.content {
|
||||
let px = surface.pixel(0, 0);
|
||||
assert!((px[0] - expect).abs() < 1e-3, "{:?} vs {expect}", px);
|
||||
}
|
||||
assert_eq!(doc.mode, ColorMode::Rgb);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ pub(crate) fn style_targets(s: &Session, p: &Value, fits: impl Fn(&Layer) -> boo
|
|||
}
|
||||
|
||||
/// A key that makes the sub-commands of one command share a single history step.
|
||||
fn step_key(s: &Session, what: &str) -> String {
|
||||
pub(crate) fn step_key(s: &Session, what: &str) -> String {
|
||||
format!("{what}#{}", s.active().map_or(0, |d| d.revision))
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +218,7 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
|
||||
let area = band.content_bounds().intersect(&canvas);
|
||||
if !area.is_empty() {
|
||||
crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock);
|
||||
crate::fill_cmds::blend_color_mask(surf, area, color, &band, mode, opacity, preserve || lock)?;
|
||||
surf.prune();
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -390,6 +390,10 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result<Value> {
|
|||
if let Some(c) = p.get("center") {
|
||||
params["center"] = c.clone();
|
||||
}
|
||||
// A targeted colour channel (#2307): the paste goes into it, only where `limit` allows.
|
||||
if let Some(k) = crate::channel_clip::paste_color_target(s, p) {
|
||||
return crate::channel_clip::paste(s, ¶ms, k, false, Some(&limit), label);
|
||||
}
|
||||
if crate::channel_cmds::target_of(p) != crate::channel_cmds::Target::Pixels {
|
||||
// A targeted mask or channel (#1035): the paste goes into it, only where `limit` allows.
|
||||
params["target"] = p.get("target").cloned().unwrap_or_default();
|
||||
|
|
@ -430,7 +434,7 @@ fn layer_from_background(s: &mut Session) -> Result<Value> {
|
|||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
fn unlock_background(l: &mut Layer) {
|
||||
pub(crate) fn unlock_background(l: &mut Layer) {
|
||||
l.name = "Layer 0".into();
|
||||
l.locks.transparency = false;
|
||||
l.locks.position = false;
|
||||
|
|
@ -613,7 +617,7 @@ fn content_pixels(doc: &Document, l: &Layer) -> photocraft_raster::Surface {
|
|||
s
|
||||
}
|
||||
|
||||
fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> {
|
||||
pub(crate) fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> {
|
||||
let d = s.active()?;
|
||||
Some(match &d.doc.layer(id)?.content {
|
||||
LayerContent::Text(_) => "type",
|
||||
|
|
@ -624,8 +628,19 @@ fn kind_of(s: &Session, id: LayerId) -> Option<&'static str> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Rasterize one layer; `only` restricts it to one kind. Returns false when there was nothing to do.
|
||||
fn rasterize_one(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result<bool> {
|
||||
/// Rasterize one layer; `only` restricts it to one kind. Without `only` (Rasterize › Layer / All
|
||||
/// Layers: "all vector data") a vector mask becomes a pixel mask too; layer effects stay live, as
|
||||
/// in Photoshop (Rasterize › Layer Style bakes them). Returns false when there was nothing to do.
|
||||
pub(crate) fn rasterize_one(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result<bool> {
|
||||
let vector_mask = only.is_none() && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| l.vector_mask.is_some());
|
||||
let content = rasterize_content(s, id, only, key)?;
|
||||
if vector_mask {
|
||||
s.execute("layer.rasterize.vectorMask", json!({"layer": id.0, "coalesce": key}))?;
|
||||
}
|
||||
Ok(content || vector_mask)
|
||||
}
|
||||
|
||||
fn rasterize_content(s: &mut Session, id: LayerId, only: Option<&str>, key: &str) -> Result<bool> {
|
||||
let Some(kind) = kind_of(s, id).filter(|k| only.is_none_or(|o| o == *k)) else { return Ok(false) };
|
||||
match kind {
|
||||
"type" => {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ pub(crate) fn list_images(dir: &str) -> Result<Vec<String>> {
|
|||
}
|
||||
|
||||
/// Extensions the batch commands pick up from a folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const OPENABLE: &[&str] = &[
|
||||
"pdn", "ora", "psd", "psb", "pcraft", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "exr", "hdr", "qoi", "ico", "pnm", "ppm", "pgm",
|
||||
"heic", "heif", "hif", "dng", "cr2", "nef", "nrw", "arw", "pef", "svg", "svgz", "af", "afdesign", "afphoto", "afpub",
|
||||
|
|
@ -152,7 +153,14 @@ pub fn extension(path: &str) -> Option<String> {
|
|||
/// Whether a save without a new path may write back to `path`: only layered files (PSD, PSB,
|
||||
/// .pcraft). A flat file goes through Save As instead, so it is never flattened over the original.
|
||||
pub fn saves_in_place(path: &str) -> bool {
|
||||
extension(path).is_some_and(|ext| matches!(ext.as_str(), "psd" | "psb" | "pcraft" | "ora"))
|
||||
extension(path).is_some_and(|ext| layered_extension(&ext))
|
||||
}
|
||||
|
||||
/// Whether the lower-case extension `ext` names a layered document format (PSD, PSB, OpenRaster,
|
||||
/// .pcraft). A save to one becomes the document's file; any other format is a copy for automation
|
||||
/// saves, desktop and headless alike (#1547, #2579).
|
||||
pub fn layered_extension(ext: &str) -> bool {
|
||||
matches!(ext, "psd" | "psb" | "pcraft" | "ora")
|
||||
}
|
||||
|
||||
/// Whether `path` names a document template (.psdt). A template opens as a new untitled document
|
||||
|
|
@ -198,6 +206,7 @@ pub(crate) fn sanitize(name: &str) -> String {
|
|||
}
|
||||
|
||||
pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<Document> {
|
||||
crate::allocation::checkpoint("importing document")?;
|
||||
let r = photocraft_io::import(name, bytes).map_err(|e| EngineError::Other(format!("{name}: {e}")))?;
|
||||
// Auxiliary imports return only a document and cannot surface the preview's fidelity warning.
|
||||
// Open has its own warning-preserving path; never silently place or process a thumbnail.
|
||||
|
|
@ -238,6 +247,7 @@ impl From<Option<f64>> for SaveOpts {
|
|||
|
||||
/// Encodes `doc` for `path`'s extension.
|
||||
pub(crate) fn encode(doc: &Document, path: &str, save: impl Into<SaveOpts>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
crate::allocation::checkpoint("saving document")?;
|
||||
let save = save.into();
|
||||
let mut opts = photocraft_io::ExportOptions { tiff_layers: save.tiff_layers, ..Default::default() };
|
||||
if let Some(q) = save.quality {
|
||||
|
|
@ -352,6 +362,7 @@ fn save_a_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
|
||||
/// Open a file's bytes, decoding them as the format `as_ext` (Open As) when given.
|
||||
pub fn open_bytes_as(s: &mut Session, name: &str, bytes: &[u8], as_ext: Option<&str>, path: Option<String>) -> Result<Value> {
|
||||
crate::allocation::checkpoint("importing document")?;
|
||||
let decode_name = match as_ext {
|
||||
Some(ext) => format!("{}.{}", stem(name), ext.trim_start_matches('.')),
|
||||
None => name.to_string(),
|
||||
|
|
@ -389,9 +400,9 @@ fn open_as(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// History label of an embedded place.
|
||||
pub const PLACE_EMBEDDED: &str = "Place Embedded";
|
||||
|
||||
/// Place a file's bytes as a smart object layer, centred and (when larger than the canvas)
|
||||
/// scaled down to fit, like Photoshop's Place with "Resize Image During Place". `linked` makes it
|
||||
/// a linked smart object that refers to that path instead of embedding the bytes.
|
||||
/// Place a file's bytes as a smart object layer, centred, and (with Preferences ▸ General ▸
|
||||
/// Resize Image During Place, on by default) scaled down to fit when larger than the canvas.
|
||||
/// `linked` makes it a linked smart object that refers to that path instead of embedding the bytes.
|
||||
pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec<u8>, linked: Option<String>, p: &Value) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let (cw, ch) = (d.doc.size.width as f64, d.doc.size.height as f64);
|
||||
|
|
@ -406,9 +417,11 @@ pub fn place_bytes(s: &mut Session, name: &str, bytes: Vec<u8>, linked: Option<S
|
|||
}
|
||||
let src = imported.document;
|
||||
let (w, h) = (src.size.width as f64, src.size.height as f64);
|
||||
// The implicit `fit` is Preferences ▸ General ▸ Resize Image During Place (on by default).
|
||||
let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(s.prefs().general.resize_image_during_place);
|
||||
let scale = match f64_param(p, "scale") {
|
||||
Some(k) => (k / 100.0).max(1e-4),
|
||||
None if p.get("fit").and_then(Value::as_bool) != Some(false) && (w > cw || h > ch) => (cw / w).min(ch / h),
|
||||
None if fit && (w > cw || h > ch) => (cw / w).min(ch / h),
|
||||
None => 1.0,
|
||||
};
|
||||
let center = match p.get("center").and_then(Value::as_array) {
|
||||
|
|
@ -760,19 +773,36 @@ impl OutputClaims {
|
|||
}
|
||||
}
|
||||
|
||||
fn batch(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.automate.batch";
|
||||
let steps =
|
||||
let mut steps =
|
||||
parse_steps(p.get("steps").or_else(|| p.get("action")).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: "missing \"steps\"".into() })?)?;
|
||||
// Each result goes to the destination folder, which overrides the action's recorded Save /
|
||||
// Save As steps (Photoshop's "Override Action 'Save As' Commands"). Recorded view steps
|
||||
// (zoom, fit) don't change the output, so they are skipped (#2752).
|
||||
steps.retain(|(id, _)| !crate::actions_cmds::shell_save_command(id) && !crate::actions_cmds::shell_view_command(id));
|
||||
if let Some((id, _)) = steps.iter().find(|(id, _)| crate::commands::find(id).is_none()) {
|
||||
return Err(EngineError::BadParams { cmd: cmd.into(), msg: format!("unknown command `{id}` in the action") });
|
||||
}
|
||||
let inputs = batch_inputs(p, cmd)?;
|
||||
let output = str_param(p, "output", cmd)?.to_string();
|
||||
let format = p.get("format").and_then(Value::as_str).unwrap_or("same").to_string();
|
||||
// Steps that call another action play it from the caller's actions; the playback stack comes
|
||||
// along so an action that batches itself is caught as a recursive call.
|
||||
let actions = crate::actions_cmds::ActionState {
|
||||
list: s.actions.list.clone(),
|
||||
playing: s.actions.playing,
|
||||
playback_stack: s.actions.playback_stack.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let r = process_files(&inputs, &output, &format, SaveOpts::from_params(p), "", &|scratch| {
|
||||
scratch.actions = actions.clone();
|
||||
for (id, params) in &steps {
|
||||
scratch.execute(id, params.clone())?;
|
||||
let r = scratch.execute(id, params.clone())?;
|
||||
// A failed step inside a called action makes this file an error, not a saved result.
|
||||
if let Some(error) = crate::actions_cmds::nested_failure(id, &r) {
|
||||
return Err(EngineError::Other(error));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
|
@ -830,10 +860,10 @@ fn load_files_into_stack(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let n = stack.layers.len();
|
||||
if p.get("createSmartObject").and_then(Value::as_bool).unwrap_or(false) {
|
||||
// "Create Smart Object after Loading Layers": the layers go inside one smart object, ready
|
||||
// for Layer › Smart Objects › Stack Mode.
|
||||
let children = std::mem::take(&mut stack.layers);
|
||||
let group = Layer::new(stem(&paths[0]), LayerContent::Group(photocraft_doc::Group { children, expanded: true, artboard: None }));
|
||||
let smart = crate::smart_cmds::layer_to_smart(&stack, &group)?;
|
||||
// for Layer › Smart Objects › Stack Mode. Like Photoshop, they sit at the top level of the
|
||||
// contents, with no wrapper group.
|
||||
let layers = std::mem::take(&mut stack.layers);
|
||||
let smart = crate::smart_cmds::layers_to_smart(&stack, &stem(&paths[0]), layers)?;
|
||||
stack.layers = vec![smart];
|
||||
}
|
||||
let i = s.add_document(stack, None);
|
||||
|
|
@ -1274,13 +1304,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Place Embedded…",
|
||||
&["File"],
|
||||
None,
|
||||
r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool=true,"center":[x,y]?}"##,
|
||||
r##"{"path":str,"scale":%? (default: fit when larger than the canvas),"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##,
|
||||
native_doc,
|
||||
|s, p| place(s, p, false)
|
||||
),
|
||||
spec!("file.placeLinked", "Place Linked…", &["File"], None, r##"{"path":str,"scale":%?,"fit":bool=true,"center":[x,y]?}"##, native_doc, |s, p| place(
|
||||
s, p, true
|
||||
)),
|
||||
spec!(
|
||||
"file.placeLinked",
|
||||
"Place Linked…",
|
||||
&["File"],
|
||||
None,
|
||||
r##"{"path":str,"scale":%?,"fit":bool? (default: Preferences > General > Resize Image During Place),"center":[x,y]?}"##,
|
||||
native_doc,
|
||||
|s, p| place(s, p, true)
|
||||
),
|
||||
spec!(
|
||||
"file.fileInfo",
|
||||
"File Info…",
|
||||
|
|
|
|||
|
|
@ -91,6 +91,28 @@ fn revert_reloads_as_one_undoable_step() {
|
|||
assert!(s.execute("file.revert", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_import_failure_does_not_open_a_document() {
|
||||
let mut s = session(8, 8, 8);
|
||||
let before = s.documents().len();
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = open_bytes_as(&mut s, "image.png", b"not decoded", None, None);
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for importing document")));
|
||||
assert_eq!(s.documents().len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_save_failure_does_not_modify_the_destination() {
|
||||
let dir = tmp("allocation-save");
|
||||
let path = join(&dir, "existing.psd");
|
||||
std::fs::write(&path, b"previous file").unwrap();
|
||||
let s = session(8, 8, 8);
|
||||
crate::allocation::fail_next_for_test();
|
||||
let result = save_doc(doc(&s), &path, None);
|
||||
assert!(result.as_ref().is_err_and(|e| e.to_string().contains("not enough memory for saving document")));
|
||||
assert_eq!(std::fs::read(path).unwrap(), b"previous file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_a_copy_keeps_path_and_dirty_state() {
|
||||
let dir = tmp("copy");
|
||||
|
|
@ -158,6 +180,23 @@ fn place_embedded_centres_fits_and_embeds() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Preferences ▸ General ▸ Resize Image During Place: off, a larger image keeps its natural size
|
||||
/// instead of being fitted to the canvas; an explicit `"fit"` still wins.
|
||||
#[test]
|
||||
fn resize_image_during_place_preference_controls_the_fit() {
|
||||
let dir = tmp("place-fit-pref");
|
||||
let big = png(&dir, "big.png", 200, 100, "#ff0000");
|
||||
let mut s = session(100, 100, 8);
|
||||
s.execute("prefs.set", json!({"path": "general.resizeImageDuringPlace", "value": false})).unwrap();
|
||||
let r = s.execute("file.placeEmbedded", json!({"path": big})).unwrap();
|
||||
assert!((r["scale"].as_f64().unwrap() - 100.0).abs() < 1e-9, "natural size: {r}");
|
||||
let b = r["bounds"].as_array().unwrap();
|
||||
assert!(b[2].as_f64().unwrap() > 100.0, "wider than the canvas: {b:?}");
|
||||
// An explicit "fit": true overrides the preference.
|
||||
let r = s.execute("file.placeEmbedded", json!({"path": big, "fit": true})).unwrap();
|
||||
assert!((r["scale"].as_f64().unwrap() - 50.0).abs() < 1e-9, "fitted on request: {r}");
|
||||
}
|
||||
|
||||
/// A 40×20 JPEG (left half red, right half blue) tagged EXIF Orientation = 6: shown upright it
|
||||
/// is 20×40, red on top.
|
||||
fn rotated_jpeg(dir: &str) -> String {
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let mut source_doc = (*source_doc).clone();
|
||||
let (src, _) = crate::channel_cmds::target_surface(&mut source_doc, id, p)
|
||||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
|
||||
Source::Pixels(area, read_rgba(src, area))
|
||||
Source::Pixels(area, read_rgba(src, area, "reading Fill's history source")?)
|
||||
}
|
||||
"contentAware" => {
|
||||
let sel = sel.as_ref().ok_or_else(|| EngineError::Other("Content-Aware fill needs a selection".into()))?;
|
||||
|
|
@ -149,23 +149,32 @@ pub fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
s.edit("Fill", |doc, _| {
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?;
|
||||
blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore });
|
||||
blend_into(surf, area, &source, sel.as_ref(), Blend { mode, opacity, keep_alpha: preserve || lock, restore })?;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn read_rgba(src: &Surface, area: Rect) -> Vec<[f32; 4]> {
|
||||
let mut px = vec![[0.0f32; 4]; area.width() as usize * area.height() as usize];
|
||||
fn read_rgba(src: &Surface, area: Rect, purpose: &str) -> Result<Vec<[f32; 4]>> {
|
||||
let len = (area.width() as usize)
|
||||
.checked_mul(area.height() as usize)
|
||||
.ok_or_else(|| EngineError::Other(format!("not enough memory for {purpose} (requested size overflowed); the document was not changed")))?;
|
||||
let mut px = crate::allocation::filled(len, [0.0f32; 4], purpose)?;
|
||||
src.read_rgba_into(area, &mut px);
|
||||
px
|
||||
Ok(px)
|
||||
}
|
||||
|
||||
/// `src` over `surf` across `area` (Normal, 100 %), each pixel weighted by `limit` when given:
|
||||
/// a paste into a layer mask or channel, which keeps the pasted pixels' luminosity (#1035).
|
||||
pub(crate) fn composite_over(surf: &mut Surface, src: &Surface, area: Rect, limit: Option<&Surface>) {
|
||||
let source = Source::Pixels(area, read_rgba(src, area));
|
||||
blend_into(surf, area, &source, limit, Blend { mode: BlendMode::Normal, opacity: 1.0, keep_alpha: false, restore: false });
|
||||
pub(crate) fn composite_over(surf: &mut Surface, src: &Surface, area: Rect, limit: Option<&Surface>) -> Result<()> {
|
||||
let source = Source::Pixels(area, read_rgba(src, area, "pasting pixels")?);
|
||||
blend_into(surf, area, &source, limit, Blend { mode: BlendMode::Normal, opacity: 1.0, keep_alpha: false, restore: false })
|
||||
}
|
||||
|
||||
/// `area` of `surf` filled with an opaque `color` (Normal, 100 %), weighted by `sel` when given:
|
||||
/// Clear on a targeted alpha channel or the Quick Mask (#2734).
|
||||
pub(crate) fn fill_color(surf: &mut Surface, area: Rect, color: [f32; 4], sel: Option<&Surface>) -> Result<()> {
|
||||
blend_into(surf, area, &Source::Color(color), sel, Blend { mode: BlendMode::Normal, opacity: 1.0, keep_alpha: false, restore: false })
|
||||
}
|
||||
|
||||
/// Reuse Edit › Fill's compositing for Edit › Stroke. The stroke's rasterized band
|
||||
|
|
@ -179,8 +188,8 @@ pub(crate) fn blend_color_mask(
|
|||
mode: BlendMode,
|
||||
opacity: f32,
|
||||
preserve_transparency: bool,
|
||||
) {
|
||||
blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false });
|
||||
) -> Result<()> {
|
||||
blend_into(surf, area, &Source::Color(color), Some(coverage), Blend { mode, opacity, keep_alpha: preserve_transparency, restore: false })
|
||||
}
|
||||
|
||||
/// The selection filled from its surroundings (`photocraft_algo::content_aware`), as straight
|
||||
|
|
@ -262,7 +271,7 @@ fn lerp(d: [f32; 4], c: [f32; 4], k: f32) -> [f32; 4] {
|
|||
/// Blend `source` into `surf` over `area`: per pixel, the selection coverage times the opacity,
|
||||
/// in the blend mode.
|
||||
/// Works tile by tile in parallel on the encoded pixels (no full-surface f32 round trip).
|
||||
fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) {
|
||||
fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surface>, b: Blend) -> Result<()> {
|
||||
use rayon::prelude::*;
|
||||
let fmt = surf.format();
|
||||
// An opaque colour in Normal mode at 100 % that may replace alpha: fully covered tiles become
|
||||
|
|
@ -275,24 +284,35 @@ fn blend_into(surf: &mut Surface, area: Rect, source: &Source, sel: Option<&Surf
|
|||
}
|
||||
_ => None,
|
||||
};
|
||||
let tiles = surf.take_tiles(area);
|
||||
let done: Vec<_> = tiles
|
||||
.into_par_iter()
|
||||
.map(|(tc, tile)| {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() });
|
||||
if let Some(s) = &solid
|
||||
&& tr == tc.rect()
|
||||
&& mask.as_ref().is_none_or(|m| m.full(tr))
|
||||
{
|
||||
return (tc, s.clone());
|
||||
}
|
||||
let mut tile = std::sync::Arc::unwrap_or_clone(tile);
|
||||
blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b);
|
||||
(tc, std::sync::Arc::new(tile))
|
||||
})
|
||||
.collect();
|
||||
let count = area.tiles().count();
|
||||
let done = crate::allocation::capacity(count, "blending pixels")?;
|
||||
let tiles = surf
|
||||
.try_take_tiles(area)
|
||||
.map_err(|e| EngineError::Other(format!("not enough memory while preparing pixel tiles ({e}); the document was not changed")))?;
|
||||
let done = std::sync::Mutex::new(done);
|
||||
tiles.into_par_iter().try_for_each(|(tc, tile)| -> Result<()> {
|
||||
let tr = tc.rect().intersect(&area);
|
||||
let mask = sel.map(|m| Mask { bytes: m.tile(tc).map_or(m.default_bytes(), |t| t.bytes()), fmt: m.format(), origin: tc.rect() });
|
||||
if let Some(s) = &solid
|
||||
&& tr == tc.rect()
|
||||
&& mask.as_ref().is_none_or(|m| m.full(tr))
|
||||
{
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, s.clone()));
|
||||
return Ok(());
|
||||
}
|
||||
let mut tile = match std::sync::Arc::try_unwrap(tile) {
|
||||
Ok(tile) => tile,
|
||||
Err(tile) => tile
|
||||
.try_clone()
|
||||
.map_err(|e| EngineError::Other(format!("not enough memory while blending a pixel tile ({e}); the document was not changed")))?,
|
||||
};
|
||||
blend_tile(tile.bytes_mut(), &fmt, tc.rect(), tr, source, mask.as_ref(), b);
|
||||
done.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((tc, std::sync::Arc::new(tile)));
|
||||
Ok(())
|
||||
})?;
|
||||
let done = done.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
surf.put_tiles(done);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The selection's coverage over one tile (its channel 0).
|
||||
|
|
@ -501,8 +521,8 @@ mod tests {
|
|||
let st = s.active().unwrap();
|
||||
let mut surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().clone();
|
||||
let t = std::time::Instant::now();
|
||||
crate::pixels::fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false);
|
||||
println!("{:<48} {:>8.1} ms", "old fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0);
|
||||
crate::pixels::try_fill_surface(&mut surf, Rect::new(0, 0, 6000, 4000), [0.2, 0.4, 0.6, 1.0], None, false).unwrap();
|
||||
println!("{:<48} {:>8.1} ms", "fallible fill_surface, no selection", t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::{EngineError, Result, Session};
|
|||
/// The fill-key command ids (routed to the Channels panel target like `edit.fill`).
|
||||
pub const IDS: [&str; 4] = ["edit.fillForeground", "edit.fillBackground", "edit.fillForegroundPreserve", "edit.fillBackgroundPreserve"];
|
||||
|
||||
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|{"channel":i}|"quickMask"?}"##;
|
||||
const PARAMS: &str = r##"{"layer":id?,"target":"pixels"|"mask"|"quickMask"|{"channel":i}?}"##;
|
||||
|
||||
fn enabled(s: &Session) -> std::result::Result<(), String> {
|
||||
let edit_fill = crate::commands::find("edit.fill").ok_or_else(|| "Fill is unavailable".to_string())?;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ impl Rule {
|
|||
}
|
||||
|
||||
// The bounds and choices are the core filters' public dialog-unit contract. Defaults remain in
|
||||
// params_for; extended/gallery filters keep their own parsing until their validation is added.
|
||||
// params_for; gallery filters are checked in gallery_cmds.
|
||||
fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
|
||||
use Rule::{Bool, Choice, Number, Unsigned};
|
||||
Some(match id {
|
||||
|
|
@ -92,11 +92,49 @@ fn fields(id: &str) -> Option<&'static [(&'static str, Rule)]> {
|
|||
})
|
||||
}
|
||||
|
||||
// Extended filters: only their documented choices are checked for now, since `prepare` injects
|
||||
// extra keys (colours, map documents) and their numeric ranges clamp in params_for.
|
||||
fn choices(id: &str) -> Option<&'static [(&'static str, &'static str)]> {
|
||||
Some(match id {
|
||||
"filter.pixelate.mezzotint" => {
|
||||
&[("type", "fineDots|mediumDots|grainyDots|coarseDots|shortLines|mediumLines|longLines|shortStrokes|mediumStrokes|longStrokes")]
|
||||
}
|
||||
"filter.stylize.diffuse" => &[("mode", "normal|darkenOnly|lightenOnly|anisotropic")],
|
||||
"filter.stylize.extrude" => &[("type", "blocks|pyramids"), ("depthMode", "random|levelBased")],
|
||||
"filter.stylize.tiles" => &[("fill", "background|foreground|inverse|unaltered")],
|
||||
"filter.stylize.traceContour" => &[("edge", "lower|upper")],
|
||||
"filter.stylize.wind" => &[("method", "wind|blast|stagger"), ("direction", "fromRight|fromLeft")],
|
||||
"filter.distort.displace" => &[("fit", "stretch|tile"), ("undefinedAreas", "repeat|wrap")],
|
||||
"filter.distort.shear" => &[("undefinedAreas", "wrap|repeat")],
|
||||
"filter.distort.zigZag" => &[("style", "pondRipples|outFromCenter|aroundCenter")],
|
||||
"filter.render.lensFlare" => &[("lens", "zoom|prime35|prime105|moviePrime")],
|
||||
"filter.render.lightingEffects" => &[("lightType", "spot|point|infinite"), ("texture", "none|red|green|blue|alpha|luminance")],
|
||||
"filter.blur.smartBlur" => &[("quality", "high|medium|low"), ("mode", "normal|edgeOnly|overlayEdge")],
|
||||
"filter.blur.lensBlur" => {
|
||||
&[("shape", "hexagon|triangle|square|pentagon|heptagon|octagon"), ("depthMap", "none|transparency|layerMask"), ("distribution", "uniform|gaussian")]
|
||||
}
|
||||
"filter.blur.shapeBlur" => &[("shape", "circle|ring|square|diamond|triangle|hexagon|star|heart|cross")],
|
||||
"filter.other.hsbHsl" => &[("inputMode", "rgb|hsb|hsl"), ("rowOrder", "hsb|hsl|rgb")],
|
||||
"filter.video.deInterlace" => &[("eliminate", "oddFields|evenFields"), ("createBy", "interpolation|duplication")],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs both at dispatch (before committing a floating selection) and at the shared runner
|
||||
/// (direct specs/Last Filter). Filters outside this module's core family are unaffected.
|
||||
/// (direct specs/Last Filter). Filters outside the core and extended families are unaffected.
|
||||
pub(crate) fn validate_params(id: &str, params: &Value) -> Result<()> {
|
||||
let Some(fields) = fields(id) else { return Ok(()) };
|
||||
let bad = |msg| EngineError::BadParams { cmd: id.into(), msg };
|
||||
if let Some(choices) = choices(id) {
|
||||
for &(key, allowed) in choices {
|
||||
if let Some(value) = params.get(key)
|
||||
&& !Rule::Choice(allowed).accepts(value)
|
||||
{
|
||||
return Err(bad(format!("`{key}` must be {}", Rule::Choice(allowed).expected())));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(fields) = fields(id) else { return crate::gallery_cmds::validate_params(id, params) };
|
||||
if params.is_null() {
|
||||
return Ok(());
|
||||
}
|
||||
|
|
@ -270,6 +308,46 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_filter_choices_match_their_specs_and_reject_unlisted_values() {
|
||||
let mut s = session(8);
|
||||
let specs = crate::filters_ext::specs();
|
||||
let ids = [
|
||||
"filter.pixelate.mezzotint",
|
||||
"filter.stylize.diffuse",
|
||||
"filter.stylize.extrude",
|
||||
"filter.stylize.tiles",
|
||||
"filter.stylize.traceContour",
|
||||
"filter.stylize.wind",
|
||||
"filter.distort.displace",
|
||||
"filter.distort.shear",
|
||||
"filter.distort.zigZag",
|
||||
"filter.render.lensFlare",
|
||||
"filter.render.lightingEffects",
|
||||
"filter.blur.smartBlur",
|
||||
"filter.blur.lensBlur",
|
||||
"filter.blur.shapeBlur",
|
||||
"filter.other.hsbHsl",
|
||||
"filter.video.deInterlace",
|
||||
];
|
||||
for id in ids {
|
||||
let doc = specs.iter().find(|spec| spec.id == id).unwrap().params;
|
||||
for &(key, allowed) in choices(id).unwrap() {
|
||||
// The table repeats the registry's params doc, so the two cannot drift apart.
|
||||
assert!(doc.contains(&format!("\"{key}\":\"{allowed}\"")), "{id}.{key}");
|
||||
for value in [json!("zzbogus"), json!(false), json!(1), Value::Null] {
|
||||
rejected(&mut s, id, key, value);
|
||||
}
|
||||
for value in allowed.split('|') {
|
||||
validate_params(id, &json!({key:value})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keys `prepare` injects, and undocumented ones, stay accepted as before.
|
||||
s.execute("filter.stylize.tiles", json!({"count":4,"foreground":[1,0,0,1]})).unwrap();
|
||||
s.execute("filter.stylize.wind", json!({"direction":"fromLeft"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_filters_leave_floating_selection_and_background_jobs_alone() {
|
||||
let mut s = session(16);
|
||||
|
|
|
|||
|
|
@ -31,29 +31,40 @@ pub struct CutParts {
|
|||
pub layer: LayerId,
|
||||
rest: Surface,
|
||||
piece: Surface,
|
||||
/// The pieces are the layer's mask (`"target":"mask"`), not its pixels.
|
||||
mask: bool,
|
||||
}
|
||||
|
||||
impl CutParts {
|
||||
/// Split `layer` of `doc` by its selection. With `copy` the selected pixels stay in the layer
|
||||
/// too (the piece is a duplicate). Without, they leave a transparent hole, or on the Background,
|
||||
/// which can't hold transparency, one filled with `background` (Photoshop). Errors without a
|
||||
/// selection or pixels.
|
||||
pub fn new(doc: &Document, layer: LayerId, copy: bool, background: [f32; 4]) -> Result<Self> {
|
||||
/// selection or pixels. With `mask` the layer's mask is split instead of its pixels; the hole it
|
||||
/// leaves takes the background colour (as on the Background layer).
|
||||
pub fn new(doc: &Document, layer: LayerId, copy: bool, background: [f32; 4], mask: bool) -> Result<Self> {
|
||||
let sel = doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?;
|
||||
let l = doc.layer(layer).ok_or(EngineError::NoLayer(layer))?;
|
||||
let LayerContent::Raster(surf) = &l.content else {
|
||||
return Err(EngineError::Other("the layer has no pixels to move".into()));
|
||||
let surf = if mask {
|
||||
&l.mask.as_ref().ok_or_else(|| EngineError::Other("the layer has no mask".into()))?.surface
|
||||
} else {
|
||||
let LayerContent::Raster(surf) = &l.content else {
|
||||
return Err(EngineError::Other("the layer has no pixels to move".into()));
|
||||
};
|
||||
surf
|
||||
};
|
||||
let fmt = surf.format();
|
||||
let with_alpha = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let mut rest = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
|
||||
let full = if fmt == with_alpha { surf.clone() } else { surf.convert(with_alpha) };
|
||||
// A mask has no alpha: it keeps its format, and the cut leaves background colour.
|
||||
let mut rest = if mask { surf.clone() } else { full.clone() };
|
||||
let mut piece = Surface::new(with_alpha);
|
||||
let fill = !copy && crate::extra_cmds::is_background(l);
|
||||
let b = sel.content_bounds().intersect(&surf.content_bounds());
|
||||
let fill = !copy && (mask || crate::extra_cmds::is_background(l));
|
||||
// A mask's untouched pixels (its default, e.g. white) move too, so take the whole canvas.
|
||||
let b = sel.content_bounds().intersect(&if mask { doc.bounds() } else { surf.content_bounds() });
|
||||
if !b.is_empty() {
|
||||
let n = with_alpha.channels();
|
||||
let a = n - 1;
|
||||
let mut rp = rest.read_region(b);
|
||||
let mut rp = full.read_region(b);
|
||||
let mut pp = rp.clone();
|
||||
let w = b.width() as usize;
|
||||
for (i, (p, r)) in pp.chunks_exact_mut(n).zip(rp.chunks_exact_mut(n)).enumerate() {
|
||||
|
|
@ -67,30 +78,44 @@ impl CutParts {
|
|||
r[a] *= 1.0 - k;
|
||||
}
|
||||
}
|
||||
rest.write_region(b, &rp);
|
||||
if !mask {
|
||||
rest.write_region(b, &rp);
|
||||
}
|
||||
if fill {
|
||||
crate::pixels::fill_surface(&mut rest, b, background, Some(sel), true);
|
||||
crate::pixels::try_fill_surface(&mut rest, b, background, Some(sel), true)?;
|
||||
}
|
||||
rest.prune();
|
||||
piece.write_region(b, &pp);
|
||||
piece.prune();
|
||||
}
|
||||
Ok(Self { layer, rest, piece })
|
||||
Ok(Self { layer, rest, piece, mask })
|
||||
}
|
||||
|
||||
/// `doc` with the piece moved by whole pixels (dx, dy) over the cut-out layer.
|
||||
pub fn moved(&self, doc: &Document, dx: i32, dy: i32) -> Result<Document> {
|
||||
let mut d = doc.clone();
|
||||
let surf = d
|
||||
.layer_mut(self.layer)
|
||||
.ok_or(EngineError::NoLayer(self.layer))?
|
||||
.surface_mut()
|
||||
.ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?;
|
||||
let mut out = self.rest.clone();
|
||||
crate::transform_cmds::composite_over(&mut out, &photocraft_algo::resample::translate_surface(&self.piece, dx, dy));
|
||||
*surf = out;
|
||||
self.place(&mut d, dx, dy)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Put the piece, moved by (dx, dy), over the cut-out layer (or mask) of `doc`.
|
||||
fn place(&self, doc: &mut Document, dx: i32, dy: i32) -> Result<()> {
|
||||
let l = doc.layer_mut(self.layer).ok_or(EngineError::NoLayer(self.layer))?;
|
||||
let surf = if self.mask {
|
||||
l.mask.as_mut().map(|m| &mut m.surface).ok_or_else(|| EngineError::Other("the layer has no mask".into()))?
|
||||
} else {
|
||||
l.surface_mut().ok_or_else(|| EngineError::Other("the layer has no pixels to move".into()))?
|
||||
};
|
||||
let mut out = self.rest.clone();
|
||||
let piece = photocraft_algo::resample::translate_surface(&self.piece, dx, dy);
|
||||
if self.mask {
|
||||
crate::fill_cmds::composite_over(&mut out, &piece, piece.content_bounds(), None)?;
|
||||
} else {
|
||||
crate::transform_cmds::composite_over(&mut out, &piece);
|
||||
}
|
||||
*surf = out;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece floating `offset` whole pixels from where it was cut, over document `revision`.
|
||||
|
|
@ -150,7 +175,7 @@ fn float(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||||
if floating(st).is_none() {
|
||||
let layer = st.active_layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
|
||||
let parts = CutParts::new(&st.doc, layer, copy, background)?;
|
||||
let parts = CutParts::new(&st.doc, layer, copy, background, crate::commands::is_mask_target(p))?;
|
||||
st.floating = Some(Floating { layer, offset: (0, 0), revision: st.revision, parts: Arc::new(parts) });
|
||||
}
|
||||
let f = st.floating.as_mut().ok_or(EngineError::NoDocument)?;
|
||||
|
|
@ -171,9 +196,7 @@ pub fn drop_floating(s: &mut Session) -> Result<Value> {
|
|||
return Ok(json!({"dropped": true, "offset": [0, 0]}));
|
||||
}
|
||||
s.edit("Move", |doc, _| {
|
||||
let moved = f.parts.moved(doc, dx, dy)?;
|
||||
let surf = moved.layer(f.layer).and_then(|l| l.surface()).cloned().ok_or(EngineError::NoLayer(f.layer))?;
|
||||
*doc.layer_mut(f.layer).ok_or(EngineError::NoLayer(f.layer))?.surface_mut().ok_or(EngineError::NoLayer(f.layer))? = surf;
|
||||
f.parts.place(doc, dx, dy)?;
|
||||
doc.selection = doc.selection.as_ref().map(|sel| photocraft_algo::resample::translate_surface(sel, dx, dy));
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -210,7 +233,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
label: "Float Selection",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
|
||||
params: r##"{"dx":px=0,"dy":px=0,"copy":bool=false,"target":"pixels"|"mask"="pixels" (mask: float the selected part of the layer's mask, the hole takes the background colour)} → {layer, offset} (cuts the selected pixels of the active layer into a floating piece the first time, or with copy leaves them and floats a duplicate; on the Background the hole takes the background colour; then moves it by whole pixels; dropped by select.drop or any other command, put back by edit.undo)"##,
|
||||
enabled: can_float,
|
||||
journal: true,
|
||||
run: float,
|
||||
|
|
|
|||
|
|
@ -148,3 +148,44 @@ fn copy_floats_a_duplicate_and_leaves_the_original() {
|
|||
let doc = &s.active().unwrap().doc;
|
||||
assert!(alpha(doc, id, 12, 12) == 1.0 && alpha(doc, id, 42, 12) == 1.0, "dropped: both");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_with_the_mask_target_moves_the_mask_not_the_layer() {
|
||||
let (mut s, id) = session();
|
||||
s.edit("mask", |doc, _| {
|
||||
let l = doc.layer_mut(id).unwrap();
|
||||
let mut m = photocraft_doc::LayerMask::reveal_all();
|
||||
m.surface.fill_rect(Rect::new(10, 10, 30, 30), &[0.0]);
|
||||
l.mask = Some(m);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||||
s.execute("select.float", json!({"dx": 25, "dy": 0, "copy": true, "target": "mask"})).unwrap();
|
||||
s.execute("select.drop", json!({})).unwrap();
|
||||
assert_eq!(mask_at(&s, 12, 12), 0.0, "the copy leaves the mask where it was");
|
||||
assert_eq!(mask_at(&s, 37, 12), 0.0, "the mask's selected part moved");
|
||||
assert_eq!(mask_at(&s, 60, 12), 1.0);
|
||||
assert_eq!(alpha(&s.active().unwrap().doc, id, 37, 12), 0.0, "the layer's pixels did not move");
|
||||
assert_eq!(alpha(&s.active().unwrap().doc, id, 12, 12), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutting_an_untouched_part_of_the_mask_moves_it_and_leaves_the_background_colour() {
|
||||
let (mut s, id) = session();
|
||||
s.edit("mask", |doc, _| {
|
||||
let mut m = photocraft_doc::LayerMask::hide_all();
|
||||
m.surface.fill_rect(Rect::new(50, 10, 70, 30), &[1.0]);
|
||||
doc.layer_mut(id).unwrap().mask = Some(m);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.tools.background = [0.5, 0.5, 0.5, 1.0];
|
||||
let mask_at = |s: &Session, x, y| s.active().unwrap().doc.layer(id).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0);
|
||||
// (10..30)² is the mask's untouched black; it moves over the white at (50..70, 10..30).
|
||||
s.execute("select.float", json!({"dx": 40, "dy": 0, "target": "mask"})).unwrap();
|
||||
s.execute("select.drop", json!({})).unwrap();
|
||||
assert_eq!(mask_at(&s, 55, 12), 0.0, "the untouched black moved");
|
||||
assert!((mask_at(&s, 12, 12) - 0.5).abs() < 0.01, "the hole takes the background colour");
|
||||
assert_eq!(mask_at(&s, 40, 12), 0.0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,41 @@ pub fn params_for(id: &str, p: &Value) -> Option<FilterParams> {
|
|||
Some(FilterParams::FilterGallery { effects: vec![effect_from_json(f, p, &Value::Null)] })
|
||||
}
|
||||
|
||||
/// An unknown choice name or a wrong-typed choice is a bad-params error, for one gallery filter
|
||||
/// or each effect of a `filter.filterGallery` stack. Checked only for new invocations (via
|
||||
/// `filters::validate_params`); stored smart filters keep decoding tolerantly.
|
||||
pub(crate) fn validate_params(id: &str, p: &Value) -> Result<()> {
|
||||
let bad = |msg: String| EngineError::BadParams { cmd: id.into(), msg };
|
||||
if id == "filter.filterGallery" {
|
||||
for (k, item) in p.get("effects").and_then(Value::as_array).into_iter().flatten().enumerate() {
|
||||
let Some(f) = item.get("filter").and_then(Value::as_str).and_then(GalleryFilter::from_key) else { continue };
|
||||
let params = item.get("params").filter(|v| v.is_object()).unwrap_or(item);
|
||||
check_choices(f, params).map_err(|m| bad(format!("effect {k}: {m}")))?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match id.strip_prefix("filter.gallery.").and_then(GalleryFilter::from_key) {
|
||||
Some(f) => check_choices(f, p).map_err(bad),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_choices(f: GalleryFilter, p: &Value) -> std::result::Result<(), String> {
|
||||
for prm in f.params() {
|
||||
let (Some(v), GalleryParamKind::Choice(names)) = (p.get(prm.key), &prm.kind) else { continue };
|
||||
let ok = match v {
|
||||
Value::String(name) => names.iter().any(|n| n.eq_ignore_ascii_case(name)),
|
||||
// A choice index, clamped as the dialog stores it.
|
||||
Value::Number(_) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !ok {
|
||||
return Err(format!("`{}` must be one of {}", prm.key, names.join("|")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether the command reads the foreground/background colours.
|
||||
pub(crate) fn uses_colours(id: &str) -> bool {
|
||||
id == "filter.filterGallery" || id.strip_prefix("filter.gallery.").and_then(GalleryFilter::from_key).is_some_and(GalleryFilter::uses_colours)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue