mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user