Merge PR #5847: Shapes: Density and Feather on shape layers (#5846)

# Conflicts:
#	docs/scorecard.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
aiongg
2026-10-21 05:04:03 +00:00
30 changed files with 1031 additions and 308 deletions
+25 -4
View File
@@ -1047,11 +1047,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
@@ -1141,8 +1143,11 @@ pub fn relayout_damage(a: &Document, b: &Document) -> Rect {
out
}
/// The bounding box of a shape layer's vector content: its path control bounds plus stroke width.
pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
/// 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<Rect> {
if sh.fill.is_none() && sh.stroke.as_ref().is_none_or(|s| s.width <= 0.0 || s.opacity <= 0.0) {
return None;
}
@@ -1152,6 +1157,20 @@ pub fn shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
(!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<Rect> {
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 {
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
/// times the rasterized vector mask.
fn mask_vals(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Vec<f32>> {
@@ -2247,6 +2266,8 @@ fn blend_span_g(dst: &mut [[f32; 4]], src: &[[f32; 4]], x0: i32, y: i32, mode: B
}
}
#[cfg(test)]
mod soft_shape_tests;
#[cfg(test)]
mod stroke_tests;
#[cfg(test)]
+4 -261
View File
@@ -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<BoxPass> {
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::<f32>() - 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<f32> = (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.
+2
View File
@@ -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()
}
}
+159
View File
@@ -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<ShapeStroke>) -> 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<i32> {
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<i32> {
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<i32> = (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");
}
+11 -2
View File
@@ -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<ShapeStroke>) -> 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()];
+20 -1
View File
@@ -317,7 +317,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").
@@ -329,6 +329,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<Arc<Vec<u8>>>,
/// 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)]
+41
View File
@@ -57,6 +57,9 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
pub(crate) 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 }
@@ -1498,4 +1501,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");
}
}
+39 -6
View File
@@ -1385,11 +1385,15 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result<Value> {
fn vector_mask_info(s: &Session, id: LayerId) -> Result<Value> {
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 }),
})
}
@@ -1441,6 +1445,35 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result<Value> {
.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()))?;
@@ -1453,8 +1486,8 @@ fn vector_mask_edit(s: &mut Session, p: &Value) -> Result<Value> {
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;
@@ -1848,7 +1881,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
+65
View File
@@ -803,6 +803,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);
+5 -1
View File
@@ -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 {
+23
View File
@@ -294,6 +294,12 @@ pub enum ContentM {
stroke: Option<ShapeStroke>,
#[serde(default)]
live: Option<LiveShape>,
/// 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));
}
}
+2
View File
@@ -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;
+14
View File
@@ -511,6 +511,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);
}
@@ -523,6 +528,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
+44 -3
View File
@@ -1930,7 +1930,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![
@@ -1968,7 +1968,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 {
@@ -2323,7 +2323,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;
@@ -2790,3 +2798,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);
}
}
}
}
+1 -1
View File
@@ -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
}
+6 -2
View File
@@ -455,7 +455,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;
@@ -1880,7 +1884,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<MaskParameters> {
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())
+22 -9
View File
@@ -88,9 +88,19 @@ fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
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.
@@ -431,10 +441,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) });
}
@@ -444,9 +453,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));
}
+6 -4
View File
@@ -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));
+2 -1
View File
@@ -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)];
+45
View File
@@ -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();
+5 -2
View File
@@ -125,7 +125,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")) {
@@ -176,7 +176,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).
+10
View File
@@ -2594,6 +2594,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 {
+18 -1
View File
@@ -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)]
+73 -1
View File
@@ -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();
@@ -174,6 +177,75 @@ 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}");
// 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);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(shape(&h).density, 1.0, "{theme:?}");
}
}
/// #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<f32> {
let s = h.state().session.active().unwrap();
+2 -1
View File
@@ -3745,7 +3745,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));
@@ -3833,6 +3833,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);
}
}
}
+281
View File
@@ -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<BoxPass> {
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::<f32>() - 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<f32> = (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:?}");
}
}
+98 -3
View File
@@ -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<f32>,
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<Rect> {
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<f32> {
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;
+3 -2
View File
@@ -1,6 +1,6 @@
# PhotoCraft vs Adobe Photoshop: target-app parity
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; 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-18 · **Change:** minor (fill and adjustment layers copy and paste whole, UI-217-23 partial, #2344; 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; manual/optical kerning, a live Warp Text preview and variable-font axis sliders (2026-10-11); type on a path only places glyphs rigidly (TYPE-218-1 partial); no RTL paragraphs, OpenType panel UI; 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 |
@@ -566,6 +566,7 @@ See [method, raw measurements and reproduction](five-algorithm-performance.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | Layers: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial; percentages unchanged |
| 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 |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity: tools, handles, modifiers and feel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; 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; selection-modifier and ⌥-wheel rows match their tests) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (shape Density and Feather, TOOL-214-12, #5846; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; 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; selection-modifier and ⌥-wheel rows match their tests) · **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,
@@ -125,6 +125,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) |
@@ -227,6 +228,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 |
+2 -2
View File
@@ -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"