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

# Conflicts:
#	docs/scorecard.md
This commit is contained in:
aiongg
2026-10-21 15:45:38 +00:00
5 changed files with 163 additions and 13 deletions
+70 -3
View File
@@ -1144,9 +1144,7 @@ pub fn relayout_damage(a: &Document, b: &Document) -> 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).
/// path control bounds plus stroke width (a conservative reach for any alignment, cap and join).
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;
@@ -1157,6 +1155,75 @@ pub fn hard_shape_bounds(sh: &photocraft_doc::ShapeLayer) -> Option<Rect> {
(!r.is_empty()).then_some(r)
}
/// The frame Free Transform, the Move tool's transform controls, snapping and Properties show for
/// a soft shape (Density below 100 % or a Feather), whose rendering reaches past its path: the
/// pixels the same shape renders in `format` over `canvas` at full Density without Feather, so
/// the frame is exactly the hard shape's whatever the stroke's alignment, cap and join (in
/// Photoshop a shape's transform frame follows its path and stroke whatever its mask Density and
/// Feather, #5846). An inverted path's frame is its own outline, not the whole canvas. Renders
/// only the strips along the edges of the rasterizers' (1 px conservative) bounds.
pub fn soft_shape_frame(sh: &photocraft_doc::ShapeLayer, format: photocraft_color::PixelFormat, canvas: Rect) -> Option<Rect> {
let mut path = sh.path.clone();
path.inverted = false;
let hard = photocraft_doc::ShapeLayer { density: 1.0, feather: 0.0, path, fill: sh.fill.clone(), stroke: sh.stroke.clone(), ..Default::default() };
let shape = photocraft_vector::CompiledShape::new(&hard, photocraft_vector::DEFAULT_TOLERANCE, canvas);
let r = shape.bounds()?.intersect(&canvas);
trim_to_painted(r, |strip| shape.render(format, strip).content_bounds())
}
/// `r` shrunk to the pixels `painted` reports (the painted bounds within a strip of `r`, empty if
/// none), looking in from each edge through strips that double in thickness: a few renders per
/// edge however wide the empty margin (a subtracted or intersected subpath can leave most of the
/// rasterizers' bounds empty), and none of `r`'s inside rendered at once.
fn trim_to_painted(mut r: Rect, mut painted: impl FnMut(Rect) -> Rect) -> Option<Rect> {
// Top: the first strip holding paint gives the first painted row.
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x0, r.y0, r.x1, r.y0.saturating_add(t).min(r.y1));
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.y0 = p.y0;
break;
}
r.y0 = strip.y1;
t = t.saturating_mul(2);
}
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x0, r.y1.saturating_sub(t).max(r.y0), r.x1, r.y1);
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.y1 = p.y1;
break;
}
r.y1 = strip.y0;
t = t.saturating_mul(2);
}
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x0, r.y0, r.x0.saturating_add(t).min(r.x1), r.y1);
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.x0 = p.x0;
break;
}
r.x0 = strip.x1;
t = t.saturating_mul(2);
}
let mut t = 1;
while !r.is_empty() {
let strip = Rect::new(r.x1.saturating_sub(t).max(r.x0), r.y0, r.x1, r.y1);
let p = painted(strip).intersect(&strip);
if !p.is_empty() {
r.x1 = p.x1;
break;
}
r.x1 = strip.x0;
t = t.saturating_mul(2);
}
(!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> {
+42 -2
View File
@@ -58,8 +58,9 @@ fn feather_matches_photoshops_ramp() {
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})");
// Same Gaussian (sigma = feather) as a vector mask: within 1 level of Photoshop (as
// scorecard TOOL-214-12 claims; measured max deviation 1 at every depth).
assert!(dev <= 1, "{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]);
@@ -157,3 +158,42 @@ fn soft_shape_bounds_cover_the_fill() {
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");
}
/// A soft shape's frame is the hard shape's painted pixels, also where a subtracted subpath
/// leaves most of the rasterizers' bounds empty (#5846), at every depth.
#[test]
fn soft_shape_frame_skips_a_subtracted_margin() {
let canvas = Rect::new(0, 0, 200, 200);
let mut cut = Subpath::polygon(&[(40.0, 40.0), (160.0, 40.0), (160.0, 140.0), (40.0, 140.0)]);
cut.op = photocraft_doc::vector::PathOp::Subtract;
let path = Path::new(vec![Subpath::polygon(&[(50.0, 50.0), (150.0, 50.0), (150.0, 150.0), (50.0, 150.0)]), cut]);
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
let sh = ShapeLayer { path: path.clone(), fill: Some(Fill::Solid(Color::BLACK)), density: 0.5, feather: 4.0, ..ShapeLayer::default() };
assert_eq!(soft_shape_frame(&sh, doc(depth).pixel_format(), canvas), Some(Rect::new(50, 140, 150, 150)), "{depth:?}");
}
}
/// The frame scan renders a few strips per edge, not one per empty row or column: a 2-px
/// painted spot in a 4000 × 3000 bounds takes a handful of renders and is found exactly.
#[test]
fn soft_shape_frame_scan_is_bounded() {
let spot = Rect::new(2500, 1700, 2502, 1702);
let mut calls = 0;
let got = trim_to_painted(Rect::new(0, 0, 4000, 3000), |strip| {
calls += 1;
strip.intersect(&spot)
});
assert_eq!(got, Some(spot));
assert!(calls <= 4 * 14, "{calls} renders");
let mut calls = 0;
assert_eq!(
trim_to_painted(Rect::new(0, 0, 4000, 3000), |_| {
calls += 1;
Rect::EMPTY
}),
None
);
assert!(calls <= 14, "an empty shape stops after the top edge: {calls} renders");
assert_eq!(trim_to_painted(Rect::EMPTY, |r| r), None);
assert_eq!(trim_to_painted(Rect::new(3, 4, 9, 7), |r| r), Some(Rect::new(3, 4, 9, 7)), "a full rect keeps its edges");
}
+3 -3
View File
@@ -95,8 +95,8 @@ fn rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
/// Bounds of a layer as a Snap To Layers target: all of its content, whatever the selection
/// holds of it (#6290: clipped to the selection, every layer's edges and centre were the
/// selection's, so a moved selection snapped back to where it started).
fn target_rect_of(l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(l)).and_then(to_rect)
fn target_rect_of(doc: &Document, l: &photocraft_doc::Layer) -> Option<[f64; 4]> {
snappable(l).then(|| crate::transform_cmds::whole_layer_bounds(doc, l)).and_then(to_rect)
}
fn snappable(l: &photocraft_doc::Layer) -> bool {
@@ -132,7 +132,7 @@ impl SnapTargets {
if !l.visible || exclude.contains(&l.id) {
continue;
}
if let Some(r) = target_rect_of(l) {
if let Some(r) = target_rect_of(doc, l) {
t.add_rect(r, SnapKind::LayerEdge, SnapKind::LayerCenter);
}
}
+32 -4
View File
@@ -29,7 +29,7 @@ pub fn moves_whole(layer: &Layer) -> bool {
/// content within the selection, or all of it for a layer that [`moves_whole`] (#2630).
/// Content scans are cached per tile: snapping asks for every layer's bounds per Move drag.
pub fn transform_bounds(doc: &Document, layer: &Layer) -> Rect {
let content = whole_layer_bounds(layer);
let content = whole_layer_bounds(doc, layer);
match &doc.selection {
Some(sel) if !moves_whole(layer) => content.intersect(&sel.content_bounds()),
_ => content,
@@ -54,12 +54,12 @@ fn framed_by_selection(doc: &Document, bounds: Rect) -> Rect {
}
/// A group moves all its descendants, so its frame must not clip raster children to the selection.
pub(crate) fn whole_layer_bounds(layer: &Layer) -> Rect {
pub(crate) fn whole_layer_bounds(doc: &Document, 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)),
LayerContent::Group(g) => g.children.iter().map(|c| whole_layer_bounds(doc, c)).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),
LayerContent::Shape(sh) if sh.has_soft_mask() => photocraft_compose::soft_shape_frame(sh, doc.pixel_format(), doc.bounds()).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 }
@@ -1539,4 +1539,32 @@ mod tests {
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");
}
/// A stroked soft shape's frame is the hard shape's rendered frame for every stroke alignment:
/// a centred stroke reaches half its width past the path, not its full width; both stop at
/// the canvas edge.
#[test]
fn a_stroked_soft_shapes_frame_matches_the_hard_shapes() {
for (align, reach, x) in [("center", 5, 50), ("inside", 0, 50), ("outside", 10, 50), ("center", 5, -20)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 200, "height": 200})).unwrap();
let stroke = json!({"width": 10, "color": "#ff0000", "align": align, "join": "round"});
let id = s.execute("shape.create", json!({"kind": "rect", "rect": [x, 50, 60, 40], "fill": "#000000", "stroke": stroke})).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);
// Hand-computed: the path is x..x + 60 x 50..90, the stroke reaches `reach` px past it.
assert_eq!(hard.0, Rect::new((x - reach).max(0), 50 - reach, x + 60 + reach, 90 + reach), "{align} at {x}: the hard shape's frame");
s.execute("layer.vectorMask.edit", json!({"density": 60, "feather": 8})).unwrap();
assert_eq!(boxes(&s), hard, "{align} at {x}: Density and Feather leave the frame");
s.execute("layer.vectorMask.edit", json!({"density": 100, "feather": 8})).unwrap();
assert_eq!(boxes(&s), hard, "{align} at {x}: Feather alone leaves the frame");
}
}
}
+16 -1
View File
@@ -2602,7 +2602,22 @@ impl PhotocraftApp {
/// 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),
photocraft_doc::LayerContent::Shape(sh) if sh.has_soft_mask() => {
// Cached per rendering, like the pixel bounds: the frame renders strips of the shape.
let (key, rev) = (layer.id.0 | 1 << 63, surface_fingerprint(surface));
if let Some((r, b)) = self.bounds_cache.get(&key)
&& *r == rev
{
return *b;
}
let canvas = self.session.active().map_or_else(|| surface.content_bounds(), |st| st.doc.bounds());
let b = photocraft_compose::soft_shape_frame(sh, surface.format(), canvas).unwrap_or(photocraft_geom::Rect::EMPTY);
if self.bounds_cache.len() > 256 {
self.bounds_cache.clear();
}
self.bounds_cache.insert(key, (rev, b));
b
}
_ => self.cached_bounds(layer.id.0, surface),
}
}