Stroke layer effect: the outer edge stays round at wide sizes (#3646) (#3650)

* 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.

* fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646)

1080008a fixed the CPU compositor's stroke outer edge to use the exact
Euclidean distance instead of the 5x5 chamfer, whose level sets are
polygons and facet a wide stroke. The GPU compositor builds the outer
stroke field from FieldKind::StrokeOutside, which still maps to the
chamfer, and the canvas is drawn on the GPU, so the fix did not reach the
app.

Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by
the stroke. The spread of drop shadows and softer outer glows keeps the
chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its
meaning. StrokeOutsideVector is exact too, matching the CPU vector outline
path.

Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field
(stroke uses the exact field, shadow spread keeps the chamfer), and the
banded/whole field test covers the new kind.

* fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646)

The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but
regressed Photoshop's own composites against the PSD flatten oracle: the
fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large
outside width do its octagon facets show. Pick the metric by the stroke's
outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and
its sub-pixel edge seeding, at or above it use the exact Euclidean transform.

The measured chamfer deviation from a point source is 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 the boundary visibly facets. Half a pixel is reached
at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer.

The CPU and GPU compositors pick the same field by the same width
(`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a
narrow shape-layer outline, while the shadow / glow spread keeps its chamfer.

io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back
to main's counts with no file newly differing; a 60 px outside stroke stays
round (`a_wide_outside_stroke_is_round_not_a_polygon`).
This commit is contained in:
Scott C Hughes 2026-10-13 12:24:53 -05:00 committed by GitHub
parent 1bcb662828
commit 99e1138877
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 197 additions and 28 deletions

View File

@ -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<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,
}
/// 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<f32> {
if m == Metric::Chamfer {
// Seeds start at their sub-pixel edge offset (1 − coverage).
@ -1657,9 +1675,14 @@ pub struct FxMaps {
per: Vec<Vec<Map>>,
bevel_paint: Vec<BevelPaint>,
din: Option<Vec<f32>>,
/// Stroke outside distance below [`STROKE_CHAMFER_MAX`]: the fitted 5 × 5 chamfer.
dout: Option<Vec<f32>>,
/// 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<Vec<f32>>,
/// Shape layers: chamfer distance outside the vector outline (from local coverage).
vdout: Option<Vec<f32>>,
/// Shape layers: exact Euclidean distance outside the vector outline (from local coverage).
vdout_exact: Option<Vec<f32>>,
/// `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<f32> {
@ -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<f32> =
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<Vec<f32>>| 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<f32>, 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),
}
}

View File

@ -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

View File

@ -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);
}
}