diff --git a/crates/algo/src/matting.rs b/crates/algo/src/matting.rs index e40b687040..2008256702 100644 --- a/crates/algo/src/matting.rs +++ b/crates/algo/src/matting.rs @@ -238,7 +238,7 @@ pub fn morph(src: &[f32], w: usize, h: usize, r: usize, grow: bool) -> Vec 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. diff --git a/crates/algo/src/matting/brush.rs b/crates/algo/src/matting/brush.rs index f70342e6d7..ae78e28d93 100644 --- a/crates/algo/src/matting/brush.rs +++ b/crates/algo/src/matting/brush.rs @@ -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 { + 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 = 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()); + } } diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index f166c239b3..ac1d2df8a0 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -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 { if !pixel_target(p) || p.get("sampleAllLayers").is_some() || has_refine_strokes(p) { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index d83d69e624..60236b57c3 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -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 diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64814126cd..206029d860 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/select_mask_edits.rs b/crates/engine/src/select_mask_edits.rs new file mode 100644 index 0000000000..10bffb5bff --- /dev/null +++ b/crates/engine/src/select_mask_edits.rs @@ -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 { + 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, min: usize, total: &mut usize) -> Result> { + 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::>>().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, key: &str, default: f32, range: std::ops::RangeInclusive) -> Result { + 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, key: &str, default: bool) -> Result { + 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) -> Result { + 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> { + 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::>>().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 { + 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 = 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 { + let m: Vec = 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, base: Option, edit: &SelectionEdit) -> Option { + let canvas = doc.bounds(); + let combine = |region: Option, 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, base: Option, edits: &[SelectionEdit]) -> Option { + 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}"); + } + } +} diff --git a/crates/engine/src/smartselect_cmds.rs b/crates/engine/src/smartselect_cmds.rs index 039f248a5b..cad2feac28 100644 --- a/crates/engine/src/smartselect_cmds.rs +++ b/crates/engine/src/smartselect_cmds.rs @@ -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> { 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 { 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 { "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 { + (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}"); + } + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3cbbfe672b..1e373d2792 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index cecf610a2b..5152f361ef 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 40069a334f..b72e1f8d2f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -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 Επιλογή διαβάθμισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e0cf3a62e9..419dd0d452 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b6df1c135b..c3a1b800ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index bdd224c440..34d749f777 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cf66b32534..275de55bdb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 6198dd099b..51ffe4d8b9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a7b7c85381..3c65c5a25f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 214a56fd93..c9018c00df 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -816,6 +816,7 @@ Split Warp Vertically ワープを垂直方向に分割 Statistics… 統計情報… Refine Strokes 調整ストローク + Edits 編集 @plural {n} item|{n} items {n} 件 Auto 自動 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 00bf8dff22..c70cf5fb1c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -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}개를 가져왔습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 472dab3980..20de061389 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -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) diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5744be49df..b66b543d74 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 67964ad894..e1195ea059 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 791dcf8fb8..66bc4d128e 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -336,6 +336,7 @@ Current & Below Текущий и ниже Current Layer Текущий слой Refine Strokes Штрихи уточнения + Edits Правки # Filter Last Filter Последний фильтр diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 9695ced464..f071ee5399 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -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) diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 342a0ebff4..90fe0c3eef 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -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) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index a96f028048..56ad5e718a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -314,6 +314,7 @@ Current & Below Поточний і нижчі Current Layer Поточний шар Refine Strokes Штрихи уточнення + Edits Правки # Filter Last Filter Останній фільтр diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9c7155fe0d..65f866e486 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c1630ad97c..16cd47edf8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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} 个色板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0771327a40..7350d24ba9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -828,6 +828,7 @@ Split Warp Vertically 垂直分割彎曲 Statistics… 統計資料… Refine Strokes 調整筆畫 + Edits 編輯 @plural {n} item|{n} items {n} 個項目 Auto 自動 (print to the printer) (列印至印表機) diff --git a/docs/scorecard.md b/docs/scorecard.md index 6e84b9847e..be32d94aee 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -149,7 +149,7 @@ Corpus floors (`crates/io/tests/it/corpus.rs`): the enforced minimums; the corpu | TOOL-213-13 | Brush regression tests: no seams, hard edge, zero-scatter defaults, ABR never black | done | [#213](https://github.com/storytold/photocraft/issues/213) | Seam, hard-edge and zero-scatter tests in brush_tests.rs; ABR tip colour test in abr_map/tests.rs | | TOOL-213-14 | Tablet: no OS arrow cursor over the canvas on macOS, Windows and Linux | partial | [#213](https://github.com/storytold/photocraft/issues/213) | Brush tools set CursorIcon::None (canvas.rs:1259-1275); not verified per OS | | TOOL-213-18 | Pattern Stamp tool: sibling of Clone Stamp in the S flyout | done | [#213](https://github.com/storytold/photocraft/issues/213) | Tool::PatternStamp in the S group; paint.patternStamp stamps the Patterns-panel tile with Aligned and Impressionist (an approximation: it jitters each dab's tile phase, not Photoshop's smeared impressionist look); engine tests cover depths, seams, hostile input | -| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout | +| TOOL-214-1 | Select and Mask workspace: view modes, Refine Edge brush, 6 outputs | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet | | TOOL-214-2 | Marquee: Shift square, Alt from centre, live W x H readout | done | [#214](https://github.com/storytold/photocraft/issues/214) | canvas.rs marquee_corners: Shift square or circle, Alt from the centre, live W x H readout beside the cursor (marquee_readout); marquee_tests.rs | | TOOL-214-3 | Color Range test: Sampled Colors at Fuzziness 0 selects the exact match only | done | [#214](https://github.com/storytold/photocraft/issues/214) | color_range_fuzziness_zero_selects_exact_match_only in selection_cmds.rs asserts exact match is selected and 1-level-off pixels are rejected at fuzziness 0; tonal presets covered in color_range_presets | | TOOL-214-4 | Move tool: box-select layers, Auto-Select hover outline, double-click fit, drag to a tab | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Auto-Select hover outline (Preferences › Tools › Show Highlight on Rollover, #2834) and box-select layers from an empty spot (move_box.rs:59, layer.selectInRect, #2844) done; dragging layers with the Move tool (or from the Layers panel) onto another document's tab copies them there (layer_transfer.rs:162, layer.copyToDocument, #589/#851); double-click fit and dragging a selection's pixels to another document open | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 6a1ac772f7..2ec39ab4d9 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -115,7 +115,7 @@ id = "TOOL-214-1" target = "Select and Mask workspace: view modes, Refine Edge brush, 6 outputs" status = "partial" issue = 214 -note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); no workspace layout" +note = "select.refineEdge dialog with a live preview in Photoshop's view modes: Onion Skin, Marching Ants, Overlay, On Black, On White, Black & White, On Layers, per-view Opacity, Show Edge (J), Show Original (P), F cycles, X shows the image (select_mask_view.rs, #1754); Refine Edge Brush (R, size, Expand Detection Area / Restore Original Edge, Alt swaps) paints select.refineEdge refineStrokes, estimated by colour-line matting (matting/brush.rs, #2972); outputs Selection, Layer Mask, New Layer, New Layer with Mask, New Document, New Document with Mask (#2998); the workspace tools Quick Selection, Brush, Lasso, Object Selection, Select Subject, Clear Selection and Invert run as select.refineEdge edits, drivable by agents (#7069); no workspace layout or toolbar yet" [[item]] id = "TOOL-214-2"