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