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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 12:58:27 -04:00
parent cec012d032
commit ca8db6e028
15 changed files with 627 additions and 123 deletions

View File

@ -47,7 +47,7 @@ pub struct WireJob {
pub stages: Option<String>,
/// The request's diagnostic overlay (`Overlay::to_parts`).
#[serde(default)]
pub overlay: (u8, f32),
pub overlay: (u8, f64),
}
impl WireJob {

View File

@ -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 })
}

View File

@ -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]

View File

@ -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<Plane> {
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 {

View File

@ -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<Evaluated> {
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);

View File

@ -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> {
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));
}
}

View File

@ -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;

View File

@ -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<bool> {
"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),

View File

@ -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<Result<Value, String>> {
let ctx = egui::Context::default();
@ -199,8 +218,43 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
Ok(Value::Null)
}
"view.maskOverlay" => {
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

View File

@ -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<Point> {
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<Pos2> = (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;

View File

@ -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"),

View File

@ -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"),

View File

@ -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,

View File

@ -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);
}

View File

@ -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%) |
<!-- /parity:summary -->
## 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 | ⬜ | | |