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:
Khawar Zamman Wani
2026-10-23 10:30:46 +05:30
co-authored by Claude Opus 5.5
parent ff47388101
commit 4970534e05
30 changed files with 568 additions and 24 deletions
+5 -4
View File
@@ -3037,13 +3037,14 @@ macro_rules! adjustment_event {
const RIPPLE_SIZE: &[(&str, &str)] = &[("Sml", "small"), ("Mdm", "medium"), ("Lrg", "large")];
/// Whether a Select and Mask step carries Refine Edge Brush strokes, which no descriptor holds.
/// Whether a Select and Mask step carries Refine Edge Brush strokes or the workspace's tool
/// edits (Quick Selection, Brush, Lasso…), which no descriptor holds.
fn has_refine_strokes(p: &Value) -> bool {
p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty()))
p.get("refineStrokes").is_some_and(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())) || crate::select_mask_edits::has_edits(p)
}
/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush, so a
/// step with `refineStrokes` can't be exported faithfully. One with `sampleAllLayers` is not
/// Select and Mask to Photoshop. The descriptor has no field for the Refine Edge Brush or the
/// workspace's tools, so a step with `refineStrokes` or `edits` can't be exported faithfully. One with `sampleAllLayers` is not
/// exported here either: it exports as `smartBrushWorkspace`.
fn refine_edge_to(_: &str, p: &Value) -> Option<Descriptor> {
if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) {
+17
View File
@@ -2466,6 +2466,23 @@ fn refine_edge_with_brush_strokes_is_not_exported_without_them() {
}
}
/// Nor does it hold the workspace's tool edits (Quick Selection, Brush, Lasso…), with or without
/// Sample All Layers, so a step with them is reported instead of exported without them.
#[test]
fn refine_edge_with_workspace_edits_is_not_exported_without_them() {
let edit = json!({"tool": "quick", "points": [[5, 4]], "size": 4});
for params in [json!({"radius": 1, "edits": [edit]}), json!({"radius": 1, "sampleAllLayers": true, "edits": [{"tool": "invert"}]})] {
let edited = Action { name: "Probe".into(), steps: vec![("select.refineEdge".into(), params.clone())], ..Default::default() };
assert!(export("Audit", &[&edited], &[], false).is_err(), "{params}");
let (_, skipped) = export("Audit", &[&edited], &[], true).unwrap();
assert_eq!(skipped, vec![Skipped { action: "Probe".into(), step: 0, id: "select.refineEdge".into() }]);
}
for params in [json!({"radius": 1, "edits": []}), json!({"radius": 1, "edits": null})] {
let plain = Action { name: "Plain".into(), steps: vec![("select.refineEdge".into(), params)], ..Default::default() };
assert!(export("Audit", &[&plain], &[], false).is_ok());
}
}
#[test]
fn image_size_resolution_only_does_not_gain_keep_print_size() {
// The .atn cannot tell resolution-only from keepPrintSize:true, so the former must not
+1
View File
@@ -108,6 +108,7 @@ pub mod retouch_cmds;
pub mod sample_cmds;
pub mod sampler_cmds;
pub mod select_extra_cmds;
pub mod select_mask_edits;
pub mod selection_cmds;
pub mod shell_commands;
pub mod shortcut_sets;
+303
View File
@@ -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}");
}
}
}
+128 -4
View File
@@ -11,6 +11,7 @@ use photocraft_geom::Rect;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::select_mask_edits;
use crate::{EngineError, Result, Session};
fn has_doc(s: &Session) -> std::result::Result<(), String> {
@@ -398,8 +399,9 @@ fn refine_strokes(p: &Value) -> Result<Vec<matting::EdgeStroke>> {
Ok(strokes)
}
/// Select and Mask: refines the selection edge, then outputs to the selection, a layer mask on
/// the active layer, a new layer or a new document (each with or without a mask).
/// Select and Mask: applies the workspace's tool `edits` to the selection (or layer mask), refines
/// its edge, then outputs to the selection, a layer mask on the active layer, a new layer or a new
/// document (each with or without a mask).
/// Decontaminating colours needs new pixels, so it upgrades `selection` / `layerMask` output to
/// `newLayerWithMask` (as Photoshop does). New-layer outputs hide the source layer; new-document
/// outputs leave the source document untouched.
@@ -419,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(), &params, 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}");
}
}
}