From 0b1d80f08d8d53baf9e9812d4a6f5c6dc8f5e4c1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 04:04:41 -0400 Subject: [PATCH] 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 --- crates/pipeline/src/tests_window.rs | 34 ++++++++++++++++++++ crates/ui-egui/src/tests_preview_limit.rs | 38 +++++++++++++++++++++++ docs/parity.md | 2 +- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 0ed5e81e1..1d5b15c65 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -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 diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index f48a2e1c3..ae57c5700 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -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() { diff --git a/docs/parity.md b/docs/parity.md index ad82e3d25..4ae97c103 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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`) | |