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:
parent
cec012d032
commit
ca8db6e028
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue