From 140d7cbc674ab7b4e8c811e7b5181da4ac95c98a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 21:53:51 -0400 Subject: [PATCH] LR-MASK-LUMRANGE: range bar, Smoothness and Show Luminance Map for luminance-range masks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Masking panel lists each component with its controls: luminance ranges get a dark → light bar with two handles (the nearer one follows the pointer; one undo step per drag), a Smoothness slider (both falloffs) and Show Luminance Map (the photo in black & white with the selected range tinted; turning it off restores the previous overlay); colour ranges get Refine. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/masking.rs | 109 ++++++++++++++++++++++++++- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/tests_masking.rs | 27 +++++++ docs/parity.md | 10 +-- 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 5f6a3d85a..dafa36a9b 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -204,8 +204,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { lightcraft_develop::MaskOp::Intersect => "∩ ", }; ui.label(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" })); - let _ = i; }); + range_controls(app, ui, i, &c.shape); } ui.add_space(6.0); ui.horizontal(|ui| { @@ -330,6 +330,113 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } +/// Controls for a range component: the selected range (two handles over a dark → light bar), +/// Smoothness, and Show Luminance Map; colour ranges get Refine. +fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape: &MaskShape) { + let t = Tokens::get(ui.ctx()); + let update = |app: &mut LightcraftApp, shape: MaskShape| { + let _ = app.run("mask.update", json!({"component": comp, "shape": shape})); + }; + match shape { + MaskShape::LuminanceRange { lo, hi, lo_feather, hi_feather } => { + ui.label(egui::RichText::new("Select Luminance Range").color(t.text_dim).size(11.5)); + let (lo, hi) = (*lo, *hi); + let w = ui.available_width().min(240.0); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag()); + register(ui.ctx(), format!("lumRange:{comp}"), rect); + let p = ui.painter(); + // dark → light ramp, the selected span outlined + let n = 24; + for k in 0..n { + let a = k as f32 / n as f32; + let r = Rect::from_min_max( + pos2(rect.left() + a * w, rect.top() + 4.0), + pos2(rect.left() + (a + 1.0 / n as f32) * w + 0.5, rect.bottom() - 4.0), + ); + let g = (a * 255.0) as u8; + p.rect_filled(r, 0.0, egui::Color32::from_gray(g)); + } + let x = |v: f64| rect.left() + v.clamp(0.0, 1.0) as f32 * w; + p.rect_stroke( + Rect::from_min_max(pos2(x(lo), rect.top() + 2.0), pos2(x(hi), rect.bottom() - 2.0)), + 2.0, + Stroke::new(1.5, t.accent), + egui::StrokeKind::Middle, + ); + for v in [lo, hi] { + p.circle_filled(pos2(x(v), rect.center().y), 5.0, egui::Color32::WHITE); + p.circle_stroke(pos2(x(v), rect.center().y), 5.0, Stroke::new(1.0, egui::Color32::from_gray(40))); + } + // drag the nearer handle; one undo step per drag + if resp.drag_started() { + let _ = app.run("develop.beginInteraction", json!({"label": "Luminance Range"})); + } + if (resp.dragged() || resp.clicked()) + && let Some(pos) = resp.interact_pointer_pos() + { + let v = (((pos.x - rect.left()) / w) as f64).clamp(0.0, 1.0); + let (nlo, nhi) = if (v - lo).abs() <= (v - hi).abs() { (v.min(hi - 0.01), hi) } else { (lo, v.max(lo + 0.01)) }; + update(app, MaskShape::LuminanceRange { lo: nlo, hi: nhi, lo_feather: *lo_feather, hi_feather: *hi_feather }); + } + if resp.drag_stopped() { + let _ = app.run("develop.endInteraction", json!({})); + } + // Smoothness: both falloffs at once + let smooth = ControlSpec { + id: "smoothness", + label: "Smoothness", + section: Section::Light, + min: 0.0, + max: 100.0, + default: 20.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let cur = ((lo_feather + hi_feather) / 2.0 / 0.5 * 100.0).clamp(0.0, 100.0); + let out = slider(ui, &smooth, cur, true, None); + apply_slider_out(app, &smooth, out, |app, v| { + let f = v / 100.0 * 0.5; + app.run("mask.update", json!({"component": comp, "shape": MaskShape::LuminanceRange { lo, hi, lo_feather: f, hi_feather: f }})) + }); + let mut map = app.ui.mask_overlay && app.ui.mask_overlay_mode == "colorOnBw"; + let r = ui.checkbox(&mut map, "Show Luminance Map"); + register(ui.ctx(), format!("check:lumMap{comp}"), r.rect); + if r.changed() { + // the luminance map: the photo in black & white with the selected range tinted + if map { + app.ui.luminance_map_restore = Some((app.ui.mask_overlay, app.ui.mask_overlay_mode.clone())); + app.ui.mask_overlay = true; + app.ui.mask_overlay_mode = "colorOnBw".into(); + } else { + let (on, mode) = app.ui.luminance_map_restore.take().unwrap_or((false, "color".into())); + app.ui.mask_overlay = on; + app.ui.mask_overlay_mode = if mode == "colorOnBw" { "color".into() } else { mode }; + } + } + } + MaskShape::ColorRange { samples, refine } => { + let spec = ControlSpec { + id: "refine", + label: "Refine", + section: Section::Color, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = slider(ui, &spec, *refine, true, None); + let samples = samples.clone(); + apply_slider_out(app, &spec, out, |app, v| { + app.run("mask.update", json!({"component": comp, "shape": MaskShape::ColorRange { samples: samples.clone(), refine: v }})) + }); + } + _ => {} + } +} + fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { for (kind, label) in [ ("brush", "Brush"), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d4c2747bf..0443cab39 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -201,6 +201,9 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, + /// The overlay (on, mode) to go back to when Show Luminance Map is turned off. + #[serde(skip)] + pub luminance_map_restore: Option<(bool, String)>, /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, @@ -401,6 +404,7 @@ impl Default for UiState { fn default() -> Self { UiState { preview_build_seen: None, + luminance_map_restore: None, view: ViewMode::Detail, left_panel: false, right: RightPanel::Edit, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index d3d5b7720..2ed8c5416 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -299,3 +299,30 @@ fn g_toggles_grids_and_shift_g_starts_guided_upright() { assert_eq!(develop(&h).geometry.upright, lightcraft_develop::Upright::Guided); h.settle(SETTLE); } + +#[test] +fn luminance_range_controls_and_map() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "luminanceRange", "lo": 0.6, "hi": 1.0})); + h.settle(SETTLE); + let lum = |h: &Headless| match &develop(h).masks[0].components[0].shape { + MaskShape::LuminanceRange { lo, hi, lo_feather, .. } => (*lo, *hi, *lo_feather), + s => panic!("{s:?}"), + }; + let undo0 = h.app.session.undo.len(); + // drag the high handle from the right end to 80 % + let r = h.request("ui.dragWidget", json!({"id": "lumRange:0", "fx": 1.0, "fy": 0.5, "dx": -48.0}), T); + assert_eq!(r["ok"], true, "{r}"); + let (lo, hi, _) = lum(&h); + assert_eq!(lo, 0.6, "the nearer handle moves"); + assert!(hi < 0.9 && hi > 0.6, "{hi}"); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step per drag"); + // Show Luminance Map: black-and-white overlay, and back + let r = h.request("ui.clickWidget", json!({"id": "check:lumMap0"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.mask_overlay && h.app.ui.mask_overlay_mode == "colorOnBw"); + let r = h.request("ui.clickWidget", json!({"id": "check:lumMap0"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_ne!(h.app.ui.mask_overlay_mode, "colorOnBw"); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index 5c8772e88..18ea3d812 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 6 | 1 | 3 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 11 | 5 | 7 | 0 | 8/8 (100%) | 3/5 (60%) | +| K. Masking (MASK) | 12 | 4 | 7 | 0 | 8/8 (100%) | 4/5 (80%) | | L. Presets (PRE) | 5 | 1 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 4 | 0 | 1 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 4 | 1 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -48,9 +48,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 69 | 7 | 4 | 1 | 49/52 (94%) | 18/23 (78%) | | Lightroom Classic extras | 18 | 12 | 49 | 9 | — | 17/21 (81%) | -| **Total** | 334 | 42 | 124 | 37 | 193/198 (97%) | 121/144 (84%) | +| **Total** | 335 | 41 | 124 | 37 | 193/198 (97%) | 122/144 (85%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **71.0%** of 500 in-scope rows — P0 98.7% of 198 · P1 90.6% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **71.1%** of 500 in-scope rows — P0 98.7% of 198 · P1 91.0% of 144 · P2 18.4% of 158. ## Top gaps @@ -318,8 +318,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-MASK-BRUSH | Brush mask | P0 | ✅ | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update` | | -| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`) | renders; sampling UX unverified | -| LR-MASK-LUMRANGE | Luminance range | P1 | 🟡 | `cmd:mask.add` (`luminanceRange`) | no luminance-map display | +| LR-MASK-COLORRANGE | Colour range | P1 | 🟡 | `cmd:mask.add` (`colorRange`), `cmd:mask.update` | renders; Refine slider in the Masking panel; sampling UX (click / drag on the photo) unverified | +| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | | LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent` | | | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | |