Merge remote-tracking branch 'origin/main' into feat/file-215-2-mezzotint-halftone

This commit is contained in:
David Mann
2026-10-21 11:38:50 +02:00
138 changed files with 7872 additions and 531 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
# PhotoCraft architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (workspace size and test counts recounted; current-state summary added; the design plan below is kept, with its deviations listed) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (accurate GPU matrix/TRC display conversion; original design plan retained) · **Target:** Adobe Photoshop 2026
## Current state (2026-10-10)
@@ -18,7 +18,7 @@ What exists on `main` today. The numbered sections below are the original design
UI, CLI, JSON control channel and MCP server all dispatch by command id. 628 Photoshop menu items
map to them (`crates/ui-egui/src/menu_catalog.rs`).
- **Rendering:** CPU compositor (`compose`, the oracle) and wgpu compositor (`gpu`, ≤ 1/255 of the
CPU); colour through our own ICC engine (`cms`), display transform folded into a GPU 3D LUT.
CPU); colour through our own ICC engine (`cms`), GPU display conversion through matrix/shaper stages or a 3D LUT fallback.
- **Files:** own PSD/PSB crate (`psd`), flat codecs (`codecs`), camera raw (`raw`), HEIF (`heif`),
Affinity (`affinity`), document mapping (`io`), native `.pcraft` (`format`, atomic saves).
Coverage: [`file-format-parity.md`](file-format-parity.md).
@@ -349,7 +349,7 @@ Both backends consume the same plan. This is the only place that encodes Photosh
### 7.3 Color
- Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available.
- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas the transform, plus Proof Colors / Gamut Warning / 32-bit preview, is baked into a 33³ 3D LUT the canvas shader's final pass applies; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports).
- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas RGB matrix/TRC transforms retain separate full-float input curves, matrix/offsets and inverse output curves (sampled densely near black); this avoids the shadow darkening of a uniform RGB cube (#7958). Other transforms and Gamut Warning retain the 33³ display LUT, and the shader applies 32-bit preview before either conversion; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports).
- **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto`, a built-in RGB profile or an `.icc` path. In `auto` each window uses the profile of the display it is on (#569): the desktop app reads every display's id, name, frame and ICC profile at launch and again when the app comes back to the front, when any window is on an unknown or resized display (at most every 30 s; a trigger inside that window is deferred) and when Edit › Color Settings opens (`ui-egui/src/monitor_status.rs`). macOS reads AppKit `NSScreen` through `osascript` (no FFI); X11 reads RandR outputs and the default colord profile matched by exact `XRANDR_name`; native Wayland and Xwayland windows from a Wayland session stay on sRGB because eframe/winit cannot safely set the app surface's Wayland image description. The macOS helper is stopped after 10 s. There is deliberately no periodic re-read (#569 decision: a read costs about 0.2 s of CPU), so a profile reassigned while PhotoCraft stays in front with no window moving is picked up at the next return to the front or Color Settings. X11 selects the root window's RandR output by winit's current-monitor name and other windows by their overlapping RandR frame; macOS follows `NSWindow.screen`. Elsewhere `auto` is sRGB. The GPU canvas keeps one texture per document and a display LUT per (document, display); CPU canvas textures are per (document, display). `ColorState::monitor_status_for` says what is applied per display: `auto`, `manual`, or `fallback` to sRGB with the reason (missing, unreadable, non-RGB or unusable as a destination), and whether the reading is an earlier one kept after a failed re-read; `edit.colorSettings` (`monitorStatus`, `displays`), Help › System Info and the Color Settings dialog show it, and a fallback in `auto` posts a notice. Letting macOS colour-match the canvas instead is #581 (an architecture decision).
- **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels.
@@ -589,6 +589,7 @@ Each phase ends with a demoable build and green CI on all targets. Phases 8–10
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | GPU display matrix/shaper path preserves near-black ICC values; complex transforms retain the 3D LUT fallback (#7958) |
| 2026-10-13 | minor | Workspace size and `#[test]` counts recounted (393k → 539k lines, 5,460 → 8,040 tests); other current-state figures unchanged |
| 2026-10-10 | minor | Current-state summary and deviations from the plan; status line; broken link to `photoshop-parity.md` repointed to `target-app-parity.md` |
| 2026-09-30 | major | Design plan v1 |
+1 -1
View File
@@ -91,7 +91,7 @@ tablets against Photoshop side by side.
| Tool | Status | Notes |
|---|---|---|
| Brush, Pencil (aliased, Auto Erase), Eraser (Brush/Pencil/Block) | done | Pencil 1 px quality #2139 |
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers) | partial | "incomplete" (#2247) |
| Mixer Brush (Wet, Load, Mix, Flow, Sample All Layers, Current Brush Load with Load Brush and Clean Brush, automatic Load and Clean) | partial | No live result while dragging, no Alt-click loading (#2247) |
| Clone Stamp (opacity, flow, aligned, sample, overlay) | partial | Clone Source W/H link and Alt target cursor (TOOL-213-8) |
| Pattern Stamp (aligned, impressionist) | done | |
| History Brush | done | Art History Brush missing |
+4 -1
View File
@@ -86,7 +86,9 @@ On Windows and Linux the first PhotoCraft holds an exclusive lock on `instance.l
## Graphics startup and device loss
`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12. Help › System Info shows the adapter, backend, driver and fallback state.
`--safe-gpu` starts with the CPU renderer for one launch (no GPU canvas; a software adapter for the window where the platform has one: WARP on Windows, llvmpipe over GL on Linux). Before creating the wgpu device the app writes and locks `gpu-starting.json` in the config directory; it clears it once the first frames have rendered. A launch that finds an unlocked marker knows the previous start died inside the graphics driver (#4) and uses the next safer backend (Windows: Vulkan → DX12 → CPU; Linux: Vulkan → GL → CPU; macOS: Metal → CPU), remembering a working hardware backend in `performance.gpuBackend` (Preferences › Performance › GPU Backend, with **Reset GPU Backend**). CPU recovery is temporary until **Keep Using CPU** is chosen in the recovery dialog; dismissing the dialog or quitting without a choice preserves the previous GPU preference (#3483). With `auto`, Intel adapters on Windows use DX12: when DXGI lists an Intel adapter, the app creates its wgpu instance with DX12 and GL only, so the first start doesn't load the Intel Vulkan driver, and a discrete GPU beside the Intel one also runs on DX12. Help › System Info shows the adapter, backend, driver and fallback state.
On Linux, on a machine with several GPUs (say an eGPU, a discrete laptop GPU and an iGPU), `auto` takes the first discrete adapter the driver lists; choose another in Preferences › Performance › Advanced › GPU adapter (`performance.gpuAdapter`, Linux; a case-insensitive piece of the adapter's name, empty is automatic), or for one launch with `PHOTOCRAFT_GPU_ADAPTER=nvidia`. An adapter that is missing or can't present to the window falls back to the automatic choice, with a note in System Info.
On DX12 the shader compiler is FXC (`d3dcompiler_47.dll`, part of Windows), or a `dxcompiler.dll` placed beside `photocraft.exe`, loaded by its full path. wgpu's default looks `dxcompiler.dll` up by name, which reaches the current directory and `PATH` and loaded other programs' incompatible builds (#712).
@@ -116,6 +118,7 @@ By default PhotoCraft's own crates log at `info` and everything else at `warn`.
| `PHOTOCRAFT_AUTOMATION_WRITE_ROOT` | Desktop app only: automation write root. Headless CLI modes require the `--automation-write-root` flag |
| `PHOTOCRAFT_CPU_CANVAS=1` | Force the CPU canvas path instead of the wgpu shader canvas |
| `PHOTOCRAFT_NATIVE_WAYLAND=1` | Linux: stay on native Wayland when a pen is attached (by default the window then opens through Xwayland, because Wayland gives the app no pen input; #639). A pen is an input device with `BTN_TOOL_PEN` and the `ABS_X`/`ABS_Y` axes; virtual keyboards that claim every key are not pens (#1072) |
| `PHOTOCRAFT_GPU_ADAPTER=nvidia` | Linux: use the graphics adapter whose name contains this (case-insensitive); overrides `performance.gpuAdapter` for one launch |
| `WGPU_BACKEND=dx12` | Pick the wgpu backend(s) (`vulkan`, `dx12`, `metal`, `gl`); overrides `performance.gpuBackend` and the startup fallback |
| `WGPU_DX12_COMPILER=fxc` | DX12 shader compiler (`fxc`, `dxc`, `auto`); `dxc` and `auto` look `dxcompiler.dll` up through the DLL search path |
| `PHOTOCRAFT_GPU_TILE=2048` | Force GPU canvas tiling (tests tile seams) |
+5
View File
@@ -42,6 +42,10 @@ what an agent can't finish alone.
### G1. PSD fidelity and Photoshop acceptance
- **Missing:** PSDs we write are not always accepted by Photoshop, and Photoshop-authored files
don't always render as Photoshop renders them.
- **Partial (2026-10-15, #5972 A1):** `GdFl` noise fills render when all three RGB ranges are
constant and transparency is disabled. Variable-range synthesis, HSB/Lab modes and
transparency remain unsupported and are preserved verbatim; the five visual examples do not
establish Photoshop's exact noise algorithm or include this degenerate constant case.
- **Evidence:** corpus floors (`crates/io/tests/corpus.rs`, scorecard): Photoshop-authored
oracles 188/258 (73%; smart filter masks 2026-10-14; CMYK and Lab adjustment layers, 32-bit unclipped adjustments, Levels, Threshold, Posterize and mask feathers 2026-10-13; Gradient Map luminosity 2026-10-13; CMYK Invert, Posterize and Threshold on the inks 2026-10-12), io corpus 148/170 (87%), psd-tools 249/309 (81%); round trips ~100%
within the corpus. Smart-filter re-render matches 5/30. User reports: a PSD with text layers
@@ -270,6 +274,7 @@ what an agent can't finish alone.
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | G1 / #5972 A1: render only provably constant-RGB `GdFl` noise fills; variable noise synthesis remains open |
| 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged |
| 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 |
| 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open |
+2
View File
@@ -22,6 +22,7 @@ HDR display to verify.
| GPU filters (Blur Gallery, Liquify, Smart Sharpen, Camera Raw) | yes | CPU (rayon) for filters; GPU for compose and effects | partial |
| Documents larger than the GPU texture limit | yes (tiled) | CPU fallback; tiling in progress (#49) | partial |
| CPU-only fallback when no GPU | yes, reduced | `cpu` canvas preference; fails to start without an adapter on some Linux systems (#2519, #2412) | partial |
| Choosing the GPU on a multi-GPU machine | Preferences › Performance › Use Graphics Processor | Linux only: Preferences › Performance › Advanced › GPU adapter and `PHOTOCRAFT_GPU_ADAPTER` (#1885); ignored on Windows and macOS | partial |
| Old / integrated GPUs | yes | black line on Intel HD (#2426), OpenCore Macs fail (#1859), Rgba32Float panic (#2020) | partial |
## Pen tablets
@@ -62,3 +63,4 @@ HDR display to verify.
| Date | Change | Summary |
|---|---|---|
| 2026-10-10 | major | First version |
| 2026-10-19 | minor | Graphics Adapter choice on Linux (#1885) |
+11
View File
@@ -38,6 +38,17 @@ with a `note` when no server answers. `"printServer"` on `file.print` sends one
server; `"dryRun": true` renders the page and reports the equivalent `lp` command without
spooling.
## Orientation (Windows)
On Windows the paper and its orientation are the driver's: the Print dialog takes them from the
printer's settings. Its Orientation buttons (Portrait, Landscape; Photoshop's Layout) turn those
settings, as Print Settings… would, and the paper and preview turn with them.
`file.print.page {"orientation": "portrait|landscape"}` does the same for scripts: it returns the
turned settings as `printerSettings`, with the page they give. A print sent to a Windows printer
whose layout is the driver's paper the other way round (a landscape layout, portrait settings)
turns the settings to the layout before printing, rather than stopping with "the driver paper or
orientation differs"; another paper is still refused.
## Results
`file.print` returns `sent`, `backend` (`lp`, `windows`, `ipp`, `portal`), `spooler` (where the
+6 -5
View File
@@ -6,10 +6,10 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 24 | 58 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 19 not run, 5 not measurable yet (baseline 2026-10-05) |
| [Performance](#performance) | 3 | 22 | 25 | 59 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 20 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 |
| [Tools](#tools) | 33 | 11 | 0 | |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 25 of 169 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 170 (target 0) |
| [Type](#type) | 4 | 3 | 2 | |
| [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 30 of 30 crates; panic_hunt present |
@@ -77,6 +77,7 @@ Baseline: 2026-10-05, commit `f5d2bcb54a`, Apple M4 Pro (48 GB), GPU Apple M4 Pr
| P59 | Chisel bevel size change, 12 MP text paragraph, size 8 (CPU) | track only | – | – | – | – | – | not run | |
| P60 | Layer opacity among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) |
| P61 | Hide a layer among 150 full-canvas layers, 6 MP (app GPU budget) | ≤ 30 ms p95 | – | – | – | – | – | not run | [#5973](https://github.com/storytold/photocraft/issues/5973) |
| P62 | File > New 10 x 5 m at 200 ppi (3.1 GP) + first reduced canvas refresh | track only | – | – | – | – | – | not run | [#4017](https://github.com/storytold/photocraft/issues/4017) |
**Not measurable yet** (the code can't do it yet; no number is recorded):
@@ -133,7 +134,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|
| TOOL-213-1 | Pencil tool: aliased 1 px painting, square pixel-aligned cursor | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::Pencil (B group): live aliased strokes on the pixel grid, square cursor, Auto Erase; pencil_tests.rs |
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, command/control dispatch and selection-clipping tests |
| TOOL-213-2 | Mixer Brush tool on the toolbar | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::MixerBrush in the Brush (B) flyout; Wet/Load/Mix/Flow/Sample All Layers options, the Current Brush Load swatch with Load Brush and Clean Brush (brush.mixer.load, brush.mixer.clean) and the automatic Load and Clean toggles; command/control dispatch and selection-clipping tests |
| TOOL-213-3 | Patch tool with a live preview of the patched area | done | [#213](https://github.com/storytold/photocraft/issues/213) | paint.patch (retouch_cmds/patch.rs, source/destination) + Tool::Patch (J group: lasso, then drag the selection); the drag shows the heal live (patch_preview.rs, coarse colour fit: 1000x1000 in 52 ms) |
| TOOL-213-4 | Content-Aware Move tool reusing inpaint completion | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.contentAwareMove (retouch_cmds/content_aware_move.rs: move/extend, Structure 1-7, Color 0-10, Sample All Layers, Transform On Drop's scaleX/scaleY/rotate; background job) + Tool::ContentAwareMove (J group: lasso, then drag; Transform On Drop puts a scale/rotate box on the dropped patch, cam_transform.rs: Enter commits as one step, Esc cancels). cam_preview.rs: once the pointer or the box rests, the result shows (a cancellable background job on a smaller sampling window, small selections only, #4385). Missing: a progressive preview for big selections |
| TOOL-213-5 | Painting a type/shape/smart/fill layer offers to rasterize, then continues the stroke | done | [#213](https://github.com/storytold/photocraft/issues/213) | rasterize_prompt.rs: OK rasterizes then paints (two history states), Cancel does nothing; drivable via ui.dialog.confirm |
@@ -179,9 +180,9 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
## Workspace and UI
**Settings that do nothing: 25 of 169** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
**Settings that do nothing: 26 of 170** (target 0, #204). Counted by the prefs audit (`xtask/src/scorecard.rs`): a `Preferences` field counts as read when code outside the prefs model (`prefs.rs` except its `impl Session` blocks; tests excluded; the schema-driven dialog never names a field) accesses `section.field`, or `.section` and `.field` in the same file, or calls a section method that reads `self.field`. It is a heuristic lower bound: a generic name can look read when it isn't.
Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_windows`, `workspace.auto_show_hidden_panels`, `workspace.enable_floating_document_window_docking`, `file_handling.save_in_background`, `performance.cache_levels`, `performance.gpu_adapter`, `performance.legacy_compositing`, `scratch_disks.disks`, `units_and_rulers.column_width`, `units_and_rulers.gutter`, `plug_ins.show_extension_panels`, `plug_ins.allow_scripts_to_connect`, `plug_ins.generator_enabled`, `type_.text_engine`, `type_.recent_fonts`, `enhanced_controls.scrubby_slider_acceleration`, `raw_defaults.color_space`, `raw_defaults.bit_depth`, `raw_defaults.resolution`, `raw_defaults.sharpen_for`, `raw_defaults.open_as_smart_object`, `raw_defaults.apply_auto_tone`, `integrations.allow_agent_control`, `integrations.control_port`.
| Id | Target | Status | Issue | Note |
|---|---|---|---|---|