diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 45a5a5f6d..1ace48078 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -688,6 +688,16 @@ pub fn render( _ => Err(m), } }); + // every visible mask's alpha, for `Overlay::AllMasks` (one tint over all masks at once) + let all_overlay_alphas: Option> = if matches!(req.overlay, lightcraft_pipeline::Overlay::AllMasks { .. }) { + let list: Vec<_> = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()).collect(); + masks.as_ref().map(|all| { + list.iter().enumerate().map(|(mi, _)| { + let b = cx.slice(all, mi * n, n); + cx.read_plane(&b, w, h) + }).collect() + }) + } else { None }; let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); @@ -723,7 +733,7 @@ pub fn render( lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32, plan.mattes.as_deref()) } }); - lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref(), all_overlay_alphas.as_deref()); // spots grew the window the render worked on: cut it back to the request if let Some(k) = plan.keep { image = image.crop(k.x, k.y, k.w, k.h); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index f7c33ee9e..82d05800b 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -551,7 +551,8 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("histogram", &mut t); let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); - visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); + let masks = overlay_alphas_all(req.overlay, &plan, &prep); + visualize::apply(&mut image, req.overlay, &plan, mask.as_ref(), masks.as_deref()); let mut image = if plan.keep.is_some() { cut(&image) } else { image }; if req.overlay == Overlay::HdrRange && edit.hdr.enabled { // the HDR rendition of the same request says how far above SDR white each pixel goes @@ -603,6 +604,24 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())) } +/// The alpha planes an `Overlay::AllMasks` shows: one per visible mask that has components, +/// reusing the render's own evaluation where available (a fresh evaluation otherwise). +fn overlay_alphas_all(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option> { + if !matches!(o, Overlay::AllMasks { .. }) { + return None; + } + let ev = plan.settings.light.exposure as f32; + let mut out = Vec::new(); + for m in plan.settings.masks.iter().filter(|m| m.visible && !m.components.is_empty()) { + if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { + out.push(e.alpha.clone()); + } else { + out.push(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev, plan.mattes.as_deref())); + } + } + Some(out) +} + /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" /// (default look, keeping the crop so framing matches, under the photo's own rendering process). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index fc3d8390f..dd87ce332 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -33,6 +33,9 @@ pub enum Overlay { /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in /// `color` at `opacity` (0..100; the colour views only). Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, + /// The evaluated alpha of every visible mask that has components, drawn as one `view` in + /// `color` at `opacity` (0..100; the colour views only) — every mask's region at once. + AllMasks { view: MaskView, color: [u8; 3], opacity: u8 }, /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). HdrRange, @@ -120,6 +123,7 @@ impl Overlay { Overlay::PointColorRange(i) => (1, i as f64), Overlay::Spots(t) => (2, t as f64), Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), + Overlay::AllMasks { view, color, opacity } => (5, pack_mask(0, view, color, opacity) as f64), Overlay::HdrRange => (4, 0.0), } } @@ -131,6 +135,14 @@ impl Overlay { 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), 4 => Overlay::HdrRange, + 5 if v.is_finite() && v >= 0.0 => { + let rgb = (v as u64 >> 20) & 0xff_ffff; + Overlay::AllMasks { + view: MaskView::ALL.get(((v as u64 >> 16) & 0xf) as usize).copied().unwrap_or_default(), + color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8], + opacity: ((v as u64 >> 44) & 0x7f).min(100) as u8, + } + } _ => Overlay::None, } } @@ -142,6 +154,7 @@ impl Overlay { Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + Overlay::AllMasks { view, color, opacity } => 5 << 60 | pack_mask(0, view, color, opacity), Overlay::HdrRange => 0x4000, } } @@ -168,8 +181,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } /// Draw overlay `o` over `img` (rendered with `plan`). `mask` is the evaluated alpha of the mask -/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { +/// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]); `masks` is the set of +/// alphas an [`Overlay::AllMasks`] shows, one per visible mask with components. +pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>, masks: Option<&[Plane]>) { match o { // drawn by the renderer from the HDR rendition ([`hdr_range`]) Overlay::None | Overlay::HdrRange => {} @@ -184,6 +198,15 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) mask_view(img, a, view, color, opacity); } } + Overlay::AllMasks { view, color, opacity } => { + if let Some(planes) = masks { + for a in planes { + if (a.width, a.height) == (img.width, img.height) { + mask_view(img, a, view, color, opacity); + } + } + } + } } } @@ -380,4 +403,33 @@ mod tests { assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); } + + #[test] + fn all_masks_overlay_shows_every_visible_mask_at_once() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::from_fn(80, 40, |_, _| [0.1, 0.1, 0.1]); + let info = SourceInfo::default(); + let left = MaskShape::Radial { center: Point::new(0.25, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let right = MaskShape::Radial { center: Point::new(0.75, 0.5), rx: 0.15, ry: 0.15, angle: 0.0, feather: 10.0, invert: false }; + let mut s = DevelopSettings::default(); + s.masks.push(Mask { id: 1, visible: true, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: left }], ..Default::default() }); + s.masks.push(Mask { id: 2, visible: true, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: right }], ..Default::default() }); + let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; + let o = Overlay::AllMasks { view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }; + let img = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + let (in_l, in_r, mid) = (20 * 80 + 12, 20 * 80 + 60, 20 * 80 + 40); + assert_eq!(img.data[in_l], [255; 4], "left mask region painted"); + assert_eq!(img.data[in_r], [255; 4], "right mask region painted"); + assert_eq!(img.data[mid], [0, 0, 0, 255], "between the masks: black"); + // a hidden mask is skipped + s.masks[1].visible = false; + let img2 = render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image; + assert_eq!(img2.data[in_r], plain.data[in_r], "hidden right mask not painted"); + assert_eq!(img2.data[in_l], [255; 4], "visible left mask still painted"); + // parts round trip and a stable, distinct cache key + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }.key()); + } } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e6..89f59d1ea 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -149,6 +149,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.visualizeHdr", "view.softProof", "view.maskOverlay", + "view.maskOverlayAll", "view.maskOverlayMode", "view.maskOverlayColor", "view.maskPins", @@ -312,6 +313,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.visualizeHdr" => Some(u.hdr_visualize), "view.softProof" => Some(u.soft_proof), "view.maskOverlay" => Some(u.mask_overlay), + "view.maskOverlayAll" => Some(u.mask_overlay_all), "view.maskPins" => Some(u.mask_pins), "view.showOriginal" => Some(u.before_after == crate::state::BeforeAfter::Original), "view.beforeAfter" => Some(u.before_after == crate::state::BeforeAfter::SideBySide), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 81a46eb98..5ae64e1a5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -91,6 +91,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.softProof", "Soft Proofing", Some("S"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Ctrl+O / Cmd+O: every mask's region at once, not just the selected one + ("view.maskOverlayAll", "Show All Mask Overlays", Some("Cmd+O"), "View"), // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), @@ -634,6 +636,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.mask_overlay_all = !app.ui.mask_overlay_all; + Ok(json!({"maskOverlayAll": app.ui.mask_overlay_all})) + } "view.maskOverlayMode" => { use lightcraft_engine::pipeline::MaskView; let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..24976d94e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1065,6 +1065,18 @@ pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcra if app.ui.fullscreen { return Overlay::None; } + // Show every visible mask's region at once (Ctrl+O / Cmd+O), ahead of the selected-mask overlay + // so the shortcut is not shadowed by the always-on single-mask overlay. + if app.ui.right == RightPanel::Masking + && app.ui.mask_overlay_all + && d.masks.iter().any(|m| m.visible && !m.components.is_empty()) + { + return Overlay::AllMasks { + view: MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(), + color: app.ui.mask_overlay_color, + opacity: app.ui.mask_overlay_opacity.clamp(0.0, 100.0).round() as u8, + }; + } if app.ui.right == RightPanel::Masking && app.ui.mask_overlay && let Some(m) = app.session.active_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)) diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 640bc4f11..47b96ee6d 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -197,6 +197,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() { app.ui.mask_overlay = !on; } + let all = app.ui.mask_overlay_all; + if icon_button(ui, "maskOverlayAll", Icon::Stack, vec2(24.0, 24.0), all, true, "Show all overlays (Ctrl+O)").clicked() { + app.ui.mask_overlay_all = !all; + } }); }); if d.masks.is_empty() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6a50e2baf..b52f7ec64 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -332,6 +332,9 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, + /// Masking: show every visible mask's region as one overlay at once (Ctrl+O / Cmd+O), + /// instead of just the selected mask's ([`mask_overlay`]). + pub mask_overlay_all: 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)>, @@ -813,6 +816,7 @@ impl Default for UiState { mask_overlay_color: [230, 30, 40], mask_overlay_opacity: 50.0, mask_pins: true, + mask_overlay_all: false, crop_overlay: CropOverlay::Thirds, crop_overlay_orient: 0, show_filenames: true, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index b95fa1ab2..db009e816 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -168,6 +168,15 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { assert_eq!(develop(&h).masks[0], mask, "toggling the overlay leaves the mask and its effect unchanged"); assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::Mask { .. })); + // Ctrl+O shows every visible mask's region at once (the new command), ahead of the selected + // mask overlay so it is not shadowed by the always-on single-mask overlay. + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(h.app.ui.mask_overlay_all, "Ctrl+O turns on the all-masks overlay"); + assert!(matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + exec(&mut h, "view.maskOverlayAll", json!({})); + assert!(!h.app.ui.mask_overlay_all, "Ctrl+O toggles the all-masks overlay off"); + assert!(!matches!(crate::panels::detail::view_overlay(&h.app, &develop(&h)), Overlay::AllMasks { .. })); + // Failed edits and global adjustments must not hide the mask overlay. assert!(h.app.run("mask.adjust", json!({"id": 999, "values": {"exposure": 1.0}})).is_err()); assert!(h.app.ui.mask_overlay);