Port selected-colour editing through main d42f9da94 and its engine/UI
module layout. Preserve native channel targets, drawing previews, tone
editors and filter behavior in the split modules, with their existing
regressions and public facade exports.
17 KiB
Editing selected colour channels
Shift-click colour rows in the Channels panel to add or remove them from the editing set. Only native colour channels can be combined. Selecting every colour channel returns to the composite. Selecting a channel shows it alone; Shift-clicking another shows exactly the selected set. The eye controls can then change visibility without changing the editing target. A subsequent row selection resets visibility to its set.
Partial sets work with the existing raster drawing tools (Brush, Pencil, erasers, stamps, Mixer Brush, retouching tools, Bucket, Gradient and raster Fill/Stroke Path), destructive adjustments, and the supported native raster filters. Erasing paints the toolbox background colour into the selected components, preserving transparency. Copy, Cut, Paste, Paste Into and Paste Outside carry the selected component planes. Each committed operation records one undo step. Live drawing previews protect the same components as the committed edit.
Levels and Curves show a neutral combined entry (for example RB) followed by the selected individual components. Colour adjustments that mix components and filters retain the original native layer context, including transparency textures, depth inputs and colour-valued settings, and restore unselected encoded samples. The combined tone operation is applied to each selected native plane; individual settings can refine those planes. Histograms use the selected layer's native component values. CMYK channels display tone (the inverse of stored ink); Lab chroma uses the native neutral encoding at each depth.
RGB, CMYK and Lab are exercised at 8-bit, 16-bit and 32-bit float depths. Unselected encoded samples, including transparency and float NaN payloads, are preserved byte for byte. Active pixel selections, including feathering, still constrain editing.
Move and pixel transforms, including Content-Aware Scale and mesh warps, on partial sets remain unavailable. Camera Raw requires the composite or one colour channel. HDR Toning requires the composite because it flattens the document. Filters that generate transparency cannot edit colour-only targets. Options that create a new layer, including Remove’s Create a new layer option, also require the composite. Rasterize a non-raster layer before editing its colour planes. Vector path transforms and moving guides remain available. This work is independent of #4648 and #6864; it does not include their isolated-channel geometry or remaining scalar-target tone-dialog changes.
Automation
The UI uses the existing engine commands. Native component indices follow the document colour mode: RGB 0–2, CMYK 0–3 and Lab 0–2.
{"command":"channel.target","params":{"colors":[0,2]}}
{"command":"channel.target","params":{"channel":{"color":1},"extend":true}}
{"command":"image.adjustments.curves","params":{"target":{"colors":[0,2]},"points":[[0,0],[128,170],[255,255]]}}
extend toggles a colour row; from the composite it toggles that row out of the full
set. Alpha channels, masks and Quick Mask cannot join a colour set. An explicit
target overrides the panel selection. Without it, applicable pixel commands follow
the selected set. The channels reply exposes selectedColors in addition to the
legacy primary target. Dialogs capture their target when opened; changing the panel
while a dialog is open does not redirect its operation.
Validation and performance
crates/engine/tests/multi_channel_editing.rs covers the native mode/depth matrix,
selected-plane math, byte preservation, live/committed drawing, selections, clipboard,
explicit layer targets, HDR Curves, selected-default masks, missing destinations,
errors, undo and redo. UI tests cover the selected tone
choices, captured drawing targets and guide/path interaction. A real pointer regression
checks normal clicks, Shift-clicks and independent eye overrides across RGB, CMYK and Lab,
including visibility persistence through an edit and the return to composite selection.
The 2026-10-26 main refresh integrates egui 0.37, the rebuilt Histogram panel, live Mixer Brush and smart-filter mask painting. Additional regressions cover selected-component Mixer previews, captured targets, exact commit/replay and undo across RGB/CMYK/Lab at all three depths, plus filter-mask Cut/Clear and dialog capture. The Art History Brush joins the drawing preservation matrix. The fused tone kernel is unchanged; the measurements below retain their original date.
The 2026-10-27 main refresh carries the same implementation and regressions into the upstream command, dialog, canvas and tool modules. Public engine/UI facades and control-protocol commands retain their existing names.
The release benchmark is crates/engine/examples/multi_channel_bench.rs. It uses a
6000 × 4000 synthetic RGB16 raster, one warmup and six measured repetitions per case,
with target modes interleaved. Document setup is outside the timed interval; command
execution, pixel edits and history commit are inside. Eraser is the ordinary Brush
eraser. Live-update cases time one 50-pixel extension after stroke creation. Timings
exclude display refresh and do not claim end-to-end stylus latency.
The observed maximum of six samples is reported as p95 in the raw output.
Run with RAYON_NUM_THREADS=8 cargo +1.99.0 run --release -p photocraft-engine --example multi_channel_bench -- --subset. For the unchanged base, use the same
fixture, parameters and timing loop and omit
--subset. The base cannot execute the new partial-set target. Add --region for the
five imaging cases through a 16 × 16 pixel selection; selection setup stays outside
the timer. Use --clipboard for a separate one-pixel paste case: copy and selection
setup stay outside the timer, while pasting edits the existing target layer.
Measured on 2026-10-25 on an AMD Ryzen 7 9700X (8 cores / 16 threads), Rust 1.99.0, release profile with thin LTO and eight Rayon workers. The three target modes are interleaved within each run. Retained runs had no concurrent Rust compilation or engine/UI tests; a process watcher discarded any interrupted measurement.
The previous implementation at 15bf0873ba03622e54189e668df20a1403e41d56 was rerun
using its retained release binary (verified by SHA-256), with the same fixture,
parameters and timing loop. The original pre-feature baseline at
6719f1813e84e96129fc31676cc945d51be0e736 is also retained in the raw data.
| Case (milliseconds) | All channels | Red only | Red + Blue | RB before fusion | RB maximum |
|---|---|---|---|---|---|
| Invert | 75.23 | 21.78 | 26.37 | 126.81 | 28.98 |
| Threshold | 94.93 | 24.07 | 29.77 | 174.85 | 31.03 |
| Levels (gamma 1.4) | 102.88 | 23.44 | 29.06 | 218.91 | 29.52 |
| Curves (one midpoint) | 91.62 | 22.68 | 28.81 | 173.50 | 32.86 |
| Gaussian Blur (radius 8) | 184.71 | 320.05 | 288.82 | 298.25 | 293.23 |
| Brush commit (120 px) | 4.61 | 7.04 | 6.55 | 6.59 | 6.64 |
| Eraser commit (120 px) | 4.36 | 7.05 | 6.49 | 6.63 | 6.58 |
| Live brush (50 px extension) | 0.33 | 1.69 | 1.41 | 1.50 | 1.44 |
| Live eraser (50 px extension) | 0.30 | 1.70 | 1.41 | 1.49 | 1.42 |
Timings are p50 except the final column, which is the observed maximum of six samples
(the legacy raw field is named p95_ms). R+B tone edits take 1.21–1.27× Red-only
and are 4.8–7.5× faster than the rerun before fusion. The composite uses its existing
generic full-colour adjustment path and is an interleaved control.
Scalar adjustments visit native tiles once for the complete selected set, cloning each destination tile once and writing only the selected components. Large integer edits prepare exact sample-code tables through the existing adjustment math once per distinct channel record; fully selected pixels use encoded lookups. Float and small-selection edits use bounded tile buffers to retain HDR formulas and avoid full-image channel extraction. A test-only copy of the previous scalar path checks the mode/depth/selection/default matrix byte for byte. Filters retain the original native context and restore unselected encoded samples after filtering. These runs establish no new performance budget or readiness percentage.
| Case, 16 × 16 selection | RB p50 (ms) | RB maximum (ms) |
|---|---|---|
| Invert | 0.98 | 1.08 |
| Threshold | 0.98 | 1.08 |
| Levels (gamma 1.4) | 1.61 | 2.08 |
| Curves (one midpoint) | 1.15 | 1.19 |
| Gaussian Blur (radius 8) | 0.92 | 0.99 |
The small-selection cases use the same 24 MP layer. They measure bounded region work, including the filter halo, with selection creation outside the timer.
The separate one-pixel Paste case measured 0.46 ms for one channel and 1.00 ms for RB. Copy and selection setup are outside the timer; the paste edits the existing 24 MP layer. Destination extraction is bounded to the paste.
The complete harness peak RSS was 650.0 MiB after fusion and 674.3 MiB before fusion. These are process peaks across all three target modes, not per-operation allocation measurements.
Raw samples: original pre-feature base,
before-fusion rerun,
candidate,
small selection,
one-pixel paste,
environment and binary hashes.
The three candidate*-before-fused-original.json files preserve the original feature run.
Validation after the main merge and review fixes: 6,542 engine and UI tests passed
(38 ignored), including 58 focused engine regressions. Strict all-target Clippy,
formatting, layering, wasm, parity generation and the adversarial command panic hunt passed.
The complete cargo xtask perf --quick runner now finishes successfully after the main
merge; its report still lists unrelated, non-enforced budget overruns. Six further local
review/fix rounds followed the latest CodeRabbit review; the last found no actionable issues.
All visual captures were rendered offscreen and inspected.
The additional 24 MP RGB16 Liquify benchmark uses
crates/ui-egui/examples/multi_channel_preview_bench.rs, with eight Rayon workers and
no concurrent builds, tests or performance scenarios. It measures field deformation plus
the dirty CPU proxy render; GPU uploads/presentation are excluded. One warmup and six
measured updates use interleaved target modes. The field's nonzero displacement is checked.
| Liquify, 120 px brush | All channels | Red only | Red + Blue |
|---|---|---|---|
| Preview update p50 | 0.111 ms | 0.345 ms | 0.447 ms |
| Maximum of six updates | 0.120 ms | 0.360 ms | 0.461 ms |
| Open workspace, one setup measurement | 22.6 ms | 246.6 ms | 317.7 ms |
Opening a component workspace builds its native proxy once. Open timings are sequential single observations, not medians; setup is excluded from update timings. The whole process, including all three workspaces, peaked at 380.5 MiB RSS. Raw samples and source/binary hashes are retained.
Visual evidence
The images below use an original synthetic gradient, rendered offscreen without
opening a window. Their JSON scripts are adjacent to the images. Reproduce them with
cargo run -p photocraft-ui-egui --example snapshot -- --safe-gpu --size 1440x900 --scale 1 --out <image.png> --script "$(cat <script.json>)"; add --click-at 720,242
for the Curves dropdown capture.
Public-domain image captures
These captures use Hokusai’s The Great Wave off Kanagawa, c. 1830–1832, a public-domain work reproduced by the Metropolitan Museum and distributed through Wikimedia Commons. The unchanged source image, public-domain notice and scripts are adjacent to the captures. These captures show the default visibility: only the selected R+B, CMY or a+b components are visible. The remaining component eyes are off. The open Curves dropdown shows the neutral combined entry and only its selected component choices.
Reproduce with the snapshot command above, adding --open docs/images/multi-channel-editing/great-wave.jpg
and using rgb-script.json, cmyk-script.json or lab-script.json. For the dropdown, add
--click-at 700,243 for RGB or --click-at 695,226 for CMYK/Lab.
CodeRabbit findings
The initial review’s Last Filter non-object parameter panic and first single-channel live retouch-preview restriction issues are fixed, with regression tests. The proposed extra CMYK clipboard inversion is unnecessary: destination extraction and scatter already convert between tone and ink. Tests cover implicit/explicit singleton copying into explicit targets and normal/Paste Into/Paste Outside paths at 8/16/32-bit depths without adding an inversion.
The post-merge review identified missing Copy/Cut parameter documentation; both commands now describe explicit colour targets and layer ids/names. Auditing that contract also found that Copy ignored an explicit layer with an implicit single-component target. Copy now resolves that layer just as Cut does, with a regression that failed before the correction.
The proposed Lab histogram index change is unnecessary: HistSource::Colors reserves
slot 0 for the combined selection and stores native components at k + 1, including Lab.
Ordinary Lab histograms instead use slots 0–2 for L/a/b. The histogram accessor returns
empty bins for an unavailable slot, rather than panicking. New tests use actual native
subset histograms for components [0, 2], render every choice in both editors, and check
Levels Auto against those bins in RGB/CMYK/Lab at 8/16/32-bit depths.
The next local review found that viewing a layer mask after selecting colour channels could still send canvas strokes to those colours. Mask view now takes priority in canvas painting, implicit engine commands and captured tone-dialog targets. Regressions cover live previews and commits plus adjustments/filters at all depths, while explicit native targets can still override the viewed mask. Selecting colour rows again leaves mask view and restores their editing target and visibility.
A further review caught mask Cut/Clear falling back to layer pixels, dialog capture retaining the old colour set over a viewed mask, and Desaturate dropping explicit target parameters. Cut/Clear now copy/fill only the selected mask samples, respecting locks and feathering. Adjustment/filter dialogs preserve their captured mask through preview, confirmation and Curves Reset, and Desaturate forwards its target and layer parameters. Tests cover pixel and clipboard preservation, undo/redo, refused edits and changed panel selections. Move/Free Transform still work on the viewed unlinked mask when colour rows remain selected behind it; native colour subsets retain their geometry restriction. Curves canvas-point gestures also honour the captured scalar target after the panel selection changes.
The named-destination visibility guard now resolves layer names just as painting does. Filter Gallery, Liquify and Adaptive Wide Angle capture the native colour target, preserve unselected components and alpha in their specialized previews, and retain the target for OK. Liquify samples only the dirty proxy or detail viewport, with a native proxy built once. These full-window workspaces require layer pixels or native colour channels; masks, saved channels and Quick Mask remain available through ordinary filter commands/dialogs. Geometric writeback retains the original alpha layout, copying selected native bytes even when the warp produces an alpha-bearing surface. Changed defaults materialize only within the supplied document/preview extent, under the same bounded-pixel limit.
The public-domain Filter Gallery comparison uses R+B with Green manually made visible. The original preview filters Green too; the corrected preview retains its original detail. The capture script records that explicit visibility override.
The same synthetic gradient and pointer script show the correction: before, the mask stays white while the stroke changes the layer; after, the stroke paints the mask while the layer's gradient stays intact.










