LR-MASK: fix range handle drags and color sampling settings

This commit is contained in:
nevicia2010-hub
2026-10-13 14:29:16 +02:00
parent b3d392b499
commit f86459db58
5 changed files with 101 additions and 7 deletions
+6 -2
View File
@@ -618,7 +618,11 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende
/// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares
/// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image.
pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> {
let plan = plan(src, info, s, req);
// Pick from the same settings as the render: disabled panels contribute nothing, and an
// SDR preview of an HDR edit includes the SDR rendition's exposure offset.
let effective = s.effective();
let rendition = settings_for(&effective, req);
let plan = plan(src, info, &rendition, req);
let mut img = plan.frame.sample(src, plan.w, plan.h);
lin_cpu(&mut img, info, &plan);
let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p);
@@ -626,7 +630,7 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings,
if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 {
return None;
}
let gain = (s.light.exposure as f32).exp2();
let gain = (plan.settings.light.exposure as f32).exp2();
let mut acc = [0f64; 3];
let mut n = 0.0;
for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) {
+55
View File
@@ -272,6 +272,61 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() {
assert!(d <= 2, "max difference {d}");
}
#[test]
fn color_range_sampling_respects_disabled_sections() {
use crate::{MaskView, Overlay, color_range_sample};
let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]);
let info = SourceInfo::default();
let req = RenderRequest::fit(32, 24);
let point = lightcraft_geom::Point::new(0.5, 0.5);
let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &req, point).unwrap();
for section in ["light", "color"] {
let mut s = DevelopSettings::default();
if section == "light" {
s.light.exposure = 3.0;
} else {
s.wb.mode = lightcraft_develop::WbMode::Custom;
s.wb.temp = 12000.0;
s.wb.tint = 100.0;
}
assert_ne!(color_range_sample(&src, &info, &s, &req, point).unwrap(), plain, "enabled {section}");
s.set_section_enabled(section, false);
let sample = color_range_sample(&src, &info, &s, &req, point).unwrap();
assert_eq!(sample, plain, "disabled {section} must not change the picked colour");
s.masks.push(lightcraft_develop::Mask {
id: 1,
components: vec![lightcraft_develop::MaskComponent {
name: None,
op: lightcraft_develop::MaskOp::Add,
invert: false,
shape: lightcraft_develop::MaskShape::ColorRange { samples: vec![sample], refine: 0.0 },
}],
..Default::default()
});
let overlay = RenderRequest { overlay: Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }, ..req };
let img = render(&src, &info, &s, &overlay).image;
assert_eq!(img.data[12 * 32 + 16], [255; 4], "the sampled colour is selected with {section} off");
}
}
#[test]
fn color_range_sampling_uses_the_requested_hdr_rendition() {
use crate::{OutputDepth, color_range_sample};
let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]);
let info = SourceInfo::default();
let point = lightcraft_geom::Point::new(0.5, 0.5);
let mut s = DevelopSettings::default();
s.hdr.enabled = true;
s.hdr.sdr_brightness = 100.0;
let req = RenderRequest::fit(32, 24);
let expected = color_range_sample(&src, &info, &s.sdr_rendition(), &req, point).unwrap();
assert_eq!(color_range_sample(&src, &info, &s, &req, point).unwrap(), expected, "SDR preview brightness affects the picked colour");
let hdr = RenderRequest { depth: OutputDepth::F32Hdr, ..req };
let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &hdr, point).unwrap();
assert_eq!(color_range_sample(&src, &info, &s, &hdr, point).unwrap(), plain, "HDR does not use the SDR offset");
assert_ne!(plain, expected);
}
/// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color
/// and Detail, which the pipeline doesn't switch off where it applies them.
#[test]
+11 -2
View File
@@ -537,18 +537,27 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape
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
// Choose at the press position, then keep that handle for the entire drag. Testing
// proximity on every frame can move the opposite endpoint after a fast/crossing drag.
let handle_id = resp.id.with("luminance-handle");
let nearer_low = |px: f32| (px - x(lo)).abs() <= (px - x(hi)).abs();
if resp.drag_started() {
if let Some(origin) = ui.input(|i| i.pointer.press_origin()) {
ui.data_mut(|d| d.insert_temp(handle_id, nearer_low(origin.x)));
}
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)) };
let low =
if resp.dragged() { ui.data(|d| d.get_temp::<bool>(handle_id)).unwrap_or_else(|| nearer_low(pos.x)) } else { nearer_low(pos.x) };
let (nlo, nhi) = if low { (v.min((hi - 0.01).max(0.0)), hi) } else { (lo, v.max((lo + 0.01).min(1.0))) };
update(app, MaskShape::LuminanceRange { lo: nlo, hi: nhi, lo_feather: *lo_feather, hi_feather: *hi_feather });
}
if resp.drag_stopped() {
ui.data_mut(|d| d.remove::<bool>(handle_id));
let _ = app.run("develop.endInteraction", json!({}));
}
// Smoothness: both falloffs at once
+25
View File
@@ -832,6 +832,31 @@ fn g_toggles_grids_and_shift_g_starts_guided_upright() {
h.settle(SETTLE);
}
#[test]
fn luminance_range_drag_keeps_the_grabbed_handle() {
let mut h = detail("panel.masking");
exec(&mut h, "mask.add", json!({"kind": "luminanceRange", "lo": 0.2, "hi": 0.8}));
h.settle(SETTLE);
let original = develop(&h);
for (start, dx, steps) in [(0.2, 180.0, 10), (0.8, -180.0, 10), (0.2, 120.0, 1), (0.8, -120.0, 1)] {
let undo0 = h.app.session.undo.len();
let r = h.request("ui.dragWidget", json!({"id": "lumRange:0", "fx": start, "fy": 0.5, "dx": dx, "steps": steps}), T);
assert_eq!(r["ok"], true, "{r}");
let d = develop(&h);
let MaskShape::LuminanceRange { lo, hi, .. } = &d.masks[0].components[0].shape else { panic!() };
if start < 0.5 {
assert_eq!(*hi, 0.8, "dragging low must leave high fixed, including past high");
assert!(*lo > 0.6 && *lo < *hi, "{lo}..{hi}");
} else {
assert_eq!(*lo, 0.2, "dragging high must leave low fixed, including past low");
assert!(*hi < 0.4 && *hi > *lo, "{lo}..{hi}");
}
assert_eq!(h.app.session.undo.len(), undo0 + 1);
exec(&mut h, "edit.undo", json!({}));
assert_eq!(develop(&h), original);
}
}
#[test]
fn luminance_range_controls_and_map() {
let mut h = detail("panel.masking");
+4 -3
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (mask range regressions: luminance drag handle identity and colour sampling with disabled sections / HDR SDR rendition; LR-MERGE-HDR: merges of raws without colour matrices keep their colour (#965); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **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
@@ -295,8 +295,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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); 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`, `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), Pick button, Refine slider |
| 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-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 |
| 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 (the pressed endpoint stays selected through fast/crossing drags; 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`, `cmd:tool.brush` | Add/Subtract > Brush creates a component with the chosen operation; Erase removes paint within that brush component |
| LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | |
@@ -789,6 +789,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | patch | LR-MASK-LUMRANGE / LR-MASK-COLORRANGE: range drags keep the grabbed endpoint; colour samples use the render's enabled sections and HDR/SDR settings; regression coverage, status unchanged |
| 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged |
| 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) |
| 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) |