LR-MASK-OVERLAY: test the mask overlay as a zoomed window

There was no zoom/pan reset on main to fix: what this PR changes is that a
zoomed view with the mask overlay shown is now a canvas-sized frame plus a
sharp window carrying the overlay, as without it, instead of a whole-frame
render at the zoomed size. Tests: the overlay on a window matches the same
pixels of the whole render for radial, refined linear and sky masks in
three views (pipeline tests_window.rs), and at 1:1 the loupe uses a window
with the overlay, keeps zoom and pan, and swaps tiles when the overlay is
hidden or shown (ui-egui tests_preview_limit.rs). Parity row updated. No
render output change (no cache bump).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-10 04:04:41 -04:00
1 parent 760819eab3
commit 0b1d80f08d
3 files changed
+73 -1

No files matched your search

+34
View File
@@ -204,6 +204,40 @@ fn mask_edge_refinement_does_not_depend_on_the_window() {
check_noisy("refine", &s, MID, 3, 0.2);
}
// Given a mask overlay (the Masking panel's view of the selected mask), a window of it matches the
// same pixels of the whole render with that overlay, for every view and for drawn, AI and
// refined shapes: the zoomed loupe can show the overlay as a window (issue #547)
#[test]
fn a_mask_overlay_window_matches_the_whole_render() {
use crate::{MaskView, Overlay};
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
use lightcraft_geom::Point;
let shapes = [
("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0),
("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0),
("sky", MaskShape::Sky, 0.0),
];
for (name, shape, refine) in shapes {
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 3,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust: LocalAdjustments { exposure: 1.0, ..Default::default() },
refine,
..Default::default()
});
for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] {
let overlay = Overlay::Mask { id: 3, view, color: [255, 0, 0], opacity: 70 };
let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image;
let w =
render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image;
let (max, mean) = compare(&f, &w, MID);
// (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`)
assert!(max <= 3 && mean <= 0.25, "{name} {view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)");
}
}
}
// ---- Deep zoom: the wide stages' kernels are far wider than any margin -------------------------
/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware
+38
View File
@@ -189,6 +189,44 @@ mod in_the_loupe {
assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}");
}
// Given a mask selected with its overlay shown at 1:1 (issue #547), the zoomed view keeps its
// zoom and pan and stays a canvas-sized frame plus a native window carrying the overlay, as
// without it, instead of a whole-frame render at the zoomed size; hiding or showing the
// overlay swaps the window for one with the other look
#[test]
fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() {
let (mut h, native) = detail();
for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] {
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{command}: {r}");
}
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
h.app.ui.pan = (0.3, 0.4);
h.app.ui.mask_overlay = false;
h.settle(SETTLE);
let (zoom, pan) = (h.app.ui.zoom, h.app.ui.pan);
assert_eq!(zoom, crate::state::Zoom::Percent(100.0));
let plain = h.app.region_view.expect("a window render without the overlay");
h.app.ui.mask_overlay = true;
h.settle(SETTLE);
let id = h.app.session.active().expect("a photo");
let d = h.app.session.develop_of(id).expect("settings");
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown");
assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan");
let main = rendered_long_edge(&h);
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
let shown = h.app.region_view.expect("a window render with the overlay");
assert_eq!(shown.full.0.max(shown.full.1), native, "cut from the photo's own pixels");
assert_eq!(shown.window, plain.window, "the same part of the photo");
assert_ne!(shown.settings, plain.settings, "the overlay is part of the window's look");
h.app.ui.mask_overlay = false;
h.settle(SETTLE);
assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "hiding it keeps them too");
assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window");
}
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
#[test]
fn an_explicit_limit_still_caps_one_to_one() {
+1 -1
View File
@@ -365,7 +365,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | |
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) |
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; no auto-show on hover |
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover |
| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |
| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |