mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
feat(engine): Select and Mask tool edits as select.refineEdge params
select.refineEdge takes `edits`: the Select and Mask workspace's Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert, applied in order to the starting selection (or layer mask) before the edge is refined. Cancel discards them, OK is one history step, and agents drive them over MCP and the control channel. - engine/select_mask_edits.rs: parse (bounded, every bad field an Err) and a pure apply/apply_one the workspace preview can share - algo matting::brush_region: soft round brush coverage (hardness) - ATN export reports steps with edits instead of dropping them - "Edits" label in the 21 catalogs Part of #7069 and #8148 (the workspace UI follows in a second PR). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff47388101
commit
4970534e05
@@ -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_region, 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.
|
||||
|
||||
@@ -14,6 +14,7 @@ use photocraft_geom::Rect;
|
||||
|
||||
use super::box_mean;
|
||||
use crate::segment::RgbImage;
|
||||
use crate::selection::Region;
|
||||
|
||||
/// One Refine Edge Brush stroke, in document pixels.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -41,14 +42,27 @@ pub struct EdgeBrush {
|
||||
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,14 +78,50 @@ 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 area one stroke of `points` sweeps with a brush `size` wide: length × size + size².
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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`. 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 or sweeps more than [`MAX_SWEPT_AREA`].
|
||||
pub fn brush_region(points: &[(f32, f32)], size: f32, hardness: f32, canvas: Rect) -> Option<Region> {
|
||||
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 bbox = points.iter().fold(Rect::EMPTY, |acc, p| acc.union(&dab_bounds(*p, r))).intersect(&canvas);
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bw = bbox.width() as usize;
|
||||
let mut cov = vec![0.0f32; bw.checked_mul(bbox.height() as usize)?];
|
||||
for (a, b) in segments(points, r) {
|
||||
let seg = dab_bounds(a, r).union(&dab_bounds(b, r)).intersect(&bbox);
|
||||
for y in seg.y0..seg.y1 {
|
||||
for x in seg.x0..seg.x1 {
|
||||
let c = soft_coverage(segment_distance((x as f32 + 0.5, y as f32 + 0.5), a, b), r, core);
|
||||
if let Some(slot) = cov.get_mut((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize)
|
||||
&& c > *slot
|
||||
{
|
||||
*slot = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mask: Vec<u8> = cov.iter().map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
|
||||
mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask })
|
||||
}
|
||||
|
||||
/// The stroke's path as short segments: points closer than a quarter of the radius to the last
|
||||
@@ -274,7 +324,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::matting::{RefineParams, refine_mask_brushed, region_reader};
|
||||
use crate::segment::ImageSampler;
|
||||
use crate::selection::Region;
|
||||
|
||||
/// Strands of the subject's colour (every 4th column from x = 60) beyond the selection's
|
||||
/// edge at x = 40: brushing over them adds them, Alt-brushing the edge keeps it as it was.
|
||||
@@ -330,4 +379,32 @@ mod tests {
|
||||
let empty = EdgeStroke { points: vec![], size: 10.0, add: true };
|
||||
assert!(EdgeBrush::new(&[off, nan, empty], canvas).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn brush_region_paints_hard_and_soft_strokes() {
|
||||
let canvas = Rect::new(0, 0, 100, 50);
|
||||
let hard = brush_region(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 100.0, canvas).expect("painted");
|
||||
assert!(hard.at(50, 25) > 0.99 && hard.at(50, 33) > 0.99, "solid inside the radius");
|
||||
assert!(hard.at(50, 36) == 0.0 && hard.at(50, 10) == 0.0, "nothing past it");
|
||||
// A soft brush fades from the centre line to the rim.
|
||||
let soft = brush_region(&[(10.0, 25.0), (90.0, 25.0)], 20.0, 0.0, canvas).expect("painted");
|
||||
let (centre, mid, rim) = (soft.at(50, 25), soft.at(50, 30), soft.at(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_region(&[(0.0, 0.0)], 10.0, 100.0, canvas).expect("painted");
|
||||
assert_eq!((dab.bbox.x0, dab.bbox.y0), (0, 0));
|
||||
assert!(dab.at(1, 1) > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_region_paints_nothing_for_hostile_strokes() {
|
||||
let canvas = Rect::new(0, 0, 100, 50);
|
||||
assert!(brush_region(&[], 10.0, 100.0, canvas).is_none());
|
||||
assert!(brush_region(&[(f32::NAN, 1.0)], 10.0, 100.0, canvas).is_none());
|
||||
assert!(brush_region(&[(1.0, 1.0)], f32::INFINITY, 100.0, canvas).is_none());
|
||||
assert!(brush_region(&[(1.0, 1.0)], 10.0, f32::NAN, canvas).is_none());
|
||||
assert!(brush_region(&[(-500.0, -500.0)], 10.0, 100.0, canvas).is_none(), "off the canvas");
|
||||
assert!(brush_region(&[(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_region(&[(50.0, 25.0)], 0.0, 500.0, canvas).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,303 @@
|
||||
//! 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 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;
|
||||
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" => SelectionEdit::Brush {
|
||||
points: points(o, 1, &mut total)?,
|
||||
size: number(o, "size", 30.0, size_range.clone())?,
|
||||
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 }
|
||||
}
|
||||
|
||||
/// The polygon through `points` (anti-aliased), over the rows and columns it spans in `canvas`.
|
||||
fn polygon_region(points: &[(f32, f32)], canvas: Rect) -> Option<Region> {
|
||||
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 x0 > x1 || y0 > y1 {
|
||||
return None;
|
||||
}
|
||||
let clamp = |v: f32| v.clamp(-1e7, 1e7) as i32;
|
||||
let bbox = Rect::new(clamp(x0.floor()) - 1, clamp(y0.floor()) - 1, clamp(x1.ceil()) + 1, clamp(y1.ceil()) + 1).intersect(&canvas);
|
||||
if bbox.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mask: Vec<u8> = sel::polygon(points, bbox, true).iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8).collect();
|
||||
mask.iter().any(|v| *v > 0).then_some(Region { bbox, mask })
|
||||
}
|
||||
|
||||
/// `base` inverted over `canvas`; `None` when that leaves nothing selected.
|
||||
fn invert(base: Option<&Surface>, canvas: Rect) -> Option<Surface> {
|
||||
let m: Vec<f32> = sel::mask_from_surface(base, canvas).into_iter().map(|v| 1.0 - v).collect();
|
||||
let mut s = sel::mask_to_surface(&m, canvas);
|
||||
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(),
|
||||
};
|
||||
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 } => combine(matting::brush_region(points, *size, *hardness, canvas), mode(*add)),
|
||||
SelectionEdit::Lasso { points, add } => combine(polygon_region(points, canvas), mode(*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.
|
||||
pub fn apply(doc: &Document, layer: Option<LayerId>, base: Option<Surface>, edits: &[SelectionEdit]) -> Option<Surface> {
|
||||
edits.iter().fold(base, |sel, e| apply_one(doc, layer, sel, e))
|
||||
}
|
||||
|
||||
#[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!({})));
|
||||
}
|
||||
|
||||
#[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),
|
||||
] {
|
||||
assert!(parse(&json!({"edits": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,10 +421,14 @@ 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)?;
|
||||
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 sel = if edits.is_empty() { start } else { edited.as_ref() }?;
|
||||
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())
|
||||
})?;
|
||||
@@ -532,7 +538,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 +1071,122 @@ 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()
|
||||
}
|
||||
|
||||
/// 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