mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(), ¶ms, 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(), ¶ms, 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(), ¶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());
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
||||
|
@@ -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.
|
@@ -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}개를 가져왔습니다
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -314,6 +314,7 @@
|
||||
Current & Below Поточний і нижчі
|
||||
Current Layer Поточний шар
|
||||
Refine Strokes Штрихи уточнення
|
||||
Edits Правки
|
||||
|
||||
# Filter
|
||||
Last Filter Останній фільтр
|
||||
|
||||
|
@@ -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.
|
@@ -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} 个色板
|
||||
|
||||
|
@@ -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.
|
Reference in New Issue
Block a user