From 42709684d25e656f1ab0d29e89ea96a4cfc5764e Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:42:53 +0200 Subject: [PATCH 1/2] LR-MASK-BRUSH: with the mask overlay hidden, the stroke being painted no longer tints the photo (#2549) While the brush button was held, the live stroke was always drawn in the overlay colour, falling back to a fixed 43% tint when the mask overlay was hidden. Hiding the overlay with O or the panel eye, or having a mask slider hide it before dodging and burning, still left each stroke red until release, so the photo could not be judged while painting. The live stroke now follows the same visibility as the committed overlay: it is tinted only when Show Mask Overlay or the all-masks overlay is on, and not for a colour overlay at opacity 0. A headless test paints after a mask slider hid the overlay, after O, at opacity 0 and with the all-masks overlay, and checks that only the all-masks case tints the stroke being painted. --- crates/ui-egui/src/panels/detail.rs | 13 +++- crates/ui-egui/src/tests_masking.rs | 100 ++++++++++++++++++++++++++++ docs/parity-checklist.md | 5 +- docs/whats-new.md | 8 +++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index b95d7b056..84531132b 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2384,10 +2384,17 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } // the stroke being painted (the render shows it once it's committed), with the committed - // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + // stroke's dab spacing, feather and flow (issue #517), tinted only when the overlay will + // show it: with the overlay hidden (O, or a mask slider hid it) or a colour overlay at + // opacity 0, the photo stays untinted while painting, so dodging and burning is judged on + // the photo (issue #2549) + use lightcraft_pipeline::MaskView; + let colored = matches!(MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), MaskView::Color | MaskView::ColorOnBw); + let shown = (app.ui.mask_overlay || app.ui.mask_overlay_all) && !(colored && app.ui.mask_overlay_opacity <= 0.0); + if shown && let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); - let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; + // opacity 0 only gets here in the image views, which ignore it: a fixed tint there + let opacity = if app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; let params = crate::brush_live::LiveParams { clip, scale: ui.ctx().pixels_per_point(), diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index a1e5846e4..f6841129f 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1782,6 +1782,106 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() { } } +/// What the photo shows along the centreline of [`hold_fast_stroke`]'s stroke at height `y`, away +/// from the cursor ring at its end. +fn stroke_centreline(h: &Headless, y: f32) -> Vec<[f32; 3]> { + let im = h.paint(); + let img = h.app.image_rect.expect("the photo is on screen"); + let ppp = h.pixels_per_point; + (1..=74) + .map(|i| { + let x = 0.17 + (0.85 - 0.17) * i as f32 / 80.0; + let (sx, sy) = (((img.left() + x * img.width()) * ppp) as usize, ((img.top() + y * img.height()) * ppp) as usize); + let c = im.pixels[sy * im.size[0] + sx]; + [c.r() as f32, c.g() as f32, c.b() as f32] + }) + .collect() +} + +/// The fast stroke of the #517 test at height `y`, with the button still held: its centreline +/// before painting and while painting. +fn hold_fast_stroke(h: &mut Headless, y: f32) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { + pointer(h, json!([{"kind": "move", "x": 0.02, "y": 0.02}])); + h.settle(SETTLE); + let photo = stroke_centreline(h, y); + pointer( + h, + json!([{"kind": "down", "x": 0.15, "y": y}, {"kind": "drag", "x": 0.17, "y": y}, {"kind": "drag", "x": 0.5, "y": y}, {"kind": "drag", "x": 0.85, "y": y}]), + ); + assert!(matches!(h.app.gesture, Some(crate::panels::detail::Gesture::Brush { .. })), "still painting"); + (photo, stroke_centreline(h, y)) +} + +/// The centreline points (photo, held) where the stroke being painted changes the photo. +fn tinted(photo: &[[f32; 3]], held: &[[f32; 3]]) -> Vec<([f32; 3], [f32; 3])> { + photo.iter().zip(held).filter(|(p, l)| (0..3).any(|k| (p[k] - l[k]).abs() > 6.0)).map(|(p, l)| (*p, *l)).collect() +} + +/// Issue #2549: with the mask overlay hidden (a mask slider hid it, or O) or a colour overlay at +/// opacity 0, the stroke being painted doesn't tint the photo either, so dodging and burning is +/// judged on the photo; the effect shows once the stroke is committed. The all-masks overlay still +/// shows it, as it will the committed stroke. +#[test] +fn a_brush_stroke_is_not_tinted_while_the_mask_overlay_is_hidden() { + let mut h = detail("panel.masking"); + h.settle(SETTLE); + exec(&mut h, "tool.brush", json!({"new": true})); + let r = h.request("ui.set", json!({"brushSize": 0.03, "brushFeather": 50.0, "brushFlow": 100.0}), T); + assert_eq!(r["ok"], true, "{r}"); + // dodging: brighten the new, still empty mask first (which hides the overlay), then paint + exec(&mut h, "mask.adjust", json!({"values": {"exposure": 1.0}})); + assert!(!h.app.ui.mask_overlay, "a mask slider hides the overlay"); + let (photo, held) = hold_fast_stroke(&mut h, 0.3); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "the hidden overlay tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first()); + assert!(h.app.brush_live.is_none(), "nothing is drawn over the photo while painting"); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.3}])); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert!(strokes[0].points.len() >= 3, "{strokes:?}"); + assert!(!h.app.ui.mask_overlay, "painting leaves the overlay hidden"); + // committed: the stroke brightens the photo + h.settle(SETTLE); + let luma = |c: &[[f32; 3]]| c.iter().map(|p| p[0] + p[1] + p[2]).sum::() / c.len() as f32; + let done = stroke_centreline(&h, 0.3); + assert!(luma(&done) > luma(&photo) + 30.0, "committed {} vs before {}", luma(&done), luma(&photo)); + + // O shows the overlay again, but a colour overlay at opacity 0 shows nothing of the committed + // strokes, nor of the one being painted + let r = h.request("ui.key", json!({"key": "o"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.mask_overlay, "O shows the overlay"); + exec(&mut h, "view.maskOverlayColor", json!({"opacity": 0})); + let (photo, held) = hold_fast_stroke(&mut h, 0.45); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "an overlay at opacity 0 tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first()); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.45}])); + + // O hides it again, at its usual opacity + exec(&mut h, "view.maskOverlayColor", json!({"opacity": 50})); + let r = h.request("ui.key", json!({"key": "o"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.ui.mask_overlay, "O hides the overlay"); + let (photo, held) = hold_fast_stroke(&mut h, 0.6); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "the overlay hidden with O tints the stroke being painted: {} of 74 points, first {:?}", t.len(), t.first()); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.6}])); + + // the all-masks overlay shows every mask's region, so the stroke being painted is tinted as it + // will be once committed + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(h.app.ui.mask_overlay_all && !h.app.ui.mask_overlay); + let (photo, held) = hold_fast_stroke(&mut h, 0.75); + let [r0, g0, b0] = h.app.ui.mask_overlay_color.map(|v| v as f32); + let op = h.app.ui.mask_overlay_opacity / 100.0; + for (p, got) in photo.iter().zip(&held) { + let want = [p[0] + (r0 - p[0]) * op, p[1] + (g0 - p[1]) * op, p[2] + (b0 - p[2]) * op]; + assert!((0..3).all(|k| (got[k] - want[k]).abs() <= 12.0), "the all-masks overlay tints the stroke: {got:?} vs {want:?}"); + } + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.75}])); + assert_eq!(develop(&h).masks.len(), 1, "every stroke went into the one mask"); +} + #[test] fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { let mut h = detail("panel.masking"); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..13eccd99f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** patch (LR-MASK-BRUSH: with the mask overlay hidden, the stroke being painted no longer tints the photo) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -296,7 +296,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); with the mask overlay hidden (O / the eye, or after a mask slider hid it) or a colour overlay at opacity 0 it is not tinted while painting either, so dodging and burning is judged on the photo, and its effect shows once the stroke is committed (#2549); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | | LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-22 | patch | LR-MASK-BRUSH: the stroke being painted follows Show Mask Overlay: hidden (or a colour overlay at opacity 0), it no longer tints the photo (#2549); status unchanged (🟡) | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index cffff83e2..121645f81 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,13 @@ # What's new in LightCraft +## 2026-10-22 + +### Dodge and burn with the brush on the photo itself +- With the mask overlay hidden (O, View ▸ Show Mask Overlay or the eye beside Masks, or after a mask slider hid it), + the brush stroke you are painting is no longer drawn in the overlay colour while you hold the button, so the photo + stays visible as you dodge and burn (issue #2549). The stroke's effect still appears when you let go. With the + overlay shown, the stroke is tinted as before; a colour overlay at opacity 0 leaves it untinted too. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges From 66ccce422b8df32d39e5e5b64e1a30d94687dfe6 Mon Sep 17 00:00:00 2001 From: Bruno-Alumn <171815746+Bruno-Alumn@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:22:58 +0200 Subject: [PATCH 2/2] Review: under the all-masks overlay, a stroke into a hidden mask isn't tinted while painting (#2549) The all-masks overlay shows only visible masks, so the live tint now follows the same rule: a stroke into a hidden mask is untinted there, and when no visible mask has anything in it the selected-mask overlay setting decides, as the rendered overlay does. --- crates/ui-egui/src/panels/detail.rs | 6 +++++- crates/ui-egui/src/tests_masking.rs | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 84531132b..7fd50d00f 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2390,7 +2390,11 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons // the photo (issue #2549) use lightcraft_pipeline::MaskView; let colored = matches!(MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), MaskView::Color | MaskView::ColorOnBw); - let shown = (app.ui.mask_overlay || app.ui.mask_overlay_all) && !(colored && app.ui.mask_overlay_opacity <= 0.0); + // the all-masks overlay ([`view_overlay`]) shows only visible masks, and gives way to the + // selected mask's when none has anything in it (a stroke goes into the selected mask, or a new one) + let visible = active.and_then(|id| d.masks.iter().find(|m| m.id == id)).is_none_or(|m| m.visible); + let all = app.ui.mask_overlay_all && (visible || d.masks.iter().any(|m| m.visible && !m.components.is_empty())); + let shown = if all { visible } else { app.ui.mask_overlay } && !(colored && app.ui.mask_overlay_opacity <= 0.0); if shown && let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); // opacity 0 only gets here in the image views, which ignore it: a fixed tint there diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index f6841129f..7c221bef5 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -1880,6 +1880,14 @@ fn a_brush_stroke_is_not_tinted_while_the_mask_overlay_is_hidden() { } pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.75}])); assert_eq!(develop(&h).masks.len(), 1, "every stroke went into the one mask"); + + // a hidden mask isn't in the all-masks overlay: nor is the stroke being painted into it + let id = develop(&h).masks[0].id; + exec(&mut h, "mask.visible", json!({"id": id, "visible": false})); + let (photo, held) = hold_fast_stroke(&mut h, 0.9); + let t = tinted(&photo, &held); + assert!(t.is_empty(), "the all-masks overlay tints a stroke into a hidden mask: {} of 74 points, first {:?}", t.len(), t.first()); + pointer(&mut h, json!([{"kind": "up", "x": 0.85, "y": 0.9}])); } #[test]