* 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:
parent
1bcb662828
commit
99e1138877
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue