Files
photocraft/crates/io/tests/effects_multi.rs
David Mann 6bb45c8d5b 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) <noreply@anthropic.com>
2026-10-15 03:07:46 +02:00

84 lines
4.5 KiB
Rust

//! Multiple instances of an effect (Photoshop CC's several strokes / shadows / overlays) and
//! multi-subpath shape components survive a PSD round trip and render the same.
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::vector::{Path, PathOp, ShapeLayer, Subpath};
use photocraft_doc::*;
use photocraft_geom::{Rect, Size};
use photocraft_io::{ExportOptions, export, import};
fn stroke(size: f32, position: StrokePosition, paint: FxPaint, opacity: f32) -> Effect {
Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, opacity), size, position, paint })
}
fn roundtrip(doc: &Document) -> Document {
let r = export(doc, "x.psd", &ExportOptions::default()).expect("export");
import("x.psd", &r.bytes).expect("import").document
}
fn max_diff(a: &Document, b: &Document) -> f32 {
let (x, y) = (photocraft_compose::flatten(a).px, photocraft_compose::flatten(b).px);
assert_eq!(x.len(), y.len());
x.iter().zip(&y).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3] - q[c] * q[3]).abs())).fold(0.0, f32::max)
}
#[test]
fn several_strokes_and_shadows_roundtrip() {
let mut d = Document::with_background("t", Size::new(64, 48), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut l = Layer::raster("blob", PixelFormat::RGBA8);
l.surface_mut().unwrap().fill_rect(Rect::new(20, 14, 44, 34), &[0.8, 0.2, 0.1, 1.0]);
let g = Gradient { stops: vec![(0.0, Color::rgb(0.0, 0.0, 1.0)), (1.0, Color::rgb(1.0, 1.0, 0.0))], angle: 30.0, ..Gradient::default() };
l.effects.items = vec![
stroke(2.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(1.0, 1.0, 1.0)), 1.0),
stroke(5.0, StrokePosition::Outside, FxPaint::Gradient(g), 0.8),
stroke(3.0, StrokePosition::Inside, FxPaint::Color(Color::rgb(0.0, 0.5, 0.0)), 1.0),
stroke(4.0, StrokePosition::Center, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)), 0.5),
Effect::default_drop_shadow(),
Effect::DropShadow(Shadow { distance: 9.0, angle: 45.0, use_global_light: false, ..shadow_of(Effect::default_drop_shadow()) }),
];
d.layers.push(l);
let back = roundtrip(&d);
let fx = &back.layers[1].effects.items;
let strokes: Vec<&StrokeFx> = fx.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect();
assert_eq!(strokes.len(), 4);
assert_eq!(
strokes.iter().map(|s| (s.size, s.position)).collect::<Vec<_>>(),
vec![(2.0, StrokePosition::Outside), (5.0, StrokePosition::Outside), (3.0, StrokePosition::Inside), (4.0, StrokePosition::Center)]
);
assert!(matches!(strokes[1].paint, FxPaint::Gradient(_)));
assert!((strokes[3].common.opacity - 0.5).abs() < 1e-3);
assert_eq!(fx.iter().filter(|e| matches!(e, Effect::DropShadow(_))).count(), 2);
let m = max_diff(&d, &back);
assert!(m <= 1.0 / 255.0 + 1e-5, "render changed by {m}");
}
fn shadow_of(e: Effect) -> Shadow {
match e {
Effect::DropShadow(s) => s,
_ => unreachable!("a drop shadow"),
}
}
#[test]
fn joined_shape_components_roundtrip() {
let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, SampleType::U8, Color::WHITE);
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, ..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)];
d.layers.push(l);
// The hole is stroked too (the outline is the joined component).
let px = |doc: &Document, x: i32, y: i32| photocraft_compose::render(doc, Rect::from_xywh(x, y, 1, 1)).px[0];
assert!(px(&d, 20, 20)[1] > 0.9, "hole shows the background");
assert!(px(&d, 20, 13)[0] > 0.9 && px(&d, 20, 13)[1] < 0.1, "stroke inside the hole: {:?}", px(&d, 20, 13));
let back = roundtrip(&d);
let LayerContent::Shape(sh) = &back.layers[1].content else { panic!("shape layer") };
assert_eq!(sh.path.subpaths.iter().map(|s| s.op).collect::<Vec<_>>(), vec![PathOp::Combine, PathOp::Join]);
let m = max_diff(&d, &back);
assert!(m <= 1.0 / 255.0 + 1e-5, "render changed by {m}");
}