Merge pull request #8783 from wanikhawar/select-mask-edits

Select and Mask: tool edits as select.refineEdge params (engine, part 1 of 2)
This commit is contained in:
aiongg
2026-10-23 09:49:52 -04:00
committed by GitHub
33 changed files with 1357 additions and 99 deletions
+19 -4
View File
@@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec<f32>
mod brush;
mod morphology;
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, swept_area};
pub use brush::{EdgeBrush, EdgeStroke, MAX_SWEPT_AREA, SIZE_RANGE as BRUSH_SIZE_RANGE, brush_surface, painted_tiles, stroke_area, 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.
@@ -413,6 +413,22 @@ where
lo.into_iter().zip(hi).map(|(a, b)| a + (b - a) * frac).collect()
}
/// Most pixels one refine should cover. Its result is a dense [`Region`] over [`refine_area`]
/// (about two bytes a pixel while it is assembled), so callers refuse a larger refine up front
/// rather than allocate it: 2³⁰ pixels, a 32k × 32k selection.
pub const MAX_REFINE_AREA: u64 = 1 << 30;
/// The area [`refine_mask`] and [`refine_mask_brushed`] compute and allocate their result over:
/// `content` grown by how far `p` can spread it, plus where `brush` adds, within `canvas`. Empty
/// when `content` misses the canvas.
pub fn refine_area(content: Rect, canvas: Rect, p: &RefineParams, brush: Option<&EdgeBrush>) -> Rect {
let content = content.intersect(&canvas);
if content.is_empty() {
return Rect::EMPTY;
}
content.inflate(p.spread()).union(&brush.map_or(Rect::EMPTY, EdgeBrush::added_bounds)).intersect(&canvas)
}
/// Refines a selection mask. `mask(r)` returns coverage over `r` (row-major); `content` bounds
/// 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.
@@ -446,11 +462,10 @@ fn refine_mask_with<F>(
where
F: Fn(&[f32], usize, usize, usize, bool) -> Vec<f32> + Copy + Sync + Send,
{
let content = content.intersect(&canvas);
if content.is_empty() {
let bbox = refine_area(content, canvas, p, brush);
if bbox.is_empty() {
return None;
}
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;
+259 -82
View File
@@ -10,10 +10,17 @@
//! 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 std::collections::{BTreeMap, BTreeSet};
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
use super::box_mean;
use crate::segment::RgbImage;
use crate::selection::{MAX_PAINTED_TILES, clip_segment};
const TS: usize = TILE_SIZE as usize;
/// One Refine Edge Brush stroke, in document pixels.
#[derive(Clone, Debug, PartialEq)]
@@ -29,26 +36,42 @@ pub struct EdgeStroke {
/// Brush diameters the strokes may use.
pub const SIZE_RANGE: std::ops::RangeInclusive<f32> = 1.0..=5000.0;
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched) over `bbox`.
/// The painted area: signed coverage (`+1` refined, `−1` restored, `0` untouched), kept only for
/// the tiles the strokes paint, so a thin stroke across a huge canvas costs those tiles, not its
/// bounding box (#8783).
#[derive(Clone, Debug, PartialEq)]
pub struct EdgeBrush {
/// Where the strokes can paint.
bbox: Rect,
/// Coverage × 127, row-major over `bbox`.
v: Vec<i8>,
/// Coverage × 127 per painted tile, row-major over the tile.
tiles: BTreeMap<TileCoord, Vec<i8>>,
/// 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 {
/// Distance from pixel centre `p` to the segment from `a` to `b`.
fn segment_distance(p: (f32, f32), a: (f32, f32), b: (f32, 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)
((p.0 - cx).powi(2) + (p.1 - cy).powi(2)).sqrt()
}
/// 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 {
(r + 0.5 - segment_distance(p, a, b)).clamp(0.0, 1.0)
}
/// Coverage (0–1) at distance `d` from a brush's path, for radius `r` and a solid core of radius
/// `core` (hardness × `r`): full inside the core, then a smooth fall to zero half a pixel past `r`
/// (a hard brush keeps just the one-pixel anti-aliased rim).
fn soft_coverage(d: f32, r: f32, core: f32) -> f32 {
let ramp = r + 0.5 - core;
let t = ((r + 0.5 - d) / ramp.max(1.0)).clamp(0.0, 1.0);
if ramp > 1.0 { t * t * (3.0 - 2.0 * t) } else { t }
}
/// The pixels a disc of radius `r` at `p` can touch, as a (possibly empty) rectangle.
@@ -64,27 +87,113 @@ 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()
strokes.iter().map(|s| stroke_area(&s.points, s.size)).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() };
/// The area one stroke of `points` sweeps with a brush `size` wide: length × size + size²;
/// non-finite strokes count as infinite.
pub fn stroke_area(points: &[(f32, f32)], size: f32) -> f64 {
let size = f64::from(size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()));
let len: f64 = 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 }
}
/// The tiles of `canvas` a round brush `size` px wide along `points` can paint, in order; `None`
/// when that is more than `limit` (a thin stroke sweeps little but touches a whole tile wherever
/// it passes, so this, not [`MAX_SWEPT_AREA`], bounds what painting it allocates; #8783).
pub fn painted_tiles(points: &[(f32, f32)], size: f32, canvas: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut set = BTreeSet::new();
// Consecutive pieces mostly fall in the same tiles.
let mut last: Option<Rect> = None;
for (a, b) in segments(points, r, canvas) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
// The tile coordinates `seg` spans (inclusive).
let span =
Rect::new(seg.x0.div_euclid(TILE_SIZE), seg.y0.div_euclid(TILE_SIZE), (seg.x1 - 1).div_euclid(TILE_SIZE), (seg.y1 - 1).div_euclid(TILE_SIZE));
if seg.is_empty() || last == Some(span) {
continue;
}
last = Some(span);
for tc in seg.tiles() {
set.insert(tc);
if set.len() > limit {
return None;
}
}
}
Some(set)
}
/// One stroke's coverage (0–255) per tile of `canvas`: the most `cov(pixel centre, a, b)` gives
/// over the path's pieces, so a stroke never paints past full. Only painted tiles are kept;
/// `None` past `limit` tiles (see [`painted_tiles`]).
fn paint_tiles(
points: &[(f32, f32)],
size: f32,
canvas: Rect,
limit: usize,
cov: impl Fn((f32, f32), (f32, f32), (f32, f32)) -> f32,
) -> Option<BTreeMap<TileCoord, Vec<u8>>> {
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let mut tiles: BTreeMap<TileCoord, Vec<u8>> = painted_tiles(points, size, canvas, limit)?.into_iter().map(|tc| (tc, vec![0u8; TS * TS])).collect();
for (a, b) in segments(points, r, canvas) {
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&canvas);
for tc in seg.tiles() {
let o = tc.rect();
let Some(buf) = tiles.get_mut(&tc) else { continue };
let tr = o.intersect(&seg);
for y in tr.y0..tr.y1 {
for x in tr.x0..tr.x1 {
let c = (cov((x as f32 + 0.5, y as f32 + 0.5), a, b).clamp(0.0, 1.0) * 255.0).round() as u8;
if let Some(slot) = buf.get_mut((y - o.y0) as usize * TS + (x - o.x0) as usize)
&& c > *slot
{
*slot = c;
}
}
}
}
}
tiles.retain(|_, b| b.iter().any(|v| *v > 0));
Some(tiles)
}
/// Select and Mask's Brush: the coverage one stroke of a round brush `size` px wide with
/// `hardness` (0–100) paints along `points`, clipped to `canvas`, as a GRAY8 selection holding
/// only the tiles it paints. Overlapping dabs keep the most coverage any of them gives, so a
/// stroke never paints past full. `None` when it paints nothing, has non-finite points, sweeps
/// more than [`MAX_SWEPT_AREA`] or paints more than [`MAX_PAINTED_TILES`] tiles.
pub fn brush_surface(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option<Surface> {
if points.is_empty() || !size.is_finite() || !hardness.is_finite() || points.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
return None;
}
if stroke_area(points, size) > MAX_SWEPT_AREA {
return None;
}
let r = size.clamp(*SIZE_RANGE.start(), *SIZE_RANGE.end()) / 2.0;
let core = r * hardness.clamp(0.0, 100.0) / 100.0;
let tiles = paint_tiles(points, size, canvas, MAX_PAINTED_TILES, |p, a, b| soft_coverage(segment_distance(p, a, b), r, core))?;
let mut s = Surface::new(PixelFormat::GRAY8);
for (tc, bytes) in &tiles {
s.write_interleaved(tc.rect(), bytes);
}
(s.tile_count() > 0).then_some(s)
}
/// The stroke's path as short segments within reach of `clip`: points closer than a quarter of
/// the radius to the last kept one are dropped, each segment is cut to `clip` grown by the brush
/// (what lies beyond can't paint inside it), and long ones are split at most `r` long, so each
/// piece's box stays close to the area it paints. The pieces are made as they are painted, never
/// collected, and only where they can paint: a 1 px stroke zigzagging across 2 million px is over
/// 100 million pieces in all, within [`MAX_SWEPT_AREA`] (the #8783 review).
fn segments(points: &[(f32, f32)], r: f32, clip: Rect) -> impl Iterator<Item = ((f32, f32), (f32, f32))> {
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 {
let reach = clip.inflate((r + 2.0).ceil().clamp(0.0, 1e7) as i32);
let mut kept: Vec<(f32, f32)> = Vec::new();
for p in points {
if kept.last().is_none_or(|l| (p.0 - l.0).hypot(p.1 - l.1) >= min_step) {
kept.push(*p);
}
}
@@ -93,25 +202,22 @@ fn segments(points: &[(f32, f32)], r: f32) -> Vec<((f32, f32), (f32, f32))> {
{
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 };
// A single point is one dab.
let dab = if kept.len() == 1 { kept.first().map(|p| (*p, *p)) } else { None };
let pairs: Vec<((f32, f32), (f32, f32))> = kept.windows(2).filter_map(|w| clip_segment(*w.first()?, *w.get(1)?, reach)).collect();
dab.into_iter().chain(pairs.into_iter().flat_map(move |(a, b)| {
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 at = move |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
(0..pieces).map(move |k| (at(k), at(k + 1)))
}))
}
impl EdgeBrush {
/// Rasterises `strokes` (clipped to `canvas`). `None` when they paint nothing or sweep more
/// than [`MAX_SWEPT_AREA`].
/// Rasterises `strokes` (clipped to `canvas`), tile by tile. `None` when they paint nothing,
/// sweep more than [`MAX_SWEPT_AREA`] or paint more than [`MAX_PAINTED_TILES`] tiles.
pub fn new(strokes: &[EdgeStroke], canvas: Rect) -> Option<EdgeBrush> {
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 {
@@ -126,61 +232,43 @@ impl EdgeBrush {
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<f32> = Vec::new();
let mut tiles: BTreeMap<TileCoord, Vec<i8>> = BTreeMap::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 cov = paint_tiles(&s.points, s.size, canvas, MAX_PAINTED_TILES, |p, a, b| capsule(p, a, b, r))?;
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;
for (tc, c) in cov {
let v = tiles.entry(tc).or_insert_with(|| vec![0i8; TS * TS]);
for (slot, c) in v.iter_mut().zip(c) {
if c > 0 {
let (cur, c) = (f32::from(*slot) / 127.0, f32::from(c) / 255.0);
*slot = ((cur + (target - cur) * c) * 127.0).round().clamp(-127.0, 127.0) as i8;
}
}
}
if tiles.len() > MAX_PAINTED_TILES {
return None;
}
}
if v.iter().all(|c| *c == 0) {
tiles.retain(|_, v| v.iter().any(|c| *c != 0));
if tiles.is_empty() {
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 mut added = Rect::EMPTY;
for (tc, v) in &tiles {
let o = tc.rect();
for (row, line) in v.as_chunks::<TS>().0.iter().enumerate() {
let (Some(first), Some(last)) = (line.iter().position(|c| *c > 0), line.iter().rposition(|c| *c > 0)) else { continue };
let y = o.y0 + row as i32;
added = added.union(&Rect::new(o.x0 + first as i32, y, o.x0 + last as i32 + 1, 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 })
Some(EdgeBrush { bbox, tiles, added, window: max_add.ceil().clamp(4.0, 64.0) as usize })
}
/// Where the strokes paint (added or restored).
@@ -200,16 +288,32 @@ impl EdgeBrush {
/// 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)
let (tc, o) = (TileCoord::containing(x, y), (y.rem_euclid(TILE_SIZE) as usize) * TS + x.rem_euclid(TILE_SIZE) as usize);
self.tiles.get(&tc).and_then(|v| v.get(o)).map_or(0.0, |c| f32::from(*c) / 127.0)
}
/// Signed coverage over `r` (row-major).
pub fn read(&self, r: Rect) -> Vec<f32> {
(r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| self.at(x, y))).collect()
let w = r.width() as usize;
let mut out = vec![0.0f32; w * r.height() as usize];
for tc in r.tiles() {
let Some(v) = self.tiles.get(&tc) else { continue };
let (o, tr) = (tc.rect(), tc.rect().intersect(&r));
for y in tr.y0..tr.y1 {
let src = (y - o.y0) as usize * TS;
let dst = (y - r.y0) as usize * w;
let (Some(src), Some(dst)) = (
v.get(src + (tr.x0 - o.x0) as usize..src + (tr.x1 - o.x0) as usize),
out.get_mut(dst + (tr.x0 - r.x0) as usize..dst + (tr.x1 - r.x0) as usize),
) else {
continue;
};
for (d, c) in dst.iter_mut().zip(src) {
*d = f32::from(*c) / 127.0;
}
}
}
out
}
}
@@ -276,6 +380,10 @@ mod tests {
use crate::segment::ImageSampler;
use crate::selection::Region;
fn at(s: &Surface, x: i32, y: i32) -> f32 {
s.pixel(x, y).first().copied().unwrap_or(0.0)
}
/// 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]
@@ -330,4 +438,73 @@ mod tests {
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
}
#[test]
fn brush_surface_paints_hard_and_soft_strokes() {
let canvas = Rect::new(0, 0, 100, 50);
let hard = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted");
assert!(at(&hard, 50, 25) > 0.99 && at(&hard, 50, 33) > 0.99, "solid inside the radius");
assert!(at(&hard, 50, 36) == 0.0 && at(&hard, 50, 10) == 0.0, "nothing past it");
// A soft brush fades from the centre line to the rim.
let soft = brush_surface(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted");
let (centre, mid, rim) = (at(&soft, 50, 25), at(&soft, 50, 30), at(&soft, 50, 34));
assert!(centre > 0.95 && mid > 0.2 && mid < 0.8 && rim < 0.1, "{centre} {mid} {rim}");
// A single point is one dab; clipped to the canvas.
let dab = brush_surface(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted");
let b = dab.content_bounds();
assert_eq!((b.x0, b.y0), (0, 0));
assert!(at(&dab, 1, 1) > 0.99);
}
#[test]
fn brush_surface_paints_nothing_for_hostile_strokes() {
let canvas = Rect::new(0, 0, 100, 50);
assert!(brush_surface(&[], 10.0, 100.0, canvas).is_none());
assert!(brush_surface(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none());
assert!(brush_surface(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none());
assert!(brush_surface(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none());
assert!(brush_surface(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas");
assert!(brush_surface(&[(0.0, 0.0), (1e7, 1e7)], 5000.0, 100.0, canvas).is_none(), "sweeps too much");
// Sizes and hardness outside their ranges are clamped, not rejected.
assert!(brush_surface(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some());
}
/// A 1 px stroke zigzagging across 2 million px stays within [`MAX_SWEPT_AREA`] yet splits
/// into over 100 million pieces: painting it must not collect them all first (it allocated
/// about 2 GB up front; the #8783 review).
#[test]
fn a_long_thin_zigzag_paints_without_collecting_its_pieces() {
let canvas = Rect::new(0, 0, 64, 64);
let points: Vec<(f32, f32)> = (0..135).map(|i| (if i % 2 == 0 { 0.0 } else { 2_000_000.0 }, 32.0 + (i % 3) as f32)).collect();
assert!(stroke_area(&points, 1.0) <= MAX_SWEPT_AREA);
// Only the pieces within reach of the canvas are made.
assert!(segments(&points, 0.5, canvas).count() < 134 * 40, "{}", segments(&points, 0.5, canvas).count());
let r = brush_surface(&points, 1.0, 100.0, canvas).expect("it paints its row");
assert!(at(&r, 10, 32) > 0.5);
let strokes = [EdgeStroke { points, size: 1.0, add: true }];
assert!(EdgeBrush::new(&strokes, canvas).is_some_and(|b| b.at(10, 32) > 0.5));
}
/// A 1 px diagonal across a 100,000 px canvas sweeps about 141,000 px, well within
/// [`MAX_SWEPT_AREA`], but its bounding box is 10 billion pixels: painting it must cost the
/// tiles it crosses, not that box (it allocated 40 GB; the #8783 review).
#[test]
fn a_thin_diagonal_across_a_huge_canvas_paints_only_its_tiles() {
let canvas = Rect::new(0, 0, 100_000, 100_000);
let diagonal = [(0.0, 0.0), (99_999.0, 99_999.0)];
assert!(stroke_area(&diagonal, 1.0) <= MAX_SWEPT_AREA);
let tiles = painted_tiles(&diagonal, 1.0, canvas, MAX_PAINTED_TILES).expect("within the limit");
assert!(tiles.len() < 1500, "{}", tiles.len());
let s = brush_surface(&diagonal, 1.0, 100.0, canvas).expect("painted");
assert!(s.tile_count() <= tiles.len());
assert!(at(&s, 50_000, 50_000) > 0.5 && at(&s, 50_000, 40_000) == 0.0);
let strokes = [EdgeStroke { points: diagonal.to_vec(), size: 1.0, add: true }];
let b = EdgeBrush::new(&strokes, canvas).expect("painted");
assert!(b.at(50_000, 50_000) > 0.5 && b.at(50_000, 40_000) == 0.0);
assert!(b.read(Rect::new(49_990, 49_990, 50_010, 50_010)).iter().any(|v| *v > 0.5));
// A zigzag crossing more tiles than the limit is refused rather than allocated.
let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect();
assert!(stroke_area(&zigzag, 1.0) <= MAX_SWEPT_AREA);
assert!(painted_tiles(&zigzag, 1.0, canvas, MAX_PAINTED_TILES).is_none());
assert!(brush_surface(&zigzag, 1.0, 100.0, canvas).is_none());
assert!(EdgeBrush::new(&[EdgeStroke { points: zigzag, size: 1.0, add: true }], canvas).is_none());
}
}
+4 -1
View File
@@ -761,10 +761,13 @@ pub fn tone_range(px: &[[f32; 4]], lo: f32, hi: f32, falloff: f32) -> Vec<f32> {
}
mod distance;
mod lasso;
mod marquee;
pub use distance::edt;
pub use marquee::{combine_surfaces, ellipse_surface};
pub(crate) use lasso::clip_segment;
pub use lasso::{MAX_PAINTED_TILES, polygon_edge_tiles, polygon_surface};
pub use marquee::{combine_sparse, combine_surfaces, ellipse_surface};
/// Grows the selection by `r` pixels.
pub fn expand(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
+291
View File
@@ -0,0 +1,291 @@
//! Lasso shapes built straight into tiles (#8783): only the tiles an edge crosses are rasterised,
//! and every tile wholly inside the polygon shares one solid tile, so a thin lasso across a huge
//! canvas costs the tiles along its edges, not its bounding box. The bytes are those
//! [`super::polygon`] gives over the canvas.
use std::collections::BTreeSet;
use std::sync::Arc;
use photocraft_color::PixelFormat;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::{Surface, Tile};
use crate::photo_util::par_map;
const TS: usize = TILE_SIZE as usize;
/// Most partly covered tiles one painted shape (a lasso, a brush stroke) may hold: 2²⁸ pixels'
/// worth, 256 MB as GRAY8. A thin shape touches a whole tile wherever it passes, so this, not
/// its area, bounds what painting it allocates.
pub const MAX_PAINTED_TILES: usize = (1 << 28) / (TS * TS);
/// The part of the segment `a`–`b` inside `r` (Liang–Barsky), or `None` when it misses it.
pub(crate) fn clip_segment(a: (f32, f32), b: (f32, f32), r: Rect) -> Option<((f32, f32), (f32, f32))> {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let (mut t0, mut t1) = (0.0f32, 1.0f32);
for (p, q) in [(-dx, a.0 - r.x0 as f32), (dx, r.x1 as f32 - a.0), (-dy, a.1 - r.y0 as f32), (dy, r.y1 as f32 - a.1)] {
if p == 0.0 {
if q < 0.0 {
return None;
}
} else {
let t = q / p;
if p < 0.0 {
t0 = t0.max(t);
} else {
t1 = t1.min(t);
}
}
}
(t0 <= t1 && t0.is_finite() && t1.is_finite()).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1)))
}
/// The pixels the polygon through `points` can touch within `clip` (its bounding box, grown by a
/// pixel, as the Lasso has always rasterised it).
fn polygon_area(points: &[(f32, f32)], clip: Rect) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
for p in points {
(x0, y0, x1, y1) = (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1));
}
if points.len() < 3 || !(x0 <= x1 && y0 <= y1) {
return Rect::EMPTY;
}
let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32;
Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&clip)
}
/// The tiles of `clip` the edges of the polygon through `points` cross (with a pixel's margin),
/// in order: the only ones whose pixels can differ. `None` when there are more than `limit`.
pub fn polygon_edge_tiles(points: &[(f32, f32)], clip: Rect, limit: usize) -> Option<BTreeSet<TileCoord>> {
let area = polygon_area(points, clip);
let mut set = BTreeSet::new();
if area.is_empty() {
return Some(set);
}
let step = TILE_SIZE as f32 / 4.0;
for (&a, &b) in points.iter().zip(points.iter().cycle().skip(1)) {
let Some((a, b)) = clip_segment(a, b, area.inflate(2)) else { continue };
let pieces = ((b.0 - a.0).hypot(b.1 - a.1) / 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)
};
for k in 0..pieces {
let (p, q) = (at(k), at(k + 1));
let lo = |u: f32, v: f32| u.min(v).floor() as i32 - 1;
let hi = |u: f32, v: f32| u.max(v).floor() as i32 + 2;
for tc in Rect::new(lo(p.0, q.0), lo(p.1, q.1), hi(p.0, q.0), hi(p.1, q.1)).intersect(&area).tiles() {
set.insert(tc);
if set.len() > limit {
return None;
}
}
}
}
Some(set)
}
/// One polygon edge, as [`super::polygon`] keeps it.
struct Edge {
top: f32,
bottom: f32,
a: (f32, f32),
b: (f32, f32),
}
/// The anti-aliased (even-odd) polygon through `points`, cut to `clip`, as a GRAY8 selection: the
/// bytes of [`super::polygon`] over `clip`, quantised. Edge tiles are rasterised, tiles wholly
/// inside share one solid tile. `None` when the edges cross more than [`MAX_PAINTED_TILES`] tiles.
pub fn polygon_surface(points: &[(f32, f32)], clip: Rect) -> Option<Surface> {
let mut s = Surface::new(PixelFormat::GRAY8);
let area = polygon_area(points, clip);
if area.is_empty() {
return Some(s);
}
let edge_tiles = polygon_edge_tiles(points, clip, MAX_PAINTED_TILES)?;
const SUB: usize = 4;
// Sample rows as `polygon` over `clip` takes them, so the bytes match.
let sample_y = |y: i32, k: usize| clip.y0 as f32 + (y - clip.y0) as f32 + (k as f32 + 0.5) / SUB as f32;
let mut edges: Vec<Edge> = points
.iter()
.zip(points.iter().cycle().skip(1))
.filter_map(|(&a, &b)| {
if a.1 < b.1 {
Some(Edge { top: a.1, bottom: b.1, a, b })
} else if b.1 < a.1 {
Some(Edge { top: b.1, bottom: a.1, a, b })
} else {
None
}
})
.collect();
edges.sort_by(|p, q| p.top.total_cmp(&q.top));
let crossings = |e: &[&Edge], y: f32, xs: &mut Vec<f32>| {
xs.clear();
xs.extend(e.iter().map(|&&Edge { a, b, .. }| a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)));
xs.sort_by(|a, b| a.total_cmp(b));
};
let full = s.solid_tile(&[1.0]);
let blank = s.solid_tile(&[0.0]);
let bands: Vec<i32> = Rect::new(area.x0, area.y0, area.x0 + 1, area.y1).tiles().map(|tc| tc.ty).collect();
let built = par_map(bands.len(), |bi| -> Vec<(TileCoord, Arc<Tile>)> {
let Some(&ty) = bands.get(bi) else { return Vec::new() };
let rows = Rect::new(area.x0, ty * TILE_SIZE, area.x1, (ty + 1) * TILE_SIZE).intersect(&area);
// In `x` order (`TileCoord` orders by `tx` first).
let cols: Vec<TileCoord> = edge_tiles.iter().filter(|tc| tc.ty == ty).copied().collect();
let mut out = Vec::new();
let (first, last) = (sample_y(rows.y0, 0), sample_y(rows.y1 - 1, SUB - 1));
let reach = edges.partition_point(|e| e.top <= last);
let band_edges = edges.get(..reach).unwrap_or_default();
let mut xs: Vec<f32> = Vec::new();
// Tiles no edge crosses are wholly in or out: one sample row decides them all.
let y_mid = sample_y(rows.y0 + rows.height() as i32 / 2, 0);
let mid: Vec<&Edge> = band_edges.iter().filter(|e| e.top <= y_mid && e.bottom > y_mid).collect();
crossings(&mid, y_mid, &mut xs);
for tc in rows.tiles() {
if cols.binary_search(&tc).is_ok() {
continue;
}
let tr = tc.rect().intersect(&rows);
let cx = (tr.x0 + tr.x1) as f32 / 2.0;
if xs.partition_point(|x| *x <= cx) % 2 == 0 {
continue;
}
if tr == tc.rect() {
out.push((tc, Arc::clone(&full)));
} else {
let mut tile = Tile::clone(&blank);
fill(&mut tile, tc, tr, u8::MAX);
out.push((tc, Arc::new(tile)));
}
}
if cols.is_empty() {
return out;
}
// The edge tiles: `polygon`'s scanline, accumulated only over their columns.
let x_of = |tc: &TileCoord| tc.rect().intersect(&rows);
let spans: Vec<Rect> = cols.iter().map(x_of).collect();
let mut acc = vec![0.0f32; cols.len() * TS];
let mut tiles: Vec<Tile> = cols.iter().map(|_| Tile::clone(&blank)).collect();
let mut pending = band_edges.iter().filter(|e| e.bottom > first).peekable();
let mut active: Vec<&Edge> = Vec::new();
let origin = clip.x0 as f32;
for y in rows.y0..rows.y1 {
acc.fill(0.0);
for k in 0..SUB {
let sy = sample_y(y, k);
while let Some(e) = pending.next_if(|e| e.top <= sy) {
active.push(e);
}
active.retain(|e| e.bottom > sy);
if active.is_empty() {
continue;
}
crossings(&active, sy, &mut xs);
for pair in xs.as_chunks::<2>().0 {
let (x0, x1) = ((pair[0] - origin).max(0.0), (pair[1] - origin).min(clip.width() as f32));
if x1 <= x0 {
continue;
}
let (c0, c1) = (clip.x0 + x0.floor() as i32, clip.x0 + x1.ceil() as i32);
let start = spans.partition_point(|r| r.x1 <= c0);
for (ci, r) in spans.iter().enumerate().skip(start).take_while(|(_, r)| r.x0 < c1) {
for x in c0.max(r.x0)..c1.min(r.x1) {
let cx = (x - clip.x0) as f32;
let cov = (cx + 1.0).min(x1) - cx.max(x0);
if let Some(v) = acc.get_mut(ci * TS + (x - r.x0) as usize) {
*v += cov.max(0.0) / SUB as f32;
}
}
}
}
}
for ((r, tile), row) in spans.iter().zip(&mut tiles).zip(acc.as_chunks::<TS>().0) {
let base = y.rem_euclid(TILE_SIZE) as usize * TS + r.x0.rem_euclid(TILE_SIZE) as usize;
let Some(dst) = tile.bytes_mut().get_mut(base..base + r.width() as usize) else { continue };
for (d, v) in dst.iter_mut().zip(row) {
*d = (v.min(1.0).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
}
for (tc, tile) in cols.into_iter().zip(tiles) {
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
if hi > 0 {
out.push((tc, if lo == u8::MAX { Arc::clone(&full) } else { Arc::new(tile) }));
}
}
out
});
s.put_tiles(built.into_iter().flatten());
Some(s)
}
/// Sets `tr` (within tile `tc`) to `v`.
fn fill(tile: &mut Tile, tc: TileCoord, tr: Rect, v: u8) {
let o = tc.rect();
for y in tr.y0..tr.y1 {
let base = (y - o.y0) as usize * TS;
if let Some(row) = tile.bytes_mut().get_mut(base + (tr.x0 - o.x0) as usize..base + (tr.x1 - o.x0) as usize) {
row.fill(v);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The dense rasteriser over `clip`, quantised as the Lasso stores it.
fn dense(points: &[(f32, f32)], clip: Rect) -> Surface {
let m: Vec<u8> = super::super::polygon(points, clip, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
let mut s = Surface::from_interleaved(PixelFormat::GRAY8, clip, &m);
s.prune();
s
}
#[test]
fn matches_the_dense_polygon() {
let clip = Rect::new(-40, 10, 1300, 900);
let shapes: [&[(f32, f32)]; 6] = [
&[(10.5, 20.25), (1200.0, 40.0), (600.0, 880.7)],
// Whole tiles inside, edges exactly on tile boundaries.
&[(0.0, 256.0), (768.0, 256.0), (768.0, 768.0), (0.0, 768.0)],
// Self-intersecting (even-odd), past the clip.
&[(-300.0, -50.0), (1500.0, 950.0), (1500.0, -50.0), (-300.0, 950.0)],
&[(100.0, 100.0), (101.0, 100.0), (100.5, 700.0)],
&[(5.0, 15.0), (5.0, 15.0), (5.0, 15.0)],
&[(2000.0, 2000.0), (2100.0, 2000.0), (2000.0, 2100.0)],
];
for pts in shapes {
let s = polygon_surface(pts, clip).expect("within the tile limit");
let d = dense(pts, clip);
let area = clip.union(&s.tile_bounds());
assert!(s.read_region(area) == d.read_region(area), "{pts:?}");
}
}
/// A thin sliver across a 100,000 px canvas: only the tiles along it are built (the dense
/// rasteriser allocated its 10-billion-pixel bounding box; the #8783 review).
#[test]
fn a_thin_sliver_across_a_huge_canvas_costs_its_edge_tiles() {
let clip = Rect::new(0, 0, 100_000, 100_000);
let sliver = [(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)];
let tiles = polygon_edge_tiles(&sliver, clip, MAX_PAINTED_TILES).expect("within the limit");
assert!(tiles.len() < 2000, "{}", tiles.len());
let s = polygon_surface(&sliver, clip).expect("within the limit");
assert!(s.tile_count() <= tiles.len());
assert!(s.pixel(50_000, 50_000)[0] > 0.0 && s.pixel(50_000, 40_000)[0] == 0.0);
// A zigzag that crosses more tiles than the limit is refused.
let zigzag: Vec<(f32, f32)> = (0..200).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 500.0)).collect();
assert!(polygon_edge_tiles(&zigzag, clip, MAX_PAINTED_TILES).is_none());
assert!(polygon_surface(&zigzag, clip).is_none());
}
#[test]
fn a_huge_polygon_shares_one_tile_inside() {
let clip = Rect::new(0, 0, 51_200, 51_200);
let s = polygon_surface(&[(-10.0, -10.0), (60_000.0, -10.0), (60_000.0, 60_000.0), (-10.0, 60_000.0)], clip).expect("no edge tiles");
let shared = s.tiles().map(|(_, t)| Arc::as_ptr(t)).collect::<BTreeSet<_>>();
assert!(s.tile_count() == 200 * 200 && shared.len() == 1, "{} tiles, {} distinct", s.tile_count(), shared.len());
}
}
+80
View File
@@ -236,6 +236,60 @@ pub fn combine_surfaces(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f3
out
}
/// [`combine_surfaces`] for an `f` that keeps `a` wherever `b` reads 0 (Add, Subtract), visiting
/// only `b`'s tiles within `area`: the rest of `a` (its other tiles and its untouched value) is
/// kept as it is, so painting a thin stroke across a huge canvas costs the tiles it paints, not
/// the canvas (#8783). Falls back to [`combine_surfaces`] over `area` for other inputs.
pub fn combine_sparse(a: &Surface, b: &Surface, area: Rect, f: impl Fn(f32, f32) -> f32) -> Surface {
let gray = PixelFormat::GRAY8;
let lut: Vec<u8> = (0..=u16::MAX).map(|i| encode8(f(decode8((i >> 8) as u8), decode8(i as u8)))).collect();
let pair = |x: u8, y: u8| lut.get(usize::from(x) << 8 | usize::from(y)).copied().unwrap_or(0);
if a.format() != gray || b.format() != gray || b.default_bytes() != [0] || !(0..=255u8).all(|v| pair(v, 0) == v) {
return combine_surfaces(a, b, area, f);
}
let da = a.default_bytes().first().copied().unwrap_or(0);
let mut out = a.clone();
let (full, untouched) = (out.solid_tile(&[1.0]), out.solid_tile(&[decode8(da)]));
let coords: Vec<TileCoord> = b.tiles().map(|(c, _)| *c).filter(|c| !c.rect().intersect(&area).is_empty()).collect();
// Each tile: `Some(tile)` to store, `None` when it reads as `a`'s untouched value.
let built = par_map(coords.len(), |k| {
let tc = *coords.get(k)?;
let rect = tc.rect();
let tr = rect.intersect(&area);
let tb = b.tile(tc)?;
let mut tile = Tile::clone(a.tile(tc).unwrap_or(&untouched));
let data = tile.bytes_mut();
let (x0, x1) = ((tr.x0 - rect.x0) as usize, (tr.x1 - rect.x0) as usize);
for ly in (tr.y0 - rect.y0) as usize..(tr.y1 - rect.y0) as usize {
let (s, e) = (ly * TS + x0, ly * TS + x1);
let (Some(dst), Some(src)) = (data.get_mut(s..e), tb.bytes().get(s..e)) else { continue };
for (d, y) in dst.iter_mut().zip(src) {
*d = pair(*d, *y);
}
}
let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), v| (lo.min(*v), hi.max(*v)));
Some((
tc,
if lo == da && hi == da {
None
} else if lo == u8::MAX {
Some(Arc::clone(&full))
} else {
Some(Arc::new(tile))
},
))
});
for (tc, tile) in built.into_iter().flatten() {
match tile {
Some(t) => out.put_tiles([(tc, t)]),
// `take_tiles` only allocates for a tile that is missing; this one is there.
None if out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
None => {}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@@ -311,4 +365,30 @@ mod tests {
}
}
}
/// Add and Subtract through only the new shape's tiles give the bytes of the whole-area
/// combination, keep the old selection's other tiles shared, and keep an untouched value
/// (a layer mask's reveal-all) outside them.
#[test]
fn sparse_combination_matches_the_whole_area_one() {
let area = Rect::new(0, 0, 1100, 700);
let mut rect = Surface::new(PixelFormat::GRAY8);
rect.fill_rect(Rect::from_xywh(200, 100, 600, 520), &[1.0]);
let ell = old_ellipse(Rect::from_xywh(-60, 250, 700, 500), area, true);
let far = ellipse_surface(Rect::from_xywh(900, 20, 150, 150), area, true);
let ops: [fn(f32, f32) -> f32; 2] = [|a, b| a.max(b), |a, b| (a - b).max(0.0)];
for (a, b) in [(&rect, &ell), (&ell, &rect), (&ell, &ell), (&rect, &far)] {
for f in ops {
let sparse = combine_sparse(a, b, area, f);
assert!(sparse.read_region(area) == old_combine(a, b, area, f).read_region(area));
assert!(sparse.tiles().all(|(_, t)| t.bytes().iter().any(|v| *v != 0)), "no empty tiles kept");
}
}
let kept = combine_sparse(&rect, &far, area, |a, b| a.max(b));
let tc = TileCoord::new(1, 1);
assert!(Arc::ptr_eq(kept.tile(tc).expect("kept"), rect.tile(tc).expect("there")), "untouched tiles stay shared");
let reveal = Surface::with_default(PixelFormat::GRAY8, &[1.0]);
let cut = combine_sparse(&reveal, &far, area, |a, b| (a - b).max(0.0));
assert!(cut.pixel(5000, 5000)[0] == 1.0 && cut.pixel(975, 95)[0] == 0.0);
}
}
+5 -4
View File
@@ -3037,13 +3037,14 @@ macro_rules! adjustment_event {
const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")];
/// Whether a Select and Mask step carries Refine Edge Brush strokes, which no descriptor holds.
/// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool
/// edits (Quick Selection, Brush, Lasso…), which no descriptor holds.
fn has_refine_strokes(p: &Value) -> bool {
p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty()))
p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) || crate::select_mask_edits::has_edits(p)
}
/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush, so a
/// step with `refineStrokes` can't be exported faithfully. One with `sampleAllLayers` is not
/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush or the
/// workspace's tools, so a step with `refineStrokes` or `edits` can't be exported faithfully. One with `sampleAllLayers` is not
/// exported here either: it exports as `smartBrushWorkspace`.
fn refine_edge_to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) {
+17
View File
@@ -2466,6 +2466,23 @@ fn refine_edge_with_brush_strokes_is_not_exported_without_them() {
}
}
/// Nor does it hold the workspace's tool edits (Quick Selection, Brush, Lasso…), with or without
/// Sample All Layers, so a step with them is reported instead of exported without them.
#[test]
fn refine_edge_with_workspace_edits_is_not_exported_without_them() {
let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4});
for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] {
let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() };
assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}");
let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]);
}
for params in [json!({"radius": 1, "edits": []}), json!({"radius": 1, "edits": null})] {
let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() };
assert!(export("Audit", &[&plain], &[], false).is_ok());
}
}
#[test]
fn image_size_resolution_only_does_not_gain_keep_print_size() {
// The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not
+1
View File
@@ -108,6 +108,7 @@ pub mod retouch_cmds;
pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod select_mask_edits;
pub mod selection_cmds;
pub mod shell_commands;
pub mod shortcut_sets;
+463
View File
@@ -0,0 +1,463 @@
//! Select and Mask's own selection tools (#7069, #8148): the edits the workspace makes to the
//! selection before it refines the edge, carried as `select.refineEdge`'s `edits` param.
//!
//! Photoshop's Select and Mask has a Quick Selection tool, a Brush, Object Selection, Lasso and
//! Polygonal Lasso, a Select Subject button and Clear Selection / Invert. Here each use is one
//! [`SelectionEdit`], applied in order to the selection (or layer mask) Select and Mask starts
//! from. Keeping them as params rather than live edits of the document means Cancel discards
//! them, OK is one history step, and the UI, CLI, MCP and the control channel all drive them the
//! same way. [`apply`] is pure, so the workspace's preview runs exactly what OK commits.
use std::collections::BTreeSet;
use photocraft_algo::matting;
use photocraft_algo::segment::{grabcut, quick, subject};
use photocraft_algo::selection::{self as sel, Region, SelectionMode, combine_region};
use photocraft_doc::{Document, LayerId};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::Value;
use crate::smartselect_cmds::with_doc_sampler;
use crate::{EngineError, Result};
const CMD: &str = "select.refineEdge";
/// Most edits one Select and Mask takes, and points across them.
pub const MAX_EDITS: usize = 4096;
pub const MAX_EDIT_POINTS: usize = 100_000;
/// One use of a Select and Mask tool. `add: false` is the tool's subtract mode (Alt).
#[derive(Clone, Debug, PartialEq)]
pub enum SelectionEdit {
/// Quick Selection: grows a region from the stroke out to the image's edges.
Quick { points: Vec<(f32, f32)>, size: f32, add: bool, sample_all_layers: bool },
/// Brush: paints the selection directly with a round brush.
Brush { points: Vec<(f32, f32)>, size: f32, hardness: f32, add: bool },
/// Lasso and Polygonal Lasso: the polygon through `points`.
Lasso { points: Vec<(f32, f32)>, add: bool },
/// Object Selection: the object in `rect` [x, y, w, h], or the one under `point`.
Object { area: ObjectArea, add: bool, sample_all_layers: bool },
/// The Select Subject button.
Subject { mode: SelectionMode, sample_all_layers: bool },
/// Global Refinements › Clear Selection.
Clear,
/// Global Refinements › Invert.
Invert,
}
/// Where Object Selection looks.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ObjectArea {
Rect(Rect),
Point(f32, f32),
}
fn bad(msg: &str) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
/// A finite coordinate within the range strokes may use.
fn coord(v: &Value) -> Option<f32> {
v.as_f64().filter(|v| v.is_finite() && v.abs() <= 1e7).map(|v| v as f32)
}
fn point(v: &Value) -> Option<(f32, f32)> {
let xy = v.as_array().filter(|a| a.len() == 2)?;
Some((coord(xy.first()?)?, coord(xy.get(1)?)?))
}
fn points(o: &serde_json::Map<String, Value>, min: usize, total: &mut usize) -> Result<Vec<(f32, f32)>> {
let list = o.get("points").and_then(Value::as_array).ok_or_else(|| bad("this edit needs a points array of [x,y] pairs"))?;
if list.len() < min {
return Err(bad(&format!("this edit needs at least {min} point{}", if min == 1 { "" } else { "s" })));
}
*total = total.saturating_add(list.len());
if *total > MAX_EDIT_POINTS {
return Err(bad(&format!("edits hold more than {MAX_EDIT_POINTS} points")));
}
list.iter().map(point).collect::<Option<Vec<_>>>().ok_or_else(|| bad("edit points must be [x,y] pairs of finite numbers"))
}
/// A number in `range`, `default` when absent; anything else is an error naming `key`.
fn number(o: &serde_json::Map<String, Value>, key: &str, default: f32, range: std::ops::RangeInclusive<f32>) -> Result<f32> {
match o.get(key) {
None => Ok(default),
Some(v) => v
.as_f64()
.map(|v| v as f32)
.filter(|v| v.is_finite() && range.contains(v))
.ok_or_else(|| bad(&format!("edit {key} must be a number in {}..{}", range.start(), range.end()))),
}
}
fn flag(o: &serde_json::Map<String, Value>, key: &str, default: bool) -> Result<bool> {
match o.get(key) {
None => Ok(default),
Some(v) => v.as_bool().ok_or_else(|| bad(&format!("edit {key} must be true or false"))),
}
}
/// The tools' `mode`: `add` (the default) or `subtract`.
fn adds(o: &serde_json::Map<String, Value>) -> Result<bool> {
match o.get("mode").map(Value::as_str) {
None | Some(Some("add")) => Ok(true),
Some(Some("subtract")) => Ok(false),
Some(_) => Err(bad("edit mode must be add|subtract")),
}
}
/// The `edits` of `p`, in order (none when absent or null).
pub fn parse(p: &Value) -> Result<Vec<SelectionEdit>> {
let Some(v) = p.get("edits").filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().ok_or_else(|| bad("edits must be an array of {\"tool\":…} objects"))?;
if list.len() > MAX_EDITS {
return Err(bad(&format!("edits holds more than {MAX_EDITS} edits")));
}
let size_range = matting::BRUSH_SIZE_RANGE;
let mut total = 0usize;
// The area the Brush edits sweep in all, bounded as `refineStrokes`' is.
let mut swept = 0.0f64;
let mut edits = Vec::with_capacity(list.len());
for e in list {
let o = e.as_object().ok_or_else(|| bad("each edit must be an object with a tool"))?;
let tool = o.get("tool").and_then(Value::as_str).ok_or_else(|| bad("each edit needs a tool: quick|brush|lasso|object|subject|clear|invert"))?;
let edit = match tool {
"quick" => SelectionEdit::Quick {
points: points(o, 1, &mut total)?,
size: number(o, "size", 30.0, size_range.clone())?,
add: adds(o)?,
sample_all_layers: flag(o, "sampleAllLayers", false)?,
},
"brush" => {
let points = points(o, 1, &mut total)?;
let size = number(o, "size", 30.0, size_range.clone())?;
swept += matting::stroke_area(&points, size);
if swept > matting::MAX_SWEPT_AREA {
return Err(bad("brush edits paint too large an area"));
}
SelectionEdit::Brush { points, size, hardness: number(o, "hardness", 100.0, 0.0..=100.0)?, add: adds(o)? }
}
"lasso" => SelectionEdit::Lasso { points: points(o, 3, &mut total)?, add: adds(o)? },
"object" => {
let area = match (o.get("rect"), o.get("point")) {
(Some(r), _) => {
let v = r.as_array().filter(|a| a.len() == 4).ok_or_else(|| bad("object rect must be [x,y,w,h]"))?;
let n = v.iter().map(coord).collect::<Option<Vec<_>>>().ok_or_else(|| bad("object rect must be four finite numbers"))?;
let (x, y, w, h) = (n.first().copied(), n.get(1).copied(), n.get(2).copied(), n.get(3).copied());
let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad("object rect must be [x,y,w,h]")) };
// Negative sizes (dragging up or left) are normalised.
let (x0, x1, y0, y1) = (x.min(x + w), x.max(x + w), y.min(y + h), y.max(y + h));
ObjectArea::Rect(Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32))
}
(None, Some(pt)) => {
let (x, y) = point(pt).ok_or_else(|| bad("object point must be [x,y]"))?;
ObjectArea::Point(x, y)
}
(None, None) => return Err(bad("object needs \"rect\": [x,y,w,h] or \"point\": [x,y]")),
};
SelectionEdit::Object { area, add: adds(o)?, sample_all_layers: flag(o, "sampleAllLayers", false)? }
}
"subject" => {
let mode = match o.get("mode").map(Value::as_str) {
None | Some(Some("replace")) => SelectionMode::Replace,
Some(Some("add")) => SelectionMode::Add,
Some(Some("subtract")) => SelectionMode::Subtract,
Some(_) => return Err(bad("subject mode must be replace|add|subtract")),
};
SelectionEdit::Subject { mode, sample_all_layers: flag(o, "sampleAllLayers", true)? }
}
"clear" => SelectionEdit::Clear,
"invert" => SelectionEdit::Invert,
other => return Err(bad(&format!("unknown edit tool `{other}`: quick|brush|lasso|object|subject|clear|invert"))),
};
edits.push(edit);
}
Ok(edits)
}
/// Whether `p` carries Select and Mask tool edits.
pub fn has_edits(p: &Value) -> bool {
p.get("edits").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty()))
}
fn mode(add: bool) -> SelectionMode {
if add { SelectionMode::Add } else { SelectionMode::Subtract }
}
/// Most tiles the Brush and Lasso edits may paint in all: four times what one may
/// ([`sel::MAX_PAINTED_TILES`]), 1 GiB of GRAY8 tiles. Each is rasterised a tile at a time, so
/// this, not the canvas size, bounds what they allocate (the #8783 review).
pub const MAX_EDIT_TILES: usize = 4 * sel::MAX_PAINTED_TILES;
/// Refuses Brush and Lasso edits that would paint too many tiles of `canvas`, one by one or in
/// all, before anything is painted.
pub fn check_size(edits: &[SelectionEdit], canvas: Rect) -> Result<()> {
let mut all = BTreeSet::new();
for e in edits {
let tiles = match e {
SelectionEdit::Brush { points, size, .. } => matting::painted_tiles(points, *size, canvas, sel::MAX_PAINTED_TILES),
SelectionEdit::Lasso { points, .. } => sel::polygon_edge_tiles(points, canvas, sel::MAX_PAINTED_TILES),
_ => continue,
};
all.extend(tiles.ok_or_else(|| bad("a brush or lasso edit crosses too many tiles of the canvas"))?);
if all.len() > MAX_EDIT_TILES {
return Err(bad("brush and lasso edits cross too many tiles of the canvas"));
}
}
Ok(())
}
/// Refuses Refine Edge Brush strokes that would paint more tiles of `canvas` than
/// [`matting::EdgeBrush`] holds.
pub fn check_strokes(strokes: &[matting::EdgeStroke], canvas: Rect) -> Result<()> {
let refused = || EngineError::BadParams { cmd: CMD.into(), msg: "refineStrokes cross too many tiles of the canvas".into() };
let mut all = BTreeSet::new();
for s in strokes {
all.extend(matting::painted_tiles(&s.points, s.size, canvas, sel::MAX_PAINTED_TILES).ok_or_else(refused)?);
if all.len() > sel::MAX_PAINTED_TILES {
return Err(refused());
}
}
Ok(())
}
/// `base` inverted over `canvas`; `None` when that leaves nothing selected. Tile by tile: the
/// canvas takes the inverse of `base`'s untouched value (one tile shared by every fully covered
/// tile, as `fill_rect` stores it), then only the tiles `base` holds are inverted, so neither a
/// sparse selection nor a huge canvas costs a canvas-sized buffer (the #8783 review).
fn invert(base: Option<&Surface>, canvas: Rect) -> Option<Surface> {
let outside = base.map_or(0.0, |b| b.default_pixel().first().copied().unwrap_or(0.0).clamp(0.0, 1.0));
let mut s = Surface::new(photocraft_color::PixelFormat::GRAY8);
if outside < 1.0 {
s.fill_rect(canvas, &[1.0 - outside]);
}
if let Some(b) = base {
let ch = b.channels().max(1);
for (tc, _) in b.tiles() {
let r = tc.rect().intersect(&canvas);
if r.is_empty() {
continue;
}
let inverted: Vec<f32> = b.read_region(r).chunks_exact(ch).map(|px| 1.0 - px.first().copied().unwrap_or(0.0).clamp(0.0, 1.0)).collect();
s.write_region(r, &inverted);
}
}
s.prune();
(!s.content_bounds().is_empty()).then_some(s)
}
/// One edit applied to `base` (the selection so far; `None` = nothing selected) in `doc`, the
/// sampling tools reading `layer` (or the composite with Sample All Layers). A tool that finds
/// nothing leaves the selection as it was, as the same tools do outside Select and Mask.
pub fn apply_one(doc: &Document, layer: Option<LayerId>, base: Option<Surface>, edit: &SelectionEdit) -> Option<Surface> {
let canvas = doc.bounds();
let combine = |region: Option<Region>, m: SelectionMode| match region {
Some(r) => combine_region(base.as_ref(), Some(&r), m),
None => base.clone(),
};
// A painted shape (tile-sparse) added or subtracted, a tile at a time.
let paint = |shape: Option<Surface>, add: bool| match (shape.filter(|s| s.tile_count() > 0), base.as_ref()) {
(None, _) => base.clone(),
(Some(s), None) => add.then_some(s),
(Some(s), Some(o)) => {
let out = if add { sel::combine_sparse(o, &s, canvas, f32::max) } else { sel::combine_sparse(o, &s, canvas, |a, b| (a - b).max(0.0)) };
(!out.content_bounds().is_empty()).then_some(out)
}
};
match edit {
SelectionEdit::Quick { points, size, add, sample_all_layers } => {
let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| quick::quick_select(smp, doc.bounds(), points, *size));
combine(region, mode(*add))
}
SelectionEdit::Brush { points, size, hardness, add } => paint(matting::brush_surface(points, *size, *hardness, canvas), *add),
SelectionEdit::Lasso { points, add } => paint(sel::polygon_surface(points, canvas), *add),
SelectionEdit::Object { area, add, sample_all_layers } => {
let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| match *area {
ObjectArea::Rect(r) => grabcut::object_select(smp, doc.bounds(), r, 160_000),
ObjectArea::Point(x, y) => grabcut::object_at(smp, doc.bounds(), x, y, grabcut::OBJECT_AT_WORK_PX),
});
combine(region, mode(*add))
}
SelectionEdit::Subject { mode, sample_all_layers } => {
let region = with_doc_sampler(doc, layer, *sample_all_layers, |smp, doc| subject::select_subject(smp, doc.bounds()));
combine(region, *mode)
}
SelectionEdit::Clear => None,
SelectionEdit::Invert => invert(base.as_ref(), canvas),
}
}
/// `edits` applied in order to `base`: the selection Select and Mask refines.
/// A run of Inverts is one Invert or none (pairs cancel exactly), so thousands of them cost at
/// most one pass each between other edits (the #8783 review).
pub fn apply(doc: &Document, layer: Option<LayerId>, base: Option<Surface>, edits: &[SelectionEdit]) -> Option<Surface> {
let mut sel = base;
let mut invert = false;
for e in edits {
if *e == SelectionEdit::Invert {
invert = !invert;
continue;
}
if std::mem::take(&mut invert) {
sel = apply_one(doc, layer, sel, &SelectionEdit::Invert);
}
sel = apply_one(doc, layer, sel, e);
}
if invert {
sel = apply_one(doc, layer, sel, &SelectionEdit::Invert);
}
sel
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_every_tool() {
let p = json!({"edits": [
{"tool": "quick", "points": [[1, 2]], "size": 12, "mode": "subtract", "sampleAllLayers": true},
{"tool": "brush", "points": [[1, 2], [3, 4]], "hardness": 50},
{"tool": "lasso", "points": [[0, 0], [10, 0], [0, 10]]},
{"tool": "object", "rect": [10, 10, -5, -5]},
{"tool": "object", "point": [3, 4], "mode": "subtract"},
{"tool": "subject", "mode": "add"},
{"tool": "clear"},
{"tool": "invert"},
]});
let edits = parse(&p).expect("valid edits");
assert_eq!(edits.len(), 8);
assert_eq!(edits[0], SelectionEdit::Quick { points: vec![(1.0, 2.0)], size: 12.0, add: false, sample_all_layers: true });
assert_eq!(edits[1], SelectionEdit::Brush { points: vec![(1.0, 2.0), (3.0, 4.0)], size: 30.0, hardness: 50.0, add: true });
assert_eq!(edits[3], SelectionEdit::Object { area: ObjectArea::Rect(Rect::new(5, 5, 10, 10)), add: true, sample_all_layers: false });
assert_eq!(edits[5], SelectionEdit::Subject { mode: SelectionMode::Add, sample_all_layers: true });
assert!(parse(&json!({})).expect("none").is_empty());
assert!(parse(&json!({"edits": null})).expect("none").is_empty());
assert!(has_edits(&p) && !has_edits(&json!({"edits": []})) && !has_edits(&json!({})));
// One long brush edit within the area limit is fine; two of them (rejects_hostile_edits) are not.
assert!(parse(&json!({"edits": [{"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}]})).is_ok());
}
#[test]
fn rejects_hostile_edits() {
let many_points = vec![json!([1, 1]); MAX_EDIT_POINTS + 1];
let many_edits = vec![json!({"tool": "clear"}); MAX_EDITS + 1];
for bad in [
json!("x"),
json!([1]),
json!([{}]),
json!([{"tool": 7}]),
json!([{"tool": "wand"}]),
json!([{"tool": "quick"}]),
json!([{"tool": "quick", "points": []}]),
json!([{"tool": "quick", "points": [[1]]}]),
json!([{"tool": "quick", "points": [["a", 1]]}]),
json!([{"tool": "quick", "points": [[1e300, 1]]}]),
json!([{"tool": "quick", "points": [[1, 1]], "size": 0}]),
json!([{"tool": "quick", "points": [[1, 1]], "size": "big"}]),
json!([{"tool": "quick", "points": [[1, 1]], "mode": "intersect"}]),
json!([{"tool": "quick", "points": [[1, 1]], "sampleAllLayers": 1}]),
json!([{"tool": "brush", "points": [[1, 1]], "hardness": 101}]),
json!([{"tool": "lasso", "points": [[1, 1], [2, 2]]}]),
json!([{"tool": "object"}]),
json!([{"tool": "object", "rect": [1, 2, 3]}]),
json!([{"tool": "object", "point": [1, "y"]}]),
json!([{"tool": "subject", "mode": "intersect"}]),
json!([{"tool": "brush", "points": many_points}]),
json!(many_edits),
// Each within the limit, together past it.
json!(vec![json!({"tool": "brush", "points": [[0, 0], [10_000_000, 0]], "size": 20}); 2]),
] {
assert!(parse(&json!({"edits": bad})).is_err(), "{bad}");
}
}
/// The dense inversion this replaced: the whole canvas read, inverted and written back.
fn dense_invert(base: Option<&Surface>, canvas: Rect) -> Vec<u8> {
let m: Vec<f32> = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect();
bytes(Some(&sel::mask_to_surface(&m, canvas)), canvas)
}
fn bytes(s: Option<&Surface>, canvas: Rect) -> Vec<u8> {
sel::mask_from_surface(s, canvas).into_iter().map(|v| (v * 255.0).round() as u8).collect()
}
/// Tile by tile, Invert gives exactly what the dense inversion gave: partial edge tiles, soft
/// values, a selection reaching past the canvas, a mask whose untouched pixels are white, and
/// nothing selected (everything, inverted).
#[test]
fn tiled_invert_matches_the_dense_one() {
let canvas = Rect::new(0, 0, 600, 333);
let mut soft = Surface::new(photocraft_color::PixelFormat::GRAY8);
soft.fill_rect(Rect::new(250, 20, 900, 300), &[1.0]);
soft.fill_rect(Rect::new(10, 10, 40, 30), &[0.4]);
let mut white = Surface::with_default(photocraft_color::PixelFormat::GRAY8, &[1.0]);
white.fill_rect(Rect::new(300, 100, 520, 140), &[0.0]);
for base in [None, Some(soft), Some(white)] {
let got = bytes(invert(base.as_ref(), canvas).as_ref(), canvas);
assert_eq!(got, dense_invert(base.as_ref(), canvas));
}
// Inverting everything selects nothing.
let mut all = Surface::new(photocraft_color::PixelFormat::GRAY8);
all.fill_rect(canvas, &[1.0]);
assert!(invert(Some(&all), canvas).is_none());
}
/// A huge canvas inverts without a canvas-sized buffer, and a run of Inverts costs one.
#[test]
fn invert_on_a_huge_canvas_and_runs_of_inverts() {
let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8);
let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8);
small.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]);
let t = std::time::Instant::now();
let one = apply(&doc, None, Some(small.clone()), &[SelectionEdit::Invert]).expect("almost everything selected");
assert!(one.sample_channel(15, 15, 0) < 0.01 && one.sample_channel(99_999, 99_999, 0) > 0.99 && one.sample_channel(50_000, 5, 0) > 0.99);
let odd = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4095]).expect("inverted");
assert!(odd.sample_channel(15, 15, 0) < 0.01 && odd.sample_channel(99_999, 99_999, 0) > 0.99);
let even = apply(&doc, None, Some(small.clone()), &vec![SelectionEdit::Invert; 4096]).expect("the selection itself");
assert!(even.sample_channel(15, 15, 0) > 0.99 && even.sample_channel(50_000, 50_000, 0) < 0.01);
// Runs split by another edit invert on each side of it.
let split = [SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Invert, SelectionEdit::Clear, SelectionEdit::Invert];
let all = apply(&doc, None, Some(small), &split).expect("cleared, then everything");
assert!(all.sample_channel(15, 15, 0) > 0.99 && all.sample_channel(70_000, 3, 0) > 0.99);
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
/// The #8783 review: a size-1 Brush edit from corner to corner of a 100,000 px canvas sweeps
/// about 141,000 px but its bounding box is 10 billion; it, a Lasso sliver along it and their
/// subtraction paint only the tiles they cross, and keep the selection there was.
#[test]
fn thin_edits_across_a_huge_canvas_paint_only_their_tiles() {
let doc = Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8);
let canvas = doc.bounds();
let mut small = Surface::new(photocraft_color::PixelFormat::GRAY8);
small.fill_rect(Rect::new(10, 60, 20, 70), &[1.0]);
let diagonal = vec![(0.0, 0.0), (99_999.0, 99_999.0)];
let brush = SelectionEdit::Brush { points: diagonal.clone(), size: 1.0, hardness: 100.0, add: true };
let lasso = SelectionEdit::Lasso { points: vec![(0.0, 0.0), (99_999.0, 99_999.0), (0.0, 1.5)], add: true };
assert!(check_size(&[brush.clone(), lasso.clone()], canvas).is_ok());
let t = std::time::Instant::now();
for edit in [&brush, &lasso] {
let s = apply(&doc, None, Some(small.clone()), std::slice::from_ref(edit)).expect("painted");
assert!(s.tile_count() < 1500, "{}", s.tile_count());
assert!(s.sample_channel(15, 65, 0) > 0.99 && s.sample_channel(50_000, 50_000, 0) > 0.0 && s.sample_channel(50_000, 40_000, 0) == 0.0);
}
let erased =
apply(&doc, None, Some(small.clone()), &[brush, SelectionEdit::Brush { points: diagonal, size: 9.0, hardness: 100.0, add: false }]).expect("kept");
assert!(erased.sample_channel(15, 65, 0) > 0.99 && erased.sample_channel(50_000, 50_000, 0) == 0.0);
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
// A zigzag across more tiles than one edit may paint is refused before painting.
let zigzag: Vec<(f32, f32)> = (0..20).map(|i| (if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0)).collect();
let over = [SelectionEdit::Brush { points: zigzag.clone(), size: 1.0, hardness: 100.0, add: true }];
assert!(check_size(&over, canvas).is_err());
assert!(check_size(&[SelectionEdit::Lasso { points: zigzag, add: true }], canvas).is_err());
// So are many edits that each fit but cross too many tiles in all.
let rows: Vec<SelectionEdit> = (0..48)
.map(|i| SelectionEdit::Brush { points: vec![(0.0, i as f32 * 2000.0), (99_999.0, i as f32 * 2000.0)], size: 1.0, hardness: 100.0, add: true })
.collect();
assert!(rows.iter().all(|e| check_size(std::slice::from_ref(e), canvas).is_ok()));
assert!(check_size(&rows, canvas).is_err());
}
}
+195 -6
View File
@@ -11,6 +11,7 @@ use photocraft_geom::Rect;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::select_mask_edits;
use crate::{EngineError, Result, Session};
fn has_doc(s: &Session) -> std::result::Result<(), String> {
@@ -398,8 +399,9 @@ fn refine_strokes(p: &Value) -> Result<Vec<matting::EdgeStroke>> {
Ok(strokes)
}
/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on
/// the active layer, a new layer or a new document (each with or without a mask).
/// Select and Mask: applies the workspace's tool `edits` to the selection (or layer mask), refines
/// its edge, then outputs to the selection, a layer mask on the active layer, a new layer or a new
/// document (each with or without a mask).
/// Decontaminating colours needs new pixels, so it upgrades `selection` / `layerMask` output to
/// `newLayerWithMask` (as Photoshop does). New-layer outputs hide the source layer; new-document
/// outputs leave the source document untouched.
@@ -419,13 +421,30 @@ fn refine_edge(s: &mut Session, p: &Value) -> Result<Value> {
if decontaminate && matches!(output.as_str(), "selection" | "layerMask") {
output = "newLayerWithMask".into();
}
let edits = select_mask_edits::parse(p)?;
// Before anything is painted: a thin stroke across a huge canvas paints few pixels but many tiles.
select_mask_edits::check_size(&edits, d.doc.bounds())?;
select_mask_edits::check_strokes(&strokes, d.doc.bounds())?;
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 target_mask = p.get("target").and_then(Value::as_str) == Some("mask");
let sel = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) }?;
let start = if target_mask { mask_surf.or(doc.selection.as_ref()) } else { doc.selection.as_ref().or(mask_surf) };
// The workspace's tool edits change the selection before its edge is refined.
let edited = if edits.is_empty() { None } else { select_mask_edits::apply(doc, active, start.cloned(), &edits) };
let Some(sel) = (if edits.is_empty() { start } else { edited.as_ref() }) else { return Ok(None) };
let brush = matting::EdgeBrush::new(&strokes, doc.bounds());
matting::refine_mask_brushed(smp, &matting::surface_reader(sel), sel.content_bounds(), doc.bounds(), &params, brush.as_ref())
})?;
// The refined result is dense over the selection's bounds: refuse what would not fit
// rather than allocate it (a thin edit across a huge canvas; the #8783 review).
let content = sel.content_bounds();
let area = matting::refine_area(content, doc.bounds(), &params, brush.as_ref());
if u64::from(area.width()) * u64::from(area.height()) > matting::MAX_REFINE_AREA {
return Err(bad(
"select.refineEdge",
&format!("the selection is too large to refine ({} × {} px; at most {} px in all)", area.width(), area.height(), matting::MAX_REFINE_AREA),
));
}
Ok(matting::refine_mask_brushed(smp, &matting::surface_reader(sel), content, doc.bounds(), &params, brush.as_ref()))
})??;
let d = s.active().ok_or(EngineError::NoDocument)?;
let active = d.active_layer.filter(|id| d.doc.layer(*id).is_some());
match output.as_str() {
@@ -532,7 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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|newDocument|newDocumentWithMask"="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)"##,
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|newDocument|newDocumentWithMask"="selection","sampleAllLayers":bool=false,"refineStrokes":[{"points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add"},…]=[],"edits":[{"tool":"quick","points":[[x,y],…],"size":px=30,"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"brush","points":[[x,y],…],"size":px=30,"hardness":0..100=100,"mode":"add|subtract"="add"} | {"tool":"lasso","points":[[x,y],…≥3],"mode":"add|subtract"="add"} | {"tool":"object","rect":[x,y,w,h] | "point":[x,y],"mode":"add|subtract"="add","sampleAllLayers":bool=false} | {"tool":"subject","mode":"replace|add|subtract"="replace","sampleAllLayers":bool=true} | {"tool":"clear"} | {"tool":"invert"},…]=[]} (refineStrokes: the Refine Edge Brush, in painting order: add refines the painted area from the image, subtract keeps the original edge there; edits: the workspace's Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert, applied in order to the selection before its edge is refined)"##,
has_selection_or_mask,
refine_edge
),
@@ -1065,4 +1084,174 @@ mod tests {
s.execute("select.focusArea", json!({})).unwrap();
assert!(cov(&s, 30, 50) >= 0.5 && cov(&s, 130, 50) < 0.5);
}
/// The selection as 8-bit coverage over `w × h`, to compare two selections exactly.
fn sel_bytes(s: &Session, w: i32, h: i32) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).map(move |x| (cov(s, x, y) * 255.0).round() as u8)).collect()
}
/// The #8783 review: a refine whose result would be dense over more than
/// [`matting::MAX_REFINE_AREA`] pixels is refused, not allocated: a thin Brush edit across a
/// huge canvas (its edited selection spans the canvas) and a selection of the whole canvas.
/// The selection stays as it was.
#[test]
fn refine_edge_refuses_a_selection_too_large_to_refine() {
let mut s = Session::new();
s.add_document(
Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8),
None,
);
s.execute("select.rect", json!({"x": 10, "y": 60, "width": 10, "height": 10})).unwrap();
let small = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
let diagonal = json!({"edits": [{"tool": "brush", "points": [[0, 0], [99_999, 99_999]], "size": 1}]});
let t = std::time::Instant::now();
let e = s.execute("select.refineEdge", diagonal).expect_err("too large to refine");
assert!(e.to_string().contains("too large to refine"), "{e}");
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&small), "the selection is unchanged");
// The small selection itself still refines.
s.execute("select.refineEdge", json!({"radius": 4})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
s.execute("select.all", json!({})).unwrap();
let all = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
assert!(s.execute("select.refineEdge", json!({"radius": 4})).is_err());
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&all));
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
}
/// The #8783 review: Brush and Lasso edits and Refine Edge Brush strokes that would paint
/// too many tiles of a huge canvas are refused before anything is painted, and the selection
/// stays as it was.
#[test]
fn refine_edge_refuses_edits_that_cross_too_many_tiles() {
let mut s = Session::new();
s.add_document(
Document::new("huge", photocraft_doc::Size::new(100_000, 100_000), photocraft_doc::ColorMode::Rgb, photocraft_color::SampleType::U8),
None,
);
s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap();
let before = s.active().and_then(|d| d.doc.selection.clone()).expect("selected");
let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [if i % 2 == 0 { 0.0 } else { 99_999.0 }, i as f32 * 5000.0]).collect();
for p in [
json!({"edits": [{"tool": "brush", "points": zigzag, "size": 1}]}),
json!({"edits": [{"tool": "lasso", "points": zigzag}]}),
json!({"refineStrokes": [{"points": zigzag, "size": 1}]}),
] {
let e = s.execute("select.refineEdge", p.clone()).expect_err("refused");
assert!(e.to_string().contains("too many tiles"), "{p}: {e}");
assert!(s.active().and_then(|d| d.doc.selection.as_ref()) == Some(&before), "{p}: the selection is unchanged");
}
}
/// Select and Mask's own tools (#7069, #8148): each edit changes the selection before the
/// edge is refined, at every depth, and the whole run is one history step.
#[test]
fn refine_edge_edits_apply_each_tool_at_every_depth() {
for depth in [8, 16, 32] {
let mut s = two_regions(depth);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
let start = sel_bytes(&s, 140, 100);
let run = |s: &mut Session, edits: Value| {
s.execute("select.refineEdge", json!({"edits": edits})).unwrap();
let after = sel_bytes(s, 140, 100);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(sel_bytes(s, 140, 100), start, "depth {depth}: one undo restores the selection");
after
};
let at = |b: &[u8], x: usize, y: usize| b[y * 140 + x];
// Quick Selection grows across the cool region; subtracting it again leaves the start.
let quick = run(&mut s, json!([{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}]));
assert!(at(&quick, 120, 80) >= 128 && at(&quick, 5, 5) >= 128 && at(&quick, 40, 60) < 128, "depth {depth}: quick");
let back = run(
&mut s,
json!([{"tool": "quick", "points": [[100, 50]], "size": 8}, {"tool": "quick", "points": [[100, 50]], "size": 8, "mode": "subtract"}]),
);
assert!(at(&back, 120, 80) < 128 && at(&back, 5, 5) >= 128, "depth {depth}: quick subtract");
// The Brush paints exactly where it goes.
let brush = run(&mut s, json!([{"tool": "brush", "points": [[30, 80], [60, 80]], "size": 10}]));
assert!(at(&brush, 45, 80) == 255 && at(&brush, 45, 90) == 0, "depth {depth}: brush");
let erased = run(&mut s, json!([{"tool": "brush", "points": [[10, 10]], "size": 6, "mode": "subtract"}]));
assert!(at(&erased, 10, 10) == 0 && at(&erased, 2, 2) == 255, "depth {depth}: brush subtract");
// Lasso adds and subtracts polygons.
let lasso = run(&mut s, json!([{"tool": "lasso", "points": [[50, 50], [90, 50], [90, 90], [50, 90]]}]));
assert!(at(&lasso, 70, 70) == 255 && at(&lasso, 95, 70) == 0 && at(&lasso, 5, 5) == 255, "depth {depth}: lasso");
let cut = run(&mut s, json!([{"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}]));
assert!(at(&cut, 5, 5) == 0 && at(&cut, 15, 15) == 255, "depth {depth}: lasso subtract");
// Clear Selection, then Invert of nothing: everything.
let all = run(&mut s, json!([{"tool": "clear"}, {"tool": "invert"}]));
assert!(all.iter().all(|v| *v == 255), "depth {depth}: clear + invert");
let inverted = run(&mut s, json!([{"tool": "invert"}]));
assert!(at(&inverted, 5, 5) == 0 && at(&inverted, 100, 50) == 255, "depth {depth}: invert");
}
}
/// Object Selection and Select Subject inside the workspace find what the standalone tools find.
#[test]
fn refine_edge_edits_object_and_subject() {
for depth in [8, 16, 32] {
let (mut s, inside) = disc(depth);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("select.refineEdge", json!({"edits": [{"tool": "object", "rect": [40, 20, 120, 120]}, {"tool": "lasso", "points": [[0, 0], [10, 0], [10, 10], [0, 10]], "mode": "subtract"}]})).unwrap();
let v = sel_iou(&s, 200, 160, &inside);
assert!(v > 0.95, "depth {depth}: object rect IoU {v}");
s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}, {"tool": "object", "point": [3, 3]}, {"tool": "object", "point": [100, 80]}]}))
.unwrap();
let v = sel_iou(&s, 200, 160, &inside);
assert!(v > 0.9, "depth {depth}: object point IoU {v} (the background point finds nothing)");
}
let (mut s, inside) = disc(8);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("select.refineEdge", json!({"edits": [{"tool": "subject"}]})).unwrap();
let v = sel_iou(&s, 200, 160, &inside);
assert!(v > 0.9, "subject replaces the selection: IoU {v}");
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("select.refineEdge", json!({"edits": [{"tool": "subject", "mode": "add"}]})).unwrap();
assert!(cov(&s, 5, 5) >= 0.5 && cov(&s, 100, 80) >= 0.5, "subject added to the selection");
}
/// An edit gives what the standalone tool gives, then the edge is refined as usual, and the
/// result reaches every output.
#[test]
fn refine_edge_edits_match_the_tools_and_reach_the_outputs() {
let mut tool = two_regions(8);
tool.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
tool.execute("select.quick", json!({"points": [[100, 50], [110, 50]], "size": 8})).unwrap();
let mut ws = two_regions(8);
ws.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
ws.execute("select.refineEdge", json!({"edits": [{"tool": "quick", "points": [[100, 50], [110, 50]], "size": 8}]})).unwrap();
assert_eq!(sel_bytes(&ws, 140, 100), sel_bytes(&tool, 140, 100));
// Refinement runs on the edited selection: Shift Edge contracts the brushed bar too.
let mut s = two_regions(8);
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap();
s.execute("select.refineEdge", json!({"shiftEdge": -100, "edits": [{"tool": "brush", "points": [[30, 80], [100, 80]], "size": 20}]})).unwrap();
assert!(cov(&s, 60, 80) >= 0.5 && cov(&s, 60, 88) < 0.5 && cov(&s, 60, 72) < 0.5, "the 20 px bar shrank");
// Layer mask and new-layer outputs take the edited selection.
let mut s = blurred_edge_selected();
let r = s.execute("select.refineEdge", json!({"output": "layerMask", "edits": [{"tool": "invert"}]})).unwrap();
assert_eq!(r["output"], "layerMask");
let m = s.active().unwrap().doc.layers[0].mask.as_ref().unwrap();
assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99);
let mut s = blurred_edge_selected();
let r = s.execute("select.refineEdge", json!({"output": "newLayerWithMask", "edits": [{"tool": "invert"}]})).unwrap();
assert_eq!(r["output"], "newLayerWithMask");
let d = s.active().unwrap();
let m = d.doc.layers.iter().find_map(|l| l.mask.as_ref()).unwrap();
assert!(m.surface.sample_channel(10, 20, 0) < 0.01 && m.surface.sample_channel(70, 20, 0) > 0.99);
// Clearing everything deselects.
let mut s = blurred_edge_selected();
let r = s.execute("select.refineEdge", json!({"edits": [{"tool": "clear"}]})).unwrap();
assert_eq!(r["selected"], false);
assert!(s.active().unwrap().doc.selection.is_none());
}
/// Bad edits are refused before anything changes.
#[test]
fn refine_edge_rejects_bad_edits_and_keeps_the_selection() {
let mut s = blurred_edge_selected();
let before = sel_bytes(&s, 80, 40);
for bad in
[json!(7), json!([{"tool": "nope"}]), json!([{"tool": "lasso", "points": [[0, 0]]}]), json!([{"tool": "brush", "points": [[0, 0]], "size": -1}])]
{
assert!(s.execute("select.refineEdge", json!({"edits": bad})).is_err(), "{bad}");
assert_eq!(sel_bytes(&s, 80, 40), before, "{bad}");
}
}
}
+1
View File
@@ -822,6 +822,7 @@
Split Warp Vertically Rozdělit deformaci svisle
Statistics… Statistika…
Refine Strokes Tahy zpřesnění
Edits Úpravy
@plural {n} item|{n} items {n} položka|{n} položky|{n} položek
Auto Automaticky
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -811,6 +811,7 @@
Split Warp Vertically Verkrümmung vertikal teilen
Statistics… Statistiken…
Refine Strokes Verfeinerungsstriche
Edits Bearbeitungen
@plural {n} item|{n} items {n} Element|{n} Elemente
Auto Automatisch
(print to the printer) (an den Drucker senden)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1597,6 +1597,7 @@
Reset Alignment Επαναφορά στοίχισης
Centre the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0)
Refine Strokes Πινελιές βελτίωσης
Edits Επεξεργασίες
# Gradient Editor window, opened from the options bar's gradient swatch.
Gradient Editor Επεξεργασία διαβάθμισης
Gradient picker Επιλογή διαβάθμισης
Can't render this file because it contains an unexpected character in line 1038 and column 18.
+1
View File
@@ -819,6 +819,7 @@
Split Warp Vertically Dividir deformación verticalmente
Statistics… Estadísticas…
Refine Strokes Trazos de perfeccionamiento
Edits Ediciones
@plural {n} item|{n} items {n} elemento|{n} elementos
Auto Automático
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -806,6 +806,7 @@
Split Warp Vertically Diviser la déformation verticalement
Statistics… Statistiques…
Refine Strokes Traits d'affinement
Edits Modifications
@plural {n} item|{n} items {n} élément|{n} éléments
Auto Auto
(print to the printer) (imprimer sur l'imprimante)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -502,6 +502,7 @@ cameraRaw Yellow पीला
Output Output
Refine Edge… Refine Edge…
Refine Strokes Refine Strokes
Edits संपादन
Selection Selection
Shift Edge Shift Edge
Smart Radius Smart Radius
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1635,6 +1635,7 @@ fontWeight Thin Hajszálvékony
Refine Finomítás
Refine Edge Brush (R) Élfinomító ecset (R)
Refine Strokes Finomító ecsetvonások
Edits Szerkesztések
Reflect Visszaverődés
Reflected Tükrözött
Rename Channel… Csatorna átnevezése…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -799,6 +799,7 @@
Split Warp Vertically Bagi Warp Vertikal
Statistics… Statistik…
Refine Strokes Goresan penyempurnaan
Edits Suntingan
# Counted status and panel messages
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} {n} swatch diekspor ke {file}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {n} swatch diimpor dari {file}
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1
View File
@@ -813,6 +813,7 @@
Split Warp Vertically Dividi altera verticalmente
Statistics… Statistiche…
Refine Strokes Tracce di perfezionamento
Edits Modifiche
@plural {n} item|{n} items {n} elemento|{n} elementi
Auto Automatico
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
813 # Dialogs and panels
814 # Dialogs and panels
815
816
817
818
819
+1
View File
@@ -816,6 +816,7 @@
Split Warp Vertically ワープを垂直方向に分割
Statistics… 統計情報…
Refine Strokes 調整ストローク
Edits 編集
@plural {n} item|{n} items {n} 件
Auto 自動
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -793,6 +793,7 @@
Split Warp Vertically 뒤틀기 세로로 분할
Statistics… 통계…
Refine Strokes 다듬기 획
Edits 편집
# Counted status and panel messages
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 색상 견본 {n}개를 {file}(으)로 내보냈습니다
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} {file}에서 색상 견본 {n}개를 가져왔습니다
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
793 @plural
794 @plural
795 @plural
796 @plural
797 @plural
798 @plural
799 @plural
+1
View File
@@ -798,6 +798,7 @@
Split Warp Vertically Verbuiging verticaal splitsen
Statistics… Statistieken…
Refine Strokes Verfijningsstreken
Edits Bewerkingen
@plural {n} item|{n} items {n} element|{n} elementen
Auto Automatisch
(print to the printer) (naar de printer afdrukken)
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -815,6 +815,7 @@
Split Warp Vertically Podziel wypaczenie w pionie
Statistics… Statystyki…
Refine Strokes Pociągnięcia udoskonalania
Edits Edycje
# Counted status and panel messages (plural forms: 1 | 2–4 except 12–14 | the rest)
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} Wyeksportowano {n} próbkę koloru do {file}|Wyeksportowano {n} próbki kolorów do {file}|Wyeksportowano {n} próbek kolorów do {file}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} Zaimportowano {n} próbkę koloru z {file}|Zaimportowano {n} próbki kolorów z {file}|Zaimportowano {n} próbek kolorów z {file}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -799,6 +799,7 @@
Split Warp Vertically Dividir deformação verticalmente
Statistics… Estatísticas…
Refine Strokes Refinar traços
Edits Edições
@plural {n} item|{n} items {n} item|{n} itens
Auto Automático
(print to the printer) (imprimir na impressora)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -336,6 +336,7 @@
Current & Below Текущий и ниже
Current Layer Текущий слой
Refine Strokes Штрихи уточнения
Edits Правки
# Filter
Last Filter Последний фильтр
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -790,6 +790,7 @@
Split Warp Vertically Podeli krivljenje vertikalno
Statistics… Statistika…
Refine Strokes Doteraj poteze
Edits Izmene
@plural {n} item|{n} items {n} stavka|{n} stavke|{n} stavki
Auto Automatski
(print to the printer) (štampaj na štampaču)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -782,6 +782,7 @@
Split Warp Vertically Çözgüyü Dikey Olarak Böl
Statistics… İstatistikler…
Refine Strokes Fırça Vuruşlarını Hassaslaştır
Edits Düzenlemeler
@plural {n} item|{n} items {n} öğe
Auto Otomatik
(print to the printer) (yazıcıya yazdır)
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1
View File
@@ -314,6 +314,7 @@
Current & Below Поточний і нижчі
Current Layer Поточний шар
Refine Strokes Штрихи уточнення
Edits Правки
# Filter
Last Filter Останній фільтр
1 # Ukrainian (uk) catalog. UTF-8: context<TAB>English source<TAB>translation.
314
315
316 # Field Blur pins (#3373)
317 # Field Blur pins (#3373)
318
319
320
+1
View File
@@ -3591,6 +3591,7 @@ cameraData Lens Ống kính
Decontaminate Khử nhiễm màu
Refine Edge Tinh chỉnh cạnh
Refine Strokes Tinh chỉnh nét
Edits Chỉnh sửa
New Document With Mask Tài liệu mới có mặt nạ
New Layer With Mask Lớp mới có mặt nạ
Convert Point Tool Công cụ Chuyển đổi điểm
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -823,6 +823,7 @@
Split Warp Vertically 垂直分割变形
Statistics… 统计…
Refine Strokes 调整描边
Edits 编辑
# Counted status and panel messages
@plural Exported {n} swatch to {file}|Exported {n} swatches to {file} 已将 {n} 个色板导出到 {file}
@plural Imported {n} swatch from {file}|Imported {n} swatches from {file} 已从 {file} 导入 {n} 个色板
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
823 @plural
824 @plural
825 @plural
826 @plural
827 @plural
828 @plural
829 @plural
+1
View File
@@ -828,6 +828,7 @@
Split Warp Vertically 垂直分割彎曲
Statistics… 統計資料…
Refine Strokes 調整筆畫
Edits 編輯
@plural {n} item|{n} items {n} 個項目
Auto 自動
(print to the printer) (列印至印表機)
Can't render this file because it contains an unexpected character in line 6 and column 47.