diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 4777c8782..aab169302 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -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 = 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, prev: Option, 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); diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs new file mode 100644 index 000000000..316af3811 --- /dev/null +++ b/crates/ui-egui/src/brush_live.rs @@ -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, + /// Samples stamped so far, and the last one (buffer pixels). + placed: usize, + last: Option, + /// Buffer pixels painted since the last upload: (x0, y0, x1, y1), exclusive ends. + dirty: Option<(usize, usize, usize, usize)>, + texture: Option, +} + +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, 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); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..78f753401 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// The brush stroke being painted, as drawn on the photo until it's committed (issue #517). + pub brush_live: Option, /// 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, diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae20..42e4bd489 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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 = 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); diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index c748ecf14..89f3e3c69 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -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})"); + } +} diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..007856b54 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |