LR-MASK-BRUSH: the live brush stroke places dabs like the committed one, no gaps on fast strokes (#517)

While the button was held the Detail view drew one hard tinted circle per
pointer sample, so a fast stroke showed separate dabs; the committed stroke
interpolates dabs a quarter radius apart with feather and flow. The live
stroke now uses the pipeline's dab placement (push_dabs, shared with
brush_dabs), dab falloff and flow accumulation in an incremental screen
buffer (crates/ui-egui/src/brush_live.rs): each frame stamps only the new
samples' dabs and uploads only the rectangle they touched. push_dabs also
caps the dabs of a hostile segment instead of looping without bound.
This commit is contained in:
Jason Baldwin
2026-10-10 17:21:22 +02:00
parent b70ae1741e
commit 923b1cde5e
6 changed files with 418 additions and 21 deletions
+87 -12
View File
@@ -448,17 +448,11 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
let hard = r * (1.0 - (s.feather / 100.0).clamp(0.0, 1.0));
// Densify the path so dabs overlap (spacing r/4).
let mut dabs: Vec<Point> = Vec::new();
for (i, p) in s.points.iter().enumerate() {
let mut prev = None;
for p in &s.points {
let q = to_out.apply(*p);
if i > 0 {
let prev = to_out.apply(s.points[i - 1]);
let dist = prev.dist(q);
let n = (dist / (r / 4.0).max(0.5)).ceil() as usize;
for k in 1..n {
dabs.push(prev.lerp(q, k as f64 / n as f64));
}
}
dabs.push(q);
push_dabs(&mut dabs, prev, q, r);
prev = Some(q);
}
BrushDabs {
dabs,
@@ -471,6 +465,49 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa
}
}
/// Distance between consecutive dabs of a brush of radius `r` (pixels): a quarter of the radius,
/// so neighbouring dabs overlap and a stroke reads as continuous however fast it was painted.
#[inline]
pub fn dab_spacing(r: f64) -> f64 {
(r / 4.0).max(0.5)
}
/// Append the dabs of one path segment, from the previous sample `prev` (already stamped) to `q`,
/// for a brush of radius `r`: evenly spaced at most [`dab_spacing`] apart, ending on `q`. The
/// committed stroke ([`brush_dabs`]) and the live one the UI draws while the button is held place
/// their dabs with this, so they cover the same pixels (issue #517).
pub fn push_dabs(dabs: &mut Vec<Point>, prev: Option<Point>, q: Point, r: f64) {
if let Some(prev) = prev {
let n = (prev.dist(q) / dab_spacing(r)).ceil();
// a hostile or degenerate segment (NaN, huge) gets no in-between dabs rather than a huge
// allocation: 1 M dabs is far more than any on-screen stroke needs
let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 };
for k in 1..n {
dabs.push(prev.lerp(q, k as f64 / n as f64));
}
}
dabs.push(q);
}
/// How much one dab paints at distance `dd` from its centre: 1 inside the hard core `hard`,
/// smoothly falling to 0 at the radius `r`.
#[inline]
pub fn dab_alpha(dd: f64, r: f64, hard: f64) -> f32 {
if dd > r {
0.0
} else if dd <= hard {
1.0
} else {
1.0 - smooth(hard as f32, r as f32, dd as f32)
}
}
/// A stroke's coverage `v` after another dab paints `a` there: flow accumulates up to density.
#[inline]
pub fn accumulate(v: f32, a: f32, flow: f32, density: f32) -> f32 {
(v + a * flow * (1.0 - v)).min(density)
}
/// Auto Mask tolerances: a pixel takes a dab's paint fully up to half of these from the colour
/// under the dab centre, none beyond them (log2 luminance, chromaticity `rgb / Y`).
pub const AUTO_TOL_EV: f32 = 0.5;
@@ -554,14 +591,14 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i
if dd > r {
continue;
}
let mut a = if dd <= hard { 1.0 } else { 1.0 - smooth(hard as f32, r as f32, dd as f32) };
let mut a = dab_alpha(dd, r, hard);
let i = y * w + x;
if let Some((rl, rch)) = refc {
a *= auto_similarity(log_l.data[i], chromaticity(img.data[i]), rl, rch);
}
let v = &mut stroke_alpha.data[i];
// flow accumulates within a stroke up to density
*v = (*v + a * flow * (1.0 - *v)).min(dens);
*v = accumulate(*v, a, flow, dens);
}
}
}
@@ -696,6 +733,44 @@ mod tests {
assert!(e[0].alpha.get(50, 50) < 0.01);
}
/// Issue #517: dabs along a long segment (a fast stroke: two pointer samples far apart) are at
/// most [`dab_spacing`] apart, painting dab by dab (the live stroke) or the whole path at once
/// (the committed one).
#[test]
fn a_long_segment_gets_dabs_at_most_the_spacing_apart() {
let r = 12.0;
let pts = [Point::new(10.0, 20.0), Point::new(610.0, 260.0), Point::new(611.0, 260.0), Point::new(40.0, 400.0)];
let mut live = Vec::new();
let mut prev = None;
for q in pts {
push_dabs(&mut live, prev, q, r);
prev = Some(q);
}
assert!(live.len() > 3 * 600 / 12, "{} dabs", live.len());
assert!(live.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(r) + 1e-9));
assert_eq!(live.first(), pts.first());
assert_eq!(live.last(), pts.last());
// the committed stroke places the same dabs (in output pixels; this frame maps 1:1 scaled)
let (w, h) = (1000, 500);
let f = frame(w, h);
let stroke = BrushStroke { points: pts.map(|p| Point::new(p.x / w as f64, p.y / h as f64)).to_vec(), ..Default::default() };
let committed = brush_dabs(&stroke, &f, w, h);
let mut again = Vec::new();
let mut prev = None;
for p in &stroke.points {
let q = f.norm_to_out(w, h).apply(*p);
push_dabs(&mut again, prev, q, committed.r);
prev = Some(q);
}
assert_eq!(committed.dabs, again);
assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9));
// a hostile segment places no in-between dabs
let mut v = Vec::new();
push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r);
push_dabs(&mut v, Some(Point::new(0.0, 0.0)), Point::new(1e300, 1.0), 0.0);
assert!(v.len() <= 1_000_002, "{}", v.len());
}
#[test]
fn brush_and_subtract() {
let f = frame(200, 100);
+250
View File
@@ -0,0 +1,250 @@
//! The brush stroke being painted, drawn on the photo while the button is held (issue #517).
//!
//! The committed stroke is rasterized by the pipeline ([`lightcraft_pipeline::masks::brush_dabs`]
//! and friends): dabs a quarter radius apart along the path, each with a hard core and a smooth
//! feather, flow accumulating up to density. The live stroke places and paints its dabs with the
//! same functions, at screen resolution, so letting go of the button doesn't change what's
//! covered. It's incremental: each frame stamps only the dabs of the samples added since the last
//! one and uploads only the rectangle they touched.
use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2};
use lightcraft_geom::Point;
use lightcraft_pipeline::masks::{accumulate, dab_alpha, push_dabs};
/// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution.
const MAX_SIDE: usize = 4096;
/// What a stroke is painted with; a change (a new canvas size, zoom, brush) repaints it from its
/// first sample.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LiveParams {
/// The area the stroke may cover (screen points): the canvas.
pub clip: Rect,
/// Buffer pixels per screen point.
pub scale: f32,
/// Brush radius (screen points) and its hard core (no feather), as the pipeline computes them.
pub r: f32,
pub hard: f32,
/// Flow and density, 0..1.
pub flow: f32,
pub density: f32,
/// Overlay colour and its opacity (0..1) at full coverage.
pub color: [u8; 3],
pub opacity: f32,
}
/// An in-progress stroke's coverage at screen resolution and the texture showing it.
pub struct LiveStroke {
params: LiveParams,
w: usize,
h: usize,
alpha: Vec<f32>,
/// Samples stamped so far, and the last one (buffer pixels).
placed: usize,
last: Option<Point>,
/// Buffer pixels painted since the last upload: (x0, y0, x1, y1), exclusive ends.
dirty: Option<(usize, usize, usize, usize)>,
texture: Option<TextureHandle>,
}
impl LiveStroke {
fn new(params: LiveParams) -> LiveStroke {
let scale = buffer_scale(params.clip, params.scale);
let params = LiveParams { scale, ..params };
let (w, h) = buffer_size(params.clip, scale);
LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, dirty: None, texture: None }
}
/// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside.
pub fn coverage(&self, x: usize, y: usize) -> f32 {
if x >= self.w {
return 0.0;
}
y.checked_mul(self.w).and_then(|i| i.checked_add(x)).and_then(|i| self.alpha.get(i)).copied().unwrap_or(0.0)
}
/// Buffer size (pixels) and pixels per screen point.
pub fn size(&self) -> ([usize; 2], f32) {
([self.w, self.h], self.params.scale)
}
/// Stamp the dabs of `points` (screen positions of the stroke's samples so far) not yet
/// painted. Returns `true` when anything new was painted.
pub fn stamp(&mut self, points: &[Pos2]) -> bool {
let Some(new) = points.get(self.placed..) else { return false };
if new.is_empty() {
return false;
}
let LiveParams { clip, scale, r, hard, flow, density, .. } = self.params;
let (r, hard) = ((r * scale) as f64, (hard * scale) as f64);
let mut dabs = Vec::new();
for q in new {
let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64);
push_dabs(&mut dabs, self.last, q, r);
self.last = Some(q);
}
self.placed = points.len();
for d in dabs {
if !(d.x.is_finite() && d.y.is_finite()) {
continue;
}
let x0 = (d.x - r).floor().clamp(0.0, self.w as f64) as usize;
let x1 = (d.x + r).ceil().clamp(0.0, self.w as f64) as usize;
let y0 = (d.y - r).floor().clamp(0.0, self.h as f64) as usize;
let y1 = (d.y + r).ceil().clamp(0.0, self.h as f64) as usize;
if x0 >= x1 || y0 >= y1 {
continue;
}
for y in y0..y1 {
let Some(row) = self.alpha.get_mut(y * self.w + x0..y * self.w + x1) else { continue };
for (x, v) in (x0..x1).zip(row.iter_mut()) {
let dd = Point::new(x as f64 + 0.5, y as f64 + 0.5).dist(d);
if dd > r {
continue;
}
*v = accumulate(*v, dab_alpha(dd, r, hard), flow, density);
}
}
self.dirty = Some(match self.dirty {
Some((a, b, c, e)) => (a.min(x0), b.min(y0), c.max(x1), e.max(y1)),
None => (x0, y0, x1, y1),
});
}
true
}
/// The overlay colour of coverage `a`.
fn pixel(&self, a: f32) -> Color32 {
let [r, g, b] = self.params.color;
let alpha = (a * self.params.opacity * 255.0).round().clamp(0.0, 255.0) as u8;
Color32::from_rgba_unmultiplied(r, g, b, alpha)
}
fn region(&self, (x0, y0, x1, y1): (usize, usize, usize, usize)) -> ColorImage {
let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0));
for y in y0..y1 {
for x in x0..x1 {
px.push(self.pixel(self.coverage(x, y)));
}
}
ColorImage::new([x1 - x0, y1 - y0], px)
}
/// Bring the texture up to date (the whole buffer the first time, then only what changed).
fn upload(&mut self, ctx: &egui::Context) -> Option<&TextureHandle> {
match (&mut self.texture, self.dirty.take()) {
(None, _) => {
let image = self.region((0, 0, self.w, self.h));
self.texture = Some(ctx.load_texture("brush-live-stroke", image, TextureOptions::LINEAR));
}
(Some(_), Some(rect)) => {
let image = self.region(rect);
if let Some(t) = &mut self.texture {
t.set_partial([rect.0, rect.1], image, TextureOptions::LINEAR);
}
}
(Some(_), None) => {}
}
self.texture.as_ref()
}
}
/// Pixels per screen point of a buffer covering `clip`: `want`, lowered so no edge exceeds
/// [`MAX_SIDE`].
fn buffer_scale(clip: Rect, want: f32) -> f32 {
let want = if want.is_finite() && want > 0.0 { want } else { 1.0 };
let long = clip.width().max(clip.height());
if long.is_finite() && long * want > MAX_SIDE as f32 { MAX_SIDE as f32 / long } else { want }
}
fn buffer_size(clip: Rect, scale: f32) -> (usize, usize) {
let side = |v: f32| {
let v = (v * scale).ceil();
if v.is_finite() { (v.max(1.0) as usize).min(MAX_SIDE) } else { 1 }
};
(side(clip.width()), side(clip.height()))
}
/// Paint the live stroke whose samples so far are `points` (screen positions) into `painter`,
/// keeping its buffer in `slot` (a new stroke, or new parameters, start it afresh).
pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2]) {
let fresh = match slot {
Some(s) => {
// the same stroke when painted with the same parameters (at the scale it settled on)
let same = s.params == LiveParams { scale: buffer_scale(params.clip, params.scale), ..params };
!same || points.len() < s.placed
}
None => true,
};
if fresh {
*slot = Some(LiveStroke::new(params));
}
let Some(s) = slot else { return };
s.stamp(points);
let clip = s.params.clip;
// the buffer's pixels may overhang the canvas by a fraction of a pixel: map them exactly
let (w, h, scale) = (s.w as f32, s.h as f32, s.params.scale);
let rect = Rect::from_min_size(clip.min, egui::vec2(w / scale, h / scale));
if let Some(t) = s.upload(ctx) {
painter.image(t.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::vec2;
fn params() -> LiveParams {
LiveParams {
clip: Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0)),
scale: 1.0,
r: 10.0,
hard: 5.0,
flow: 1.0,
density: 1.0,
color: [230, 30, 40],
opacity: 0.5,
}
}
/// Issue #517: two samples far apart (a fast stroke) paint a continuous band between them,
/// not two separate dabs.
#[test]
fn a_fast_segment_is_painted_continuously() {
let mut s = LiveStroke::new(params());
s.stamp(&[pos2(20.0, 100.0)]);
s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)]);
for x in 20..380 {
assert!(s.coverage(x, 100) > 0.99, "a gap at x = {x}: {}", s.coverage(x, 100));
}
// the feather: full inside the core, fading to nothing at the radius
assert!(s.coverage(200, 104) > 0.99);
assert!((0.01..0.99).contains(&s.coverage(200, 108)), "{}", s.coverage(200, 108));
assert_eq!(s.coverage(200, 111), 0.0);
}
/// Painting the samples one frame at a time gives the same coverage as all at once (what the
/// committed stroke does).
#[test]
fn incremental_stamping_matches_painting_the_whole_path() {
let pts = [pos2(30.0, 40.0), pos2(200.0, 150.0), pos2(210.0, 152.0), pos2(390.0, 20.0)];
let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() });
once.stamp(&pts);
let mut steps = LiveStroke::new(LiveParams { flow: 0.4, ..params() });
for n in 1..=pts.len() {
steps.stamp(&pts[..n]);
}
assert_eq!(once.alpha, steps.alpha);
}
#[test]
fn hostile_input_paints_nothing_and_does_not_panic() {
let mut s = LiveStroke::new(params());
s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)]);
assert!(s.alpha.iter().all(|a| *a == 0.0));
// an enormous canvas is painted at a capped resolution
let big = LiveStroke::new(LiveParams { clip: Rect::from_min_size(Pos2::ZERO, vec2(1e6, 10.0)), scale: 2.0, ..params() });
assert!(big.w <= MAX_SIDE && big.h >= 1);
}
}
+4
View File
@@ -7,6 +7,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod album_picker;
pub mod brush_live;
pub mod control;
pub mod credits;
pub mod date_picker;
@@ -271,6 +272,8 @@ pub struct LightcraftApp {
pub widgets: Vec<(String, egui::Rect)>,
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
pub gesture: Option<panels::detail::Gesture>,
/// The brush stroke being painted, as drawn on the photo until it's committed (issue #517).
pub brush_live: Option<brush_live::LiveStroke>,
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
/// "thumb", "none").
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
@@ -365,6 +368,7 @@ impl LightcraftApp {
film_scroll: None,
widgets: vec![],
gesture: None,
brush_live: None,
loupe_shown: None,
region_view: None,
region_before_view: None,
+18 -8
View File
@@ -1547,10 +1547,6 @@ fn radial_body_contains(shape: &MaskShape, at: Point, map: &CanvasMap) -> bool {
/// 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 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);
@@ -1676,15 +1672,29 @@ 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)
// the stroke being painted (the render shows it once it's committed), with the committed
// stroke's dab spacing, feather and flow (issue #517)
if let Some(Gesture::Brush { points }) = &app.gesture {
for q in points {
p.circle_filled(map.screen(*q), r, tint);
}
let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect();
let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 };
let params = crate::brush_live::LiveParams {
clip,
scale: ui.ctx().pixels_per_point(),
r,
hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)),
flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0),
density: 1.0,
color: app.ui.mask_overlay_color,
opacity: opacity.clamp(0.0, 1.0),
};
crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen);
} else {
app.brush_live = None;
}
if (resp.drag_stopped() || resp.clicked())
&& let Some(Gesture::Brush { points }) = app.gesture.take()
{
app.brush_live = None;
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
// holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing
let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt);
+58
View File
@@ -1116,3 +1116,61 @@ fn ai_masks_without_the_model_offer_the_download() {
assert!(develop(&h).masks.is_empty());
assert!(t.elapsed() < SETTLE, "{:?}", t.elapsed());
}
/// Issue #517: a fast brush stroke (pointer samples several brush widths apart) shows on the photo
/// while the button is held as the continuous, feathered band it will be once committed, not as
/// separate dabs at the samples with gaps between them.
#[test]
fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() {
let mut h = detail("panel.masking");
h.settle(SETTLE);
exec(&mut h, "tool.brush", json!({"new": true}));
let r = h.request("ui.set", json!({"brushSize": 0.03, "brushFeather": 50.0, "brushFlow": 100.0}), T);
assert_eq!(r["ok"], true, "{r}");
// the photo before painting, and where a normalized image point is on screen
pointer(&mut h, json!([{"kind": "move", "x": 0.02, "y": 0.02}]));
h.settle(SETTLE);
let photo = h.paint();
let img = h.app.image_rect.expect("the photo is on screen");
let ppp = h.pixels_per_point;
let px = |im: &egui::ColorImage, x: f32, y: f32| {
let (sx, sy) = (((img.left() + x * img.width()) * ppp) as usize, ((img.top() + y * img.height()) * ppp) as usize);
let c = im.pixels[sy * im.size[0] + sx];
[c.r() as f32, c.g() as f32, c.b() as f32]
};
// a drag that starts (past egui's drag threshold, at `a`) and then moves fast: samples far
// apart, button held
let (a, b, c) = ((0.17_f32, 0.4_f32), (0.5_f32, 0.4_f32), (0.85_f32, 0.4_f32));
pointer(
&mut h,
json!([{"kind": "down", "x": 0.15, "y": a.1}, {"kind": "drag", "x": a.0, "y": a.1}, {"kind": "drag", "x": b.0, "y": b.1}, {"kind": "drag", "x": c.0, "y": c.1}]),
);
assert!(matches!(h.app.gesture, Some(crate::panels::detail::Gesture::Brush { .. })), "still painting");
let held = h.paint();
let [r0, g0, b0] = h.app.ui.mask_overlay_color.map(|v| v as f32);
let op = h.app.ui.mask_overlay_opacity / 100.0;
// along the centreline between the samples (away from the cursor ring at the last one), the
// overlay colour at full coverage over the photo
let mut gaps = vec![];
for i in 1..=74 {
let x = a.0 + (c.0 - a.0) * i as f32 / 80.0;
let p = px(&photo, x, a.1);
let want = [p[0] + (r0 - p[0]) * op, p[1] + (g0 - p[1]) * op, p[2] + (b0 - p[2]) * op];
let got = px(&held, x, a.1);
if (0..3).any(|k| (got[k] - want[k]).abs() > 12.0) {
gaps.push((x, got, want));
}
}
assert!(gaps.is_empty(), "the live stroke has gaps: {} of 74 points, first {:?}", gaps.len(), gaps.first());
pointer(&mut h, json!([{"kind": "up", "x": c.0, "y": c.1}]));
let d = develop(&h);
let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") };
assert!(strokes[0].points.len() >= 3, "{strokes:?}");
// committed: the rendered overlay covers what the live stroke did, feather included
h.settle(SETTLE);
let done = h.paint();
for (x, y) in [(0.3, a.1), (0.6, a.1), (0.3, a.1 + 0.02), (0.6, a.1 - 0.025), (0.45, a.1 + 0.06)] {
let (l, k) = (px(&held, x, y), px(&done, x, y));
assert!((0..3).all(|i| (l[i] - k[i]).abs() < 24.0), "live {l:?} vs committed {k:?} at ({x}, {y})");
}
}
+1 -1
View File
@@ -356,7 +356,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, shows the model folder and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) |
| LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic |
| LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; live-preview gaps (#517) and stationary accumulation (#518) remain; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); stationary accumulation (#518) remains; Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider |