fix(compose): a wide stroke effect keeps a round outer edge (#3646)

A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a
245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact
Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer
edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is
fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px
of a circle (was 2.5 px).

Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance
(a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764).
Full photocraft-compose suite: 206 pass; fmt and clippy clean.
This commit is contained in:
scott committed 2026-10-11 12:54:44 -05:00
1 parent e26442657f
commit 1080008ad9
2 files changed
+48 -13

No files matched your search

+12 -8
View File
@@ -14,10 +14,10 @@
//! of strokes blend onto the backdrop with their own modes, an upper stroke covering the
//! lower ones. Drop shadows are knocked out only through see-through fill.
//!
//! Shapes come from the layer's alpha (after its mask). Strokes and spread / choke measure
//! distances with a 5 × 5 chamfer metric seeded at sub-pixel edge offsets (as Photoshop does);
//! precise glows and chiselled bevels use an exact Euclidean distance transform. Soft falloffs
//! are two box blurs (a tent).
//! Shapes come from the layer's alpha (after its mask). A stroke's outer edge, and precise glows /
//! chiselled bevels, use an exact Euclidean transform: a 5 × 5 chamfer's level sets are polygons,
//! which a wide stroke shows as flat facets (#3646). Inside stroke edges and spread / choke keep the
//! cheaper chamfer. Soft falloffs are two box blurs (a tent).
use photocraft_color::blend::BlendMode;
use photocraft_doc::Pattern;
@@ -389,9 +389,9 @@ fn dist_outside(s: &Map) -> Vec<f32> {
/// Distance metric for effect distance fields.
#[derive(Clone, Copy, PartialEq)]
enum Metric {
/// Exact Euclidean (precise glows, bevels).
/// Exact Euclidean (effect edges).
Euclidean,
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
/// 5 × 5 chamfer (inside strokes, spread / choke; see [`chamfer_nearest`]).
Chamfer,
}
@@ -1714,10 +1714,14 @@ pub(crate) fn build_maps_prepared(
.collect();
let has_stroke = items.iter().any(|e| matches!(e, Effect::Stroke(_)));
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Chamfer))) } else { (None, None) };
// The outer edge uses exact Euclidean distances, not the 5 × 5 chamfer: the chamfer's level
// sets are polygons (an ~8-fold octagon here), so a wide stroke's outer boundary shows flat
// facets — at 245 px it wanders by ~10 px (#3646). Inside stroke distances keep the chamfer,
// whose sub-pixel edge seeding is fitted to Photoshop at small offsets.
let (din, dout) = if has_stroke { (Some(dist_inside_by(&shape, Metric::Chamfer)), Some(dist_outside_by(&shape, Metric::Euclidean))) } else { (None, None) };
// Without an outline (unfilled shapes), estimate the vector outline from local coverage.
let outline = crate::effect_outline(layer).is_some();
let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Chamfer));
let vdout = (has_stroke && vector_shape && !outline).then(|| dist_outside_by(&local_coverage(&shape), Metric::Euclidean));
let mut frames: Vec<(u32, Rect)> = Vec::new();
for e in &items {
if let Effect::Stroke(st) = e
+36 -5
View File
@@ -1116,17 +1116,48 @@ fn shapeless_effect_maps_cover_only_the_band_being_rendered() {
// ---------- PSD-fidelity effect semantics (fitted on Photoshop composites) ----------
#[test]
fn stroke_corners_follow_the_5x5_chamfer_metric() {
// Square 10..30, outside stroke 3: pixel (9, 7) is offset (1, 3) from the corner pixel.
// Photoshop measures √5 + 1 = 3.236 there (exact distance 3.162): coverage 3 + 1 − 3.236.
fn stroke_edges_use_the_exact_euclidean_distance() {
// #3646: stroke distances are exact Euclidean. The 5 × 5 chamfer is a polygon at scale (see
// `a_wide_outside_stroke_is_round_not_a_polygon`), so it can't be the metric here.
// Square 10..30, outside stroke 3: (9, 7) is offset (1, 3) from the corner pixel: distance
// √10 = 3.162, coverage 3 + 1 − 3.162 = 0.838.
let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);
let a = 1.0 - px(&d, 9, 7)[0]; // blue stroke over white: red channel drops by coverage
assert!((a - 0.764).abs() < 0.01, "{a}");
// (2, 2) stays Euclidean (two diagonal steps).
assert!((a - 0.838).abs() < 0.01, "{a}");
// (2, 2) is the same in both metrics (two diagonal steps).
let a = 1.0 - px(&d, 8, 8)[0];
assert!((a - 1.0).abs() < 0.01, "{a}");
}
#[test]
fn a_wide_outside_stroke_is_round_not_a_polygon() {
// A point source dilated by 60 px must stay a circle. The 5 × 5 chamfer's octagonal level set
// made the boundary swing ~2.5 px at this radius (~10 px at 245 px) — the "low-poly" #3646.
let mut d = doc_white(170, 170);
let mut l = solid_layer("dot", Rect::new(85, 85, 86, 86), [1.0, 0.0, 0.0, 1.0]);
l.effects.items = vec![stroke(60.0, StrokePosition::Outside)];
d.layers.push(l);
let buf = render(&d, Rect::new(0, 0, 170, 170));
let at = |x: i32, y: i32| buf.px[(y as usize) * 170 + x as usize];
let (mut lo, mut hi) = (f64::MAX, 0.0f64);
let stroke_at = |x: i32, y: i32| x >= 0 && y >= 0 && x < 170 && y < 170 && at(x, y)[2] > 0.5 && at(x, y)[0] < 0.5;
for k in 0..72 {
let th = (k as f64) * std::f64::consts::TAU / 72.0;
let (dx, dy) = (th.cos(), th.sin());
let mut r = 0.0f64;
let mut t = 0.0f64;
while t < 84.0 {
if stroke_at((85.0 + dx * t).round() as i32, (85.0 + dy * t).round() as i32) {
r = t;
}
t += 0.25;
}
lo = lo.min(r);
hi = hi.max(r);
}
assert!(hi - lo <= 2.0, "the 60 px stroke boundary swings {lo}..{hi} px — a polygon, not a circle (#3646)");
}
#[test]
fn inside_stroke_respects_partial_edge_coverage() {
// A 25 %-alpha edge column puts the edge 0.75 px further out: a 3 px inside stroke then