diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 97d08f00da..6966254547 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -15,10 +15,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; @@ -394,12 +394,30 @@ 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, } +/// Outside width from which a stroke's outer edge uses the exact Euclidean transform instead of +/// the fitted 5 × 5 chamfer. +/// +/// The chamfer's level sets are octagons: measured against a point source, its distance exceeds +/// the Euclidean radius by up to `0.027 · r` px — 0.48 px at r = 18 (the widest outside stroke any +/// Photoshop oracle matches with the chamfer) and 1.6 px at r = 60, where a wide stroke's boundary +/// visibly facets (#3646). Half a pixel of deviation is reached at r ≈ 19, so strokes narrower +/// than 20 px keep the chamfer, whose sub-pixel edge seeding Photoshop matches at small offsets, +/// and wider ones stay round. +pub const STROKE_CHAMFER_MAX: f32 = 20.0; + +/// Whether a stroke whose outside edge is `out_w` px wide uses the exact Euclidean transform (round) +/// rather than the 5 × 5 chamfer's octagon ([`STROKE_CHAMFER_MAX`]). The CPU compositor and the GPU +/// compositor's `program_with` both call this, so a stroke picks the same field in each. +pub fn stroke_outside_exact(out_w: f32) -> bool { + out_w >= STROKE_CHAMFER_MAX +} + fn dist_outside_by(s: &Map, m: Metric) -> Vec { if m == Metric::Chamfer { // Seeds start at their sub-pixel edge offset (1 − coverage). @@ -1657,9 +1675,14 @@ pub struct FxMaps { per: Vec>, bevel_paint: Vec, din: Option>, + /// Stroke outside distance below [`STROKE_CHAMFER_MAX`]: the fitted 5 × 5 chamfer. dout: Option>, - /// Shape layers: distance outside the vector outline (from local coverage). + /// Stroke outside distance at or above [`STROKE_CHAMFER_MAX`]: the exact Euclidean transform. + dout_exact: Option>, + /// Shape layers: chamfer distance outside the vector outline (from local coverage). vdout: Option>, + /// Shape layers: exact Euclidean distance outside the vector outline (from local coverage). + vdout_exact: Option>, /// `shape` joins the alpha with a filled shape's outline (`crate::effect_outline`). outline: bool, /// Frames of gradient strokes by outside width (bits): see [`stroke_frame`]. @@ -1678,7 +1701,14 @@ impl FxMaps { pub fn bytes(&self) -> usize { let n = self.shape.v.len(); let maps: usize = self.per.iter().map(Vec::len).sum(); - (1 + maps + usize::from(self.din.is_some()) + usize::from(self.dout.is_some()) + usize::from(self.vdout.is_some())) * n * 4 + (1 + maps + + usize::from(self.din.is_some()) + + usize::from(self.dout.is_some()) + + usize::from(self.dout_exact.is_some()) + + usize::from(self.vdout.is_some()) + + usize::from(self.vdout_exact.is_some())) + * n + * 4 } fn crop_vec(&self, v: &[f32], to: Rect, fill: f32) -> Vec { @@ -1765,10 +1795,34 @@ 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) }; - // Without an outline (unfilled shapes), estimate the vector outline from local coverage. + // A stroke's outer edge keeps the 5 × 5 chamfer while its outside width is under + // `STROKE_CHAMFER_MAX` — Photoshop matches the chamfer's sub-pixel edge seeding at small + // offsets — and uses the exact Euclidean transform at or above it, where the chamfer's octagon + // facets a wide stroke (#3646). Inside stroke distances always keep the chamfer. Each outer + // field is built only when a stroke of that width is present; the compositor picks per stroke, + // so one layer may hold both a narrow and a wide stroke. + let out_widths: Vec = + items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(stroke_widths(st).1) } else { None }).filter(|w| *w > 0.0).collect(); + let chamfer_out = out_widths.iter().any(|&w| !stroke_outside_exact(w)); + let exact_out = out_widths.iter().any(|&w| stroke_outside_exact(w)); + let (din, dout, dout_exact) = if has_stroke { + ( + Some(dist_inside_by(&shape, Metric::Chamfer)), + chamfer_out.then(|| dist_outside_by(&shape, Metric::Chamfer)), + exact_out.then(|| dist_outside_by(&shape, Metric::Euclidean)), + ) + } else { + (None, None, None) + }; + // Without an outline (unfilled shapes), estimate the vector outline from local coverage; it + // picks the chamfer or the exact transform the same way. 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, vdout_exact) = if has_stroke && vector_shape && !outline { + let lc = local_coverage(&shape); + (chamfer_out.then(|| dist_outside_by(&lc, Metric::Chamfer)), exact_out.then(|| dist_outside_by(&lc, Metric::Euclidean))) + } else { + (None, None) + }; let mut frames: Vec<(u32, Rect)> = Vec::new(); for e in &items { if let Effect::Stroke(st) = e @@ -1780,7 +1834,7 @@ pub(crate) fn build_maps_prepared( } } } - FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames } + FxMaps { rect, shape, per, bevel_paint, din, dout, dout_exact, vdout, vdout_exact, outline, frames } } /// Far outside any shape (distance fill for cropped distance fields). @@ -1963,10 +2017,13 @@ pub(crate) fn composite_with_effects_prepared( // Strokes. Inside parts are painted over the layer (bottom instance first); outside parts // are slid beneath it, so the first listed (top) instance is processed first. let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(s) = e { Some(s) } else { None }).collect(); - let (din, dout) = match (&maps.din, &maps.dout) { - (Some(a), Some(b)) if !strokes.is_empty() => (maps.crop_vec(a, big, 0.0), maps.crop_vec(b, big, FAR)), - _ => (Vec::new(), Vec::new()), - }; + let ready = !strokes.is_empty(); + let din = if ready { maps.din.as_ref().map(|a| maps.crop_vec(a, big, 0.0)).unwrap_or_default() } else { Vec::new() }; + // A stroke's outer edge picks the chamfer or the exact field by its width + // ([`stroke_outside_exact`]); both are cropped here, and only the ones a stroke needs exist. + let crop_out = |v: &Option>| v.as_ref().map(|b| maps.crop_vec(b, big, FAR)); + let (dout, dout_exact, vdout, vdout_exact) = + if ready { (crop_out(&maps.dout), crop_out(&maps.dout_exact), crop_out(&maps.vdout), crop_out(&maps.vdout_exact)) } else { (None, None, None, None) }; let widths = stroke_widths; // A gradient stroke aligned with the layer spans the stroke's own extent. let frame = |st: &photocraft_doc::StrokeFx| maps.stroke_frame(st).unwrap_or(sb); @@ -1994,7 +2051,9 @@ pub(crate) fn composite_with_effects_prepared( for (p, a) in lay.px.iter_mut().zip(&shape.v) { p[3] *= a.min(1.0); } - let vdout = maps.vdout.as_ref().filter(|_| vector_shape && !strokes.is_empty()).map(|v| maps.crop_vec(v, big, FAR)); + // Shape layers without a filled outline stroke their vector outline, estimated from local + // coverage; every other layer strokes its shape. The width then picks the field. + let vector_outline = vector_shape && !maps.outline; // Outside parts lie beneath the layer and blend onto the backdrop with their own mode. A // higher stroke covers the ones below it (Photoshop: a Multiply stroke listed above a wider // Normal stroke multiplies the backdrop, not the lower stroke). Each stroke's share is its @@ -2012,7 +2071,14 @@ pub(crate) fn composite_with_effects_prepared( // Shape layers: Photoshop strokes the vector outline (estimated from local coverage // for unfilled shapes); the stroke never shows through the shape's pixels. Along a // filled shape's outline it covers the part of each edge pixel outside the path. - let d = vdout.as_ref().unwrap_or(&dout); + let exact = stroke_outside_exact(out_w); + let d: &[f32] = if vector_outline { + (if exact { vdout_exact.as_deref() } else { vdout.as_deref() }).unwrap_or_default() + } else if exact { + dout_exact.as_deref().unwrap_or_default() + } else { + dout.as_deref().unwrap_or_default() + }; let mut share = vec![0.0f32; w * h]; for (i, sh) in share.iter_mut().enumerate() { let band = (out_w + 0.5 - d[i]).clamp(0.0, 1.0); @@ -2176,15 +2242,23 @@ pub enum FieldKind { BevelOutside, /// `bevel_dist_inside` of the shape: the inside distance of chiselled bevels. BevelInside, - /// Chamfer distance outside the shape: outside strokes, and the spread of drop shadows and - /// softer outer glows ([`dilate`]). + /// Chamfer distance outside the shape: a narrow raster stroke's outer edge (see + /// [`stroke_outside_exact`]), and the spread of drop shadows and softer outer glows + /// ([`dilate`]); their metric is fitted to Photoshop (#224) and keeps the sub-pixel seeding. StrokeOutside, /// Chamfer distance outside `1 - alpha`: the choke of inner shadows and softer inner glows. ChokeInside, /// Stroke distance inside the shape. StrokeInside, - /// Stroke distance outside a shape layer's outline (from its local coverage). + /// Exact Euclidean distance outside the shape: a wide raster stroke's outer edge (see + /// [`stroke_outside_exact`]). The chamfer's level sets are polygons (an ~8-fold octagon), so a + /// wide stroke's outer boundary facets; the shadow / glow spread above keeps the chamfer (#3646). + StrokeOutsideExact, + /// Stroke distance outside a shape layer's outline (from its local coverage), exact Euclidean. StrokeOutsideVector, + /// Chamfer distance outside a shape layer's outline (from its local coverage): a narrow vector + /// stroke's outer edge, where the chamfer matches Photoshop (see [`STROKE_CHAMFER_MAX`]). + StrokeOutsideVectorChamfer, } /// The `kind` distance field of the shape `alpha` (row-major `w × h`). Values past `reach` pixels @@ -2199,7 +2273,9 @@ pub fn distance_field(kind: FieldKind, alpha: Vec, w: usize, h: usize, reac FieldKind::StrokeOutside => dist_outside_by(&s, Metric::Chamfer), FieldKind::ChokeInside => dist_outside_by(&s.map(|a| 1.0 - a), Metric::Chamfer), FieldKind::StrokeInside => dist_inside_by(&s, Metric::Chamfer), - FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Chamfer), + FieldKind::StrokeOutsideExact => dist_outside_by(&s, Metric::Euclidean), + FieldKind::StrokeOutsideVector => dist_outside_by(&local_coverage(&s), Metric::Euclidean), + FieldKind::StrokeOutsideVectorChamfer => dist_outside_by(&local_coverage(&s), Metric::Chamfer), } } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6680f11a90..f9e49ac04e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1315,17 +1315,61 @@ 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_chamfer_when_narrow_and_the_exact_distance_when_wide() { + // #3646: a narrow stroke's outer edge keeps the 5 × 5 chamfer, whose sub-pixel seeding + // Photoshop matches at small offsets (see `stroke_outside_exact`); only a wide one — where the + // chamfer's octagon facets (see `a_wide_outside_stroke_is_round_not_a_polygon`) — uses the + // exact Euclidean transform. + // Square 10..30, outside stroke 3: (9, 7) is offset (1, 3) from the corner pixel. Photoshop + // measures √5 + 1 = 3.236 there, so coverage is 3 + 1 − 3.236 = 0.764 (the exact distance is + // 3.162, coverage 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). + // (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 the_stroke_metric_switches_at_the_fitted_threshold() { + // The widest outside stroke any Photoshop oracle matches with the chamfer is 18 px + // (ag-psd read-write/strokes): its octagon deviates 0.48 px from a circle there; 20 px keeps + // it. Wider strokes (the 60 px #3646 case) are round. + assert!(!crate::effects::stroke_outside_exact(18.0)); + assert!(crate::effects::stroke_outside_exact(crate::effects::STROKE_CHAMFER_MAX)); + assert!(crate::effects::stroke_outside_exact(60.0)); +} + +#[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 diff --git a/crates/gpu/src/fx.rs b/crates/gpu/src/fx.rs index cc396d8ba0..2149aaa412 100644 --- a/crates/gpu/src/fx.rs +++ b/crates/gpu/src/fx.rs @@ -434,7 +434,15 @@ pub(crate) fn program_with(e: &Effect, light: &GlobalLight, vector_shape: bool, // composite_with_effects' stroke bands (maps 0 outside, 1 inside) let (in_w, out_w) = stroke_widths(st); if out_w > 0.0 { - let d = b.field(if vector_shape { FieldKind::StrokeOutsideVector } else { FieldKind::StrokeOutside }, out_w); + // Narrow strokes keep the fitted chamfer, wide ones the exact transform (#3646); + // the CPU picks the same field by the same width (`stroke_outside_exact`). + let kind = match (vector_shape, photocraft_compose::effects::stroke_outside_exact(out_w)) { + (true, true) => FieldKind::StrokeOutsideVector, + (true, false) => FieldKind::StrokeOutsideVectorChamfer, + (false, true) => FieldKind::StrokeOutsideExact, + (false, false) => FieldKind::StrokeOutside, + }; + let d = b.field(kind, out_w); let mut s = stage(Kernel::MStroke, Some(d), None, [out_w, 0.0, 0.0, 0.0], 0); s.out = Some(0); b.stages.push(s); @@ -522,7 +530,8 @@ pub(crate) fn field(kind: FieldKind, reach: i32, shape: &[f32], region: Rect, ou /// (the chamfer fields, a sequential sweep) the window is cut into bands that go in parallel. Without /// threads (wasm) there is nothing to spread over, and bands keep the memory small. fn threaded_inside(kind: FieldKind) -> bool { - !cfg!(target_arch = "wasm32") && matches!(kind, FieldKind::Outside | FieldKind::Inside | FieldKind::BevelOutside | FieldKind::BevelInside) + !cfg!(target_arch = "wasm32") + && matches!(kind, FieldKind::Outside | FieldKind::Inside | FieldKind::BevelOutside | FieldKind::BevelInside | FieldKind::StrokeOutsideExact) } /// [`field`] with the halo given (the tests shrink it to see that they notice). @@ -748,7 +757,9 @@ mod tests { FieldKind::ChokeInside, FieldKind::StrokeOutside, FieldKind::StrokeInside, + FieldKind::StrokeOutsideExact, FieldKind::StrokeOutsideVector, + FieldKind::StrokeOutsideVectorChamfer, ] { let reach = 9; let whole = photocraft_compose::effects::distance_field(kind, shape.clone(), w, h, reach as f32); @@ -825,4 +836,42 @@ mod tests { } assert!(too_small_noticed, "a 2 row halo went unnoticed, so this test guards nothing"); } + + /// #3646: a stroke's outer edge is the fitted 5 × 5 chamfer while it is narrow and the exact + /// Euclidean transform once it is wide — the chamfer's octagon facets a wide stroke. The GPU + /// builds it from the field its width picks (`stroke_outside_exact`), while the spread of drop + /// shadows and softer outer glows keeps the chamfer field (fitted to Photoshop, #224) — so + /// routing a wide stroke through the chamfer, or a shadow through the exact field, fails here. + #[test] + fn a_stroke_uses_the_chamfer_narrow_and_the_exact_field_wide() { + use photocraft_color::{BlendMode, Color}; + use photocraft_doc::{FxCommon, FxPaint, StrokeFx, StrokePosition}; + let light = GlobalLight::default(); + let stroke = + |size, position| Effect::Stroke(StrokeFx { common: FxCommon::new(BlendMode::Normal, 1.0), size, position, paint: FxPaint::Color(Color::BLACK) }); + // Narrow (< STROKE_CHAMFER_MAX): the chamfer, exactly as before #3646. + let narrow = program(&stroke(6.0, StrokePosition::Outside), &light, false); + assert!(narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "a narrow raster outside stroke keeps the chamfer: {:?}", narrow.fields); + assert!(!narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideExact), "…not the exact field: {:?}", narrow.fields); + // Wide: the exact transform. + let wide = program(&stroke(40.0, StrokePosition::Outside), &light, false); + assert!( + wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideExact), + "a wide raster outside stroke needs the exact outer field: {:?}", + wide.fields + ); + assert!(!wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "…not the chamfer field: {:?}", wide.fields); + // A shape layer's outline stroke follows the same width rule; the inside edge keeps the chamfer. + let vector_narrow = program(&stroke(6.0, StrokePosition::Outside), &light, true); + assert!(vector_narrow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideVectorChamfer), "{:?}", vector_narrow.fields); + let vector_wide = program(&stroke(40.0, StrokePosition::Outside), &light, true); + assert!(vector_wide.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutsideVector), "{:?}", vector_wide.fields); + let inner = program(&stroke(40.0, StrokePosition::Inside), &light, false); + assert!(inner.fields.iter().any(|(k, _)| *k == FieldKind::StrokeInside), "{:?}", inner.fields); + // The shadow / softer-glow spread is unchanged: still the chamfer. + let Effect::DropShadow(mut s) = Effect::default_drop_shadow() else { unreachable!() }; + s.spread = 0.5; + let shadow = program(&Effect::DropShadow(s), &light, false); + assert!(shadow.fields.iter().any(|(k, _)| *k == FieldKind::StrokeOutside), "the shadow spread keeps the chamfer: {:?}", shadow.fields); + } }