From c1bd296dcf8429ef084cc357113c08b56caead67 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 18:00:25 +0200 Subject: [PATCH] Select and Mask: Refine Edge Brush (#2972) (#2989) * Select and Mask: live preview in Photoshop's view modes (#1754) The Select and Mask dialog had no preview of the refined selection. It now previews live in Photoshop's view modes: Onion Skin (default), Marching Ants, Overlay, On Black, On White, Black & White and On Layers, with a per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views, O/M/V/A/T/K/Y pick one and holding X shows the image without a view. The view state lives in the dialog's fields, so ui.dialog.set drives it. The preview runs select.refineEdge on the proxy with output=selection and builds the view from it (veil fill layers, a layer mask, or the refined selection, whose marching ants the canvas draws scaled back up), through the existing filter-preview worker. Co-Authored-By: Claude Opus 5.5 * Select and Mask: Refine Edge Brush (#2972) Select and Mask had no Refine Edge Brush. select.refineEdge now takes refineStrokes: [{"points":[[x,y],...],"size":px,"mode":"add|subtract"}], applied in painting order. Added areas become unknown and take a colour estimate: each pixel's colour projected on the line between the mean known foreground and background colours around it (normalised convolution over a box of the brush size, four times as far where none are near), so hair or fur beyond the edge-detection band joins the selection. Subtracted areas keep the original edge. Malformed or oversized strokes are rejected (stroke / point / swept-area limits), never a panic. The dialog gets the brush: Refine Edge Brush (R), Mode (Expand Detection Area / Restore Original Edge, Alt swaps), Size ([ and ]) and Clear Brush Strokes. With it on, dragging on the canvas paints strokes into the dialog's refineStrokes field, so the live preview, Show Edge and OK use them, and ui.dialog.set / agents drive them like any other param. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/algo/src/matting.rs | 63 +++- crates/algo/src/matting/brush.rs | 333 ++++++++++++++++++++ crates/algo/src/matting/morphology/tests.rs | 8 +- crates/engine/src/smartselect_cmds.rs | 94 +++++- crates/ui-egui/src/canvas.rs | 11 +- crates/ui-egui/src/i18n/cs.tsv | 6 + crates/ui-egui/src/i18n/de.tsv | 6 + crates/ui-egui/src/i18n/el.tsv | 6 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/fr.tsv | 6 + crates/ui-egui/src/i18n/id.tsv | 6 + crates/ui-egui/src/i18n/it.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/ko.tsv | 6 + crates/ui-egui/src/i18n/nl.tsv | 6 + crates/ui-egui/src/i18n/pl.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/select_mask_view.rs | 224 ++++++++++++- docs/gaps.md | 4 +- docs/scorecard.md | 2 +- scorecard/tools.toml | 2 +- 25 files changed, 815 insertions(+), 22 deletions(-) create mode 100644 crates/algo/src/matting/brush.rs diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index db8e7580..a9dcac98 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -224,8 +224,11 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec morphology::morph(src, w, h, r, grow) } +mod brush; mod morphology; +pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area}; + /// Running max (or min) over a window of radius `r` (edge-clipped), van Herk / Gil–Werman: /// three comparisons per sample whatever the radius. fn running_extreme(src: &[f32], out: &mut [f32], r: usize, max: bool, g: &mut Vec, hb: &mut Vec) { @@ -293,10 +296,18 @@ pub fn edge_width(img: &RgbImage, r: usize) -> Vec { /// Refines a soft mask buffer `m` (`w × h`) with `img` as guide (required when `radius > 0`). pub fn refine_buffer(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams) -> Vec { - refine_buffer_with(img, m, w, h, p, morph) + refine_buffer_with(img, m, w, h, p, None, morph) } -fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, morphology: F) -> Vec +/// The Refine Edge Brush over one buffer: signed coverage (`w × h`, see [`EdgeBrush`]) and the +/// box radius of its colour estimate. +#[derive(Clone, Copy)] +struct BrushBuf<'a> { + v: &'a [f32], + window: usize, +} + +fn refine_buffer_with(img: Option<&RgbImage>, m: &[f32], w: usize, h: usize, p: &RefineParams, brush: Option, morphology: F) -> Vec where F: Fn(&[f32], usize, usize, usize, bool) -> Vec + Copy, { @@ -324,13 +335,26 @@ where Some(wd) => wd[i].clamp(1.5, p.radius.max(1.5)), None => p.radius, }; - let wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0); + let mut wgt = (reff + 1.0 - dist[i]).clamp(0.0, 1.0); + // Restored by the brush: the original edge. + if let Some(b) = brush.and_then(|b| b.v.get(i)).filter(|b| **b < 0.0) { + wgt *= 1.0 + b; + } if wgt > 0.0 { a[i] = m[i] + (q[i].clamp(0.0, 1.0) - m[i]) * wgt; } } } } + // Added by the brush: coverage estimated from the image. + if let (Some(b), Some(img)) = (brush.filter(|b| b.v.iter().any(|v| *v > 0.0)), img) { + let est = brush::estimate(img, m, b.v, w, h, b.window); + for ((ai, ei), bi) in a.iter_mut().zip(&est).zip(b.v) { + if *bi > 0.0 { + *ai += (ei - *ai) * bi; + } + } + } let ss = p.smooth_sigma(); if ss >= 0.3 { a = gaussian_blur(&a, w, h, ss); @@ -357,7 +381,21 @@ where /// its non-zero pixels; the result is clipped to `canvas`. `sampler` supplies the guide image /// (only read when `radius > 0`). Returns `None` when nothing remains selected. pub fn refine_mask(sampler: &dyn Sampler, mask: &(dyn Fn(Rect) -> Vec + Sync), content: Rect, canvas: Rect, p: &RefineParams) -> Option { - refine_mask_with(sampler, mask, content, canvas, p, morph) + refine_mask_with(sampler, mask, content, canvas, p, None, morph) +} + +/// [`refine_mask`] with the Refine Edge Brush: where `brush` adds, coverage comes from the image +/// (the area can reach beyond the edge-detection band); where it restores, the original edge +/// stays. The sampler is read wherever the brush adds, whatever the radius. +pub fn refine_mask_brushed( + sampler: &dyn Sampler, + mask: &(dyn Fn(Rect) -> Vec + Sync), + content: Rect, + canvas: Rect, + p: &RefineParams, + brush: Option<&EdgeBrush>, +) -> Option { + refine_mask_with(sampler, mask, content, canvas, p, brush, morph) } fn refine_mask_with( @@ -366,6 +404,7 @@ fn refine_mask_with( content: Rect, canvas: Rect, p: &RefineParams, + brush: Option<&EdgeBrush>, morphology: F, ) -> Option where @@ -375,8 +414,8 @@ where if content.is_empty() { return None; } - let bbox = content.inflate(p.spread()).intersect(&canvas); - let halo = p.halo(); + let bbox = content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas); + let halo = p.halo() + brush.map_or(0, |b| i32::try_from(b.window() * 4).unwrap_or(i32::MAX)); let mut tiles = Vec::new(); let mut y = bbox.y0; while y < bbox.y1 { @@ -401,11 +440,17 @@ where } out }; - if (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) { + let painted = brush.filter(|b| !b.bounds().intersect(&r).is_empty()).map(|b| b.read(r)); + let adds = painted.as_ref().is_some_and(|v| v.iter().any(|v| *v > 0.0)); + if !adds && (first == 0.0 || first == 1.0) && m.iter().all(|v| *v == first) { return (*t, vec![q(first); cw * ch]); } - let img = p.uses_image().then(|| sampler.rgb(r)); - let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, morphology); + let img = (p.uses_image() || adds).then(|| sampler.rgb(r)); + let bb = match (&painted, brush) { + (Some(v), Some(b)) => Some(BrushBuf { v, window: b.window() }), + _ => None, + }; + let out = refine_buffer_with(img.as_ref(), &m, rw, rh, p, bb, morphology); (*t, core(&out)) }; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs new file mode 100644 index 00000000..f70342e6 --- /dev/null +++ b/crates/algo/src/matting/brush.rs @@ -0,0 +1,333 @@ +//! Select and Mask's Refine Edge Brush: strokes that set where edge refinement happens. +//! +//! Painting (`add`) marks an area as unknown: its coverage is estimated from the image, so soft +//! detail such as hair beyond the edge-detection band is picked up. Painting with `add = false` +//! (Photoshop's Alt) removes the refinement there and keeps the original edge. Strokes apply in +//! order; a later one paints over an earlier one. +//! +//! The estimate projects each painted pixel's colour on the line between the mean colours of the +//! known foreground and background around it (normalised convolution over a box twice the brush +//! size), the colour-line model of A. Levin, D. Lischinski and Y. Weiss, "A Closed-Form Solution +//! to Natural Image Matting" (CVPR 2006), with one foreground and one background colour per window. + +use photocraft_geom::Rect; + +use super::box_mean; +use crate::segment::RgbImage; + +/// One Refine Edge Brush stroke, in document pixels. +#[derive(Clone, Debug, PartialEq)] +pub struct EdgeStroke { + /// The stroke's path (a single point paints a dab). + pub points: Vec<(f32, f32)>, + /// Brush diameter in pixels. + pub size: f32, + /// Add to the refinement area (true) or restore the original edge (false). + pub add: bool, +} + +/// Brush diameters the strokes may use. +pub const SIZE_RANGE: std::ops::RangeInclusive = 1.0..=5000.0; + +/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`. +#[derive(Clone, Debug, PartialEq)] +pub struct EdgeBrush { + bbox: Rect, + /// Coverage × 127, row-major over `bbox`. + v: Vec, + /// Bounds of the added (positive) coverage. + added: Rect, + /// Box radius of the colour estimate (pixels). + window: usize, +} + +/// Coverage (0–1) of a round brush of radius `r` swept from `a` to `b`, at pixel centre `p`. +fn capsule(p: (f32, f32), a: (f32, f32), b: (f32, f32), r: f32) -> f32 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + let (cx, cy) = (a.0 + t * dx, a.1 + t * dy); + let d = ((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt(); + (r + 0.5 - d).clamp(0.0, 1.0) +} + +/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle. +fn dab_bounds(p: (f32, f32), r: f32) -> Rect { + let lo = |v: f32| (v - r - 1.0).floor().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32; + let hi = |v: f32| (v + r + 1.0).ceil().clamp(i32::MIN as f32 / 2.0, i32::MAX as f32 / 2.0) as i32; + Rect::new(lo(p.0), lo(p.1), hi(p.0), hi(p.1)) +} + +/// Most pixels the strokes of one [`EdgeBrush`] may sweep (Σ length × size + size² per stroke): +/// about eleven times a 24 MP canvas. +pub const MAX_SWEPT_AREA: f64 = (1u64 << 28) as f64; + +/// The area `strokes` sweep (see [`MAX_SWEPT_AREA`]); non-finite strokes count as infinite. +pub fn swept_area(strokes: &[EdgeStroke]) -> f64 { + strokes + .iter() + .map(|s| { + let size = f64::from(s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end())); + let len: f64 = s.points.windows(2).filter_map(|w| Some(f64::from((w.get(1)?.0 - w.first()?.0).hypot(w.get(1)?.1 - w.first()?.1)))).sum(); + if len.is_finite() { len * size + size * size } else { f64::INFINITY } + }) + .sum() +} + +/// The stroke's path as short segments: points closer than a quarter of the radius to the last +/// kept one are dropped, and long segments are split at most `r` long, so each segment's box +/// stays close to the area it paints. +fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> { + let Some(&first) = points.first() else { return Vec::new() }; + let min_step = (r / 4.0).max(0.5); + let max_step = r.max(2.0); + let mut kept = vec![first]; + for p in points.iter().skip(1) { + let last = kept.last().copied().unwrap_or(first); + if (p.0 - last.0).hypot(p.1 - last.1) >= min_step { + kept.push(*p); + } + } + if let (Some(last), Some(end)) = (kept.last().copied(), points.last().copied()) + && last != end + { + kept.push(end); + } + if kept.len() == 1 { + return vec![(first, first)]; + } + let mut out = Vec::new(); + for w in kept.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / max_step).ceil().clamp(1.0, 1e6) as usize; + let at = |k: usize| { + let t = k as f32 / pieces as f32; + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }; + out.extend((0..pieces).map(|k| (at(k), at(k + 1)))); + } + out +} + +impl EdgeBrush { + /// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more + /// than [`MAX_SWEPT_AREA`]. + pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option { + let usable = |s: &&EdgeStroke| s.size.is_finite() && !s.points.is_empty() && s.points.iter().all(|p| p.0.is_finite() && p.1.is_finite()); + if swept_area(strokes) > MAX_SWEPT_AREA { + return None; + } + let strokes: Vec<&EdgeStroke> = strokes.iter().filter(usable).collect(); + let stroke_bounds = |s: &EdgeStroke| -> Rect { + let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + s.points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas) + }; + let bbox = strokes.iter().fold(Rect::EMPTY, |acc, s| acc.union(&stroke_bounds(s))); + if bbox.is_empty() { + return None; + } + let bw = bbox.width() as usize; + let mut v = vec![0i8; bw.checked_mul(bbox.height() as usize)?]; + let mut cov: Vec = Vec::new(); + let mut max_add = 0.0f32; + for s in strokes { + let sb = stroke_bounds(s); + if sb.is_empty() { + continue; + } + let r = s.size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0; + if s.add { + max_add = max_add.max(2.0 * r); + } + let sw = sb.width() as usize; + cov.clear(); + cov.resize(sw * sb.height() as usize, 0.0); + // The stroke's coverage: the most any of its segments gives (a single dab for one point). + for (a, b) in segments(&s.points, r) { + let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&sb); + for y in seg.y0..seg.y1 { + for x in seg.x0..seg.x1 { + let c = capsule((x as f32 + 0.5, y as f32 + 0.5), a, b, r); + if let Some(slot) = cov.get_mut((y - sb.y0) as usize * sw + (x - sb.x0) as usize) + && c > *slot + { + *slot = c; + } + } + } + } + let target = if s.add { 1.0 } else { -1.0 }; + for y in sb.y0..sb.y1 { + for x in sb.x0..sb.x1 { + let c = cov.get((y - sb.y0) as usize * sw + (x - sb.x0) as usize).copied().unwrap_or(0.0); + if c <= 0.0 { + continue; + } + if let Some(slot) = v.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize) { + let cur = f32::from(*slot) / 127.0; + *slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8; + } + } + } + } + if v.iter().all(|c| *c == 0) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN); + for (row, line) in v.chunks_exact(bw).enumerate() { + let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue }; + let y = bbox.y0 + row as i32; + (x0, x1, y0, y1) = (x0.min(bbox.x0 + first as i32), x1.max(bbox.x0 + last as i32 + 1), y0.min(y), y1.max(y + 1)); + } + let added = if x0 < x1 { Rect::new(x0, y0, x1, y1) } else { Rect::EMPTY }; + Some(EdgeBrush { bbox, v, added, window: max_add.ceil().clamp(4.0, 64.0) as usize }) + } + + /// Where the strokes paint (added or restored). + pub fn bounds(&self) -> Rect { + self.bbox + } + + /// Where the strokes add to the refinement area (empty when they only restore). + pub fn added_bounds(&self) -> Rect { + self.added + } + + /// Box radius of the colour estimate (it reads up to four times as far). + pub fn window(&self) -> usize { + self.window + } + + /// Signed coverage at document pixel `(x, y)` (zero outside the painted area). + pub fn at(&self, x: i32, y: i32) -> f32 { + if !self.bbox.contains(x, y) { + return 0.0; + } + let i = (y - self.bbox.y0) as usize * self.bbox.width() as usize + (x - self.bbox.x0) as usize; + self.v.get(i).map_or(0.0, |c| f32::from(*c) / 127.0) + } + + /// Signed coverage over `r` (row-major). + pub fn read(&self, r: Rect) -> Vec { + (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect() + } +} + +/// Coverage estimated from the image where `brush` (signed, `w × h`) adds: each pixel's colour +/// projected on the line between the mean known foreground and background colours within +/// `window` px (or `4 × window` where there are none that close). Known means outside the painted area and (nearly) fully in or out of `m`. Pixels +/// with no foreground or background nearby, or no contrast between them, keep `m`. +pub(super) fn estimate(img: &RgbImage, m: &[f32], brush: &[f32], w: usize, h: usize, window: usize) -> Vec { + let n = w * h; + let known = |want_fg: bool| -> Vec { + (0..n) + .map(|i| { + let (mv, bv) = (m.get(i).copied().unwrap_or(0.0), brush.get(i).copied().unwrap_or(0.0)); + let ok = bv <= 0.0 && if want_fg { mv >= 0.98 } else { mv <= 0.02 }; + if ok { 1.0 } else { 0.0 } + }) + .collect() + }; + let (wf, wb) = (known(true), known(false)); + // Normalised convolution of the known colours: weight sums and weighted colour sums, over the + // window and (where the window finds none) four times as far. + let means = |wt: &[f32], r: usize| -> (Vec, Vec>) { + let sums = (0..3) + .map(|c| { + let v: Vec = img.px.iter().zip(wt).map(|(p, k)| p.get(c).copied().unwrap_or(0.0) * k).collect(); + box_mean(&v, w, h, r) + }) + .collect(); + (box_mean(wt, w, h, r), sums) + }; + let near = [means(&wf, window), means(&wb, window)]; + let far = [means(&wf, window * 4), means(&wb, window * 4)]; + // The mean known colour at `i` (0 = foreground, 1 = background). + let mean = |k: usize, i: usize| -> Option<[f32; 3]> { + let pick = |(d, s): &(Vec, Vec>)| -> Option<[f32; 3]> { + let d = d.get(i).copied().filter(|d| *d >= 1e-4)?; + Some([0, 1, 2].map(|c| s.get(c).and_then(|v| v.get(i)).copied().unwrap_or(0.0) / d)) + }; + near.get(k).and_then(pick).or_else(|| far.get(k).and_then(pick)) + }; + let mut out = m.to_vec(); + for (i, o) in out.iter_mut().enumerate() { + if brush.get(i).copied().unwrap_or(0.0) <= 0.0 { + continue; + } + let (Some(fm), Some(bm), Some(px)) = (mean(0, i), mean(1, i), img.px.get(i)) else { continue }; + let mut num = 0.0f32; + let mut den = 0.0f32; + for ((f, b), p) in fm.iter().zip(&bm).zip(px) { + num += (p - b) * (f - b); + den += (f - b) * (f - b); + } + if den > 1e-4 { + *o = (num / den).clamp(0.0, 1.0); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::matting::{RefineParams, refine_mask_brushed, region_reader}; + use crate::segment::ImageSampler; + use crate::selection::Region; + + /// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's + /// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was. + #[test] + fn brush_picks_up_strands_beyond_the_band_and_restores_the_edge() { + let (w, h) = (120usize, 60usize); + let (fg, bg) = ([0.9f32, 0.7, 0.2], [0.1f32, 0.3, 0.8]); + let strand = |x: usize| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4)); + let img = RgbImage::from_fn(w, h, |x, _| if strand(x) { fg } else { bg }); + let s = ImageSampler { img: &img, origin: (0, 0) }; + let canvas = Rect::new(0, 0, w as i32, h as i32); + let reg = Region { bbox: canvas, mask: (0..w * h).map(|i| if i % w < 40 { 255 } else { 0 }).collect() }; + let p = RefineParams { radius: 4.0, ..Default::default() }; + let refine = |strokes: &[EdgeStroke]| { + let brush = EdgeBrush::new(strokes, canvas); + refine_mask_brushed(&s, ®ion_reader(®), reg.bbox, canvas, &p, brush.as_ref()).expect("refined") + }; + let plain = refine(&[]); + assert!(plain.at(64, 30) < 0.01, "the band alone stops short of the strands"); + let over_strands = EdgeStroke { points: vec![(70.0, 10.0), (70.0, 50.0)], size: 30.0, add: true }; + let brushed = refine(std::slice::from_ref(&over_strands)); + assert!(brushed.at(64, 30) > 0.9 && brushed.at(72, 30) > 0.9, "strands added: {}", brushed.at(64, 30)); + assert!(brushed.at(66, 30) < 0.1, "the background between them stays out: {}", brushed.at(66, 30)); + assert!(brushed.at(20, 30) > 0.99 && brushed.at(100, 30) < 0.01, "unpainted areas keep the refined selection"); + // Restoring paints the refinement out: with Shift Edge 0 the edge is the original one. + let soft = RefineParams { radius: 4.0, feather: 0.0, ..Default::default() }; + let blurred = RgbImage::from_fn(w, h, |x, _| { + let t = ((x as f32 - 36.0) / 8.0).clamp(0.0, 1.0); + [0, 1, 2].map(|c| fg[c] * (1.0 - t) + bg[c] * t) + }); + let sb = ImageSampler { img: &blurred, origin: (0, 0) }; + let refined = refine_mask_brushed(&sb, ®ion_reader(®), reg.bbox, canvas, &soft, None).expect("refined"); + assert!(refined.at(41, 30) > 0.1, "the band softens the blurred edge"); + let restore = [EdgeStroke { points: vec![(40.0, 0.0), (40.0, 60.0)], size: 20.0, add: false }]; + let restored = refine_mask_brushed(&sb, ®ion_reader(®), reg.bbox, canvas, &soft, EdgeBrush::new(&restore, canvas).as_ref()).expect("refined"); + assert!(restored.at(41, 30) < 0.01 && restored.at(38, 30) > 0.99, "the original edge is back"); + } + + #[test] + fn strokes_paint_in_order_and_clip_to_the_canvas() { + let canvas = Rect::new(0, 0, 100, 50); + let add = EdgeStroke { points: vec![(10.0, 25.0), (90.0, 25.0)], size: 10.0, add: true }; + let b = EdgeBrush::new(std::slice::from_ref(&add), canvas).expect("painted"); + assert!(b.at(50, 25) > 0.99 && b.at(50, 40) == 0.0); + assert_eq!(b.window(), 10); + // A later subtract stroke paints over the add. + let sub = EdgeStroke { points: vec![(50.0, 25.0)], size: 20.0, add: false }; + let b = EdgeBrush::new(&[add.clone(), sub], canvas).expect("painted"); + assert!(b.at(50, 25) < -0.99 && b.at(20, 25) > 0.99); + // Off the canvas, non-finite or empty strokes paint nothing. + let off = EdgeStroke { points: vec![(-500.0, -500.0)], size: 10.0, add: true }; + let nan = EdgeStroke { points: vec![(f32::NAN, 3.0)], size: 10.0, add: true }; + let empty = EdgeStroke { points: vec![], size: 10.0, add: true }; + assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none()); + } +} diff --git a/crates/algo/src/matting/morphology/tests.rs b/crates/algo/src/matting/morphology/tests.rs index 81a1d3ba..4100f70b 100644 --- a/crates/algo/src/matting/morphology/tests.rs +++ b/crates/algo/src/matting/morphology/tests.rs @@ -261,8 +261,8 @@ fn tiled_refinement_release_comparison() { let region = trim_region(Region { bbox: canvas, mask }).unwrap(); let reader = region_reader(®ion); let p = RefineParams { radius: 64.0, shift_edge: 100.0, ..Default::default() }; - let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, repeated).unwrap(); - let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, morph).unwrap(); + let old = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, repeated).unwrap(); + let new = refine_mask_with(&sampler, &reader, region.bbox, canvas, &p, None, morph).unwrap(); assert_eq!(new.bbox, old.bbox); assert_eq!(new.mask, old.mask); let (mut old_ms, mut new_ms) = (Vec::new(), Vec::new()); @@ -270,9 +270,9 @@ fn tiled_refinement_release_comparison() { let mut measure = |fast: bool| { let start = Instant::now(); let out = if fast { - refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, morph) + refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, morph) } else { - refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, repeated) + refine_mask_with(&sampler, black_box(&reader), region.bbox, canvas, &p, None, repeated) } .unwrap(); let elapsed = start.elapsed().as_secs_f64() * 1000.0; diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 8fc04c94..5a1d87ad 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -205,12 +205,66 @@ fn refine_params(p: &Value) -> RefineParams { } } +/// Most Refine Edge Brush strokes, and points across them, one Select and Mask takes. +pub const MAX_STROKES: usize = 4096; +pub const MAX_STROKE_POINTS: usize = 100_000; + +/// The Refine Edge Brush strokes of `refineStrokes`: +/// `[{"points":[[x,y],…],"size":px,"mode":"add|subtract"}]`, in painting order. +fn refine_strokes(p: &Value) -> Result> { + const CMD: &str = "select.refineEdge"; + let Some(v) = p.get("refineStrokes").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad(CMD, "refineStrokes must be an array of strokes"))?; + if list.len() > MAX_STROKES { + return Err(bad(CMD, &format!("refineStrokes holds more than {MAX_STROKES} strokes"))); + } + let mut total = 0usize; + let mut strokes = Vec::with_capacity(list.len()); + for stroke in list { + let o = stroke.as_object().ok_or_else(|| bad(CMD, "each refineStrokes entry must be {\"points\":[[x,y],…],\"size\":px,\"mode\":\"add|subtract\"}"))?; + let pts = o.get("points").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| bad(CMD, "each stroke needs a non-empty points array"))?; + total = total.saturating_add(pts.len()); + if total > MAX_STROKE_POINTS { + return Err(bad(CMD, &format!("refineStrokes holds more than {MAX_STROKE_POINTS} points"))); + } + let points = pts + .iter() + .map(|pt| { + let xy = pt.as_array().filter(|a| a.len() == 2)?; + let (x, y) = (xy.first()?.as_f64()?, xy.get(1)?.as_f64()?); + let ok = |v: f64| v.is_finite() && v.abs() <= 1e7; + (ok(x) && ok(y)).then_some((x as f32, y as f32)) + }) + .collect::>>() + .ok_or_else(|| bad(CMD, "stroke points must be [x,y] pairs of finite numbers"))?; + let range = matting::BRUSH_SIZE_RANGE; + let size = match o.get("size") { + None => 30.0, + Some(v) => v + .as_f64() + .filter(|s| s.is_finite() && (f64::from(*range.start())..=f64::from(*range.end())).contains(s)) + .ok_or_else(|| bad(CMD, &format!("stroke size must be {}..{} px", range.start(), range.end())))? as f32, + }; + let add = match o.get("mode").map(|m| m.as_str()) { + None | Some(Some("add")) => true, + Some(Some("subtract")) => false, + Some(_) => return Err(bad(CMD, "stroke mode must be add|subtract")), + }; + strokes.push(matting::EdgeStroke { points, size, add }); + } + if matting::swept_area(&strokes) > matting::MAX_SWEPT_AREA { + return Err(bad(CMD, "refineStrokes paint too large an area")); + } + Ok(strokes) +} + /// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on /// the active layer, or a new layer (with or without a mask). Decontaminating colours needs a /// new layer, so it upgrades `selection` / `layerMask` output to `newLayerWithMask` (as /// Photoshop does). New-layer outputs hide the source layer. fn refine_edge(s: &mut Session, p: &Value) -> Result { let params = refine_params(p); + let strokes = refine_strokes(p)?; let decontaminate = b(p, "decontaminate", false); let amount = f(p, "amount", 100.0).clamp(0.0, 100.0); let d = s.active().ok_or(EngineError::NoDocument)?; @@ -227,7 +281,8 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result { let region = with_sampler(s, b(p, "sampleAllLayers", false), |smp, doc| { let mask_surf = active.and_then(|id| doc.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| &m.surface); let sel = doc.selection.as_ref().or(mask_surf)?; - matting::refine_mask(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms) + let brush = matting::EdgeBrush::new(&strokes, doc.bounds()); + matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), ¶ms, brush.as_ref()) })?; let d = s.active().ok_or(EngineError::NoDocument)?; let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some()); @@ -319,7 +374,7 @@ pub fn specs() -> Vec { "select.refineEdge", "Refine Edge", [], - r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask"="selection","sampleAllLayers":bool=false}"##, + r##"{"radius":px=0,"smartRadius":bool=false,"smooth":0..100=0,"feather":px=0,"contrast":0..100=0,"shiftEdge":-100..100=0,"decontaminate":bool=false,"amount":0..100=100,"output":"selection|layerMask|newLayer|newLayerWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there)"##, has_selection_or_mask, refine_edge ), @@ -620,6 +675,41 @@ mod tests { assert!(cov(&s, 38, 20) == 1.0 && cov(&s, 42, 20) == 0.0); } + /// The Refine Edge Brush adds strands beyond the radius band, at every depth, and rejects + /// malformed strokes. + #[test] + fn refine_edge_brush_strokes() { + for depth in [8, 16, 32] { + // Warm subject (x < 40) with warm strands every 4th column from x = 60, on blue. + let strand = |x: u32| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4)); + let mut s = session_with(120, 40, depth, |x, _| if strand(x) { [0.9, 0.7, 0.2] } else { [0.1, 0.3, 0.8] }); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap(); + let stroke = json!([{"points": [[70, 5], [70, 35]], "size": 30}]); + s.execute("select.refineEdge", json!({"radius": 4, "refineStrokes": stroke})).unwrap(); + assert!(cov(&s, 64, 20) > 0.9 && cov(&s, 72, 20) > 0.9, "depth {depth}: strands {}", cov(&s, 64, 20)); + assert!(cov(&s, 66, 20) < 0.1 && cov(&s, 100, 20) < 0.01, "depth {depth}: background"); + s.execute("edit.undo", json!({})).unwrap(); + // Subtract over the same place paints the refinement out again. + let both = json!([{"points": [[70, 5], [70, 35]], "size": 30}, {"points": [[70, 5], [70, 35]], "size": 40, "mode": "subtract"}]); + s.execute("select.refineEdge", json!({"radius": 4, "refineStrokes": both})).unwrap(); + assert!(cov(&s, 64, 20) < 0.01, "depth {depth}"); + s.execute("edit.undo", json!({})).unwrap(); + for bad in [ + json!(7), + json!([5]), + json!([{"points": []}]), + json!([{"points": [[1, "x"]]}]), + json!([{"points": [[1, 2, 3]]}]), + json!([{"points": [[1, 2]], "size": 0}]), + json!([{"points": [[1, 2]], "size": 1e9}]), + json!([{"points": [[1, 2]], "mode": "xor"}]), + json!([{"points": [[0, 0], [1e6, 1e6], [0, 0], [1e6, 1e6]], "size": 5000}]), + ] { + assert!(s.execute("select.refineEdge", json!({"refineStrokes": bad})).is_err(), "{bad}"); + } + } + } + #[test] fn refine_edge_works_on_layer_mask_without_selection() { let mut s = blurred_edge_selected(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cb5b475b..55bbc2e7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2810,7 +2810,10 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: let picking = primary && (crate::color_picker_ui::top(app).is_some() || curves_picking); let range_picking = primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling); - let hand = app.ui.tool == Tool::Hand && !picking && !range_picking; + // Select and Mask's Refine Edge Brush paints on the image. + let refine_brushing = + primary && app.ui.dialogs.last().is_some_and(|d| crate::select_mask_view::owns(&d.fields) && crate::select_mask_view::brush_on(&d.fields)); + let hand = app.ui.tool == Tool::Hand && !picking && !range_picking && !refine_brushing; if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) { let d = xf.unmap_vec(d) / (view.zoom / ppp); view.center[0] -= d.x; @@ -2832,6 +2835,12 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: crate::color_picker_ui::sample_at(app, d[0], d[1]); } } + } else if refine_brushing + && !middle + && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect) + { + let press = crate::dialogs::free_press(&ctx, rect).map(|q| xf.to_doc(q)); + crate::select_mask_view::canvas_brush(app, &painter, p, press, xf.zoom_x()); } else if primary && !middle && let Some(p) = crate::dialogs::free_pointer_over(&ctx, rect).filter(|p| img_rect.contains(*p)) diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9dd0c2f7..93f8fd59 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2572,6 +2572,12 @@ cameraData Lens Objektiv Show Edge Zobrazit okraj Show Original Zobrazit originál F cycles the views; hold X to see the image F přepíná zobrazení; podržením X uvidíte obraz +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Štětec pro upřesnění okraje (R) + Expand Detection Area Rozšířit oblast detekce + Restore Original Edge Obnovit původní okraj + Paint over hair or fur on the image; Alt paints the other mode Malujte přes vlasy nebo srst v obraze; Alt maluje v opačném režimu + Clear Brush Strokes Vymazat tahy štětce Show rulers Zobrazit pravítka # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 457e1832..26871a1e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2603,6 +2603,12 @@ cameraData Lens Objektiv Show Edge Kante anzeigen Show Original Original anzeigen F cycles the views; hold X to see the image F wechselt die Ansicht; X gedrückt halten zeigt das Bild +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Kante-verbessern-Pinsel (R) + Expand Detection Area Erkennungsbereich erweitern + Restore Original Edge Ursprüngliche Kante wiederherstellen + Paint over hair or fur on the image; Alt paints the other mode Über Haare oder Fell im Bild malen; Alt malt im anderen Modus + Clear Brush Strokes Pinselstriche löschen Show rulers Lineale anzeigen # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5cb83933..dc39a341 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3664,6 +3664,12 @@ cameraData Lens Φακός Show Edge Εμφάνιση άκρης Show Original Εμφάνιση αρχικού F cycles the views; hold X to see the image Το F εναλλάσσει τις προβολές· κρατήστε το X για να δείτε την εικόνα +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Πινέλο βελτίωσης ακμής (R) + Expand Detection Area Επέκταση περιοχής ανίχνευσης + Restore Original Edge Επαναφορά αρχικής ακμής + Paint over hair or fur on the image; Alt paints the other mode Βάψτε πάνω σε μαλλιά ή γούνα στην εικόνα· το Alt βάφει με την άλλη λειτουργία + Clear Brush Strokes Απαλοιφή πινελιών Show rulers Εμφάνιση χαράκων # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d331384b..c65d4dd0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2644,6 +2644,12 @@ cameraData Lens Objetivo Show Edge Mostrar borde Show Original Mostrar original F cycles the views; hold X to see the image F alterna las vistas; mantén X para ver la imagen +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Pincel para perfeccionar bordes (R) + Expand Detection Area Ampliar área de detección + Restore Original Edge Restaurar borde original + Paint over hair or fur on the image; Alt paints the other mode Pinte sobre el pelo o el pelaje de la imagen; Alt pinta en el otro modo + Clear Brush Strokes Borrar trazos de pincel Show rulers Mostrar reglas # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 20871398..6a20c352 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2981,6 +2981,12 @@ cameraData Lens Objectif Show Edge Afficher le bord Show Original Afficher l'original F cycles the views; hold X to see the image F fait défiler les vues ; maintenez X pour voir l'image +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Pinceau d'amélioration du contour (R) + Expand Detection Area Étendre la zone de détection + Restore Original Edge Restaurer le contour d'origine + Paint over hair or fur on the image; Alt paints the other mode Peignez sur les cheveux ou la fourrure dans l'image ; Alt peint dans l'autre mode + Clear Brush Strokes Effacer les traits de pinceau Show rulers Afficher les règles # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 248865b8..9f1b970d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2968,6 +2968,12 @@ cameraData Lens Lensa Show Edge Tampilkan Tepi Show Original Tampilkan Asli F cycles the views; hold X to see the image F berganti tampilan; tahan X untuk melihat gambar +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Kuas Perhalus Tepi (R) + Expand Detection Area Perluas Area Deteksi + Restore Original Edge Pulihkan Tepi Asli + Paint over hair or fur on the image; Alt paints the other mode Lukis di atas rambut atau bulu pada gambar; Alt melukis dalam mode lainnya + Clear Brush Strokes Hapus Goresan Kuas Show rulers Tampilkan Penggaris # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 398a11f4..4924ecfc 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2635,6 +2635,12 @@ cameraData Lens Obiettivo Show Edge Mostra bordo Show Original Mostra originale F cycles the views; hold X to see the image F scorre le viste; tieni premuto X per vedere l'immagine +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Pennello per migliorare il bordo (R) + Expand Detection Area Espandi area di rilevamento + Restore Original Edge Ripristina bordo originale + Paint over hair or fur on the image; Alt paints the other mode Dipingi su capelli o pelo nell'immagine; Alt dipinge nell'altra modalità + Clear Brush Strokes Cancella tratti del pennello Show rulers Mostra righelli # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b1fcbeea..be6169e8 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3578,6 +3578,12 @@ cameraData Lens レンズ Show Edge エッジを表示 Show Original 元の状態を表示 F cycles the views; hold X to see the image F で表示を切り替え、X を押している間は画像を表示 +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) 境界線調整ブラシ (R) + Expand Detection Area 検出領域を拡張 + Restore Original Edge 元のエッジを復元 + Paint over hair or fur on the image; Alt paints the other mode 画像の髪や毛の上をペイントします。Alt で反対のモードでペイント + Clear Brush Strokes ブラシストロークを消去 Show rulers 定規を表示 # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 78c75e4e..b9ee70a4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3147,6 +3147,12 @@ cameraData Lens 렌즈 Show Edge 가장자리 표시 Show Original 원본 표시 F cycles the views; hold X to see the image F로 보기 전환, X를 누르고 있으면 이미지 표시 +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) 가장자리 다듬기 브러시 (R) + Expand Detection Area 감지 영역 확장 + Restore Original Edge 원래 가장자리 복원 + Paint over hair or fur on the image; Alt paints the other mode 이미지의 머리카락이나 털 위를 칠합니다. Alt는 다른 모드로 칠합니다 + Clear Brush Strokes 브러시 획 지우기 Show rulers 눈금자 표시 # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f883800f..00d793cb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3724,6 +3724,12 @@ cameraData Lens Lens Show Edge Rand tonen Show Original Origineel tonen F cycles the views; hold X to see the image F wisselt de weergaven; houd X ingedrukt om de afbeelding te zien +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Penseel Rand verfijnen (R) + Expand Detection Area Detectiegebied uitbreiden + Restore Original Edge Oorspronkelijke rand herstellen + Paint over hair or fur on the image; Alt paints the other mode Schilder over haar of vacht in de afbeelding; Alt schildert in de andere modus + Clear Brush Strokes Penseelstreken wissen Show rulers Linialen tonen Frame Tool Kadergereedschap Create a new rectangular frame Nieuw rechthoekig kader maken diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a6761717..d6018ec9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3579,6 +3579,12 @@ cameraData Lens Obiektyw Show Edge Pokaż krawędź Show Original Pokaż oryginał F cycles the views; hold X to see the image F przełącza widoki; przytrzymaj X, aby zobaczyć obraz +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Pędzel dopracowania krawędzi (R) + Expand Detection Area Rozszerz obszar wykrywania + Restore Original Edge Przywróć oryginalną krawędź + Paint over hair or fur on the image; Alt paints the other mode Maluj po włosach lub sierści na obrazie; Alt maluje w drugim trybie + Clear Brush Strokes Wyczyść pociągnięcia pędzla Show rulers Pokaż linijki # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b26081f4..1f89561a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2988,6 +2988,12 @@ cameraData Lens Lente Show Edge Mostrar borda Show Original Mostrar original F cycles the views; hold X to see the image F alterna as visualizações; segure X para ver a imagem +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Pincel de refinamento de borda (R) + Expand Detection Area Expandir área de detecção + Restore Original Edge Restaurar borda original + Paint over hair or fur on the image; Alt paints the other mode Pinte sobre cabelo ou pelo na imagem; Alt pinta no outro modo + Clear Brush Strokes Limpar traços do pincel Show rulers Mostrar réguas # Color Sampler (added with the i18n coverage repair) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 419c25b7..25e24037 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2904,6 +2904,12 @@ cameraData Lens Объектив Show Edge Показать край Show Original Показать исходное F cycles the views; hold X to see the image F переключает виды; удерживайте X, чтобы увидеть изображение +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Кисть уточнения края (R) + Expand Detection Area Расширить область обнаружения + Restore Original Edge Восстановить исходный край + Paint over hair or fur on the image; Alt paints the other mode Рисуйте по волосам или шерсти на изображении; Alt рисует в другом режиме + Clear Brush Strokes Очистить мазки кисти Show rulers Показать линейки New Crop Preset… Новый набор параметров кадрирования… Delete Crop Preset… Удалить набор параметров кадрирования… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 16a41e35..a28efb4a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3654,6 +3654,12 @@ cameraData Lens Об'єктив Show Edge Показати край Show Original Показати початкове F cycles the views; hold X to see the image F перемикає види; утримуйте X, щоб побачити зображення +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) Пензель уточнення краю (R) + Expand Detection Area Розширити область виявлення + Restore Original Edge Відновити початковий край + Paint over hair or fur on the image; Alt paints the other mode Малюйте по волоссю чи хутру на зображенні; Alt малює в іншому режимі + Clear Brush Strokes Очистити мазки пензля Show rulers Показати лінійки Frame Tool Інструмент «Рамка» Create a new rectangular frame Створити прямокутну рамку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0594427f..5c7caba0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2573,6 +2573,12 @@ cameraData Lens 镜头 Show Edge 显示边缘 Show Original 显示原始 F cycles the views; hold X to see the image 按 F 切换视图;按住 X 查看图像 +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) 调整边缘画笔 (R) + Expand Detection Area 扩展检测区域 + Restore Original Edge 恢复原始边缘 + Paint over hair or fur on the image; Alt paints the other mode 在图像中的头发或毛发上涂抹;按住 Alt 以另一种模式涂抹 + Clear Brush Strokes 清除画笔描边 Show rulers 显示标尺 New Crop Preset… 新建裁剪预设… Delete Crop Preset… 删除裁剪预设… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 80458d29..08a7c3be 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2543,6 +2543,12 @@ cameraData Lens 鏡頭 Show Edge 顯示邊緣 Show Original 顯示原始 F cycles the views; hold X to see the image 按 F 切換檢視;按住 X 檢視影像 +# Select and Mask Refine Edge Brush (#2972) + Refine Edge Brush (R) 調整邊緣筆刷 (R) + Expand Detection Area 擴展偵測區域 + Restore Original Edge 恢復原始邊緣 + Paint over hair or fur on the image; Alt paints the other mode 在影像中的頭髮或毛髮上塗抹;按住 Alt 以另一種模式塗抹 + Clear Brush Strokes 清除筆刷筆觸 Show rulers 顯示尺標 New Crop Preset… 新增裁切預設集… Delete Crop Preset… 刪除裁切預設集… diff --git a/crates/ui-egui/src/select_mask_view.rs b/crates/ui-egui/src/select_mask_view.rs index ffd9da74..9f5440c4 100644 --- a/crates/ui-egui/src/select_mask_view.rs +++ b/crates/ui-egui/src/select_mask_view.rs @@ -129,12 +129,19 @@ pub fn owns(fields: &Map) -> bool { } /// A view key pressed in the dialog: a view letter picks that view, F the next one, J toggles -/// Show Edge and P Show Original. Returns whether the key was one of them. +/// Show Edge and P Show Original, R the Refine Edge Brush, and [ / ] change its size. Returns +/// whether the key was one of them. pub fn apply_key(fields: &mut Map, key: egui::Key) -> bool { if key == egui::Key::F { fields.insert("__view".into(), json!(mode(fields).next().id())); } else if let Some(m) = Mode::ALL.into_iter().find(|m| m.key() == key) { fields.insert("__view".into(), json!(m.id())); + } else if key == egui::Key::R { + fields.insert("__brush".into(), json!(!brush_on(fields))); + } else if key == egui::Key::OpenBracket || key == egui::Key::CloseBracket { + let size = brush_size(fields); + let next = if key == egui::Key::OpenBracket { (size * 0.9).min(size - 1.0) } else { (size * 1.1).max(size + 1.0) }; + fields.insert("__brushSize".into(), json!(next.round().clamp(BRUSH_SIZES.0, BRUSH_SIZES.1))); } else if key == egui::Key::J || key == egui::Key::P { let field = if key == egui::Key::J { "__showEdge" } else { "__showOriginal" }; fields.insert(field.into(), json!(!flag(fields, field))); @@ -193,6 +200,114 @@ pub fn controls(ui: &mut egui::Ui, fields: &mut Map) { ui.add_space(4.0); crate::widgets::hairline(ui); ui.add_space(6.0); + brush_controls(ui, fields); +} + +/// The Refine Edge Brush (R): painting on the canvas sets where edge refinement happens. +fn brush_controls(ui: &mut egui::Ui, fields: &mut Map) { + let t = Tokens::get(ui.ctx()); + let mut on = brush_on(fields); + crate::widgets::checkbox(ui, &mut on, tl!("Refine Edge Brush (R)")); + fields.insert("__brush".into(), json!(on)); + if on { + let mut subtract = brush_subtract(fields); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Mode")).color(t.text_dim)); + let options = [(false, tl!("Expand Detection Area")), (true, tl!("Restore Original Edge"))]; + crate::widgets::dropdown(ui, "select-mask-brush-mode", &mut subtract, &options, 170.0); + }); + fields.insert("__brushMode".into(), json!(if subtract { "subtract" } else { "add" })); + let mut size = brush_size(fields); + crate::widgets::slider_row(ui, tl!("Size"), &mut size, BRUSH_SIZES.0..=BRUSH_SIZES.1, "px", None); + fields.insert("__brushSize".into(), json!(size.round().clamp(BRUSH_SIZES.0, BRUSH_SIZES.1))); + ui.label(egui::RichText::new(tl!("Paint over hair or fur on the image; Alt paints the other mode")).color(t.text_dim).small()); + } + let painted = fields.get("refineStrokes").and_then(Value::as_array).is_some_and(|a| !a.is_empty()); + if painted && crate::widgets::secondary_button(ui, tl!("Clear Brush Strokes"), 0.0).clicked() { + fields.remove("refineStrokes"); + } + ui.add_space(4.0); + crate::widgets::hairline(ui); + ui.add_space(6.0); +} + +/// The Refine Edge Brush's diameters (px) in the dialog. +const BRUSH_SIZES: (f32, f32) = (1.0, 1000.0); + +/// Whether the Refine Edge Brush is the dialog's tool. +pub fn brush_on(fields: &Map) -> bool { + flag(fields, "__brush") +} + +fn brush_size(fields: &Map) -> f32 { + let size = fields.get("__brushSize").and_then(Value::as_f64).filter(|v| v.is_finite()).map_or(30.0, |v| v as f32); + size.clamp(BRUSH_SIZES.0, BRUSH_SIZES.1) +} + +/// The brush restores the original edge (Photoshop's − mode) instead of expanding the area. +fn brush_subtract(fields: &Map) -> bool { + fields.get("__brushMode").and_then(Value::as_str) == Some("subtract") +} + +/// Paints the Refine Edge Brush at document point `at` into the dialog's `refineStrokes`: +/// `start` begins a new stroke (in `subtract` mode or not), otherwise the point extends the last +/// one. Points closer than an eighth of the brush to the previous one are skipped, and painting +/// stops at the command's stroke and point limits. +pub fn paint(fields: &mut Map, at: [f64; 2], start: bool, subtract: bool) { + if !at[0].is_finite() || !at[1].is_finite() { + return; + } + let size = f64::from(brush_size(fields)); + let mut strokes = fields.get("refineStrokes").and_then(Value::as_array).cloned().unwrap_or_default(); + let points = |s: &Value| s.get("points").and_then(Value::as_array).map_or(0, Vec::len); + if strokes.iter().map(points).sum::() >= photocraft_engine::smartselect_cmds::MAX_STROKE_POINTS { + return; + } + let round = |v: f64| (v * 10.0).round() / 10.0; + let pt = json!([round(at[0]), round(at[1])]); + let room = strokes.len() < photocraft_engine::smartselect_cmds::MAX_STROKES; + let last = strokes.last_mut().and_then(|s| s.get_mut("points")).and_then(Value::as_array_mut); + match last { + Some(pts) if !start => { + let prev = pts.last().and_then(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))); + if prev.is_none_or(|(x, y)| (x - at[0]).hypot(y - at[1]) >= (size / 8.0).max(1.0)) { + pts.push(pt); + } + } + _ if room => { + strokes.push(json!({"points": [pt], "size": size, "mode": if subtract { "subtract" } else { "add" }})); + } + _ => return, + } + fields.insert("refineStrokes".into(), Value::Array(strokes)); +} + +/// The canvas under a Select and Mask dialog with the Refine Edge Brush on: shows the brush at +/// `over` (zoom = screen points per document pixel) and paints at document point `press`. False +/// when the brush isn't the top dialog's tool. +pub fn canvas_brush(app: &mut PhotocraftApp, painter: &egui::Painter, over: egui::Pos2, press: Option<[f64; 2]>, zoom: f32) -> bool { + let ctx = painter.ctx().clone(); + let Some(d) = app.ui.dialogs.last_mut().filter(|d| owns(&d.fields) && brush_on(&d.fields)) else { return false }; + let (alt, start) = ctx.input(|i| (i.modifiers.alt, i.pointer.primary_pressed())); + // Alt paints in the other mode, as in Photoshop. + let subtract = brush_subtract(&d.fields) != alt; + let r = (brush_size(&d.fields) / 2.0 * zoom).max(1.5); + let (dark, light) = (egui::Stroke::new(1.0, egui::Color32::from_black_alpha(140)), egui::Stroke::new(1.0, egui::Color32::from_white_alpha(220))); + painter.circle_stroke(over, r + 0.5, dark); + painter.circle_stroke(over, r, light); + let arm = 3.0; + for stroke in [egui::Stroke::new(2.5, egui::Color32::from_black_alpha(140)), egui::Stroke::new(1.0, egui::Color32::from_white_alpha(220))] { + painter.line_segment([over - egui::vec2(arm, 0.0), over + egui::vec2(arm, 0.0)], stroke); + if !subtract { + painter.line_segment([over - egui::vec2(0.0, arm), over + egui::vec2(0.0, arm)], stroke); + } + } + ctx.set_cursor_icon(egui::CursorIcon::None); + if let Some(at) = press { + paint(&mut d.fields, at, start, subtract); + ctx.request_repaint(); + } + true } /// The preview request: the command's params plus the view (`__view`), so a view change @@ -268,6 +383,30 @@ impl Coverage { Coverage { area, values: grown.iter().zip(&shrunk).map(|(g, s)| (g - s).clamp(0.0, 1.0)).collect(), outside: 0.0 } } + /// Coverage at canvas pixel `(x, y)`. + fn at(&self, x: i32, y: i32) -> f32 { + if !self.area.contains(x, y) { + return self.outside; + } + let i = (y - self.area.y0) as usize * self.area.width() as usize + (x - self.area.x0) as usize; + self.values.get(i).copied().unwrap_or(self.outside) + } + + /// The edge band as the Refine Edge Brush changes it: painted areas join it, restored ones + /// leave it. + fn with_brush(self, brush: Option<&photocraft_algo::matting::EdgeBrush>, canvas: Rect) -> Coverage { + let Some(brush) = brush else { return self }; + let area = self.area.union(&brush.bounds()).intersect(&canvas); + let values = (area.y0..area.y1) + .flat_map(|y| (area.x0..area.x1).map(move |x| (x, y))) + .map(|(x, y)| { + let (v, b) = (self.at(x, y), brush.at(x, y)); + if b >= 0.0 { v.max(b) } else { v * (1.0 + b) } + }) + .collect(); + Coverage { area, values, outside: self.outside } + } + /// A GRAY8 mask surface holding `f(coverage)`. fn surface(&self, f: impl Fn(f32) -> f32) -> Surface { let g = |v: f32| f(v).clamp(0.0, 1.0); @@ -317,7 +456,8 @@ pub fn preview_document(doc: &Document, active: Option, params: &Value, let original = proxy.selection.clone().or_else(|| active.and_then(|id| proxy.layer(id)).and_then(|l| l.mask.as_ref()).map(|m| m.surface.clone())); let canvas = Rect::new(0, 0, i32::try_from(proxy.size.width).unwrap_or(i32::MAX), i32::try_from(proxy.size.height).unwrap_or(i32::MAX)); let coverage = if view.show_edge { - Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize) + let band = Coverage::edge_band(original.as_ref(), canvas, length(params, "radius", k).round().clamp(1.0, 250.0) as usize); + band.with_brush(scaled_strokes(params, k).as_ref().and_then(|s| photocraft_algo::matting::EdgeBrush::new(s, canvas)).as_ref(), canvas) } else if view.show_original { Coverage::read(original.as_ref(), canvas) } else { @@ -344,10 +484,54 @@ fn refine(proxy: &Document, active: Option, params: &Value, k: u32, can p.insert(key.into(), json!(length(params, key, k))); } } + if let Some(strokes) = p.get("refineStrokes").and_then(Value::as_array).cloned() { + let k = f64::from(k.max(1)); + let scale = |v: &Value| v.as_f64().map_or(v.clone(), |n| json!(n / k)); + let scaled: Vec = strokes + .into_iter() + .map(|mut s| { + if let Some(o) = s.as_object_mut() { + if let Some(size) = o.get("size").and_then(Value::as_f64) { + o.insert("size".into(), json!((size / k).max(1.0))); + } + if let Some(pts) = o.get_mut("points").and_then(Value::as_array_mut) { + for pt in pts.iter_mut() { + if let Some(xy) = pt.as_array_mut() { + xy.iter_mut().for_each(|v| *v = scale(v)); + } + } + } + } + s + }) + .collect(); + p.insert("refineStrokes".into(), Value::Array(scaled)); + } s.execute(COMMAND, Value::Object(p)).ok()?; Some(s.active()?.doc.selection.clone()) } +/// The Refine Edge Brush strokes of `params`, reduced by `k`, for Show Edge; `None` when there +/// are none (or they are malformed: the refinement itself reports those). +fn scaled_strokes(params: &Value, k: u32) -> Option> { + let k = k.max(1) as f32; + params + .get("refineStrokes")? + .as_array()? + .iter() + .map(|s| { + let points = s + .get("points")? + .as_array()? + .iter() + .map(|p| Some((p.get(0)?.as_f64()? as f32 / k, p.get(1)?.as_f64()? as f32 / k))) + .collect::>>()?; + let size = (s.get("size").and_then(Value::as_f64).unwrap_or(30.0) as f32 / k).max(1.0); + Some(photocraft_algo::matting::EdgeStroke { points, size, add: s.get("mode").and_then(Value::as_str) != Some("subtract") }) + }) + .collect() +} + /// A solid fill layer named for the view. fn veil(name: &str, color: Color, opacity: f32, mask: Option) -> Layer { let mut l = Layer::new(name, LayerContent::Fill(Fill::Solid(color))); @@ -472,6 +656,42 @@ mod tests { assert!(preview_document(&doc, None, &bad, 3, None).is_some()); } + /// Refine Edge Brush strokes painted in the dialog reach the (reduced) preview: strands + /// beyond the band join the selection, and Show Edge includes the painted area. + #[test] + fn brush_strokes_reach_the_preview() { + // Warm subject (x < 40) with warm strands every 4th column from x = 60, on blue. + let strand = |x: u32| x < 40 || ((60..84).contains(&x) && x.is_multiple_of(4)); + let mut doc = Document::with_background("d", Size::new(120, 40), ColorMode::Rgb, SampleType::U8, Color::WHITE); + if let Some(bg) = doc.layers.first_mut().and_then(|l| l.surface_mut()) { + let n = bg.channels(); + let data: Vec = (0..40u32) + .flat_map(|_| (0..120u32).flat_map(move |x| if strand(x) { [0.9, 0.7, 0.2, 1.0] } else { [0.1, 0.3, 0.8, 1.0] }.into_iter().take(n))) + .collect(); + bg.write_region(Rect::new(0, 0, 120, 40), &data); + } + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 40, 40), &[1.0]); + doc.selection = Some(sel); + let active = doc.layers.first().map(|l| l.id); + let mut f = Map::new(); + apply_key(&mut f, egui::Key::R); + assert!(brush_on(&f), "R picks the brush"); + f.insert("__brushSize".into(), json!(30)); + paint(&mut f, [70.0, 2.0], true, false); + paint(&mut f, [70.0, 2.5], false, false); + paint(&mut f, [70.0, 38.0], false, false); + let strokes = f.get("refineStrokes").cloned().unwrap_or_default(); + assert_eq!(strokes, json!([{"points": [[70.0, 2.0], [70.0, 38.0]], "size": 30.0, "mode": "add"}]), "near points are skipped"); + let mut params = json!({"radius": 4, "refineStrokes": strokes, "__view": {"mode": "blackWhite"}}); + let shown = preview_document(&doc, active, ¶ms, 2, None).expect("a preview"); + assert!(pixel(&shown, 32, 10)[0] > 0.8, "a strand at full size x = 64 joins the selection"); + assert!(pixel(&shown, 50, 10)[0] < 0.01, "the background past the strands stays out"); + params["__view"]["showEdge"] = json!(true); + let edge = preview_document(&doc, active, ¶ms, 2, None).expect("a preview"); + assert!(pixel(&edge, 35, 10)[0] > 0.99 && pixel(&edge, 55, 10)[0] < 0.01, "Show Edge includes the painted area"); + } + /// Show Edge shows the band around the selection's edge; Show Original the unrefined selection. #[test] fn show_edge_and_show_original() { diff --git a/docs/gaps.md b/docs/gaps.md index ddffcc15..e1508caa 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -65,8 +65,8 @@ what an agent can't finish alone. - **Estimate:** 60–100 h (2–9 h each, per the calibration). ### G7. Workspaces shown as parameter dialogs -- **Missing:** Select and Mask workspace layout, Refine Edge brush and outputs - (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754); Content-Aware Fill workspace with a paintable sampling area +- **Missing:** Select and Mask workspace layout and New Document outputs + (TOOL-214-1, Refine Hair #2671; its view modes preview live since #1754, Refine Edge Brush #2972); Content-Aware Fill workspace with a paintable sampling area (TOOL-214-15); Vanishing Point plane editor (TOOL-214-14); Content-Aware Scale through the transform box (TOOL-214-7); Puppet Warp mesh following the outline (TOOL-214-6); Content-Aware Move's Transform On Drop (TOOL-213-4). diff --git a/docs/scorecard.md b/docs/scorecard.md index ae97ade7..4adb3223 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -145,7 +145,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask; no workspace layout or New Document outputs | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Fuzziness-0 tests cover midtones/shadows only (selection_cmds.rs:444-447) | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs, layer.selectInRect, #2844) done; double-click fit and drag to a tab open | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 2f993051..e73f7cde 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); no workspace layout or Refine Edge brush" +note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask; no workspace layout or New Document outputs" [[item]] id = "TOOL-214-2"