LR-MASK-LUMRANGE: range bar, Smoothness and Show Luminance Map for luminance-range masks

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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 21:53:51 -04:00
co-authored by Claude Opus 5.5
parent 3b4308040f
commit 140d7cbc67
4 changed files with 144 additions and 6 deletions
+108 -1
View File
@@ -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"),
+4
View File
@@ -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,
+27
View File
@@ -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);
}
+5 -5
View File
@@ -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.
<!-- /parity:summary -->
## 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` | |