From ca8db6e028bb2a1854cc0b6186e1e7feb2f8eca3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:58:27 -0400 Subject: [PATCH] LR-MASK-OVERLAY + LR-MASK-PINS: rendered mask overlay (5 views, colour/opacity, O / Shift+O) and draggable pins per component The selected mask's evaluated alpha is drawn by the renderer as Overlay::Mask (CPU and GPU share the alpha the render used; hidden masks are evaluated on demand), kept out of the view-preview cache. Pins for radial/linear/brush components select their mask on click and move the component on drag; view.maskPins hides them. CPU/GPU equivalence and headless keyboard/pointer tests. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/wire.rs | 2 +- crates/gpu/src/render.rs | 27 +++- crates/gpu/tests/equivalence.rs | 37 ++++++ crates/pipeline/src/lib.rs | 16 ++- crates/pipeline/src/masks.rs | 69 +++++----- crates/pipeline/src/visualize.rs | 172 ++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menubar.rs | 3 + crates/ui-egui/src/menus.rs | 58 ++++++++- crates/ui-egui/src/panels/detail.rs | 184 ++++++++++++++++----------- crates/ui-egui/src/panels/masking.rs | 60 +++++++++ crates/ui-egui/src/shortcuts.rs | 5 + crates/ui-egui/src/state.rs | 13 ++ crates/ui-egui/src/tests_masking.rs | 91 +++++++++++++ docs/parity.md | 10 +- 15 files changed, 627 insertions(+), 123 deletions(-) create mode 100644 crates/ui-egui/src/tests_masking.rs diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 6dbe81b2..835721c6 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -47,7 +47,7 @@ pub struct WireJob { pub stages: Option, /// The request's diagnostic overlay (`Overlay::to_parts`). #[serde(default)] - pub overlay: (u8, f32), + pub overlay: (u8, f64), } impl WireJob { diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0174da93..59a5370a 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -135,6 +135,14 @@ impl<'a> Cx<'a> { out } + /// `len` values of `b` from `offset` (both in 32-bit words), as a new buffer (recorded). + pub fn slice(&mut self, b: &Buf, offset: usize, len: usize) -> Buf { + let out = self.gpu.buffer(len); + let enc = self.enc.get_or_insert_with(|| self.gpu.encoder()); + enc.copy_buffer_to_buffer(b.raw(), (offset * 4) as u64, out.raw(), 0, (len * 4) as u64); + out + } + pub fn read_rgb(&mut self, b: &Buf, w: usize, h: usize) -> Rgb32f { Rgb32f { width: w, height: h, data: self.read(b, w * h * 3) } } @@ -481,13 +489,30 @@ pub fn render( ], groups2(w, h, [16, 16]), ); + // the alpha a mask overlay shows: copied out before the readback below submits + let overlay_mask = req.overlay.mask(s).map(|m| { + let list = s.masks.iter().filter(|m| m.visible && !m.components.is_empty()); + match (list.map(|m| m.id).position(|id| id == m.id), masks.as_ref()) { + (Some(mi), Some(all)) => Ok(cx.slice(all, mi * n, n)), + _ => Err(m), + } + }); let data: Vec<[u8; 4]> = cx.read(&out, n); let image = Rgba8 { width: w, height: h, data }; lap("finish + readback", &mut t, &mut cx); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t, &mut cx); let mut image = image; - lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); + let overlay_mask = overlay_mask.map(|r| match r { + Ok(b) => cx.read_plane(&b, w, h), + // a hidden mask: evaluate it on the CPU + Err(m) => { + let img = host.lin.get_or_insert_with(|| Arc::new(cx.read_rgb(&lin, w, h))).clone(); + let l = host.log_l.get_or_insert_with(|| Arc::new(cx.read_plane(&prep.log_l, w, h))).clone(); + lightcraft_pipeline::masks::evaluate_one(m, &plan.frame, w, h, &img, &l, s.light.exposure as f32) + } + }); + lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan, overlay_mask.as_ref()); Some(Rendered { image, histogram }) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index d6fdab54..0a15922c 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -414,6 +414,43 @@ fn overlays_match() { eprintln!("visualize spots t={t:<3} white {:.3}% differing {:.4}%", white * 100.0, differ * 100.0); assert!(differ < 0.002, "spots t={t}: {differ}"); } + // Mask overlays: the alpha the GPU evaluated (or, for a hidden mask, the CPU's) drawn the same way. + let mut s = DevelopSettings::default(); + let stroke = BrushStroke { points: vec![Point::new(0.2, 0.3), Point::new(0.6, 0.5)], size: 0.05, ..Default::default() }; + s.masks = vec![ + Mask { + id: 1, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { center: Point::new(0.4, 0.6), rx: 0.25, ry: 0.15, angle: 20.0, feather: 60.0, invert: false }, + }], + ..Default::default() + }, + Mask { + id: 2, + components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } }], + ..Default::default() + }, + Mask { + id: 3, + visible: false, + components: vec![MaskComponent { + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.5, 0.0), end: Point::new(0.5, 0.6) }, + }], + ..Default::default() + }, + ]; + s.masks[0].adjust.exposure = 0.5; + use lightcraft_pipeline::{MaskView, Overlay}; + for (id, view) in + [(1, MaskView::Color), (2, MaskView::ColorOnBw), (2, MaskView::WhiteOnBlack), (3, MaskView::ImageOnWhite), (1, MaskView::ImageOnBlack)] + { + let req = RenderRequest { overlay: Overlay::Mask { id, view, color: [230, 30, 40], opacity: 50 }, ..RenderRequest::fit(640, 640) }; + check(&format!("mask {id} overlay {}", view.name()), &src, &raw, &s, &req); + } } #[test] diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 548febbd..ad6b56e7 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -37,7 +37,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use visualize::Overlay; +pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; use std::any::Any; @@ -349,10 +349,22 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); let mut image = image; - visualize::apply(&mut image, req.overlay, &plan); + let mask = overlay_alpha(req.overlay, &plan, &prep); + visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); Rendered { image, histogram } } +/// The alpha plane a mask overlay shows: the one the render evaluated, or (for a hidden mask) a +/// fresh evaluation. +fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option { + let m = o.mask(&plan.settings)?; + if let Some(e) = prep.masks.iter().find(|e| e.id == m.id) { + return Some(e.alpha.clone()); + } + let ev = plan.settings.light.exposure as f32; + Some(masks::evaluate_one(m, &plan.frame, plan.w, plan.h, &prep.img, &prep.log_l, ev)) +} + /// 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). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 8f1796a7..33af94d3 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -11,6 +11,8 @@ use crate::for_rows; use crate::geometry::Frame; pub struct Evaluated { + /// The mask's id ([`Mask::id`]). + pub id: u32, pub alpha: Plane, pub adjust: LocalAdjustments, } @@ -21,44 +23,49 @@ fn smooth(e0: f32, e1: f32, x: f32) -> f32 { t * t * (3.0 - 2.0 * t) } +/// The visible masks with components, evaluated in order. pub fn evaluate(masks: &[Mask], frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Vec { masks .iter() .filter(|m| m.visible && !m.components.is_empty()) - .map(|m| { - let mut alpha = Plane::new(w, h); - let mut first = true; - for comp in &m.components { - let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); - if comp.invert { - c.data.iter_mut().for_each(|v| *v = 1.0 - *v); - } - if first && comp.op != MaskOp::Intersect { - alpha = c; - first = false; - continue; - } - for (a, c) in alpha.data.iter_mut().zip(&c.data) { - *a = match comp.op { - MaskOp::Add => a.max(*c), - MaskOp::Subtract => *a * (1.0 - c), - MaskOp::Intersect => *a * c, - }; - } - first = false; - } - if m.invert { - alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); - } - let amt = (m.adjust.amount / 100.0) as f32; - if (amt - 1.0).abs() > 1e-6 { - alpha.data.iter_mut().for_each(|v| *v *= amt); - } - Evaluated { alpha, adjust: m.adjust } - }) + .map(|m| Evaluated { id: m.id, alpha: evaluate_one(m, frame, w, h, img, log_l, ev), adjust: m.adjust }) .collect() } +/// The alpha plane of mask `m` (whether visible or not): its components combined, inverted and +/// scaled by its amount. +pub fn evaluate_one(m: &Mask, frame: &Frame, w: usize, h: usize, img: &Rgb32f, log_l: &Plane, ev: f32) -> Plane { + let mut alpha = Plane::new(w, h); + let mut first = true; + for comp in &m.components { + let mut c = shape_alpha(&comp.shape, frame, w, h, img, log_l, ev); + if comp.invert { + c.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + if first && comp.op != MaskOp::Intersect { + alpha = c; + first = false; + continue; + } + for (a, c) in alpha.data.iter_mut().zip(&c.data) { + *a = match comp.op { + MaskOp::Add => a.max(*c), + MaskOp::Subtract => *a * (1.0 - c), + MaskOp::Intersect => *a * c, + }; + } + first = false; + } + if m.invert { + alpha.data.iter_mut().for_each(|v| *v = 1.0 - *v); + } + let amt = (m.adjust.amount / 100.0) as f32; + if (amt - 1.0).abs() > 1e-6 { + alpha.data.iter_mut().for_each(|v| *v *= amt); + } + alpha +} + /// Per-pixel positions in long-edge units for output pixel centres. fn for_each_pos(frame: &Frame, w: usize, h: usize, out: &mut Plane, f: impl Fn(Point, usize) -> f32 + Sync + Send) { let m = frame.out_to_norm(w, h); diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index e8e47e06..6b85113e 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -6,6 +6,9 @@ //! - **Visualize Spots** (Remove tool): a high-pass of the luminance thresholded to black/white, //! so dust spots and specks stand out as white dots. The scale is relative to the image (the //! same spots show at any preview size); the threshold slider (0..100) raises sensitivity. +//! - **Mask overlay** (Masking): the evaluated alpha of one mask, drawn as a colour tint, a colour +//! tint on a black-and-white image, the image on black / white, or the alpha as white on black +//! ([`MaskView`]). Both renderers hand the same alpha plane (the one the render used) to [`apply`]. use std::borrow::Cow; @@ -13,7 +16,7 @@ use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; use lightcraft_color::transfer::srgb_to_linear; use lightcraft_color::{REC2020, SRGB}; use lightcraft_develop::DevelopSettings; -use lightcraft_raster::Rgba8; +use lightcraft_raster::{Plane, Rgba8}; use crate::colorops::PointK; use crate::{Plan, for_rows}; @@ -27,23 +30,99 @@ pub enum Overlay { PointColorRange(u8), /// Visualize Spots with a threshold 0..100 (higher = more sensitive). Spots(u8), + /// 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 }, +} + +/// How [`Overlay::Mask`] draws the mask. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MaskView { + /// The photo with the mask tinted in the overlay colour. + #[default] + Color, + /// A black-and-white photo with the mask tinted in the overlay colour. + ColorOnBw, + /// The photo where the mask is, black elsewhere. + ImageOnBlack, + /// The photo where the mask is, white elsewhere. + ImageOnWhite, + /// The mask itself: white on black. + WhiteOnBlack, +} + +impl MaskView { + pub const ALL: [MaskView; 5] = [MaskView::Color, MaskView::ColorOnBw, MaskView::ImageOnBlack, MaskView::ImageOnWhite, MaskView::WhiteOnBlack]; + + /// Stable name (control channel, settings). + pub fn name(self) -> &'static str { + match self { + MaskView::Color => "color", + MaskView::ColorOnBw => "colorOnBw", + MaskView::ImageOnBlack => "imageOnBlack", + MaskView::ImageOnWhite => "imageOnWhite", + MaskView::WhiteOnBlack => "whiteOnBlack", + } + } + + pub fn label(self) -> &'static str { + match self { + MaskView::Color => "Color Overlay", + MaskView::ColorOnBw => "Color Overlay on B&W", + MaskView::ImageOnBlack => "Image on Black", + MaskView::ImageOnWhite => "Image on White", + MaskView::WhiteOnBlack => "White on Black", + } + } + + pub fn parse(s: &str) -> Option { + MaskView::ALL.into_iter().find(|v| v.name() == s) + } + + /// The next view (Shift+O cycles them). + pub fn next(self) -> MaskView { + let i = MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0); + MaskView::ALL[(i + 1) % MaskView::ALL.len()] + } + + fn index(self) -> u64 { + MaskView::ALL.iter().position(|v| *v == self).unwrap_or(0) as u64 + } +} + +/// [`Overlay::Mask`]'s fields in 51 bits (exact in an `f64`): id 16, view 4, colour 24, opacity 7. +fn pack_mask(id: u16, view: MaskView, color: [u8; 3], opacity: u8) -> u64 { + let rgb = (color[0] as u64) << 16 | (color[1] as u64) << 8 | color[2] as u64; + id as u64 | view.index() << 16 | rgb << 20 | (opacity.min(100) as u64) << 44 +} + +fn unpack_mask(v: u64) -> Overlay { + let rgb = (v >> 20) & 0xff_ffff; + Overlay::Mask { + id: (v & 0xffff) as u16, + view: MaskView::ALL.get(((v >> 16) & 0xf) as usize).copied().unwrap_or_default(), + color: [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8], + opacity: ((v >> 44) & 0x7f).min(100) as u8, + } } impl Overlay { /// A plain (kind, value) pair, e.g. for sending a request to a web worker. - pub fn to_parts(self) -> (u8, f32) { + pub fn to_parts(self) -> (u8, f64) { match self { Overlay::None => (0, 0.0), - Overlay::PointColorRange(i) => (1, i as f32), - Overlay::Spots(t) => (2, t as f32), + 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), } } /// Inverse of [`Overlay::to_parts`] (unknown kinds: no overlay). - pub fn from_parts(kind: u8, v: f32) -> Overlay { + pub fn from_parts(kind: u8, v: f64) -> Overlay { match kind { 1 => Overlay::PointColorRange(v as u8), 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), + 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), _ => Overlay::None, } } @@ -54,6 +133,15 @@ impl Overlay { Overlay::None => 0, 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), + } + } + + /// The mask an [`Overlay::Mask`] shows (if it exists in `s` and has components). + pub fn mask(self, s: &DevelopSettings) -> Option<&lightcraft_develop::Mask> { + match self { + Overlay::Mask { id, .. } => s.masks.iter().find(|m| m.id == id as u32 && !m.components.is_empty()), + _ => None, } } } @@ -70,8 +158,9 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { } } -/// Draw overlay `o` over `img` (rendered with `plan`). -pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { +/// 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>) { match o { Overlay::None => {} Overlay::PointColorRange(i) => { @@ -80,9 +169,37 @@ pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>) { } } Overlay::Spots(t) => spots(img, t, plan.px_per_long), + Overlay::Mask { view, color, opacity, .. } => { + if let Some(a) = mask.filter(|a| (a.width, a.height) == (img.width, img.height)) { + mask_view(img, a, view, color, opacity); + } + } } } +/// Draw mask alpha `a` over `img` as `view`. +pub fn mask_view(img: &mut Rgba8, a: &Plane, view: MaskView, color: [u8; 3], opacity: u8) { + let op = opacity.min(100) as f32 / 100.0; + let col = color.map(|c| c as f32); + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let a = a.data[y * w + x].clamp(0.0, 1.0); + let p = [px[0] as f32, px[1] as f32, px[2] as f32]; + let grey = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let out: [f32; 3] = match view { + MaskView::Color => std::array::from_fn(|c| p[c] + (col[c] - p[c]) * a * op), + MaskView::ColorOnBw => std::array::from_fn(|c| grey + (col[c] - grey) * a * op), + MaskView::ImageOnBlack => p.map(|v| v * a), + MaskView::ImageOnWhite => p.map(|v| v * a + 255.0 * (1.0 - a)), + MaskView::WhiteOnBlack => [255.0 * a; 3], + }; + let o = out.map(|v| v.round().clamp(0.0, 255.0) as u8); + *px = [o[0], o[1], o[2], 255]; + } + }); +} + /// Blur radius (Gaussian sigma) of the spot view, as a fraction of the long edge. const SPOT_SIGMA: f64 = 0.006; @@ -184,4 +301,45 @@ mod tests { let e = render(&src, &info, &s, &RenderRequest::fit(32, 16)).image.data[8 * 32 + 4]; assert!(e[1].abs_diff(px[1]) > 10, "{e:?} vs {px:?}"); } + + #[test] + fn mask_overlay_shows_the_evaluated_alpha() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let src = Rgb32f::from_fn(80, 40, |x, _| if x < 40 { [0.18, 0.18, 0.18] } else { [0.05, 0.1, 0.3] }); + let info = SourceInfo::default(); + let shape = MaskShape::Radial { center: Point::new(0.25, 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: 7, components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }], ..Default::default() }); + let plain = render(&src, &info, &s, &RenderRequest::fit(80, 40)).image; + let shot = |s: &DevelopSettings, view: MaskView| { + let o = Overlay::Mask { id: 7, view, color: [255, 0, 0], opacity: 100 }; + render(&src, &info, s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image + }; + let (inside, outside) = (20 * 80 + 20, 20 * 80 + 70); + let c = shot(&s, MaskView::Color); + assert_eq!(c.data[inside], [255, 0, 0, 255], "full tint inside the mask"); + assert_eq!(c.data[outside], plain.data[outside], "untouched outside"); + let bw = shot(&s, MaskView::WhiteOnBlack); + assert_eq!((bw.data[inside], bw.data[outside]), ([255; 4], [0, 0, 0, 255])); + let ib = shot(&s, MaskView::ImageOnBlack); + assert_eq!((ib.data[inside], ib.data[outside]), (plain.data[inside], [0, 0, 0, 255])); + let iw = shot(&s, MaskView::ImageOnWhite); + assert_eq!(iw.data[outside], [255; 4]); + let g = shot(&s, MaskView::ColorOnBw).data[outside]; + assert!(g[0] == g[1] && g[1] == g[2], "grey outside: {g:?}"); + // a hidden mask still shows (it's the one being edited) + s.masks[0].visible = false; + assert_eq!(shot(&s, MaskView::WhiteOnBlack).data[inside], [255; 4]); + // an unknown mask draws nothing + let o = Overlay::Mask { id: 9, view: MaskView::WhiteOnBlack, color: [0; 3], opacity: 50 }; + assert_eq!(render(&src, &info, &s, &RenderRequest { overlay: o, ..RenderRequest::fit(80, 40) }).image, plain); + // parts round trip (web worker requests) and distinct cache keys + let o = Overlay::Mask { id: 513, view: MaskView::ImageOnWhite, color: [12, 200, 255], opacity: 65 }; + let (k, v) = o.to_parts(); + assert_eq!(Overlay::from_parts(k, v), o); + assert_ne!(o.key(), Overlay::Mask { id: 513, view: MaskView::Color, color: [12, 200, 255], opacity: 65 }.key()); + assert_eq!(MaskView::WhiteOnBlack.next(), MaskView::Color); + assert_eq!(MaskView::parse("colorOnBw"), Some(MaskView::ColorOnBw)); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 71aed1e8..1371a880 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,9 @@ pub mod state; pub mod theme; pub mod widgets; +#[cfg(test)] +mod tests_masking; + use std::sync::mpsc::{Receiver, Sender}; use lightcraft_engine::Session; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1b56db04..0026ebe4 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -97,6 +97,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "---", "view.clipping", "view.maskOverlay", + "view.maskOverlayMode", + "view.maskPins", "view.cropOverlay", "---", "@Sort", @@ -197,6 +199,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), "view.maskOverlay" => Some(u.mask_overlay), + "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), "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4d31a002..fe287a1e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,6 +34,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.histogram", "Histogram", Some("Cmd+Shift+H"), "View"), ("view.maskOverlay", "Show Mask Overlay", Some("O"), "View"), + // Shift+O in the Masking panel (elsewhere it cycles the crop overlay) + ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), + ("view.maskOverlayColor", "Mask Overlay Color", None, ""), + ("view.maskPins", "Show Mask Pins", None, "View"), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.back", "Back to Grid", Some("Escape"), ""), @@ -96,6 +100,21 @@ fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str let _ = app.session.end_interaction(); } +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { + if let Some(s) = v.as_str() { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 { + return None; + } + let c = |i: usize| u8::from_str_radix(h.get(i..i + 2)?, 16).ok(); + return Some([c(0)?, c(2)?, c(4)?]); + } + let a = v.as_array()?; + let c = |i: usize| a.get(i)?.as_f64().map(|x| x.clamp(0.0, 255.0).round() as u8); + Some([c(0)?, c(1)?, c(2)?]) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { let ctx = egui::Context::default(); @@ -199,8 +218,43 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.mask_overlay = !app.ui.mask_overlay; - Ok(Value::Null) + app.ui.mask_overlay = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_overlay); + Ok(json!({"maskOverlay": app.ui.mask_overlay})) + } + "view.maskOverlayMode" => { + use lightcraft_engine::pipeline::MaskView; + let cur = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + let next = match p.get("mode").and_then(Value::as_str) { + Some(m) => match MaskView::parse(m) { + Some(v) => v, + None => { + let names: Vec<&str> = MaskView::ALL.iter().map(|v| v.name()).collect(); + return Some(Err(format!("view.maskOverlayMode: unknown mode `{m}` ({})", names.join("|")))); + } + }, + None => cur.next(), + }; + app.ui.mask_overlay_mode = next.name().into(); + app.ui.mask_overlay = true; + app.toast(&ctx, next.label()); + Ok(json!({"mode": next.name()})) + } + "view.maskOverlayColor" => { + if let Some(c) = p.get("color") { + match parse_rgb(c) { + Some(rgb) => app.ui.mask_overlay_color = rgb, + None => return Some(Err("view.maskOverlayColor: `color` is \"#rrggbb\" or [r, g, b]".into())), + } + } + if let Some(o) = p.get("opacity").and_then(Value::as_f64) { + app.ui.mask_overlay_opacity = o.clamp(0.0, 100.0) as f32; + } + let [r, g, b] = app.ui.mask_overlay_color; + Ok(json!({"color": format!("#{r:02x}{g:02x}{b:02x}"), "opacity": app.ui.mask_overlay_opacity})) + } + "view.maskPins" => { + app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); + Ok(json!({"maskPins": app.ui.mask_pins})) } "view.visualizeSpots" => { // like Lightroom's A: opens the Remove tool with the view on, or toggles it there diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 54e2575e..6491ae41 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -40,8 +40,11 @@ pub enum Gesture { start: (f32, f32), at: Pos2, }, + /// Dragging component `comp` of `mask`: its pin (`handle` 0) or a linear gradient's start (1) + /// or end (2). MaskHandle { mask: u32, + comp: usize, handle: u8, }, /// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates). @@ -313,9 +316,22 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon } } -/// The diagnostic overlay the loupe shows (Point Color's visualized range). -fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { - use lightcraft_pipeline::Overlay; +/// The diagnostic overlay the loupe shows (the selected mask, Point Color's visualized range, +/// Visualize Spots). +pub(crate) fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { + use lightcraft_pipeline::{MaskView, Overlay}; + 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)) + && !m.components.is_empty() + { + return Overlay::Mask { + id: m.id.min(u16::MAX as u32) as u16, + 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::Remove && app.ui.visualize_spots { return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); } @@ -594,26 +610,55 @@ fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] { // ------------------------------------------------------------------------ masks +/// Where a mask component's pin sits (normalized), for shapes that have a place on the photo. +pub(crate) fn component_pin(shape: &MaskShape) -> Option { + match shape { + MaskShape::Radial { center, .. } => Some(*center), + MaskShape::Linear { start, end } => Some(Point::new((start.x + end.x) / 2.0, (start.y + end.y) / 2.0)), + MaskShape::Brush { strokes } => strokes.iter().find(|s| !s.erase).and_then(|s| s.points.first()).copied(), + _ => None, + } +} + +/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`. +fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape { + let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y); + match shape.clone() { + MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert }, + MaskShape::Linear { start, end } => match handle { + 1 => MaskShape::Linear { start: at, end }, + 2 => MaskShape::Linear { start, end: at }, + _ => MaskShape::Linear { start: mv(start), end: mv(end) }, + }, + MaskShape::Brush { mut strokes } => { + for st in &mut strokes { + st.points.iter_mut().for_each(|p| *p = mv(*p)); + } + MaskShape::Brush { strokes } + } + s => s, + } +} + +/// The Masking tool on the photo: outlines and handles of the selected mask, a pin per mask +/// component (click selects its mask, drag moves the component), and brush painting. The mask +/// itself shows as a rendered overlay ([`view_overlay`]). fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { - let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90); + let tint = { + let [r, g, b] = app.ui.mask_overlay_color; + Color32::from_rgba_unmultiplied(r, g, b, 110) + }; let long = (map.rect.width().max(map.rect.height())) as f64; let active = app.session.active_mask; let clip = app.canvas_rect.unwrap_or(map.rect); let p = &ui.painter_at(clip); + // (mask, component, handle, screen position) of everything that can be grabbed + let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); for m in &d.masks { let sel = Some(m.id) == active; - for c in &m.components { + for (ci, c) in m.components.iter().enumerate() { match &c.shape { - MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => { - for s in strokes { - let r = (s.size * long) as f32; - for q in &s.points { - p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red }); - } - } - } - MaskShape::Radial { center, rx, ry, angle, .. } => { - let c0 = map.screen(*center); + MaskShape::Radial { center, rx, ry, angle, .. } if sel => { let pts: Vec = (0..64) .map(|i| { let a = i as f64 / 64.0 * std::f64::consts::TAU; @@ -623,35 +668,31 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1)) }) .collect(); - if sel && app.ui.mask_overlay { - p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE)); - } - p.add(egui::Shape::closed_line( - pts, - Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })), - )); - pin(p, c0, sel); - if sel { - register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0))); - } + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230)))); } - MaskShape::Linear { start, end } => { + MaskShape::Linear { start, end } if sel => { let (a, b) = (map.screen(*start), map.screen(*end)); let dir = (b - a).normalized(); let perp = vec2(-dir.y, dir.x) * 3000.0; for (q, alpha) in [(a, 230), (b, 110)] { - p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 }))); + p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(alpha))); } - pin(p, a + (b - a) * 0.5, sel); - if sel { - for (i, q) in [a, b].iter().enumerate() { - p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); - register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); - } + for (i, q) in [a, b].iter().enumerate() { + p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE)); + register(ui.ctx(), format!("maskHandle:{}:{ci}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0))); + grips.push((m.id, ci, i as u8 + 1, *q)); } } _ => {} } + if app.ui.mask_pins + && let Some(at) = component_pin(&c.shape) + { + let q = map.screen(at); + pin(p, q, sel); + register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); + grips.push((m.id, ci, 0, q)); + } } } // brush tool @@ -671,9 +712,10 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), } } + // the stroke being painted (the render shows it once it's committed) if let Some(Gesture::Brush { points }) = &app.gesture { for q in points { - p.circle_filled(map.screen(*q), r, red); + p.circle_filled(map.screen(*q), r, tint); } } if (resp.drag_stopped() || resp.clicked()) @@ -682,57 +724,53 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); let _ = app.run( "mask.brushStroke", - json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}), + json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, + "erase": app.ui.brush_erase, "autoMask": app.ui.brush_auto_mask}), ); } return; } - // drag pins/handles of the active mask - let Some(mid) = active else { return }; - let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return }; - let Some(comp) = m.components.first() else { return }; - if resp.drag_started() + // the grip under the pointer: the selected mask's first, then the nearest + let hit = |q: Pos2| { + grips + .iter() + .filter(|g| g.3.distance(q) < 10.0) + .min_by(|a, b| { + (Some(a.0) != active, a.3.distance(q)).partial_cmp(&(Some(b.0) != active, b.3.distance(q))).unwrap_or(std::cmp::Ordering::Equal) + }) + .copied() + }; + if resp.clicked() && let Some(q) = resp.interact_pointer_pos() + && let Some((mid, ..)) = hit(q) + && Some(mid) != active { - let handle = match &comp.shape { - MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0), - MaskShape::Linear { start, end } => { - if map.screen(*start).distance(q) < 14.0 { - Some(1) - } else if map.screen(*end).distance(q) < 14.0 { - Some(2) - } else { - Some(0) - } + let _ = app.run("mask.select", json!({"id": mid})); + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + let grip = hit(q).map(|(m, c, h, _)| (m, c, h)).or_else(|| { + // anywhere on the photo drags the selected linear gradient + let m = d.masks.iter().find(|m| Some(m.id) == active)?; + matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0)) + }); + if let Some((mask, comp, handle)) = grip { + if Some(mask) != active { + let _ = app.run("mask.select", json!({"id": mask})); } - _ => None, - }; - if let Some(h) = handle { let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); - app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h }); + app.gesture = Some(Gesture::MaskHandle { mask, comp, handle }); } } if resp.dragged() - && let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + && let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + && let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape) { let n = map.norm(q); - let dn = { - let q0 = q - resp.drag_delta(); - let n0 = map.norm(q0); - Point::new(n.x - n0.x, n.y - n0.y) - }; - let new_shape = match comp.shape.clone() { - MaskShape::Radial { center, rx, ry, angle, feather, invert } => { - MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert } - } - MaskShape::Linear { start, end } => match handle { - 1 => MaskShape::Linear { start: n, end }, - 2 => MaskShape::Linear { start, end: n }, - _ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) }, - }, - s => s, - }; - let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape})); + let n0 = map.norm(q - resp.drag_delta()); + let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n); + let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape})); } if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) { app.gesture = None; diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index eea553e8..7c4c2500 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -142,6 +142,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("mask.select", json!({"id": m.id})); } } + if !d.masks.is_empty() { + ui.add_space(6.0); + overlay_options(app, ui); + } }); let Some(mid) = app.session.active_mask else { return }; let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return }; @@ -202,6 +206,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = Stroke::NONE; } +/// Overlay colours offered as swatches (the colour of the selected one is used for the tint). +const OVERLAY_COLORS: [[u8; 3]; 5] = [[230, 30, 40], [40, 200, 70], [40, 110, 240], [250, 210, 30], [255, 255, 255]]; + +/// How the selected mask is shown: overlay mode, colour, opacity, pins. +fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use lightcraft_pipeline::MaskView; + let t = Tokens::get(ui.ctx()); + let view = MaskView::parse(&app.ui.mask_overlay_mode).unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Overlay").color(t.text_dim)); + let r = crate::widgets::dropdown(ui, "maskOverlayMode", view.label(), t.font(12.5), t.text_label); + egui::Popup::menu(&r).show(|ui| { + for v in MaskView::ALL { + if ui.selectable_label(v == view, v.label()).clicked() { + let _ = app.run("view.maskOverlayMode", json!({"mode": v.name()})); + } + } + }); + }); + let colored = matches!(view, MaskView::Color | MaskView::ColorOnBw); + if colored { + ui.horizontal(|ui| { + for c in OVERLAY_COLORS { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); + ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); + if app.ui.mask_overlay_color == c { + ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, t.text), egui::StrokeKind::Inside); + } + if resp.clicked() { + let _ = app.run("view.maskOverlayColor", json!({"color": c})); + } + } + }); + } + let spec = ControlSpec { + id: "ui.maskOverlayOpacity", + label: "Opacity", + section: Section::Light, + min: 0.0, + max: 100.0, + default: 50.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = slider(ui, &spec, app.ui.mask_overlay_opacity as f64, colored && app.ui.mask_overlay, None); + if let Some(v) = out.value { + app.ui.mask_overlay_opacity = v as f32; + } + let mut pins = app.ui.mask_pins; + if ui.checkbox(&mut pins, "Show Pins").changed() { + let _ = app.run("view.maskPins", json!({"show": pins})); + } +} + fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) { for (kind, label) in [ ("brush", "Brush"), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 01f91fae..06580217 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -179,6 +179,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop { continue; } + // Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere) + if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking { + let _ = app.run("view.maskOverlayMode", json!({})); + continue; + } let _ = app.run(&f, json!({})); match f.as_str() { "photo.pick" => app.toast(ctx, "Flagged as Pick"), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3ba282..cb358392 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -97,7 +97,13 @@ pub struct UiState { pub single_panel: bool, pub show_clipping: bool, pub histogram: bool, + /// Masking: show the selected mask as a rendered overlay (O), how (`MaskView` name, ⇧O cycles), + /// in which colour and opacity (0..100, colour views), and whether pins are drawn. pub mask_overlay: bool, + pub mask_overlay_mode: String, + pub mask_overlay_color: [u8; 3], + pub mask_overlay_opacity: f32, + pub mask_pins: bool, pub crop_overlay: CropOverlay, pub show_filenames: bool, pub search: String, @@ -113,6 +119,8 @@ pub struct UiState { pub brush_feather: f32, pub brush_flow: f32, pub brush_erase: bool, + /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. + pub brush_auto_mask: bool, pub remove_size: f32, /// Selected Point Color sample. pub point_color: usize, @@ -199,6 +207,10 @@ impl Default for UiState { show_clipping: false, histogram: true, mask_overlay: true, + mask_overlay_mode: "color".into(), + mask_overlay_color: [230, 30, 40], + mask_overlay_opacity: 50.0, + mask_pins: true, crop_overlay: CropOverlay::Thirds, show_filenames: true, search: String::new(), @@ -210,6 +222,7 @@ impl Default for UiState { brush_feather: 50.0, brush_flow: 60.0, brush_erase: false, + brush_auto_mask: false, remove_size: 0.02, point_color: 0, point_color_visualize: false, diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs new file mode 100644 index 00000000..ca60889b --- /dev/null +++ b/crates/ui-egui/src/tests_masking.rs @@ -0,0 +1,91 @@ +//! Headless tests of the Masking and Remove tools on the photo: overlay keys, pins, spot editing. + +use std::time::Duration; + +use lightcraft_develop::MaskShape; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::RightPanel; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn detail(panel: &str) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn pointer(h: &mut Headless, events: serde_json::Value) { + let r = h.request("ui.pointer", json!({"events": events}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +#[test] +fn mask_overlay_keys_and_pins() { + use lightcraft_pipeline::{MaskView, Overlay}; + let mut h = detail("panel.masking"); + assert_eq!(h.app.ui.right, RightPanel::Masking); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]})); + assert_eq!(h.app.session.active_mask, Some(2)); + // the loupe asks the renderer for the selected mask's overlay + let d = develop(&h); + let o = crate::panels::detail::view_overlay(&h.app, &d); + assert_eq!(o, Overlay::Mask { id: 2, view: MaskView::Color, color: [230, 30, 40], opacity: 50 }); + // O toggles it, Shift+O cycles the mode (and leaves the crop overlay alone) + h.request("ui.key", json!({"key": "o"}), T); + assert!(!h.app.ui.mask_overlay); + assert_eq!(crate::panels::detail::view_overlay(&h.app, &d), Overlay::None); + h.request("ui.key", json!({"key": "o"}), T); + let crop = h.app.ui.crop_overlay; + h.request("ui.key", json!({"key": "o", "shift": true}), T); + assert_eq!(h.app.ui.mask_overlay_mode, "colorOnBw"); + assert_eq!(h.app.ui.crop_overlay, crop); + exec(&mut h, "view.maskOverlayMode", json!({"mode": "whiteOnBlack"})); + exec(&mut h, "view.maskOverlayColor", json!({"color": "#2870f0", "opacity": 80})); + assert_eq!((h.app.ui.mask_overlay_color, h.app.ui.mask_overlay_opacity), ([0x28, 0x70, 0xf0], 80.0)); + let r = h.request("engine.execute", json!({"command": "view.maskOverlayMode", "params": {"mode": "nope"}}), T); + assert_eq!(r["ok"], false, "{r}"); + // clicking another mask's pin selects that mask + pointer(&mut h, json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "up", "x": 0.3, "y": 0.4}])); + assert_eq!(h.app.session.active_mask, Some(1)); + // dragging a pin moves its component (one undo step) + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.4}, {"kind": "drag", "x": 0.35, "y": 0.45}, {"kind": "drag", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}]), + ); + let MaskShape::Radial { center, .. } = develop(&h).masks[0].components[0].shape.clone() else { panic!("radial") }; + assert!((center.x - 0.5).abs() < 0.02 && (center.y - 0.6).abs() < 0.02, "{center:?}"); + // dragging the linear gradient's pin (a non-selected mask) selects and moves it + pointer( + &mut h, + json!([{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "drag", "x": 0.72, "y": 0.4}, {"kind": "drag", "x": 0.8, "y": 0.4}, {"kind": "up", "x": 0.8, "y": 0.4}]), + ); + assert_eq!(h.app.session.active_mask, Some(2)); + let MaskShape::Linear { start, end } = develop(&h).masks[1].components[0].shape.clone() else { panic!("linear") }; + assert!((start.x - 0.8).abs() < 0.02 && (end.x - 0.8).abs() < 0.02 && (start.y - 0.2).abs() < 0.02, "{start:?} {end:?}"); + // hidden pins can't be grabbed + exec(&mut h, "view.maskPins", json!({"show": false})); + pointer(&mut h, json!([{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "up", "x": 0.5, "y": 0.6}])); + assert_eq!(h.app.session.active_mask, Some(2)); + h.settle(SETTLE); +} diff --git a/docs/parity.md b/docs/parity.md index 97cada6d..489a445e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -31,7 +31,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | -| K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | +| K. Masking (MASK) | 8 | 8 | 7 | 0 | 5/8 (63%) | 3/5 (60%) | | L. Presets (PRE) | 1 | 4 | 2 | 1 | 0/2 (0%) | 1/2 (50%) | | M. Versions & history (VER) | 3 | 1 | 1 | 0 | 1/1 (100%) | 2/3 (67%) | | N. Copy / paste / sync (SYNC) | 4 | 0 | 1 | 0 | 3/3 (100%) | 1/1 (100%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | | Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 208 | 103 | 189 | 37 | 152/198 (77%) | 48/144 (33%) | +| **Total** | 210 | 101 | 189 | 37 | 153/198 (77%) | 49/144 (34%) | ## Top gaps @@ -57,8 +57,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -2. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask - kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. 3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. 4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, @@ -348,8 +346,8 @@ 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 | ⬜ | | | | LR-MASK-SLIDERS | Local adjustment sliders | P0 | 🟡 | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | noise / moiré / defringe stored but not rendered; no local curve or effect presets | -| LR-MASK-OVERLAY | Mask overlay | P0 | 🟡 | `cmd:view.maskOverlay`, `crates/ui-egui/src/panels/detail.rs` | brush dabs + shape outlines only; no rendered alpha, colour or mode options | -| LR-MASK-PINS | Pins | P1 | 🟡 | `crates/ui-egui/src/panels/detail.rs` | handles drawn; no show/hide-pins option | +| 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; 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`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | |