mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 11:16:53 -04:00
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:
1 parent
e26442657f
commit
1080008ad9
2 files changed
+48
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user