mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
3b4308040f
commit
140d7cbc67
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
Reference in New Issue
Block a user