From 6bb45c8d5b552ba00924bd25b92842641aab01b8 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 03:02:19 +0200 Subject: [PATCH 1/4] Shapes: Density and Feather on shape layers (#5846) A shape layer's path is its vector mask in Photoshop (Properties > Masks), with its own vector-mask density and feather. PhotoCraft's ShapeLayer had neither, so soft shapes could not be made, and stroked soft shapes from PSDs lost both on import. - doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px, default 0). - vector: the shape renderer applies them to the fill as a soft vector mask (fill shows beyond the path at 1 - density; the feather is the vector mask's Gaussian), so the shape's own pixels, the GPU (which samples them) and the stroke/fill split all follow. The stroke stays crisp. The feather blur moves from compose::masks to vector::feather and both share it and the feathered area, so a shape and an equal vector mask render the same pixels. - compose: soft shapes' bounds reach their fill, their layer effects follow the soft alpha, and the split cache key includes density and feather. - engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one history step (path/enabled/linked/invert are refused there); density is now validated (finite 0..100) instead of clamped. `layer.vectorMask.info` reports a shape's values. - ui: Properties shows the Vector Mask Density and Feather sliders for a selected shape layer in both themes (live drag, one undo per gesture). - .pcraft: manifest and convert carry both with serde defaults (older files load at 100 % and 0 px). - PSD: export writes them as the vector density/feather mask parameters; import reads them into the shape (re-rendered, as the stored pixels lack the fill beyond the path). Soft unstroked solid/gradient shapes now import as shapes instead of fill layers with a vector mask; pattern-filled ones stay fill layers. Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72: ours is within 1 level of every measured value (8, 16 and 32 bits); density 50 % shows 127/128 beyond the path and 0 inside. Scorecard: TOOL-214-12 done. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 23 +- crates/compose/src/masks.rs | 265 +------------------- crates/compose/src/shape_split.rs | 2 + crates/compose/src/soft_shape_tests.rs | 159 ++++++++++++ crates/compose/src/stroke_tests.rs | 13 +- crates/doc/src/vector.rs | 21 +- crates/engine/src/vector_cmds.rs | 45 +++- crates/engine/src/vector_cmds/tests.rs | 65 +++++ crates/format/src/convert.rs | 6 +- crates/format/src/manifest.rs | 23 ++ crates/format/tests/common/mod.rs | 2 + crates/format/tests/roundtrip.rs | 14 ++ crates/gpu/tests/parity.rs | 47 +++- crates/io/src/affinity.rs | 2 +- crates/io/src/psd_export.rs | 8 +- crates/io/src/psd_import.rs | 31 ++- crates/io/tests/corpus_regressions.rs | 10 +- crates/io/tests/effects_multi.rs | 3 +- crates/io/tests/vector.rs | 45 ++++ crates/ui-egui/src/layer_props_ui.rs | 5 +- crates/ui-egui/src/mask_props_ui.rs | 19 +- crates/ui-egui/src/mask_props_ui_tests.rs | 58 ++++- crates/ui-egui/src/panels.rs | 1 + crates/vector/src/feather.rs | 281 ++++++++++++++++++++++ crates/vector/src/lib.rs | 101 +++++++- docs/scorecard.md | 4 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 4 +- scorecard/tools.toml | 4 +- 29 files changed, 958 insertions(+), 308 deletions(-) create mode 100644 crates/compose/src/soft_shape_tests.rs create mode 100644 crates/vector/src/feather.rs diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b742540b95..366c13b543 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -802,11 +802,13 @@ pub fn effect_source_bounds(layer: &Layer, canvas: Rect) -> Rect { /// shape's layer effects from its vector outline, not from its pixels: a stroke runs along the /// whole path even where a gradient fill fades out (psd-tools stroke-effects). `None` for other /// layers, shapes without fill, empty and inverted paths, and shapes whose vector stroke reaches -/// past the path (their pixels give the outline: psd-tools double-stroke-effects). +/// past the path (their pixels give the outline: psd-tools double-stroke-effects), and shapes +/// with a density or feather. pub fn effect_outline(layer: &Layer) -> Option<&photocraft_doc::vector::Path> { let LayerContent::Shape(sh) = &layer.content else { return None }; let stroke_inside = sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.align == photocraft_doc::vector::StrokeAlign::Inside); - (sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted).then_some(&sh.path) + // A soft (feathered or less dense) fill's own alpha is its outline. + (sh.fill.is_some() && stroke_inside && !sh.path.is_empty() && !sh.path.inverted && !sh.has_soft_mask()).then_some(&sh.path) } /// The shape a layer's effects are built from over `rect` (row-major): its content's alpha @@ -864,7 +866,8 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } -/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width. +/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width +/// (and the reach of a soft fill). pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { return None; @@ -872,7 +875,17 @@ pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { let (x0, y0, x1, y1) = sh.path.control_bounds()?; let stroke_margin = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| s.width.ceil()) as i32; let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32).inflate(stroke_margin); - (!r.is_empty()).then_some(r) + if r.is_empty() { + return None; + } + // A soft fill reaches past the path: by its feather, or everywhere below 100 % density. + if sh.fill.is_some() && sh.has_soft_mask() { + if sh.density.is_finite() && sh.density < 1.0 { + return Some(Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4)); + } + return Some(r.union(&photocraft_vector::feather::path_area(&sh.path, masks::feather_sigma(sh.feather)))); + } + Some(r) } /// The layer's effective mask over `rect` (row-major), read once per tile: the pixel mask @@ -1889,6 +1902,8 @@ fn blend_into_g(backdrop: &mut Buffer, src: &Buffer, mode: BlendMode, opacity: f } } +#[cfg(test)] +mod soft_shape_tests; #[cfg(test)] mod stroke_tests; #[cfg(test)] diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index 8323e1b939..802a0f2df9 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -171,17 +171,8 @@ fn key(layer: &Layer, canvas: Rect) -> u64 { h.finish() } -/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. -/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector -/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's -/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). -pub fn feather_sigma(px: f32) -> f32 { - if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } -} - -const FEATHER_SIGMA: f32 = 1.0; -/// Photoshop's feather tops out at 1000 px. -const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA; +pub use photocraft_vector::feather::feather_sigma; +use photocraft_vector::feather::gaussian; /// Whether `layer` has an enabled mask with a feather (rendered through [`combined_mask`]). pub fn has_feather(layer: &Layer) -> bool { @@ -189,166 +180,6 @@ pub fn has_feather(layer: &Layer) -> bool { || layer.vector_mask.as_ref().is_some_and(|v| v.enabled && feather_sigma(v.feather) > 0.0) } -/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just -/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. -#[derive(Clone, Copy, Debug, PartialEq)] -struct BoxPass { - r: usize, - end: f32, -} - -impl BoxPass { - /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et - /// al. 2011), or `None` when there is nothing to blur. - fn for_sigma(sigma: f32) -> Option { - if !sigma.is_finite() || sigma <= 0.0 { - return None; - } - // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. - let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; - // The largest whole radius whose box variance r(r+1)/3 is at most v. - let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; - let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; - while r > 0 && var(r) > v { - r -= 1; - } - while var(r + 1) <= v { - r += 1; - } - let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); - let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); - Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) - } -} - -/// Apply one of the three box passes to a single line. `scratch` preserves the input while -/// the sliding sum writes the output, matching the previous sequential implementation exactly. -fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { - if line.is_empty() { - return; - } - let Ok(radius) = isize::try_from(pass.r) else { return }; - scratch.copy_from_slice(line); - let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; - let mut acc: f32 = (-radius..=radius).map(get).sum(); - let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; - // The sample leaving the window is the next one's low end weight. - let mut below = get(-radius - 1); - for (i, dst) in line.iter_mut().enumerate() { - let i = i as isize; - let (above, leaving) = (get(i + radius + 1), get(i - radius)); - *dst = (acc + pass.end * (below + above)) / n; - acc += above - leaving; - below = leaving; - } -} - -/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used -/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon -/// work here can deadlock when every worker waits on the same OnceLock (#276). -fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { - let mut scratch = vec![0.0; w]; - for row in v.chunks_mut(w) { - for _ in 0..3 { - blur_line(row, &mut scratch, pass); - } - } -} - -fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { - if w == 0 || v.is_empty() { - return; - } - let h = v.len() / w; - let threads = if cfg!(target_arch = "wasm32") || v.len() < 1 << 16 { - 1 - } else { - std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) - }; - if threads == 1 { - blur_rows_chunk(v, w, pass); - return; - } - let rows_per_worker = h.div_ceil(threads); - let chunk_len = rows_per_worker.saturating_mul(w); - std::thread::scope(|scope| { - for chunk in v.chunks_mut(chunk_len) { - scope.spawn(move || blur_rows_chunk(chunk, w, pass)); - } - }); -} - -/// Cache-friendly blocked transpose. It makes the vertical blur contiguous without a Rayon job -/// or unsafe disjoint-column access; the temporary is released before the next mask is built. -fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { - const BLOCK: usize = 32; - for by in (0..h).step_by(BLOCK) { - for bx in (0..w).step_by(BLOCK) { - for y in by..(by + BLOCK).min(h) { - for x in bx..(bx + BLOCK).min(w) { - out[x * h + y] = v[y * w + x]; - } - } - } - } -} - -/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is -/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into -/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like -/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels -/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes -/// are processed in parallel on bounded OS threads, preserving each line's floating-point -/// operation order. -fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { - if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { - return; - } - let Some(pass) = BoxPass::for_sigma(sigma) else { return }; - // Reserve before modifying the input so allocation failure can use the sequential fallback - // without leaving a half-blurred mask. - let mut transposed = Vec::new(); - if transposed.try_reserve_exact(v.len()).is_err() { - gaussian_sequential(v, w, h, pass); - return; - } - transposed.resize(v.len(), 0.0); - blur_rows(v, w, pass); - transpose_into(v, &mut transposed, w, h); - blur_rows(&mut transposed, h, pass); - transpose_into(&transposed, v, h, w); -} - -/// Allocation-light reference path used if parallel transpose storage cannot be reserved. -fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { - let mut line = Vec::new(); - let mut scratch = Vec::new(); - let len = w.max(h); - if line.try_reserve_exact(len).is_err() || scratch.try_reserve_exact(len).is_err() { - return; - } - line.resize(len, 0.0); - scratch.resize(len, 0.0); - for axis in [false, true] { - let (lines, line_len) = if axis { (w, h) } else { (h, w) }; - for k in 0..lines { - for i in 0..line_len { - line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; - } - for _ in 0..3 { - blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); - } - for i in 0..line_len { - if axis { - v[i * w + k] = line[i]; - } else { - v[k * w + i] = line[i]; - } - } - } - } -} - /// The layer's mask values (pixel mask with density × vector mask, each blurred by its feather, /// exactly as the CPU compositor applies them) as a single-channel f32 surface over `canvas`, or /// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel @@ -372,29 +203,14 @@ fn build_mask(layer: &Layer, canvas: Rect) -> Surface { let d = m.surface.default_pixel().first().copied().unwrap_or(1.0); 1.0 - m.density * (1.0 - d) }); - let grow = |r: Rect, sigma: f32| { - let m = (sigma * 3.0).ceil() as i32 + 2; - if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) } - }; // Where the product can differ from `p_def × v_out`: the path's bounds, plus the pixel // mask's painted tiles when the vector mask lets them through outside the path, each grown // by its feather. - let mut area = match vm.and_then(|vm| vm.path.control_bounds()) { - Some((x0, y0, x1, y1)) => grow( - Rect::new( - (x0.floor() as i32).saturating_sub(2), - (y0.floor() as i32).saturating_sub(2), - (x1.ceil() as i32).saturating_add(2), - (y1.ceil() as i32).saturating_add(2), - ), - sv, - ), - None => Rect::EMPTY, - }; + let mut area = vm.map_or(Rect::EMPTY, |vm| photocraft_vector::feather::path_area(&vm.path, sv)); if let Some(m) = pixel && v_out > 0.0 { - let b = grow(m.surface.content_bounds(), sp); + let b = photocraft_vector::feather::grow(m.surface.content_bounds(), sp); area = if area.is_empty() { b } else if b.is_empty() { @@ -729,79 +545,6 @@ mod tests { } } - /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole - /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). - #[test] - fn feather_blur_has_the_exact_variance() { - for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { - let (w, c) = (801usize, 400usize); - let mut v = vec![0.0f32; w]; - v[c] = 1.0; - gaussian(&mut v, w, 1, sigma); - // Within the kernel's reach (the running sums leave ~1e-8 behind further out). - let reach = (8.0 * sigma) as usize + 4; - let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); - assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); - assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); - } - assert_eq!(BoxPass::for_sigma(0.0), None); - assert_eq!(BoxPass::for_sigma(f32::NAN), None); - assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); - } - - /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools - /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it - /// within 1.5 levels, where whole-pixel boxes were 4 off. - #[test] - fn a_feathered_edge_is_a_gaussian_step() { - // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). - let erf = |x: f64| { - let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); - let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); - y.copysign(x) - }; - let (w, edge, sigma) = (240usize, 120usize, 6.0f32); - let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); - gaussian(&mut v, w, 1, sigma); - for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { - let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); - let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); - // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). - assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); - } - } - - #[test] - fn parallel_gaussian_matches_the_sequential_reference() { - let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); - let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); - let mut sequential = parallel.clone(); - let pass = BoxPass::for_sigma(sigma).unwrap(); - gaussian_sequential(&mut sequential, w, h, pass); - gaussian(&mut parallel, w, h, sigma); - assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); - } - - #[test] - #[ignore = "manual 50 MP sequential-vs-parallel mask blur measurement"] - fn benchmark_50mp_mask_blur() { - use std::time::Instant; - - let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); - let pass = BoxPass::for_sigma(sigma).unwrap(); - let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); - let mut sequential = source.clone(); - let start = Instant::now(); - gaussian_sequential(&mut sequential, w, h, pass); - let sequential_time = start.elapsed(); - let mut parallel = source; - let start = Instant::now(); - gaussian(&mut parallel, w, h, sigma); - let parallel_time = start.elapsed(); - assert_eq!(parallel, sequential); - eprintln!("50 MP mask blur: sequential={sequential_time:?}, parallel={parallel_time:?}"); - } - #[test] fn cache_budget_holds_five_50mp_u8_input_u16_result_masks() { // Account for whole 256×256 tiles, as entry_bytes does, rather than just logical pixels. diff --git a/crates/compose/src/shape_split.rs b/crates/compose/src/shape_split.rs index 9f0143f889..09d404669b 100644 --- a/crates/compose/src/shape_split.rs +++ b/crates/compose/src/shape_split.rs @@ -53,6 +53,7 @@ pub fn split(sh: &ShapeLayer, canvas: Rect, depth: SampleType) -> Option<(Surfac (canvas.x0, canvas.y0, canvas.x1, canvas.y1).hash(&mut h); depth.hash(&mut h); format!("{:?}{:?}{:?}{:?}", sh.path, sh.fill, sh.stroke, sh.live).hash(&mut h); + (sh.density.to_bits(), sh.feather.to_bits()).hash(&mut h); if let Some(c) = &sh.cache { format!("{:?}{:?}", c.format(), c.default_pixel()).hash(&mut h); for (k, t) in c.tiles() { @@ -163,6 +164,7 @@ mod tests { live: None, cache: None, psd_raw: None, + ..ShapeLayer::default() } } diff --git a/crates/compose/src/soft_shape_tests.rs b/crates/compose/src/soft_shape_tests.rs new file mode 100644 index 0000000000..d506c3e55b --- /dev/null +++ b/crates/compose/src/soft_shape_tests.rs @@ -0,0 +1,159 @@ +//! A shape layer's own Density and Feather (Properties › Masks: its path is its vector mask), +//! against Photoshop's measured pixels and against a fill layer with an equal vector mask. + +use super::*; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath, VectorMask}; +use photocraft_doc::{Fill, Layer, LayerContent}; +use photocraft_geom::Size; + +/// Photoshop: new 200×200 white RGB document, a black 100×100 rectangle shape at 50..150 with a +/// 10 px vector-mask feather, row y = 100 at x = 30, 33, …, 72 (8-bit grey levels). +const PS_FEATHER_10: [u8; 15] = [249, 242, 232, 216, 195, 170, 142, 113, 85, 60, 39, 23, 13, 6, 2]; + +fn square() -> Path { + Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)])]) +} + +fn doc(depth: SampleType) -> Document { + Document::with_background("t", Size::new(200, 200), ColorMode::Rgb, depth, Color::WHITE) +} + +/// The document with a black square shape layer of `density` (0..1) and `feather` (px). +fn shape_doc(depth: SampleType, density: f32, feather: f32, stroke: Option) -> Document { + let mut d = doc(depth); + let mut sh = ShapeLayer { path: square(), fill: Some(Fill::Solid(Color::BLACK)), stroke, density, feather, ..ShapeLayer::default() }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + d.layers.push(Layer::new("Rectangle 1", LayerContent::Shape(sh))); + d +} + +/// The same appearance built as Photoshop stores it: a black fill layer with a vector mask. +fn fill_doc(depth: SampleType, density: f32, feather: f32) -> Document { + let mut d = doc(depth); + let mut l = Layer::new("Color Fill 1", LayerContent::Fill(Fill::Solid(Color::BLACK))); + l.vector_mask = Some(VectorMask { density, feather, ..VectorMask::new(square()) }); + d.layers.push(l); + d +} + +/// Row `y` of the composite as 8-bit grey levels. +fn row(d: &Document, y: i32) -> Vec { + render(d, Rect::new(0, y, 200, y + 1)).px.iter().map(|p| (p[0] * 255.0).round() as i32).collect() +} + +/// Row `y` of the composite in 16-bit levels. +fn row16(d: &Document, y: i32) -> Vec { + render(d, Rect::new(0, y, 200, y + 1)).px.iter().map(|p| (p[0] * 65535.0).round() as i32).collect() +} + +fn max_diff(a: &[i32], b: &[i32]) -> i32 { + a.iter().zip(b).map(|(x, y)| (x - y).abs()).max().unwrap_or(0) +} + +/// A 10 px feather is a symmetric ramp centred on the path, as Photoshop draws it. +#[test] +fn feather_matches_photoshops_ramp() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = row(&shape_doc(depth, 1.0, 10.0, None), 100); + let ours: Vec = (0..15).map(|i| r[30 + 3 * i]).collect(); + let dev = ours.iter().zip(PS_FEATHER_10).map(|(a, b)| (a - i32::from(b)).abs()).max().unwrap_or(0); + // Same Gaussian (sigma = feather) as a vector mask: within 3 levels of Photoshop. + assert!(dev <= 3, "{depth:?}: ours {ours:?} vs Photoshop {PS_FEATHER_10:?} (max {dev})"); + eprintln!("{depth:?}: ours {ours:?}, Photoshop {PS_FEATHER_10:?}, max deviation {dev}"); + // The path runs between pixels 49 and 50: their mean is half way. + assert!((r[49] + r[50] - 255).abs() <= 2, "{depth:?}: half way on the path: {} {}", r[49], r[50]); + assert_eq!(r[100], 0, "{depth:?}: the centre stays solid"); + // Symmetric about the square's centre. + assert!((0..200).all(|x| (r[x] - r[199 - x]).abs() <= 1), "{depth:?}: {r:?}"); + } +} + +/// Density 50 %: the fill shows at half strength beyond the path, full inside, hard edge. +#[test] +fn density_shows_the_fill_beyond_the_path() { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let r = row(&shape_doc(depth, 0.5, 0.0, None), 100); + for x in [0, 20, 49, 150, 199] { + assert!((127..=128).contains(&r[x]), "{depth:?}: x={x} outside the path: {}", r[x]); + } + for x in [50, 100, 149] { + assert_eq!(r[x], 0, "{depth:?}: x={x} inside the path"); + } + let top = row(&shape_doc(depth, 0.5, 0.0, None), 0); + assert!(top.iter().all(|v| (127..=128).contains(v)), "{depth:?}: everywhere beyond the path"); + } +} + +/// A shape and a fill layer with an equal vector mask render the same pixels. (Density 50 % puts +/// white × 0.5 = 127.5 exactly on a rounding tie, which the shape's stored pixels and the fill's +/// mask break differently: 127 and 128, both what Photoshop's "127/128" allows.) +#[test] +fn soft_shape_matches_an_equal_vector_mask() { + for (density, feather) in [(1.0, 10.0), (0.45, 0.0), (0.3, 6.5), (0.0, 0.0), (1.0, 0.0), (0.5, 3.0)] { + for depth in [SampleType::U8, SampleType::U16] { + let (s, f) = (shape_doc(depth, density, feather, None), fill_doc(depth, density, feather)); + for y in [0, 45, 50, 100, 151, 199] { + // An 8-bit shape keeps its rendering in 8-bit pixels (the fill layer's mask is + // 16-bit), which can round its alpha one level apart, as at any anti-aliased edge. + let d8 = max_diff(&row(&s, y), &row(&f, y)); + assert!(d8 <= i32::from(depth == SampleType::U8), "{depth:?} density {density} feather {feather}, row {y}: {d8} levels"); + // The compositor keeps masks at 16 bits (an 8-bit shape's pixels are 8-bit). + if depth == SampleType::U16 { + let d16 = max_diff(&row16(&s, y), &row16(&f, y)); + assert!(d16 <= 2, "{depth:?} density {density} feather {feather}, row {y}: {d16} 16-bit levels"); + } + } + } + } +} + +/// Density and feather soften the fill only: the vector stroke keeps its crisp edge. +#[test] +fn soft_shape_keeps_its_stroke_crisp() { + let stroke = ShapeStroke { width: 4.0, paint: Fill::Solid(Color::rgb(1.0, 0.0, 0.0)), ..ShapeStroke::default() }; + let d = shape_doc(SampleType::U8, 0.5, 10.0, Some(stroke)); + let px = render(&d, Rect::new(0, 100, 200, 101)).px; + // Centred 4 px stroke over x = 48..52: opaque red. + for x in [48, 49, 50, 51] { + let p = px[x]; + assert!(p[0] > 0.99 && p[1] < 0.01 && p[2] < 0.01, "x={x}: {p:?}"); + } + // Beyond the stroke the feathered fill continues: at 10 px out, 50 % density plus half of + // the feather's 16 % ≈ 58 % black. + assert!((px[40][1] - 0.42).abs() < 0.03, "{:?}", px[40]); +} + +/// Defaults (density 100 %, no feather) leave a shape exactly as before. +#[test] +fn default_shape_is_unchanged() { + let d = shape_doc(SampleType::U8, 1.0, 0.0, None); + let r = row(&d, 100); + assert_eq!(r[49], 255); + assert_eq!(r[50], 0); + assert_eq!(r[149], 0); + assert_eq!(r[150], 255); + assert!(row(&d, 10).iter().all(|v| *v == 255)); +} + +/// File-provided values outside the range never panic and render as the nearest valid ones. +#[test] +fn hostile_values_render() { + for (density, feather) in [(f32::NAN, f32::NAN), (-1.0, -5.0), (7.0, 1.0e9), (f32::INFINITY, f32::NEG_INFINITY)] { + let d = shape_doc(SampleType::U8, density, feather, None); + let r = row(&d, 100); + assert_eq!(r.len(), 200, "{density} {feather}"); + } +} + +/// The layer's bounds reach as far as its soft fill. +#[test] +fn soft_shape_bounds_cover_the_fill() { + let canvas = Rect::new(0, 0, 200, 200); + let feathered = shape_doc(SampleType::U8, 1.0, 10.0, None); + let b = layer_bounds(&feathered.layers[1], canvas); + assert!(b.x0 <= 50 - 30 && b.x1 >= 150 + 30, "feather reach: {b:?}"); + let dense = shape_doc(SampleType::U8, 0.5, 0.0, None); + assert_eq!(layer_bounds(&dense.layers[1], canvas), canvas); + assert!(effect_outline(&dense.layers[1]).is_none(), "a soft fill's alpha is its outline"); +} diff --git a/crates/compose/src/stroke_tests.rs b/crates/compose/src/stroke_tests.rs index ab265fa208..e2af6f03b3 100644 --- a/crates/compose/src/stroke_tests.rs +++ b/crates/compose/src/stroke_tests.rs @@ -31,7 +31,7 @@ fn blue() -> FxPaint { /// A shape layer over the square `x0..x1` with its Photoshop-style cached pixels. fn shape(x0: f64, x1: f64, fill: Fill, stroke: Option) -> Layer { let path = Path::new(vec![Subpath::polygon(&[(x0, x0), (x1, x0), (x1, x1), (x0, x1)])]); - let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(fill), stroke, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, Rect::new(0, 0, 40, 40))); Layer::new("shape", LayerContent::Shape(sh)) } @@ -45,6 +45,7 @@ fn gradient_shape(width: u32, height: u32) -> ShapeLayer { live: None, cache: None, psd_raw: None, + ..ShapeLayer::default() } } @@ -234,7 +235,15 @@ fn hairline_vector_stroke_keeps_the_files_edge_pixel_colour() { let mut cache = photocraft_raster::Surface::new(fmt); cache.fill_rect(Rect::new(10, 10, 30, 30), &[edge, edge, edge, 1.0]); cache.fill_rect(Rect::new(11, 11, 29, 29), &[fill, fill, fill, 1.0]); - let sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), stroke: Some(vs), live: None, cache: Some(cache), psd_raw: None }; + let sh = ShapeLayer { + path, + fill: Some(Fill::Solid(Color::rgb(fill, fill, fill))), + stroke: Some(vs), + live: None, + cache: Some(cache), + psd_raw: None, + ..ShapeLayer::default() + }; let mut l = Layer::new("shape", LayerContent::Shape(sh)); // Any layer effect sends the shape through the fill/stroke split. l.effects.items = vec![Effect::default_drop_shadow()]; diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f36df185a3..a2ec151f69 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -316,7 +316,7 @@ pub enum LiveShape { } /// A shape layer: a filled and/or stroked path. -#[derive(Clone, Debug, Default, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct ShapeLayer { pub path: Path, /// `None` = no fill (Photoshop's fill "none"). @@ -328,6 +328,25 @@ pub struct ShapeLayer { /// Data of the PSD vector mask block (`vsms`, else `vmsk`). On export it replaces that entry /// in [`crate::Layer::psd_blocks`] while it still decodes to `path`. pub psd_raw: Option>>, + /// Density `0..=1` of the path as the fill's mask (Properties › Masks, like + /// [`VectorMask::density`]): below 1 the fill shows beyond the path at `1 - density`. + pub density: f32, + /// Feather radius in px of the path as the fill's mask (like [`VectorMask::feather`]). + pub feather: f32, +} + +impl Default for ShapeLayer { + fn default() -> Self { + ShapeLayer { path: Path::default(), fill: None, stroke: None, live: None, cache: None, psd_raw: None, density: 1.0, feather: 0.0 } + } +} + +impl ShapeLayer { + /// Whether the fill's edge is softened by a density below 100 % or a feather (non-finite + /// values count as the defaults). + pub fn has_soft_mask(&self) -> bool { + (self.density.is_finite() && self.density < 1.0) || (self.feather.is_finite() && self.feather > 0.0) + } } #[cfg(test)] diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 14579e5954..9bc17d43e8 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1369,11 +1369,15 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { fn vector_mask_info(s: &Session, id: LayerId) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - Ok(match &l.vector_mask { - Some(v) => { + Ok(match (&l.vector_mask, &l.content) { + (Some(v), _) => { json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }) } - None => json!({ "layer": id.0, "vectorMask": null }), + // A shape layer's path is its vector mask. + (None, LayerContent::Shape(sh)) => { + json!({ "layer": id.0, "path": path_json(&sh.path), "enabled": true, "linked": true, "shape": true, "density": sh.density * 100.0, "feather": sh.feather }) + } + (None, _) => json!({ "layer": id.0, "vectorMask": null }), }) } @@ -1425,6 +1429,35 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) }) .transpose()?; + let density = p + .get("density") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=100.0).contains(n)) + .map(|n| (n / 100.0) as f32) + .ok_or_else(|| bad(CMD, "`density` must be a finite number within 0..100 (%)")) + }) + .transpose()?; + // A shape layer's path is its vector mask (Photoshop's Properties › Masks): Density and + // Feather soften its fill; the path itself is edited with the shape and path commands. + if s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)) && l.vector_mask.is_none()) { + if let Some(k) = ["path", "enabled", "linked", "invert"].into_iter().find(|k| p.get(*k).is_some()) { + return Err(bad(CMD, format!("`{k}`: a shape layer's vector mask is its path; only `density` and `feather` apply"))); + } + if density.is_none() && feather.is_none() { + return vector_mask_info(s, id); + } + with_shape(s, id, "Edit Vector Mask", |sh, _| { + if let Some(d) = density { + sh.density = d; + } + if let Some(f) = feather { + sh.feather = f; + } + Ok(()) + })?; + return vector_mask_info(s, id); + } s.edit("Edit Vector Mask", |doc, _| { let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; @@ -1437,8 +1470,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { if let Some(b) = p.get("linked").and_then(Value::as_bool) { vm.linked = b; } - if let Some(d) = f64p(p, "density") { - vm.density = (d / 100.0).clamp(0.0, 1.0) as f32; + if let Some(d) = density { + vm.density = d; } if let Some(f) = feather { vm.feather = f; @@ -1832,7 +1865,7 @@ pub fn specs() -> Vec { "Edit Vector Mask", [], leak(format!( - r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. {PATH_FORM}"## + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. On a shape layer (whose path is its vector mask) only density and feather apply: they soften its fill, which shows beyond the path at 100 − density %. {PATH_FORM}"## )), has_layer, vector_mask_edit diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 7e1c871dff..a2e3f178f7 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -746,6 +746,71 @@ fn vector_mask_edit_rejects_out_of_range_feather() { assert_eq!(feather(&s), 1000.0); } +/// #5846: a shape layer's path is its vector mask; `layer.vectorMask.edit` sets its Density and +/// Feather in one undoable step, re-rendering the fill, which shows beyond the path at 100 − +/// density %. Hostile values are refused before any edit. +#[test] +fn shape_density_and_feather_through_vector_mask_edit() { + for depth in [8, 16, 32] { + let mut s = session(200, 200, depth); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [50, 50, 100, 100], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let info = s.execute("layer.vectorMask.info", json!({})).unwrap(); + assert_eq!((info["shape"].clone(), info["density"].clone(), info["feather"].clone()), (json!(true), json!(100.0), json!(0.0)), "{depth}"); + let steps = s.active().unwrap().history.past_len(); + let r = s.execute("layer.vectorMask.edit", json!({"density": 50})).unwrap(); + assert_eq!(r["density"], 50.0); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "one history step"); + let sh = shape(&s, id); + assert_eq!((sh.density, sh.feather), (0.5, 0.0)); + // The shape's own pixels carry the soft fill (half-transparent black beyond the path). + let cache = sh.cache.as_ref().unwrap(); + assert!((cache.rgba(10, 10)[3] - 0.5).abs() < 0.01, "{depth}: {:?}", cache.rgba(10, 10)); + assert!(cache.rgba(100, 100)[3] > 0.99); + s.execute("layer.vectorMask.edit", json!({"feather": 10, "density": 100})).unwrap(); + let sh = shape(&s, id); + assert_eq!((sh.density, sh.feather), (1.0, 10.0)); + let a = |x| sh.cache.as_ref().unwrap().rgba(x, 100)[3]; + assert!(a(30) > 0.0 && a(30) < 0.05 && (a(49) + a(50) - 1.0).abs() < 0.02 && a(70) > 0.95, "{depth}: {} {} {} {}", a(30), a(49), a(50), a(70)); + // Fill and stroke stay editable: the shape is still a shape. + s.execute("shape.edit", json!({"fill": "#ff0000"})).unwrap(); + assert_eq!(shape(&s, id).feather, 10.0, "shape.edit keeps the feather"); + s.undo(); + s.undo(); + assert_eq!((shape(&s, id).density, shape(&s, id).feather), (0.5, 0.0), "{depth}: undo restores"); + } +} + +#[test] +fn shape_vector_mask_edit_fails_gracefully() { + let mut s = session(64, 64, 8); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [8, 8, 30, 30]})).unwrap()["layer"].as_u64().unwrap(); + let before = doc(&s).clone(); + let steps = s.active().unwrap().history.past_len(); + let bad = [ + json!({"density": -1}), + json!({"density": 100.5}), + json!({"density": 1e39}), + json!({"density": "50"}), + json!({"density": null}), + json!({"feather": -0.5}), + json!({"feather": 1001}), + json!({"feather": [1]}), + json!({"feather": 5, "path": {"subpaths": []}}), + json!({"density": 50, "enabled": false}), + json!({"invert": true}), + json!({"layer": u64::MAX, "density": 50}), + ]; + for p in bad { + assert!(s.execute("layer.vectorMask.edit", p.clone()).is_err(), "{p}"); + assert_eq!(*doc(&s), before, "{p}"); + assert_eq!(s.active().unwrap().history.past_len(), steps, "{p}"); + } + // A locked shape refuses it too. + s.execute("layer.lockLayers", json!({"all": true})).unwrap(); + assert!(s.execute("layer.vectorMask.edit", json!({"layer": id, "density": 50})).is_err()); + assert_eq!(shape(&s, id).density, 1.0); +} + #[test] fn translate_vectors_moves_shapes_and_masks() { let mut s = session(100, 100, 8); diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 95d45fafb9..3f21e7e47c 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -152,6 +152,8 @@ fn layer_m(l: &Layer, sink: &mut dyn Sink) -> LayerM { path: s.path.clone(), stroke: s.stroke.clone(), live: s.live.clone(), + density: s.density, + feather: s.feather, }, LayerContent::Smart(s) => ContentM::Smart { contents_id: Some(s.contents_id.0), @@ -466,13 +468,15 @@ impl Loader<'_> { warp: warp.clone(), }) } - ContentM::Shape { fill, cache, psd_raw, path, stroke, live } => LayerContent::Shape(ShapeLayer { + ContentM::Shape { fill, cache, psd_raw, path, stroke, live, density, feather } => LayerContent::Shape(ShapeLayer { path: path.clone(), fill: fill.clone(), stroke: stroke.clone(), live: live.clone(), cache: self.opt_surface(cache)?, psd_raw: self.opt_blob(psd_raw)?, + density: *density, + feather: *feather, }), ContentM::Smart { contents_id, source, transform, smart_filters, cache, psd_raw, filters_enabled, filter_mask, warp, stack_mode, perspective } => { LayerContent::Smart(SmartObject { diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 82480e05c0..8cb31f653e 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -294,6 +294,12 @@ pub enum ContentM { stroke: Option, #[serde(default)] live: Option, + /// The path's density as the fill's mask; older files have none (100 %). + #[serde(default = "full_density")] + density: f32, + /// The path's feather in px; older files have none. + #[serde(default)] + feather: f32, }, Smart { /// Shared source identity; older bundles did not record it. @@ -318,6 +324,10 @@ pub enum ContentM { }, } +fn full_density() -> f32 { + 1.0 +} + fn yes() -> bool { true } @@ -385,3 +395,16 @@ mod channel_tests { assert_eq!(c.indicates, photocraft_doc::ColorIndicates::MaskedAreas); } } + +#[cfg(test)] +mod shape_tests { + use super::*; + + /// Shapes saved before Density and Feather load at 100 % and no feather. + #[test] + fn older_shapes_load_at_full_density_without_feather() { + let old = serde_json::json!({ "kind": "shape", "fill": null, "cache": null, "psd_raw": null }); + let ContentM::Shape { density, feather, .. } = serde_json::from_value(old).unwrap() else { panic!("a shape") }; + assert_eq!((density, feather), (1.0, 0.0)); + } +} diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index 8d5641d9cd..0c91e33e32 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -154,6 +154,8 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { live: Some(LiveShape::Rect { rect: [1.0, 2.0, 30.0, 40.0], radii: [4.0, 0.0, 4.0, 0.0] }), cache: Some(cache.clone()), psd_raw: Some(blob(10, 30)), + density: 0.6, + feather: 3.5, }), ); shape.label = LabelColor::Green; diff --git a/crates/format/tests/roundtrip.rs b/crates/format/tests/roundtrip.rs index f78e93947e..b8a19fa0e1 100644 --- a/crates/format/tests/roundtrip.rs +++ b/crates/format/tests/roundtrip.rs @@ -488,6 +488,11 @@ fn vector_fields_roundtrip_and_default_when_absent() { for l in layers.as_array_mut().unwrap() { l.as_object_mut().unwrap().remove("vector_mask"); let c = l["content"].as_object_mut().unwrap(); + // A shape's density and feather (#5846) came later still. + if c.get("kind").and_then(|k| k.as_str()) == Some("shape") { + c.remove("density"); + c.remove("feather"); + } for k in ["path", "stroke", "live"] { c.remove(k); } @@ -500,6 +505,15 @@ fn vector_fields_roundtrip_and_default_when_absent() { let old: photocraft_format::manifest::DocM = serde_json::from_value(v).unwrap(); assert!(old.paths.is_empty() && old.work_path.is_none()); assert!(old.layers.iter().all(|l| l.vector_mask.is_none())); + let shapes: Vec<_> = old + .layers + .iter() + .filter_map(|l| match &l.content { + photocraft_format::manifest::ContentM::Shape { density, feather, .. } => Some((*density, *feather)), + _ => None, + }) + .collect(); + assert!(!shapes.is_empty() && shapes.iter().all(|s| *s == (1.0, 0.0)), "{shapes:?}"); } /// Type kerning (#206): the auto mode and manual kerning survive .pcraft; manifests written diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index 84d3d0e937..7099dce856 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1758,7 +1758,7 @@ fn layer_effects_on_shape_layers() { let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); let mut fill = Color::rgb(0.3, 0.6, 0.9); fill.alpha = alpha; - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(fill)), stroke: None, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); l.effects.items = vec![ @@ -1796,7 +1796,7 @@ fn stroke_effects_on_filled_and_stroked_shapes() { paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() }); - let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill: Some(fill), stroke: stroke_v, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); if masked { @@ -2151,7 +2151,15 @@ fn stroked_shapes_with_clipped_layers() { d.layers.push(noise_layer("bg", d.pixel_format(), Rect::from_xywh(0, 0, 80, 64), 1, 0.3)); let path = Path::new(vec![Subpath::polygon(&[(10.3, 8.6), (66.2, 12.1), (58.7, 54.4), (16.9, 48.2)])]); let stroke = ShapeStroke { width: 5.0, paint: Fill::Solid(Color::rgb(0.9, 0.9, 0.1)), ..Default::default() }; - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), stroke: Some(stroke), live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { + path, + fill: Some(Fill::Solid(Color::rgb(0.2, 0.3, 0.8))), + stroke: Some(stroke), + live: None, + cache: None, + psd_raw: None, + ..ShapeLayer::default() + }; sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); let mut l = Layer::new("shape", LayerContent::Shape(sh)); l.blend = blend; @@ -2618,3 +2626,36 @@ fn pass_through_clipping_base_with_an_adjustment() { d.layers.push(clip); check(&mut g, &d, "pass-through clipping base with an adjustment"); } + +/// #5846: a shape's Density and Feather soften its fill (its own rendered pixels), with and +/// without a vector stroke over clipped layers and layer effects: the GPU matches the CPU. +#[test] +fn soft_shapes_match_the_cpu() { + let Some(mut g) = gpu() else { return }; + use photocraft_doc::vector::{Path, ShapeLayer, ShapeStroke, Subpath}; + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + for (density, feather, stroked, fx) in [(1.0, 6.0, false, false), (0.4, 0.0, true, false), (0.6, 4.0, true, true), (1.0, 3.0, false, true)] { + let mut d = fx_doc(90, 70, depth); + let path = Path::new(vec![Subpath::polygon(&[(14.3, 12.6), (70.2, 18.1), (60.7, 58.4), (24.9, 50.2)])]); + let stroke = stroked.then(|| ShapeStroke { width: 3.0, paint: Fill::Solid(Color::rgb(0.1, 0.8, 0.2)), ..ShapeStroke::default() }); + let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.3, 0.6, 0.9))), stroke, density, feather, ..ShapeLayer::default() }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + let mut l = Layer::new("shape", LayerContent::Shape(sh)); + if fx { + l.effects.items = vec![Effect::DropShadow(shadow(BlendMode::Multiply, 0.7, 120.0, 4.0, 5.0, 0.0))]; + } + d.layers.push(l); + if stroked { + let mut c = noise_layer("clipped", d.pixel_format(), Rect::from_xywh(0, 0, 90, 70), 11, 0.9); + c.clipped = true; + d.layers.push(c); + } + let what = format!("{depth:?} density {density} feather {feather} stroked {stroked} fx {fx}"); + if fx { + fx_check(&mut g, &d, &what); + } else { + check(&mut g, &d, &what); + } + } + } +} diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index efba868be0..639fc18fee 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -424,7 +424,7 @@ impl Builder { dash_offset: s.dash.as_ref().map_or(0.0, |(_, o)| (o / s.width.max(1e-9)) as f32), }) }); - let mut sh = ShapeLayer { path, fill, stroke, live: None, cache: None, psd_raw: None }; + let mut sh = ShapeLayer { path, fill, stroke, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, self.format, self.canvas)); sh } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 4fb952ba25..16b3ea0e3f 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -431,7 +431,11 @@ impl Ex { self.warnings.extend(warnings); let mut mask = MaskData::None; // Vector mask density/feather live in the mask parameters, with or without a user mask. - let vector = l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)); + // A shape layer's path is its vector mask, with the shape's own density and feather. + let vector = match &l.content { + LayerContent::Shape(sh) if l.vector_mask.is_none() => Some((sh.density, sh.feather)), + _ => l.vector_mask.as_ref().map(|vm| (vm.density, vm.feather)), + }; if let Some(m) = &l.mask { let (md, ch) = self.mask(m, vector); mask = md; @@ -1789,7 +1793,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c /// every value is the default (density 1, feather 0). fn mask_parameters(user: Option<(f32, f32)>, vector: Option<(f32, f32)>) -> Option { let density = |v: Option<(f32, f32)>| v.and_then(|(d, _)| (d < 1.0).then(|| q255(d))); - let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f != 0.0).then_some(f64::from(f))); + let feather = |v: Option<(f32, f32)>| v.and_then(|(_, f)| (f.is_finite() && f > 0.0).then_some(f64::from(f))); let p = MaskParameters { flags: 0, user_density: density(user), user_feather: feather(user), vector_density: density(vector), vector_feather: feather(vector) }; let flags = u8::from(p.user_density.is_some()) diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index 94c4ea9932..ddf12edf7d 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -88,9 +88,19 @@ fn selected_real_mask(rec: &LayerRecord) -> Option { rec.layer_mask()?.real } +/// The (density, feather) a shape layer's mask parameters give its path, which is its vector +/// mask: (1, 0) without them. +fn shape_mask_params(rec: &LayerRecord) -> (f32, f32) { + let p = rec.layer_mask().and_then(|m| m.parameters); + let density = p.and_then(|p| p.vector_density).map_or(1.0, |d| f32::from(d) / 255.0); + let feather = p.and_then(|p| p.vector_feather).filter(|f| f.is_finite()).map_or(0.0, |f| (f as f32).clamp(0.0, 1000.0)); + (density, feather) +} + /// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer -/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a -/// feather (`.1` true), or when it fills with a pattern. +/// with a vector mask: when it fills with a pattern, which the shape renderer can't draw (`.1` +/// true when its vector mask is also soft: a density below 100 % or a feather). Other soft +/// shapes stay shapes with that density and feather. fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<(photocraft_doc::Fill, bool)> { if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() { return None; @@ -104,7 +114,7 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4 // (our own export writes fill layers so, #1907): it is a pattern fill layer seen through // its vector mask. let pattern = matches!(fill, photocraft_doc::Fill::Pattern { .. }); - (soft || pattern).then_some((fill, soft)) + pattern.then_some((fill, soft)) } /// Where a placed layer's source goes: read from `SoLd`/`SoLE`, or from a CS2 `PlLd` record. @@ -417,10 +427,9 @@ impl Ctx<'_> { perspective, }) } else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) { - // A shape whose vector mask has a density or feather is a fill layer seen through a - // soft vector mask: the fill shows beyond the path, which the stored pixels (the - // shape alone) lack. Import it as exactly that. A pattern fill keeps the stored - // pixels as its rendering (a soft mask's pixels lack the fill beyond the path). + // A pattern-filled shape is a pattern fill layer seen through its vector mask (soft + // or not). Unless that mask is soft, the stored pixels stay its rendering (a soft + // mask's pixels lack the fill beyond the path). if !soft && matches!(f, photocraft_doc::Fill::Pattern { .. }) && !rec.rect.is_empty() { fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) }); } @@ -430,9 +439,13 @@ impl Ctx<'_> { let mut sh = ShapeLayer { fill, cache: Some(self.record_surface(rec, &name)), psd_raw: vector_key.and_then(principal), ..Default::default() }; let lookup = |k: &[u8; 4]| rec.block(k).map(|b| b.data.clone()); crate::vector_map::shape_from_blocks(&mut sh, &lookup, self.file.header.width, self.file.header.height, self.dpi); + (sh.density, sh.feather) = shape_mask_params(rec); // No stored pixels (32-bit documents, files saved without layer pixels): render the - // shape from its path and fill, as Photoshop does when it opens the file. - if (rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted) { + // shape from its path and fill, as Photoshop does when it opens the file. A soft + // shape's stored pixels hold the shape alone, without the fill its density and feather + // show beyond the path: render it too. + let soft = sh.has_soft_mask() && sh.fill.is_some(); + if soft || ((rec.rect.is_empty() || rec.rect.size().is_err()) && (!sh.path.subpaths.is_empty() || sh.path.inverted)) { let canvas = Rect::new(0, 0, self.file.header.width as i32, self.file.header.height as i32); sh.cache = Some(photocraft_vector::render_shape(&sh, self.fmt, canvas)); } diff --git a/crates/io/tests/corpus_regressions.rs b/crates/io/tests/corpus_regressions.rs index 0555fc47ca..b335642cc9 100644 --- a/crates/io/tests/corpus_regressions.rs +++ b/crates/io/tests/corpus_regressions.rs @@ -116,9 +116,10 @@ fn real_user_mask_with_parameters_is_decoded() { /// psd-tools layer_mask_data.psd, mask-parameters-no-real-channel.psd: a shape layer whose /// vector mask has a density (or feather) shows its fill beyond the path at 1 - density; the -/// stored pixels hold only the shape. It imports as a fill layer with a soft vector mask. +/// stored pixels hold only the shape. It imports as a shape with that density (#5846), rendered +/// from its path rather than from the stored pixels. #[test] -fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() { +fn shape_with_vector_mask_density_imports_as_soft_shape() { let mut d = doc(SampleType::U8); let mut l = Layer::new("Ellipse", LayerContent::Fill(Fill::Solid(Color::BLACK))); let mut vm = VectorMask::new(square(8.0, 8.0, 24.0, 24.0)); @@ -138,8 +139,9 @@ fn shape_with_vector_mask_density_imports_as_fill_with_soft_mask() { } let imp = reimport(&file); let l = &imp.document.layers[1]; - assert!(matches!(l.content, LayerContent::Fill(_)), "{}", l.content.kind_name()); - assert_eq!(l.vector_mask.as_ref().map(|v| v.density), Some(204.0 / 255.0)); + let LayerContent::Shape(sh) = &l.content else { panic!("{}", l.content.kind_name()) }; + assert_eq!(sh.density, 204.0 / 255.0); + assert!(l.vector_mask.is_none()); let f = photocraft_compose::flatten(&imp.document); assert!(f.get(16, 16)[0] < 0.01, "inside the path: black"); assert!((f.get(2, 2)[0] - 0.8).abs() < 0.01, "outside: 20% of the fill shows: {:?}", f.get(2, 2)); diff --git a/crates/io/tests/effects_multi.rs b/crates/io/tests/effects_multi.rs index 4eda6ad81b..81db8aa12f 100644 --- a/crates/io/tests/effects_multi.rs +++ b/crates/io/tests/effects_multi.rs @@ -65,7 +65,8 @@ fn joined_shape_components_roundtrip() { let outer = Subpath::polygon(&[(5.0, 5.0), (35.0, 5.0), (35.0, 35.0), (5.0, 35.0)]); let hole = Subpath::polygon(&[(12.0, 12.0), (12.0, 28.0), (28.0, 28.0), (28.0, 12.0)]).with_op(PathOp::Join); let path = Path::new(vec![outer, hole]); - let mut sh = ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None }; + let mut sh = + ShapeLayer { path, fill: Some(Fill::Solid(Color::rgb(0.2, 0.4, 0.9))), stroke: None, live: None, cache: None, psd_raw: None, ..ShapeLayer::default() }; sh.cache = Some(photocraft_vector::render_shape(&sh, PixelFormat::RGBA8, d.bounds())); let mut l = Layer::new("ring", LayerContent::Shape(sh)); l.effects.items = vec![stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 0.0, 0.0)), 1.0)]; diff --git a/crates/io/tests/vector.rs b/crates/io/tests/vector.rs index 594a1827e8..e32cbed3fa 100644 --- a/crates/io/tests/vector.rs +++ b/crates/io/tests/vector.rs @@ -171,6 +171,51 @@ fn custom_stroke_styles_roundtrip_through_psd_vstk() { } } +/// A shape layer's Density and Feather (#5846) live in the PSD mask parameters, as Photoshop +/// writes a shape's vector-mask density and feather; with and without a vector stroke, at 8 and +/// 16 bits, the shape comes back a shape with both and renders its soft fill again. +#[test] +fn shape_density_and_feather_roundtrip_through_mask_parameters() { + let square = Path::new(vec![Subpath::polygon(&[(64.0, 64.0), (192.0, 64.0), (192.0, 192.0), (64.0, 192.0)])]); + for depth in [SampleType::U8, SampleType::U16] { + for stroke in [None, Some(ShapeStroke { width: 2.0, ..ShapeStroke::default() })] { + let mut d = Document::with_background("v", Size::new(256, 256), ColorMode::Rgb, depth, Color::WHITE); + let mut sh = ShapeLayer { + path: square.clone(), + fill: Some(Fill::Solid(Color::BLACK)), + stroke: stroke.clone(), + density: 0.5, + feather: 6.0, + ..ShapeLayer::default() + }; + sh.cache = Some(photocraft_vector::render_shape(&sh, d.pixel_format(), d.bounds())); + d.layers.push(Layer::new("Shape", LayerContent::Shape(sh))); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + let rec = file.layers().last().unwrap(); + let p = rec.layer_mask().and_then(|m| m.parameters).expect("mask parameters"); + assert_eq!((p.vector_density, p.vector_feather), (Some(128), Some(6.0)), "{depth:?}"); + assert!(rec.channel(-2).is_none(), "no user mask channel"); + let b = import("x.psd", &bytes).unwrap().document; + let LayerContent::Shape(back) = &b.layers[1].content else { panic!("{depth:?} {stroke:?}: {}", b.layers[1].content.kind_name()) }; + assert_eq!((back.density, back.feather), (128.0 / 255.0, 6.0), "{depth:?} {stroke:?}"); + assert_eq!(back.stroke, stroke); + assert!(b.layers[1].mask.is_none() && b.layers[1].vector_mask.is_none()); + let f = photocraft_compose::flatten(&b); + assert!(f.get(128, 128)[0] < 0.01, "inside: black"); + assert!((f.get(4, 4)[0] - 127.0 / 255.0).abs() < 0.01, "{depth:?}: beyond the path at half density: {:?}", f.get(4, 4)); + } + } + // Defaults write no parameters. + let mut d = doc(); + d.layers.push(shape_layer(ShapeLayer { path: square, fill: Some(Fill::Solid(Color::BLACK)), ..ShapeLayer::default() })); + let bytes = export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes; + let file = photocraft_psd::PsdFile::from_bytes(&bytes).unwrap(); + assert!(file.layers().last().unwrap().layer_mask().and_then(|m| m.parameters).is_none()); + let LayerContent::Shape(back) = &import("x.psd", &bytes).unwrap().document.layers[1].content else { panic!("a shape") }; + assert_eq!((back.density, back.feather), (1.0, 0.0)); +} + #[test] fn vector_mask_roundtrip_and_removal() { let mut d = doc(); diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index c2ea848c5e..96899a81de 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -177,7 +177,10 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { } match &layer.content { LayerContent::Text(_) => crate::type_tool::type_properties(app, ui), - LayerContent::Shape(_) => crate::vector_ui::shape_properties(app, ui, layer.id), + LayerContent::Shape(_) => { + crate::vector_ui::shape_properties(app, ui, layer.id); + crate::mask_props_ui::shape_properties(app, ui, layer); + } _ => {} } // Re-read: a section above may have changed the layer (e.g. point to paragraph type). diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index 295310bf97..c2674b138e 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -1,6 +1,6 @@ //! Non-destructive layer-mask controls (pixel and vector), shared by docked and floating Properties. -use photocraft_doc::Layer; +use photocraft_doc::{Layer, LayerContent}; use serde_json::json; use crate::props_layout::COL_GAP; @@ -49,6 +49,23 @@ pub fn vector_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Lay }); } +/// A shape layer's path is its vector mask (Photoshop's Properties › Masks): its Density and +/// Feather soften the fill through `layer.vectorMask.edit`, the shape staying editable. +pub fn shape_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { + let LayerContent::Shape(sh) = &layer.content else { return }; + if layer.vector_mask.is_some() { + return; + } + let Some(document) = app.session.active().map(|s| s.doc.id.0) else { return }; + ui.push_id((document, layer.id, "shape-mask-properties"), |ui| { + if !crate::props_layout::section(ui, "shape-mask", "Vector Mask") { + return; + } + let (density, feather) = (sh.density, sh.feather); + sliders(app, ui, layer.id.0, document, "layer.vectorMask.edit", "shape-mask", density, feather); + }); +} + /// Density (%) and Feather (px) sliders writing one undoable edit per drag: the value lives on the /// mask, not in its pixels. `command` is the mask's edit command and `prefix` keys its coalescing. #[allow(clippy::too_many_arguments)] diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index 1cde904682..eacafafa23 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -1,5 +1,8 @@ use super::*; -use egui_kittest::{Harness, kittest::Queryable}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; fn harness(adjustment: bool, theme: crate::theme::ThemeKind) -> Harness<'static, PhotocraftApp> { let mut session = photocraft_engine::Session::new(); @@ -150,6 +153,59 @@ fn vector_mask_properties_edit_density_and_feather() { assert!((vm.density - 0.5).abs() < 0.02, "density edited through the vector mask command: {}", vm.density); } +/// #5846: a selected shape layer shows its path's Density and Feather (Photoshop's Properties › +/// Masks), in both themes; a drag previews live and leaves one undo step per gesture, and the +/// shape stays a shape. +#[test] +fn shape_properties_edit_density_and_feather() { + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let id = session.execute("shape.create", json!({"kind": "rect", "rect": [16, 16, 32, 32]})).unwrap()["layer"].as_u64().unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + app.ui.panels.properties = true; + app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 1600.0)); + let mut h = Harness::builder().with_size(vec2(800.0, 1600.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) { + if Tokens::get(ui.ctx()).pro { + egui::ScrollArea::vertical().show(ui, |ui| properties_body(app, ui)); + } else { + properties_window(app, ui.ctx()); + } + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + let shape = |h: &Harness<'_, PhotocraftApp>| match &h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().content { + photocraft_doc::LayerContent::Shape(sh) => sh.clone(), + other => panic!("not a shape: {}", other.kind_name()), + }; + let slider = |h: &Harness<'_, PhotocraftApp>, label: &str| { + h.query_all_by_role(egui::accesskit::Role::Slider) + .position(|n| n.accesskit_node().label().as_deref() == Some(label)) + .unwrap_or_else(|| panic!("{theme:?}: no {label} slider")) + }; + let (density, feather) = (slider(&h, "Density"), slider(&h, "Feather")); + let before = h.state().session.active().unwrap().history.past_len(); + drag(&mut h, density, &[1.0, 0.8, 0.6, 0.5]); + assert!((shape(&h).density - 0.5).abs() < 0.02, "{theme:?}: density {}", shape(&h).density); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 1, "{theme:?}: one step per drag"); + // Live: the fill already shows beyond the path at half strength. + let a = shape(&h).cache.as_ref().unwrap().rgba(2, 2)[3]; + assert!((a - 0.5).abs() < 0.03, "{theme:?}: {a}"); + drag(&mut h, feather, &[0.0, 0.005, 0.01]); + assert!((shape(&h).feather - 10.0).abs() < 0.5, "{theme:?}: feather {}", shape(&h).feather); + assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 2); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(&h).feather, 0.0); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(shape(&h).density, 1.0, "{theme:?}"); + } +} + /// Document-wide row of mask coverage (or of the layer's own red channel) across the middle. fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec { let s = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 769f5a8b9a..4df4441831 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3506,6 +3506,7 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } if matches!(layer.content, LayerContent::Shape(_)) { crate::vector_ui::shape_properties(app, ui, id); + crate::mask_props_ui::shape_properties(app, ui, layer); } } } diff --git a/crates/vector/src/feather.rs b/crates/vector/src/feather.rs new file mode 100644 index 0000000000..5048916d49 --- /dev/null +++ b/crates/vector/src/feather.rs @@ -0,0 +1,281 @@ +//! Mask feathering: the Gaussian blur Properties › Feather applies to a mask (pixel masks, vector +//! masks and a shape layer's own path, so equal feathers match exactly). Pure `f32` planes; the +//! compositor (`photocraft-compose::masks`) and the shape renderer share it. + +use photocraft_doc::Path; +use photocraft_geom::Rect; + +/// Gaussian sigma of a mask feather of `px` pixels (Properties › Feather): the feather itself. +/// Blurred with exactly that variance, psd-tools mask-parameters-no-real-channel.psd (6 px vector +/// and 3 px pixel feather) matches Photoshop within 1.5 levels and layer_mask_data.psd's +/// feathered edges are 27 % → 4 % off (the rest are its shapes' antialiased edges). +pub fn feather_sigma(px: f32) -> f32 { + if px.is_finite() { (px * FEATHER_SIGMA).clamp(0.0, MAX_FEATHER_SIGMA) } else { 0.0 } +} + +const FEATHER_SIGMA: f32 = 1.0; +/// Photoshop's feather tops out at 1000 px. +const MAX_FEATHER_SIGMA: f32 = 1000.0 * FEATHER_SIGMA; + +/// `r` grown by a feather of Gaussian `sigma`: how far the blur carries a mask's edge (three +/// sigmas and two pixels, beyond which it is constant to well under a level). +pub fn grow(r: Rect, sigma: f32) -> Rect { + let m = (sigma * 3.0).ceil() as i32 + 2; + if r.is_empty() { r } else { Rect::new(r.x0.saturating_sub(m), r.y0.saturating_sub(m), r.x1.saturating_add(m), r.y1.saturating_add(m)) } +} + +/// Where a feathered path mask can differ from its value far outside the path: the path's control +/// bounds (and two pixels of anti-aliasing) grown by the feather. Empty for a path without knots. +/// A vector mask and a soft shape layer use the same area, so equal settings match exactly. +pub fn path_area(path: &Path, sigma: f32) -> Rect { + match path.control_bounds() { + Some((x0, y0, x1, y1)) => grow( + Rect::new( + (x0.floor() as i32).saturating_sub(2), + (y0.floor() as i32).saturating_sub(2), + (x1.ceil() as i32).saturating_add(2), + (y1.ceil() as i32).saturating_add(2), + ), + sigma, + ), + None => Rect::EMPTY, + } +} + +/// One box pass: weight 1 on `±r` around each sample and `end` (0..1) on the two samples just +/// beyond, so the pass's variance is any real number, not only the `r(r+1)/3` of whole boxes. +#[derive(Clone, Copy, Debug, PartialEq)] +struct BoxPass { + r: usize, + end: f32, +} + +impl BoxPass { + /// The pass whose three-fold repetition has variance `sigma²` (an "extended box", Gwosdek et + /// al. 2011), or `None` when there is nothing to blur. + fn for_sigma(sigma: f32) -> Option { + if !sigma.is_finite() || sigma <= 0.0 { + return None; + } + // Feathers top out at 1000 px; the cap keeps the radius arithmetic far from overflow. + let v = f64::from(sigma.min(MAX_FEATHER_SIGMA)).powi(2) / 3.0; + // The largest whole radius whose box variance r(r+1)/3 is at most v. + let mut r = (((12.0 * v + 1.0).sqrt() - 1.0) / 2.0).floor().max(0.0) as usize; + let var = |r: usize| (r as f64) * (r as f64 + 1.0) / 3.0; + while r > 0 && var(r) > v { + r -= 1; + } + while var(r + 1) <= v { + r += 1; + } + let (rf, n) = (r as f64, 2.0 * r as f64 + 1.0); + let end = (n * (rf * (rf + 1.0) / 3.0 - v)) / (2.0 * v - 2.0 * (rf + 1.0) * (rf + 1.0)); + Some(BoxPass { r, end: end.clamp(0.0, 1.0) as f32 }) + } +} + +/// Apply one of the three box passes to a single line. `scratch` preserves the input while +/// the sliding sum writes the output, matching the previous sequential implementation exactly. +fn blur_line(line: &mut [f32], scratch: &mut [f32], pass: BoxPass) { + if line.is_empty() { + return; + } + let Ok(radius) = isize::try_from(pass.r) else { return }; + scratch.copy_from_slice(line); + let get = |i: isize| scratch[i.clamp(0, scratch.len() as isize - 1) as usize]; + let mut acc: f32 = (-radius..=radius).map(get).sum(); + let n = (2 * pass.r + 1) as f32 + 2.0 * pass.end; + // The sample leaving the window is the next one's low end weight. + let mut below = get(-radius - 1); + for (i, dst) in line.iter_mut().enumerate() { + let i = i as isize; + let (above, leaving) = (get(i + radius + 1), get(i - radius)); + *dst = (acc + pass.end * (below + above)) / n; + acc += above - leaving; + below = leaving; + } +} + +/// Apply three clamped box passes to independent contiguous rows. Scoped OS threads are used +/// instead of Rayon because mask construction can happen inside a Rayon tile job; nested Rayon +/// work here can deadlock when every worker waits on the same OnceLock (#276). +fn blur_rows_chunk(v: &mut [f32], w: usize, pass: BoxPass) { + let mut scratch = vec![0.0; w]; + for row in v.chunks_mut(w) { + for _ in 0..3 { + blur_line(row, &mut scratch, pass); + } + } +} + +fn blur_rows(v: &mut [f32], w: usize, pass: BoxPass) { + if w == 0 || v.is_empty() { + return; + } + let h = v.len() / w; + let threads = if cfg!(target_arch = "wasm32") || v.len() < 1 << 16 { + 1 + } else { + std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16).min(h.max(1)) + }; + if threads == 1 { + blur_rows_chunk(v, w, pass); + return; + } + let rows_per_worker = h.div_ceil(threads); + let chunk_len = rows_per_worker.saturating_mul(w); + std::thread::scope(|scope| { + for chunk in v.chunks_mut(chunk_len) { + scope.spawn(move || blur_rows_chunk(chunk, w, pass)); + } + }); +} + +/// Cache-friendly blocked transpose. It makes the vertical blur contiguous without a Rayon job +/// or unsafe disjoint-column access; the temporary is released before the next mask is built. +fn transpose_into(v: &[f32], out: &mut [f32], w: usize, h: usize) { + const BLOCK: usize = 32; + for by in (0..h).step_by(BLOCK) { + for bx in (0..w).step_by(BLOCK) { + for y in by..(by + BLOCK).min(h) { + for x in bx..(bx + BLOCK).min(w) { + out[x * h + y] = v[y * w + x]; + } + } + } + } +} + +/// Approximate Gaussian blur of a `w`×`h` plane: three box passes per axis whose variance is +/// exactly `sigma²` ([`BoxPass`]; edges clamp, which is exact here since the planes extend into +/// constant mask regions). Whole-pixel boxes rounded the variance (a 6 px feather blurred like +/// 6.48 px, a 3 px one like 3.46) and missed Photoshop's feathered edges by up to 4 levels +/// (psd-tools mask-parameters-no-real-channel.psd); the exact variance is within 1.5. The axes +/// are processed in parallel on bounded OS threads, preserving each line's floating-point +/// operation order. +pub fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { + if w == 0 || h == 0 || v.len() != w.saturating_mul(h) { + return; + } + let Some(pass) = BoxPass::for_sigma(sigma) else { return }; + // Reserve before modifying the input so allocation failure can use the sequential fallback + // without leaving a half-blurred mask. + let mut transposed = Vec::new(); + if transposed.try_reserve_exact(v.len()).is_err() { + gaussian_sequential(v, w, h, pass); + return; + } + transposed.resize(v.len(), 0.0); + blur_rows(v, w, pass); + transpose_into(v, &mut transposed, w, h); + blur_rows(&mut transposed, h, pass); + transpose_into(&transposed, v, h, w); +} + +/// Allocation-light reference path used if parallel transpose storage cannot be reserved. +fn gaussian_sequential(v: &mut [f32], w: usize, h: usize, pass: BoxPass) { + let mut line = Vec::new(); + let mut scratch = Vec::new(); + let len = w.max(h); + if line.try_reserve_exact(len).is_err() || scratch.try_reserve_exact(len).is_err() { + return; + } + line.resize(len, 0.0); + scratch.resize(len, 0.0); + for axis in [false, true] { + let (lines, line_len) = if axis { (w, h) } else { (h, w) }; + for k in 0..lines { + for i in 0..line_len { + line[i] = if axis { v[i * w + k] } else { v[k * w + i] }; + } + for _ in 0..3 { + blur_line(&mut line[..line_len], &mut scratch[..line_len], pass); + } + for i in 0..line_len { + if axis { + v[i * w + k] = line[i]; + } else { + v[k * w + i] = line[i]; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Three passes have the variance sigma² exactly, for whole and fractional sigmas alike (whole + /// boxes rounded a 6 px feather to 6.48 and a 3 px one to 3.46). + #[test] + fn feather_blur_has_the_exact_variance() { + for sigma in [0.3f32, 1.0, 2.6, 3.0, 6.0, 13.7, 40.0] { + let (w, c) = (801usize, 400usize); + let mut v = vec![0.0f32; w]; + v[c] = 1.0; + gaussian(&mut v, w, 1, sigma); + // Within the kernel's reach (the running sums leave ~1e-8 behind further out). + let reach = (8.0 * sigma) as usize + 4; + let var: f64 = v.iter().enumerate().skip(c - reach).take(2 * reach + 1).map(|(i, &x)| f64::from(x) * ((i as f64) - c as f64).powi(2)).sum(); + assert!((var.sqrt() - f64::from(sigma)).abs() < 1e-3 * f64::from(sigma).max(1.0), "sigma {sigma}: {}", var.sqrt()); + assert!((v.iter().sum::() - 1.0).abs() < 1e-4, "sigma {sigma} keeps the total"); + } + assert_eq!(BoxPass::for_sigma(0.0), None); + assert_eq!(BoxPass::for_sigma(f32::NAN), None); + assert!(BoxPass::for_sigma(f32::MAX).is_some_and(|p| p.r < 2000 && (0.0..1.0).contains(&p.end))); + } + + /// A feathered edge follows the Gaussian Photoshop draws with sigma = feather: psd-tools + /// mask-parameters-no-real-channel.psd (a 6 px vector and 3 px pixel feather) matches it + /// within 1.5 levels, where whole-pixel boxes were 4 off. + #[test] + fn a_feathered_edge_is_a_gaussian_step() { + // erf, Abramowitz & Stegun 7.1.26 (1.5e-7). + let erf = |x: f64| { + let t = 1.0 / (1.0 + 0.327_591_1 * x.abs()); + let y = 1.0 - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t + 0.254_829_592) * t * (-x * x).exp(); + y.copysign(x) + }; + let (w, edge, sigma) = (240usize, 120usize, 6.0f32); + let mut v: Vec = (0..w).map(|i| if i >= edge { 1.0 } else { 0.0 }).collect(); + gaussian(&mut v, w, 1, sigma); + for (i, &got) in v.iter().enumerate().skip(edge - 24).take(48) { + let d = (i as f64 - (edge as f64 - 0.5)) / f64::from(sigma); + let want = 0.5 * (1.0 + erf(d / std::f64::consts::SQRT_2)); + // Three boxes are not quite a Gaussian: within 2.5 levels (whole boxes: 4.6). + assert!((f64::from(got) - want).abs() < 0.01, "{i}: {got} vs Gaussian {want}"); + } + } + + #[test] + fn parallel_gaussian_matches_the_sequential_reference() { + let (w, h, sigma): (usize, usize, f32) = (320, 256, 13.0); + let mut parallel: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); + let mut sequential = parallel.clone(); + let pass = BoxPass::for_sigma(sigma).unwrap(); + gaussian_sequential(&mut sequential, w, h, pass); + gaussian(&mut parallel, w, h, sigma); + assert_eq!(parallel, sequential, "parallel blur must retain the exact per-line arithmetic"); + } + + #[test] + #[ignore = "manual 50 MP sequential-vs-parallel mask blur measurement"] + fn benchmark_50mp_mask_blur() { + use std::time::Instant; + + let (w, h, sigma): (usize, usize, f32) = (6336, 7920, 1000.0); + let pass = BoxPass::for_sigma(sigma).unwrap(); + let source: Vec<_> = (0..w * h).map(|i| ((i * 37 % 1009) as f32) / 1008.0).collect(); + let mut sequential = source.clone(); + let start = Instant::now(); + gaussian_sequential(&mut sequential, w, h, pass); + let sequential_time = start.elapsed(); + let mut parallel = source; + let start = Instant::now(); + gaussian(&mut parallel, w, h, sigma); + let parallel_time = start.elapsed(); + assert_eq!(parallel, sequential); + eprintln!("50 MP mask blur: sequential={sequential_time:?}, parallel={parallel_time:?}"); + } +} diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index d336435492..ac5fe8aad5 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -5,6 +5,7 @@ //! mask back into a path for "Make Work Path"). //! //! * [`edit`]: point-level path editing (Direct Selection, Convert Point) and hit testing. +//! * [`feather`]: the Gaussian blur of a feathered mask (vector masks, pixel masks, soft shapes). //! * [`flatten`]: cubic Bézier flattening with a distance tolerance. //! * [`map`]: non-affine maps of paths (Distort, Perspective, Warp). //! * [`raster`]: exact area-coverage scanline rasterizer (non-zero / even-odd per component, @@ -20,6 +21,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod edit; +pub mod feather; pub mod flatten; pub mod map; pub mod raster; @@ -458,9 +460,101 @@ fn dither(c: &mut [f32; 4], x: i32, y: i32) { } } +/// How much of a shape's fill shows at each pixel: the path's coverage, or (with a density or +/// feather) the path as the fill's soft vector mask. +enum FillCoverage { + Path(Rasterizer), + /// Density without feather: `1 - density × (1 - coverage)`. + Dense(Rasterizer, f32), + /// Feathered (and maybe less dense): the mask over `area`, `outside` everywhere else. + Plane { + area: Rect, + values: Vec, + outside: f32, + }, +} + +impl FillCoverage { + /// Builds the fill's coverage for `shape` over the frame `canvas` (a feather is blurred + /// within it, as the compositor blurs a vector mask within the canvas). + fn new(shape: &ShapeLayer, area: &Rasterizer, canvas: Rect) -> Self { + if !shape.has_soft_mask() { + return FillCoverage::Path(area.clone()); + } + let density = if shape.density.is_finite() { shape.density.clamp(0.0, 1.0) } else { 1.0 }; + let sigma = feather::feather_sigma(shape.feather); + if sigma <= 0.0 { + return FillCoverage::Dense(area.clone(), density); + } + // Exactly what the compositor does for a vector mask with these settings + // (`photocraft-compose::masks::build_mask`): coverage with density, blurred. + let soften = |v: &mut [f32]| { + if density < 1.0 { + for x in v.iter_mut() { + *x = 1.0 - density * (1.0 - *x); + } + } + }; + let mut far = area.render(Rect::from_xywh(canvas.x0.saturating_sub(1_000_000), canvas.y0.saturating_sub(1_000_000), 1, 1)); + soften(&mut far); + let outside = far.first().copied().unwrap_or(0.0); + let region = feather::path_area(&shape.path, sigma).intersect(&canvas); + let (w, h) = (region.width() as usize, region.height() as usize); + let mut values = Vec::new(); + if values.try_reserve_exact(w.saturating_mul(h)).is_err() { + // Too large to blur: keep the edge hard rather than fail the render. + return FillCoverage::Dense(area.clone(), density); + } + values.resize(w * h, 0.0); + if !values.is_empty() { + area.render_into(region, &mut values); + soften(&mut values); + feather::gaussian(&mut values, w, h, sigma); + } + FillCoverage::Plane { area: region, values, outside } + } + + /// Pixel bounds of the visible fill (`None` = nothing). + fn bounds(&self) -> Option { + const ALL: Rect = Rect::new(i32::MIN / 4, i32::MIN / 4, i32::MAX / 4, i32::MAX / 4); + match self { + FillCoverage::Path(r) => r.pixel_bounds(), + FillCoverage::Dense(_, d) if *d < 1.0 => Some(ALL), + FillCoverage::Dense(r, _) => r.pixel_bounds(), + FillCoverage::Plane { outside, .. } if *outside > 0.0 => Some(ALL), + FillCoverage::Plane { area, .. } => (!area.is_empty()).then_some(*area), + } + } + + fn render(&self, rect: Rect) -> Vec { + match self { + FillCoverage::Path(r) => r.render(rect), + FillCoverage::Dense(r, d) => { + let mut v = r.render(rect); + for x in &mut v { + *x = 1.0 - d * (1.0 - *x); + } + v + } + FillCoverage::Plane { area, values, outside } => { + let w = area.width() as usize; + let mut v = Vec::with_capacity(rect.width() as usize * rect.height() as usize); + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let inside = area.contains(x, y); + let i = if inside { (y - area.y0) as usize * w + (x - area.x0) as usize } else { usize::MAX }; + v.push(values.get(i).copied().unwrap_or(*outside)); + } + } + v + } + } + } +} + /// Everything needed to render a shape layer, compiled once. pub struct CompiledShape { - fill: Option<(Rasterizer, Paint)>, + fill: Option<(FillCoverage, Paint)>, stroke: Option<(Rasterizer, Paint, f32)>, /// Fill area used to clip inside/outside strokes. align: Option<(Rasterizer, bool)>, @@ -476,8 +570,9 @@ impl CompiledShape { let area = fill_rasterizer(&shape.path, tol); let mut bounds = None; let fill = shape.fill.as_ref().map(|f| { - bounds = area.pixel_bounds(); - (area.clone(), Paint::from_fill(f, pb, cb)) + let coverage = FillCoverage::new(shape, &area, canvas); + bounds = coverage.bounds(); + (coverage, Paint::from_fill(f, pb, cb)) }); let all_closed = shape.path.subpaths.iter().all(|s| s.closed); let mut align = None; diff --git a/docs/scorecard.md b/docs/scorecard.md index fe9c144ba8..177fc3b6a0 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 13 | 7 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 185/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 164 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -156,7 +156,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | done | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15) | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c798116f89..22901de907 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PhotoCraft vs Adobe Photoshop: target-app parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (shape layers take Density and Feather, TOOL-214-12 done, #5846; on-canvas corner radius widgets, TOOL-214-12; `.atn` import maps 112 events; Free Transform H/V skew fields; stale rows corrected against `main` and the scorecard, estimates unchanged; Photoshop Actions `.atn` load and save; 2026-10-10: Tilt-Shift measurements added; earlier the same day: major, full re-measure against Photoshop 2026 27.11.0; merged `parity-estimate.md` and the roadmap's honest assessment; stale missing-tool rows corrected: Color Replacement and Red Eye are live toolbar tools, Frame landed; percentages unchanged) · **Target:** Adobe Photoshop 2026 The authoritative answer to "how close is PhotoCraft to Photoshop, and how much work is left". The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); @@ -174,7 +174,7 @@ parity for the area and are already contained in the dimension rows above. | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right, with a right-click transform menu (TOOL-214-5) and H/V skew fields; Puppet Warp mesh follows the object outline (TOOL-214-6); Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); missing path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Add/Delete Anchor Point and Convert Point tools exist (`Tool` enum); on-canvas corner radius widgets on live rectangles (TOOL-214-12, 2026-10-13); shape Density and Feather, the path as the fill's soft vector mask, matching Photoshop's 10 px feather ramp within 1 level (#5846, 2026-10-15); missing path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2); `.atn` load/save since 2026-10-12 (112 events mapped, the rest kept verbatim); no ExtendScript/UXP | @@ -565,6 +565,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Vector shapes: shape layers take Density and Feather (Properties › Masks), PSD and .pcraft round trip; TOOL-214-12 done (#5846); estimates unchanged | | 2026-10-13 | minor | Vector shapes: on-canvas corner radius widgets on live rectangles (TOOL-214-12); estimates unchanged | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the Pen flyout) are live toolbar tools and Frame landed, so Art History Brush is the only tool still missing; percentages unchanged | | 2026-10-12 | minor | Free Transform options bar H/V skew fields; percentages unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7fbe8932cc..792826aee8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (shape Density and Feather, TOOL-214-12, #5846; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -124,6 +124,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | | Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | +| Shape Density and Feather (Properties › Masks: a shape's path is its vector mask) | done: Density and Feather sliders for a selected shape layer, one undo step per drag, through `layer.vectorMask.edit`; the fill shows beyond the path at 100 − density %, a feather is the vector mask's Gaussian (Photoshop's 10 px ramp within 1 level); fill and stroke stay editable, the stroke stays crisp; PSD mask parameters and .pcraft round trip (#5846) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | | Curved shape bounds exact | partial | one pixel too wide (#2537) | @@ -225,6 +226,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Shape Density and Feather row added as done (TOOL-214-12, #5846) | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51f6b1cf33..92a02d9801 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -190,9 +190,9 @@ note = "path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool [[item]] id = "TOOL-214-12" target = "Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance" -status = "partial" +status = "done" issue = 214 -note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather" +note = "On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15)" [[item]] id = "TOOL-214-13" From 2ab37e7e55c426e0a11c838b8431d2192e5e7584 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 14:06:00 +0200 Subject: [PATCH 2/4] Scorecard: regenerate after merging main (TOOL-214-12 done, #5846) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/scorecard.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/scorecard.md b/docs/scorecard.md index 1e0576ba02..e9d0fbb5af 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 7 | 14 | 6 | io corpus: oracle ≥ 148/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 249/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 188/256, round trip ≥ 258/256 | -| [Tools](#tools) | 33 | 11 | 0 | | +| [Tools](#tools) | 34 | 10 | 0 | | | [Workspace and UI](#workspace-and-ui) | 13 | 6 | 4 | settings that do nothing: 26 of 166 (target 0) | | [Type](#type) | 4 | 3 | 2 | | | [Automation](#automation) | 5 | 2 | 4 | 631 of 631 menu items live (100.0%) | @@ -156,7 +156,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); Shift-click puts the next anchor on the nearest 45 deg ray from the previous one and Shift-drag turns the handles to 45 deg steps, live (vector_ui.rs pen_shift_click_puts_the_next_anchor_on_a_45_degree_ray, pen_shift_drag_turns_handles_to_45_degrees_live, #3751) | | TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | -| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256). Missing: shape feather | +| TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | done | [#214](https://github.com/storytold/photocraft/issues/214) | On-canvas corner radius widgets on every selected live rectangle with Path Selection, the Rectangle tool and Move + Show Transform Controls: drag rounds every corner of every selected rectangle alike, Alt that corner on each, corner radius cursor and R: readout, one history step (corner_radius.rs, 2026-10-13); select.convertToShape with tolerance (vector_cmds.rs:1256); shape Density and Feather (Properties, layer.vectorMask.edit on a shape layer; the path as the fill's soft vector mask, Photoshop's 10 px feather ramp within 1 level; PSD mask parameters and .pcraft, #5846, 2026-10-15) | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15); on-canvas plane editor (vp_ui.rs: Create/Edit Plane, edge nodes, valid/doubtful/invalid grid colours, Grid Size, clipboard pasted onto the plane on OK); clone stamp, marquee and tear-off planes in the window still missing | | TOOL-214-15 | Content-Aware Fill workspace: paint the sampling area, preview, rotate/scale/mirror, new layer | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Engine sampling auto, rect, custom (edit_menu_cmds.rs:324); generic dialog | From 24a77a18c0008be07b41c9445606a3a9d0135360 Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 15 Oct 2026 17:08:05 +0200 Subject: [PATCH 3/4] Shapes: a soft shape's transform frame follows its path (#5846) A shape below 100 % Density renders over the whole canvas, and a Feather grows its pixels, so Free Transform, the Move tool's transform controls, snapping and Properties > Transform framed the canvas or the blur. As in Photoshop the frame now follows the path and stroke whatever the mask Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds without the soft reach) feeds whole_layer_bounds and the Properties Transform fields. Rendering keeps the soft bounds; other layers and hard shapes are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/lib.rs | 18 ++++++---- crates/engine/src/transform_cmds.rs | 41 +++++++++++++++++++++++ crates/ui-egui/src/layer_props_ui.rs | 2 +- crates/ui-egui/src/lib.rs | 10 ++++++ crates/ui-egui/src/mask_props_ui_tests.rs | 15 +++++++++ crates/ui-egui/src/panels.rs | 2 +- 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 50d248d3ce..5c41a75eab 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -867,18 +867,24 @@ pub fn paint_bounds(layer: &Layer) -> Option { (!r.is_empty()).then_some(r) } -/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width -/// (and the reach of a soft fill). -pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { +/// The bounding box of a shape layer's path and stroke, ignoring its Density and Feather: its +/// path control bounds plus stroke width. It is the frame Free Transform and the Move tool's +/// transform controls show for a soft shape, whose rendering reaches past it (in Photoshop a +/// shape's transform frame follows its path whatever its mask Density and Feather). +pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) { return None; } let (x0, y0, x1, y1) = sh.path.control_bounds()?; let stroke_margin = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| s.width.ceil()) as i32; let r = Rect::new(x0.floor() as i32, y0.floor() as i32, x1.ceil() as i32, y1.ceil() as i32).inflate(stroke_margin); - if r.is_empty() { - return None; - } + (!r.is_empty()).then_some(r) +} + +/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width +/// (and the reach of a soft fill). +pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option { + let r = hard_shape_bounds(sh)?; // A soft fill reaches past the path: by its feather, or everywhere below 100 % density. if sh.fill.is_some() && sh.has_soft_mask() { if sh.density.is_finite() && sh.density < 1.0 { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index 1c0153a42e..baab069809 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -57,6 +57,9 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect { fn whole_layer_bounds(layer: &Layer) -> Rect { let content = match &layer.content { LayerContent::Group(g) => g.children.iter().map(whole_layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b)), + // A soft shape (Density below 100 % or a Feather) renders past its path, over the whole + // canvas below 100 %: its frame follows the path and stroke, as in Photoshop (#5846). + LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::hard_shape_bounds(sh).unwrap_or(Rect::EMPTY), _ => layer.surface().map_or(Rect::EMPTY, photocraft_compose::bounds::content_bounds), }; if content.is_empty() { layer.mask.as_ref().map_or(Rect::EMPTY, |m| photocraft_compose::bounds::content_bounds(&m.surface)) } else { content } @@ -1423,4 +1426,42 @@ mod tests { assert!(s.execute("edit.transform", bad.clone()).is_err(), "{bad}"); } } + + /// #5846: a shape's Density and Feather render past its path (over the whole canvas below + /// 100 %), but its transform frame follows the path, as in Photoshop: the same box as the hard + /// shape for Free Transform, the Move tool's controls and snapping, never the canvas. + #[test] + fn a_soft_shapes_frame_follows_its_path() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [20, 30, 60, 40], "fill": "#000000"})).unwrap()["layer"].as_u64().unwrap(); + let id = photocraft_doc::LayerId(id); + let boxes = |s: &Session| { + let st = s.active().unwrap(); + let l = st.doc.layer(id).unwrap(); + (transform_bounds(&st.doc, l), transform_frame(&st.doc, l), crate::snap::layer_rect(&st.doc, id)) + }; + let hard = boxes(&s); + assert_eq!(hard.0, Rect::new(20, 30, 80, 70), "the hard shape's frame"); + s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 20})).unwrap(); + { + let st = s.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("a shape") }; + let painted = photocraft_compose::bounds::content_bounds(sh.cache.as_ref().unwrap()); + assert_eq!(painted, st.doc.bounds(), "the soft fill renders over the whole canvas"); + } + let soft = boxes(&s); + assert_eq!(soft, hard, "the frame ignores Density and Feather"); + assert_ne!(soft.1, s.active().unwrap().doc.bounds()); + // Feather alone (density 100 %) leaves the frame on the path too, not grown by the blur. + s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 20})).unwrap(); + assert_eq!(boxes(&s), hard); + // Free Transform scales about the path's centre, not the canvas's. + s.execute("edit.transform", json!({"layer": id.0, "scaleX": 200})).unwrap(); + let st = s.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(id).unwrap().content else { panic!("a shape") }; + let (x0, y0, x1, y1) = sh.path.control_bounds().unwrap(); + assert_eq!((x0.round(), y0.round(), x1.round(), y1.round()), (-10.0, 30.0, 110.0, 70.0)); + assert_eq!((sh.density, sh.feather), (1.0, 20.0), "the transform keeps the soft settings"); + } } diff --git a/crates/ui-egui/src/layer_props_ui.rs b/crates/ui-egui/src/layer_props_ui.rs index 96899a81de..70ecc28199 100644 --- a/crates/ui-egui/src/layer_props_ui.rs +++ b/crates/ui-egui/src/layer_props_ui.rs @@ -126,7 +126,7 @@ const LINK_W: f32 = 20.0; pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let mut run: Vec<(String, Value)> = Vec::new(); if let Some(s) = layer.surface() { - let b = app.cached_bounds(layer.id.0, s); + let b = app.layer_frame_bounds(layer, s); let u = LenUnit::of(app); let (dw, dh) = app.session.active().map_or((1.0, 1.0), |s| (f64::from(s.doc.size.width), f64::from(s.doc.size.height))); if section(ui, "transform", tl!("Transform")) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d95f2d662..75e356cf76 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2349,6 +2349,16 @@ impl PhotocraftApp { self.bounds_cache.insert(key, (rev, b)); b } + + /// The box Properties › Transform shows for `layer` (X, Y, W, H): its pixels' bounds, but a + /// soft shape's path and stroke, whose Density and Feather render past them (#5846), as its + /// transform frame does. + pub fn layer_frame_bounds(&mut self, layer: &photocraft_doc::Layer, surface: &photocraft_raster::Surface) -> photocraft_geom::Rect { + match &layer.content { + photocraft_doc::LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::hard_shape_bounds(sh).unwrap_or(photocraft_geom::Rect::EMPTY), + _ => self.cached_bounds(layer.id.0, surface), + } + } } impl PhotocraftApp { diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index eacafafa23..15486be0e3 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -206,6 +206,21 @@ fn shape_properties_edit_density_and_feather() { } } +/// #5846: Properties › Transform (X, Y, W, H) frames a soft shape by its path, as its transform +/// frame does, though its Density renders it over the whole canvas. +#[test] +fn a_soft_shapes_transform_fields_follow_its_path() { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); + let id = session.execute("shape.create", json!({"kind": "rect", "rect": [16, 8, 20, 30]})).unwrap()["layer"].as_u64().unwrap(); + session.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 20})).unwrap(); + let mut app = PhotocraftApp::new(session, crate::Services::default()); + let layer = app.session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clone(); + let surface = layer.surface().unwrap().clone(); + assert_eq!(surface.content_bounds(), photocraft_geom::Rect::new(0, 0, 64, 64), "the soft fill covers the canvas"); + assert_eq!(app.layer_frame_bounds(&layer, &surface), photocraft_geom::Rect::new(16, 8, 36, 38)); +} + /// Document-wide row of mask coverage (or of the layer's own red channel) across the middle. fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec { let s = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0dc692d068..e5da6386dd 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3442,7 +3442,7 @@ fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, fn layer_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let t = Tokens::get(ui.ctx()); if let Some(s) = layer.surface() { - let b = app.cached_bounds(layer.id.0, s); + let b = app.layer_frame_bounds(layer, s); egui::Grid::new("props-grid").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| { for (k, v) in [("X", b.x0.to_string()), ("Y", b.y0.to_string()), ("W", b.width().to_string()), ("H", b.height().to_string())] { ui.label(RichText::new(k).color(t.text_dim)); From ce7e4f3752f081f5eb8646caae75b418dbc93826 Mon Sep 17 00:00:00 2001 From: David Mann Date: Tue, 20 Oct 2026 17:44:35 +0200 Subject: [PATCH 4/4] Shape Density/Feather test: drag the curved Feather track from main (#6545) Main made the Properties Feather slider's track cubic (#6727), so a 1% drag no longer reaches 10 px; drag a fifth of the way (8 px) as main's own mask test does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/mask_props_ui_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index a58eccbfd7..782337ed36 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -220,8 +220,9 @@ fn shape_properties_edit_density_and_feather() { // Live: the fill already shows beyond the path at half strength. let a = shape(&h).cache.as_ref().unwrap().rgba(2, 2)[3]; assert!((a - 0.5).abs() < 0.03, "{theme:?}: {a}"); - drag(&mut h, feather, &[0.0, 0.005, 0.01]); - assert!((shape(&h).feather - 10.0).abs() < 0.5, "{theme:?}: feather {}", shape(&h).feather); + // Feather's track is curved (#6545): a fifth of the way is 8 px. + drag(&mut h, feather, &[0.0, 0.1, 0.2]); + assert!((shape(&h).feather - 8.0).abs() < 0.3, "{theme:?}: feather {}", shape(&h).feather); assert_eq!(h.state().session.active().unwrap().history.past_len(), before + 2); h.state_mut().run("edit.undo", json!({})).unwrap(); assert_eq!(shape(&h).feather, 0.0);