diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index d77941847..d0aa8cdb6 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -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 { /// 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 diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a141f42ff..2a012de61 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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