photocraft/crates/compose/src/effects.rs

3116 lines
136 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! CPU reference renderer for layer effects (layer styles).
//!
//! Render order, bottom to top (Photoshop's layer-style stack read upwards):
//! drop shadows, outer glows, outer bevel, then the layer itself (content at
//! fill opacity) with pattern/gradient/colour overlays, satin, inner glows,
//! inner shadows, strokes and the inner bevel on top. Exterior effects blend
//! straight into the backdrop with their own modes; the layer and its
//! interior effects blend with the layer's mode. Emboss and pillow emboss then
//! shade the composited result (inside and outside halves). Layer opacity
//! applies to the whole stack; fill opacity only to the layer's own pixels.
//!
//! Interior effects are painted relative to the layer's shape and then take the shape's alpha
//! (an overlay recolours a half-transparent edge without adding coverage). The outside parts
//! 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).
use photocraft_color::blend::BlendMode;
use photocraft_doc::Pattern;
use photocraft_doc::{
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Layer, Shadow,
StrokePosition,
};
use photocraft_geom::Rect;
use crate::pattern::{PREPARED_PATTERN_BYTES, Placement, PreparedPatterns};
use crate::{Buffer, psblend};
/// `true` if the layer has at least one enabled effect and the master switch is on.
pub fn has_effects(layer: &Layer) -> bool {
layer.effects.enabled && layer.effects.items.iter().any(Effect::enabled)
}
/// Pixels an effect stack can reach beyond the layer's shape.
pub fn margin(layer: &Layer) -> i32 {
let mut m = 0.0f32;
for e in layer.effects.items.iter().filter(|e| e.enabled()) {
let r = match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
Effect::Stroke(s) => s.size,
Effect::Satin(s) => satin_distance(s) + s.size,
Effect::BevelEmboss(b) => b.size + b.soften,
_ => 0.0,
};
if r.is_finite() {
m = m.max(r);
}
}
// Bounded so malformed values can't request huge buffers; effects that
// reach further are clipped at this distance.
m.clamp(0.0, MAX_REACH).ceil() as i32 + 2
}
/// Maximum distance (px) an effect is rendered from the layer's shape.
pub const MAX_REACH: f32 = 512.0;
/// Whether every enabled effect's maps are local: each map pixel depends only on the shape
/// within [`margin`] of it (shifts, chamfer distances compared with a size, tent blurs), so
/// maps built over a part of the layer's region padded by twice the margin equal the
/// whole-region maps over that part (#1909). Not local: gradient strokes (laid out over the whole
/// shape's extent), Precise glows and chiselled bevels (exact Euclidean distances, whose f32
/// arithmetic depends on where the region starts) and noise (it speckles any coverage above
/// zero, so it would pick up the blur's float noise, which depends on where the region starts).
pub(crate) fn maps_are_local(layer: &Layer) -> bool {
layer.effects.items.iter().filter(|e| e.enabled()).all(|e| match e {
Effect::Stroke(s) => !matches!(s.paint, FxPaint::Gradient(_)),
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.noise <= 0.0,
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.technique != GlowTechnique::Precise && g.noise <= 0.0,
Effect::BevelEmboss(b) => b.technique == BevelTechnique::Smooth,
_ => true,
})
}
/// A single-channel map over a rectangle.
#[derive(Clone)]
struct Map {
w: usize,
h: usize,
v: Vec<f32>,
}
impl Map {
fn new(w: usize, h: usize, fill: f32) -> Self {
Map { w, h, v: vec![fill; w * h] }
}
fn get(&self, x: i64, y: i64) -> f32 {
if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { 0.0 } else { self.v[y as usize * self.w + x as usize] }
}
/// Shifted copy (bilinear), outside reads `outside`.
fn shifted(&self, dx: f32, dy: f32, outside: f32) -> Map {
let mut out = Map::new(self.w, self.h, 0.0);
let (fx, fy) = (dx.floor(), dy.floor());
let (ax, ay) = (dx - fx, dy - fy);
let s = |x: i64, y: i64| if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 { outside } else { self.v[y as usize * self.w + x as usize] };
for y in 0..self.h as i64 {
for x in 0..self.w as i64 {
let (sx, sy) = (x - fx as i64, y - fy as i64);
let v00 = s(sx, sy);
let v10 = s(sx - 1, sy);
let v01 = s(sx, sy - 1);
let v11 = s(sx - 1, sy - 1);
let top = v00 * (1.0 - ax) + v10 * ax;
let bot = v01 * (1.0 - ax) + v11 * ax;
out.v[y as usize * self.w + x as usize] = top * (1.0 - ay) + bot * ay;
}
}
out
}
fn map(mut self, f: impl Fn(f32) -> f32) -> Self {
for v in &mut self.v {
*v = f(*v);
}
self
}
}
/// 1D squared distance transform (Felzenszwalb & Huttenlocher).
fn dt1(f: &[f32], out: &mut [f32], v: &mut [usize], z: &mut [f32], near: &mut [usize]) {
let n = f.len();
let mut k = 0usize;
v[0] = 0;
z[0] = f32::NEG_INFINITY;
z[1] = f32::INFINITY;
for q in 1..n {
loop {
let p = v[k];
let s = ((f[q] + (q * q) as f32) - (f[p] + (p * p) as f32)) / (2.0 * q as f32 - 2.0 * p as f32);
if s <= z[k] && k > 0 {
k -= 1;
continue;
}
if s <= z[k] {
// k == 0 and parabola dominates: replace.
v[0] = q;
z[0] = f32::NEG_INFINITY;
z[1] = f32::INFINITY;
break;
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f32::INFINITY;
break;
}
}
k = 0;
for (q, o) in out.iter_mut().enumerate() {
while z[k + 1] < q as f32 {
k += 1;
}
let p = v[k];
let d = q as f32 - p as f32;
*o = d * d + f[p];
near[q] = p;
}
}
/// Exact Euclidean distance from every pixel to the nearest pixel where
/// `inside` is true (0 on those pixels).
#[cfg(test)]
fn edt(inside: &[bool], w: usize, h: usize) -> Vec<f32> {
edt_nearest(inside, w, h).0
}
/// Maps with fewer pixels (or other units of work) than this are not worth spawning threads for.
const PAR_MIN: usize = 1 << 16;
/// Most threads one pass uses (as for the blurs, [`rows_par`]).
const PAR_THREADS: usize = 16;
#[cfg(test)]
thread_local! {
/// Test hook: the thread count [`par_threads`] gives on this thread, whatever the work.
static THREADS: std::cell::Cell<Option<usize>> = const { std::cell::Cell::new(None) };
}
/// How many threads a pass over `work` pixels (or similar units) uses.
fn par_threads(work: usize) -> usize {
#[cfg(test)]
if let Some(n) = THREADS.with(std::cell::Cell::get) {
return n;
}
if cfg!(target_arch = "wasm32") || work < PAR_MIN { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, PAR_THREADS) }
}
/// Runs `f(scratch, u, a_u, b_u)` for every unit `u` of two slices: unit `u` of `a` is
/// `a[u * unit_a..][..unit_a]` (the last may be shorter), the same of `b` with `unit_b`, which is 0
/// when there is no second slice. `work` says how big the whole pass is ([`par_threads`]). Groups of
/// units go to scoped OS threads, each with a scratch value from `init`; not to rayon, as for
/// [`rows_par`]: maps are built from inside rayon tiles, where a waiting worker steals other tiles.
/// A unit is computed from its own arguments alone, so the result does not depend on the threads.
fn par_units<S, A: Send, B: Send>(
work: usize,
a: &mut [A],
unit_a: usize,
b: &mut [B],
unit_b: usize,
init: impl Fn() -> S + Sync,
f: impl Fn(&mut S, usize, &mut [A], &mut [B]) + Sync,
) {
if unit_a == 0 {
return;
}
let units = a.len().div_ceil(unit_a);
let threads = par_threads(work).min(units);
if threads <= 1 {
run_units(&mut init(), 0, a, unit_a, b, unit_b, &f);
return;
}
// Many more groups than threads, so that a heavy one (an edge-rich stretch) does not hold the rest up.
let group = units.div_ceil(threads * 8).max(1);
let mut groups = Vec::new();
let (mut rest_a, mut rest_b, mut first) = (a, b, 0usize);
while !rest_a.is_empty() {
let (ga, na) = rest_a.split_at_mut((group * unit_a).min(rest_a.len()));
let (gb, nb) = rest_b.split_at_mut((group * unit_b).min(rest_b.len()));
groups.push((first, ga, gb));
(rest_a, rest_b, first) = (na, nb, first + group);
}
let queue = std::sync::Mutex::new(groups.into_iter());
let worker = || {
let mut scratch = init();
loop {
let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next();
let Some((first, ga, gb)) = next else { break };
run_units(&mut scratch, first, ga, unit_a, gb, unit_b, &f);
}
};
std::thread::scope(|sc| {
// A thread that cannot be spawned leaves its share to the others.
for _ in 1..threads {
let _ = std::thread::Builder::new().spawn_scoped(sc, worker);
}
worker();
});
}
fn run_units<S, A, B>(scratch: &mut S, first: usize, a: &mut [A], unit_a: usize, b: &mut [B], unit_b: usize, f: &impl Fn(&mut S, usize, &mut [A], &mut [B])) {
for (k, ua) in a.chunks_mut(unit_a).enumerate() {
let ub = b.get_mut(k * unit_b..((k + 1) * unit_b).min(b.len())).unwrap_or(&mut []);
f(scratch, first + k, ua, ub);
}
}
/// A `w × h` buffer of `fill`, then filled row by row through `f(y, row)`.
fn par_fill<T: Clone + Send>(w: usize, h: usize, fill: T, f: impl Fn(usize, &mut [T]) + Sync) -> Vec<T> {
let mut out = vec![fill; w.saturating_mul(h)];
par_units(out.len(), &mut out, w.max(1), &mut [] as &mut [()], 0, || (), |_, y, row, _| f(y, row));
out
}
/// Working buffers of the 1D distance transform, for lines of up to `n` pixels.
struct Dt1Scratch {
f: Vec<f32>,
o: Vec<f32>,
v: Vec<usize>,
z: Vec<f32>,
near: Vec<usize>,
}
impl Dt1Scratch {
fn new(n: usize) -> Self {
Dt1Scratch { f: vec![0.0; n], o: vec![0.0; n], v: vec![0; n], z: vec![0.0; n + 1], near: vec![0; n] }
}
}
/// [`edt`] plus the index of the nearest `inside` pixel (as `u32`: a map past 2³² pixels has no
/// nearest pixel to give, and every distance is then "far").
///
/// Both passes run line by line in parallel: the columns, then the rows. The columns' results are
/// kept column by column (`gc[x * h + y]`) so that each thread writes its own stretch of them.
fn edt_nearest(inside: &[bool], w: usize, h: usize) -> (Vec<f32>, Vec<u32>) {
const INF: f32 = 1e20;
let total = w.saturating_mul(h);
if total != inside.len() || total > u32::MAX as usize {
return (vec![INF; inside.len()], vec![0; inside.len()]);
}
let n = w.max(h);
let mut gc = vec![0.0f32; total];
let mut nc = vec![0u32; total];
par_units(
total,
&mut gc,
h.max(1),
&mut nc,
h.max(1),
|| Dt1Scratch::new(n),
|t, x, g, near| {
for (y, f) in t.f[..h].iter_mut().enumerate() {
*f = if inside[y * w + x] { 0.0 } else { INF };
}
dt1(&t.f[..h], &mut t.o[..h], &mut t.v, &mut t.z, &mut t.near[..h]);
g.copy_from_slice(&t.o[..h]);
for (d, &p) in near.iter_mut().zip(&t.near[..h]) {
*d = p as u32;
}
},
);
let mut g = vec![0.0f32; total];
let mut nearest = vec![0u32; total];
par_units(
total,
&mut g,
w.max(1),
&mut nearest,
w.max(1),
|| Dt1Scratch::new(n),
|t, y, out, near| {
for (x, f) in t.f[..w].iter_mut().enumerate() {
*f = gc[x * h + y];
}
dt1(&t.f[..w], &mut t.o[..w], &mut t.v, &mut t.z, &mut t.near[..w]);
for (x, (d, ne)) in out.iter_mut().zip(near.iter_mut()).enumerate() {
*d = t.o[x].sqrt();
let px = t.near[x];
*ne = (nc[px * h + y] as usize * w + px) as u32;
}
},
);
(g, nearest)
}
/// Chamfer distance transform with a 5 × 5 mask of exact Euclidean step lengths (1, √2, √5),
/// plus the index of the nearest `inside` pixel (0 on inside pixels). Photoshop's effect
/// distances follow this metric: its stroke corners measure 3.236 = √5 + 1 at offset (1, 3)
/// and 3.650 = √5 + √2 at (2, 3), where the exact distances are 3.162 and 3.606.
#[cfg(test)]
fn chamfer_nearest(inside: &[bool], w: usize, h: usize) -> (Vec<f32>, Vec<usize>) {
chamfer_from(inside.iter().map(|&b| if b { 0.0 } else { CHAMFER_INF }).collect(), w, h)
}
const CHAMFER_INF: f32 = 1e20;
/// [`chamfer_nearest`] from per-pixel start values (`CHAMFER_INF` = not a seed): the result is
/// the minimum over seeds of start value + path length, so partly covered seeds can start at
/// their sub-pixel edge offset.
fn chamfer_from(mut d: Vec<f32>, w: usize, h: usize) -> (Vec<f32>, Vec<usize>) {
const S2: f32 = std::f32::consts::SQRT_2;
const S5: f32 = 2.236_068;
// Forward-pass neighbours (already visited in raster order); the backward pass mirrors them.
const FWD: [(i64, i64, f32); 8] = [(-1, 0, 1.0), (-1, -1, S2), (0, -1, 1.0), (1, -1, S2), (-1, -2, S5), (1, -2, S5), (-2, -1, S5), (2, -1, S5)];
let mut near: Vec<usize> = (0..w * h).collect();
let (wi, hi) = (w as i64, h as i64);
let relax = |x: i64, y: i64, sign: i64, d: &mut Vec<f32>, near: &mut Vec<usize>| {
let i = (y * wi + x) as usize;
let mut best = d[i];
let mut bn = near[i];
for &(ox, oy, wt) in &FWD {
let (nx, ny) = (x + ox * sign, y + oy * sign);
if nx < 0 || ny < 0 || nx >= wi || ny >= hi {
continue;
}
let j = (ny * wi + nx) as usize;
let c = d[j] + wt;
if c < best {
best = c;
bn = near[j];
}
}
d[i] = best;
near[i] = bn;
};
for y in 0..hi {
for x in 0..wi {
relax(x, y, 1, &mut d, &mut near);
}
}
for y in (0..hi).rev() {
for x in (0..wi).rev() {
relax(x, y, -1, &mut d, &mut near);
}
}
(d, near)
}
/// Distance from each pixel centre outside the shape to the shape's edge.
/// Pixels with non-zero alpha are inside; the edge inside the nearest such
/// pixel is placed by its alpha (coverage), as Photoshop does. Inside pixels
/// get ≤ 0.
fn dist_outside(s: &Map) -> Vec<f32> {
dist_outside_by(s, Metric::Euclidean)
}
/// Distance metric for effect distance fields.
#[derive(Clone, Copy, PartialEq)]
enum Metric {
/// Exact Euclidean (precise glows, bevels).
Euclidean,
/// 5 × 5 chamfer (strokes, spread / choke; see [`chamfer_nearest`]).
Chamfer,
}
fn dist_outside_by(s: &Map, m: Metric) -> Vec<f32> {
if m == Metric::Chamfer {
// Seeds start at their sub-pixel edge offset (1 − coverage).
let start = s.v.iter().map(|&a| if a > INSIDE_EPS { 1.0 - a.min(1.0) } else { CHAMFER_INF }).collect();
let (d, _) = chamfer_from(start, s.w, s.h);
return d.iter().zip(&s.v).map(|(d, &a)| if a > INSIDE_EPS { -0.5 } else { d - 0.5 }).collect();
}
let inside = covered(s, |a| a > INSIDE_EPS);
let (d, near) = edt_nearest(&inside, s.w, s.h);
par_fill(s.w, s.h, 0.0f32, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
let i = y * s.w + x;
*out = if inside[i] { -0.5 } else { d[i] - 0.5 + (1.0 - s.v[near[i] as usize].min(1.0)) };
}
})
}
/// Which pixels of `s` satisfy `test`, row by row in parallel.
fn covered(s: &Map, test: impl Fn(f32) -> bool + Sync) -> Vec<bool> {
par_fill(s.w, s.h, false, |y, row| {
for (e, &a) in row.iter_mut().zip(s.v.get(y * s.w..).unwrap_or_default()) {
*e = test(a);
}
})
}
/// Distance from each pixel centre inside the shape to the shape's edge
/// (≤ 0 outside).
fn dist_inside(s: &Map) -> Vec<f32> {
dist_inside_by(s, Metric::Euclidean)
}
fn dist_inside_by(s: &Map, m: Metric) -> Vec<f32> {
if m == Metric::Chamfer {
// Partly covered edge pixels (alpha below their neighbourhood's) are seeds starting at
// their coverage, mirroring `dist_outside`: a 25 % edge column puts the edge 0.25 px in.
let cov = local_coverage(s);
let start =
s.v.iter()
.zip(&cov.v)
.map(|(&a, &c)| {
if a <= INSIDE_EPS {
0.0
} else if c < 1.0 - INSIDE_EPS {
c
} else {
CHAMFER_INF
}
})
.collect();
let (d, _) = chamfer_from(start, s.w, s.h);
return d.iter().zip(&s.v).map(|(d, &a)| if a <= INSIDE_EPS { -0.5 } else { d - 0.5 }).collect();
}
let outside = covered(s, |a| a <= INSIDE_EPS);
let (d, _) = edt_nearest(&outside, s.w, s.h);
par_fill(s.w, s.h, 0.0f32, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
let i = y * s.w + x;
*out = if outside[i] { -0.5 } else { d[i] - 0.5 };
}
})
}
/// Alpha relative to the 3×3 neighbourhood maximum: separates edge coverage
/// from the fill's own opacity on shape layers.
fn local_coverage(s: &Map) -> Map {
let mut out = s.clone();
for y in 0..s.h as i64 {
for x in 0..s.w as i64 {
let a = s.get(x, y);
if a <= INSIDE_EPS {
continue;
}
let mut mx = a;
for dy in -1..=1 {
for dx in -1..=1 {
mx = mx.max(s.get(x + dx, y + dy));
}
}
out.v[y as usize * s.w + x as usize] = (a / mx).min(1.0);
}
}
out
}
/// Signed distance from the centre of a pixel of coverage `a` to the straight edge crossing it,
/// positive when the centre is inside the shape. `n` is the edge's unit normal (its signs don't
/// matter).
///
/// A unit pixel cut by a straight edge at distance `r` from its centre (on the side that holds at
/// most half of the pixel) is covered `1/2 - r/u` while the edge crosses two opposite sides, and
/// `((u + v)/2 - r)² / (2uv)` once it only cuts off a corner, with `u ≥ v` the larger and smaller
/// component of the normal. Inverting that places the edge by its coverage at any angle. For an
/// axis-aligned edge (`v = 0`) it is the `a - 1/2` that [`dist_outside`] assumes.
fn edge_offset(n: [f32; 2], a: f32) -> f32 {
if !a.is_finite() {
return 0.0;
}
let (u, v) = (n[0].abs().max(n[1].abs()), n[0].abs().min(n[1].abs()));
let (c, inside) = if a > 0.5 { (1.0 - a, true) } else { (a, false) };
let c = c.clamp(0.0, 0.5);
// The coverage below which the edge only cuts off a corner.
let corner = if u > 0.0 { v / (2.0 * u) } else { 0.0 };
let r = if c >= corner { (0.5 - c) * u } else { 0.5 * (u + v) - (2.0 * u * v * c).sqrt() };
if inside { r } else { -r }
}
/// A sharp, anti-aliased edge pixel: where it puts the straight edge crossing it.
#[derive(Clone, Copy)]
struct SharpEdge {
/// Signed distance from the pixel's centre to the edge, positive inside ([`edge_offset`]).
offset: f32,
/// The edge's unit normal pointing into the shape (`[0, 0]` when the coverage gives no direction).
normal: [f32; 2],
}
/// Half the spacing of the edge pixels along an edge (at most √2 / 2, along a diagonal): a pixel
/// within it of the foot of the perpendicular sees the edge as a line, one further out as its end.
const EDGE_REACH: f32 = 0.75;
impl SharpEdge {
/// Distance to the edge from the pixel `(dx, dy)` away from this one, positive on the shape's
/// side (`inside`) or on the empty side. It is the distance to the edge's line where the
/// perpendicular lands within [`EDGE_REACH`] of this pixel, and to the end of that stretch
/// beyond, so the distance to a straight edge is exact and does not follow the pixel staircase.
/// `None` when the edge has no direction or `(dx, dy)` is on the wrong side of it.
fn distance(&self, dx: f32, dy: f32, inside: bool) -> Option<f32> {
let [nx, ny] = self.normal;
if nx == 0.0 && ny == 0.0 {
return None;
}
let signed = self.offset + dx * nx + dy * ny;
let across = if inside { signed } else { -signed };
let along = (dx * -ny + dy * nx).abs();
(across > 0.0).then(|| across.hypot((along - EDGE_REACH).max(0.0)))
}
}
/// A sharp edge pixel and where it sits.
#[derive(Clone, Copy)]
struct EdgePixel {
/// Index of the pixel in the map.
at: u32,
/// The pixel's coverage ([`local_coverage`] of it).
cov: f32,
edge: SharpEdge,
}
/// The sharp edge pixels of a map: a short list, and for every pixel a 4 byte slot (0 = no edge, k =
/// the k-th of the list). Maps are big and edge pixels few, so a per-pixel `Option<SharpEdge>` (16
/// bytes) made every pass over it, and the copies the refinement needs, memory-bound.
struct SharpEdges {
w: usize,
h: usize,
slot: Vec<u32>,
pixels: Vec<EdgePixel>,
}
impl SharpEdges {
fn empty(w: usize, h: usize) -> Self {
SharpEdges { w, h, slot: Vec::new(), pixels: Vec::new() }
}
/// The edge of pixel `i`, if it is a sharp edge pixel.
fn get(&self, i: usize) -> Option<SharpEdge> {
let k = (*self.slot.get(i)? as usize).checked_sub(1)?;
self.pixels.get(k).map(|p| p.edge)
}
/// The edge of pixel `(x, y)`, if it is a sharp edge pixel (`None` off the map).
fn at(&self, x: usize, y: usize) -> Option<SharpEdge> {
if x >= self.w || y >= self.h { None } else { self.get(y * self.w + x) }
}
fn has(&self, x: usize, y: usize) -> bool {
self.at(x, y).is_some()
}
fn is_empty(&self) -> bool {
self.pixels.is_empty()
}
/// Every sharp edge pixel, as (index in the map, its edge), in pixel order.
#[cfg(test)]
fn iter(&self) -> impl Iterator<Item = (usize, SharpEdge)> + '_ {
self.pixels.iter().map(|p| (p.at as usize, p.edge))
}
}
/// Where the sharp, anti-aliased edge pixels of `s` put the edge ([`edge_offset`], along the normal
/// the coverage gradient gives). Sharp pixels are partly covered and touch both an empty and a full
/// pixel; hard edges and soft ramps have none and keep the `alpha - 1/2` of [`dist_outside`].
///
/// An edge pixel is covered and has an empty neighbour, so a pass over which pixels are covered finds
/// the few candidates without computing [`local_coverage`] of the whole map; the coverage is then
/// worked out where it is needed, by the same formula. Rows are independent and go in parallel.
fn sharp_edges(s: &Map) -> SharpEdges {
let (w, h) = (s.w, s.h);
if w == 0 || h == 0 || s.v.len() != w * h || s.v.len() >= u32::MAX as usize {
return SharpEdges::empty(w, h);
}
let (wi, hi) = (w as i64, h as i64);
// `local_coverage` of the pixel at (x, y), on the map.
let cov_at = |x: i64, y: i64| -> f32 {
let a = s.v[y as usize * w + x as usize];
if a <= INSIDE_EPS {
return a;
}
let mut mx = a;
for dy in -1..=1 {
for dx in -1..=1 {
mx = mx.max(s.get(x + dx, y + dy));
}
}
(a / mx).min(1.0)
};
// The coverage with the map's border repeated, for the gradient.
let cov_clamped = |x: i64, y: i64| cov_at(x.max(0).min(wi - 1), y.max(0).min(hi - 1));
// Whether a pixel is not empty. Off the map counts as covered: only a neighbour on the map can be empty.
let solid = |x: i64, y: i64| -> bool {
if x < 0 || y < 0 || x >= wi || y >= hi {
return true;
}
let a = s.v[y as usize * w + x as usize];
a.is_nan() || a > INSIDE_EPS
};
let mut rows: Vec<Vec<EdgePixel>> = vec![Vec::new(); h];
par_units(
w * h,
&mut rows,
1,
&mut [] as &mut [()],
0,
|| (),
|_, y, row, _| {
let Some(row) = row.first_mut() else { return };
let y = y as i64;
for x in 0..wi {
if !solid(x, y) || (-1..=1).all(|dy| (-1..=1).all(|dx| solid(x + dx, y + dy))) {
continue;
}
let i = (y * wi + x) as usize;
let c = cov_at(x, y);
if s.v[i] <= INSIDE_EPS || c >= 1.0 - INSIDE_EPS {
continue;
}
let (mut empty, mut full) = (false, false);
for (dx, dy) in (-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))) {
let (nx, ny) = (x + dx, y + dy);
if (dx, dy) == (0, 0) || nx < 0 || ny < 0 || nx >= wi || ny >= hi {
continue;
}
empty |= s.get(nx, ny) <= INSIDE_EPS;
full |= cov_at(nx, ny) >= 1.0 - INSIDE_EPS;
}
if !(empty && full) {
continue;
}
let at = &cov_clamped;
let gx = (at(x + 1, y - 1) + 2.0 * at(x + 1, y) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x - 1, y) + at(x - 1, y + 1));
let gy = (at(x - 1, y + 1) + 2.0 * at(x, y + 1) + at(x + 1, y + 1)) - (at(x - 1, y - 1) + 2.0 * at(x, y - 1) + at(x + 1, y - 1));
let len = gx.hypot(gy);
let (normal, n) = if len.is_finite() && len > 1e-6 { ([gx / len, gy / len], [gx / len, gy / len]) } else { ([0.0, 0.0], [1.0, 0.0]) };
row.push(EdgePixel { at: i as u32, cov: c, edge: SharpEdge { offset: edge_offset(n, c), normal } });
}
},
);
let mut pixels: Vec<EdgePixel> = Vec::with_capacity(rows.iter().map(Vec::len).sum());
for row in rows {
pixels.extend(row);
}
let mut slot = vec![0u32; w * h];
for (k, p) in pixels.iter().enumerate() {
if let Some(s) = slot.get_mut(p.at as usize) {
*s = k as u32 + 1;
}
}
refine_edge_normals(&mut pixels, &slot, w, h);
SharpEdges { w, h, slot, pixels }
}
/// How far (in pixels) around an edge pixel [`refine_edge_normals`] looks for its neighbours.
const NORMAL_RADIUS: i64 = 3;
/// The 3×3 gradient of the coverage gives an edge's direction to 1-2 degrees, and its error is
/// systematic, so it tilts the distance of every pixel the same way and shows as a faint ripple.
/// The edge points of a straight edge's pixels (each pixel's centre moved to its edge) lie on one
/// line, and the line through the points of the pixels around one gives the direction to a fraction
/// of a degree. Where those points are not on a line (corners, tight curves) the gradient's normal
/// stays.
fn refine_edge_normals(pixels: &mut [EdgePixel], slot: &[u32], w: usize, h: usize) {
let (wi, hi) = (w as i64, h as i64);
let points: Vec<[f32; 2]> = pixels
.iter()
.map(|p| [(p.at as i64 % wi) as f32 - p.edge.offset * p.edge.normal[0], (p.at as i64 / wi) as f32 - p.edge.offset * p.edge.normal[1]])
.collect();
let before: Vec<SharpEdge> = pixels.iter().map(|p| p.edge).collect();
// Every pixel reads the unrefined edges around it and writes its own, so they go in parallel.
let work = pixels.len().saturating_mul(32);
par_units(work, pixels, 1, &mut [] as &mut [()], 0, Vec::<[f32; 2]>::new, |near, k, pixel, _| {
let (Some(pixel), Some(&e)) = (pixel.first_mut(), before.get(k)) else { return };
let (x, y) = (pixel.at as i64 % wi, pixel.at as i64 / wi);
let (mut n, mut sum) = (0.0f32, [0.0f32; 2]);
near.clear();
for (dx, dy) in (-NORMAL_RADIUS..=NORMAL_RADIUS).flat_map(|dy| (-NORMAL_RADIUS..=NORMAL_RADIUS).map(move |dx| (dx, dy))) {
let (nx, ny) = (x + dx, y + dy);
if dx * dx + dy * dy > NORMAL_RADIUS * NORMAL_RADIUS || nx < 0 || ny < 0 || nx >= wi || ny >= hi {
continue;
}
let Some(j) = slot.get((ny * wi + nx) as usize).and_then(|&s| (s as usize).checked_sub(1)) else { continue };
let (Some(o), Some(&p)) = (before.get(j), points.get(j)) else { continue };
if o.normal[0] * e.normal[0] + o.normal[1] * e.normal[1] > 0.9 {
near.push(p);
sum = [sum[0] + p[0], sum[1] + p[1]];
n += 1.0;
}
}
if near.len() < 3 {
return;
}
let mean = [sum[0] / n, sum[1] / n];
let (mut sxx, mut sxy, mut syy) = (0.0f32, 0.0f32, 0.0f32);
for p in near.iter() {
let (dx, dy) = (p[0] - mean[0], p[1] - mean[1]);
sxx += dx * dx;
sxy += dx * dy;
syy += dy * dy;
}
// Spread along and across the best line through the points.
let half = 0.5 * (sxx + syy);
let root = (0.25 * (sxx - syy) * (sxx - syy) + sxy * sxy).sqrt();
let (along, across) = (((half + root) / n).max(0.0).sqrt(), ((half - root) / n).max(0.0).sqrt());
if !(along > EDGE_LINE_MIN_SPREAD && across < EDGE_LINE_MAX_OFF) {
return;
}
let angle = 0.5 * (2.0 * sxy).atan2(sxx - syy);
let mut normal = [-angle.sin(), angle.cos()];
if normal[0] * e.normal[0] + normal[1] * e.normal[1] < 0.0 {
normal = [-normal[0], -normal[1]];
}
pixel.edge.normal = normal;
pixel.edge.offset = edge_offset(normal, pixel.cov);
});
}
/// The edge points around a pixel must spread at least this far (px, standard deviation) along
/// their line to give it a direction, and stay within [`EDGE_LINE_MAX_OFF`] of it.
const EDGE_LINE_MIN_SPREAD: f32 = 0.7;
const EDGE_LINE_MAX_OFF: f32 = 0.1;
/// How far from the foot of the perpendicular (in pixels) [`edge_distance`] looks for edge pixels.
const FOOT_RADIUS: i64 = 2;
/// The distance from pixel `p` to the sharp edge whose pixel `q` is nearest to it, or `None` when
/// `q` gives no usable distance. The nearest pixel by its centre is not the best one: it can sit a
/// few pixels along the edge from the foot of the perpendicular and deeper than the edge, and the
/// further `p` is from the edge the further, so its distance ripples along the edge. So this takes
/// the smallest distance of the edge pixels around that foot, which is exact for a straight edge.
fn edge_distance(sharp: &SharpEdges, p: [usize; 2], q: [usize; 2], inside: bool) -> Option<f32> {
let (px, py) = (p[0] as f32, p[1] as f32);
let e = sharp.at(q[0], q[1])?;
let mut best = e.distance(px - q[0] as f32, py - q[1] as f32, inside)?;
// Where p's perpendicular meets q's line.
let signed = e.offset + (px - q[0] as f32) * e.normal[0] + (py - q[1] as f32) * e.normal[1];
let (fx, fy) = ((px - signed * e.normal[0]).round() as i64, (py - signed * e.normal[1]).round() as i64);
for sy in (fy - FOOT_RADIUS).max(0)..=(fy + FOOT_RADIUS).min(sharp.h as i64 - 1) {
for sx in (fx - FOOT_RADIUS).max(0)..=(fx + FOOT_RADIUS).min(sharp.w as i64 - 1) {
if let Some(near) = sharp.at(sx as usize, sy as usize)
&& let Some(d) = near.distance(px - sx as f32, py - sy as f32, inside)
{
best = best.min(d);
}
}
}
Some(best)
}
/// [`dist_inside`] for chiselled bevels. A sharp edge pixel puts the edge where its coverage says,
/// at any angle, and the pixels near it measure to that edge ([`edge_distance`]), so the distance
/// ramps stay planar along an oblique edge instead of following the pixel staircase. The plain
/// distance (to the nearest empty pixel, less half a pixel) never falls short of the true one, so
/// it caps the result where an edge lies exactly on a pixel boundary and has no sharp pixel; a mask
/// without sharp pixels gives exactly [`dist_inside`]. Values past `reach` pixels are not refined.
fn bevel_dist_inside(s: &Map, sharp: &SharpEdges, reach: f32) -> Vec<f32> {
let empty = covered(s, |a| a <= INSIDE_EPS);
let (d, near) = edt_nearest(&empty, s.w, s.h);
let at = |x: usize, y: usize| -> f32 {
let i = y * s.w + x;
if empty[i] {
return -0.5;
}
if let Some(e) = sharp.get(i) {
return e.offset;
}
let plain = d[i] - 0.5;
if d[i] > reach + EDGE_MARGIN || sharp.is_empty() {
return plain;
}
// The sharp pixel next to the nearest empty one that is nearest to this pixel.
let (px, py) = (x as i64, y as i64);
let (ox, oy) = ((near[i] as usize % s.w) as i64, (near[i] as usize / s.w) as i64);
let q = (-1..=1)
.flat_map(|dy| (-1..=1).map(move |dx| (ox + dx, oy + dy)))
.filter(|&(x, y)| x >= 0 && y >= 0 && sharp.has(x as usize, y as usize))
.min_by_key(|&(x, y)| (x - px) * (x - px) + (y - py) * (y - py));
match q.and_then(|(x, y)| edge_distance(sharp, [px as usize, py as usize], [x as usize, y as usize], true)) {
Some(refined) => refined.min(plain),
None => plain,
}
};
par_fill(s.w, s.h, 0.0f32, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
*out = at(x, y);
}
})
}
/// [`dist_outside`] for chiselled bevels, with sharp edge pixels placed as in [`bevel_dist_inside`].
fn bevel_dist_outside(s: &Map, sharp: &SharpEdges, reach: f32) -> Vec<f32> {
let inside = covered(s, |a| a > INSIDE_EPS);
let (d, near) = edt_nearest(&inside, s.w, s.h);
let at = |x: usize, y: usize| -> f32 {
let i = y * s.w + x;
if let Some(e) = sharp.get(i) {
return -e.offset;
}
if inside[i] {
return -0.5;
}
let q = near[i] as usize;
let Some(e) = sharp.get(q) else { return d[i] - (s.v[q].min(1.0) - 0.5) };
let (p, qp) = ([x, y], [q % s.w, q / s.w]);
let measured = if d[i] <= reach + EDGE_MARGIN {
edge_distance(sharp, p, qp, false)
} else {
e.distance(p[0] as f32 - qp[0] as f32, p[1] as f32 - qp[1] as f32, false)
};
measured.unwrap_or(d[i] - e.offset)
};
par_fill(s.w, s.h, 0.0f32, |y, row| {
for (x, out) in row.iter_mut().enumerate() {
*out = at(x, y);
}
})
}
/// Pixels past the reach that are still refined, so the value is right where the ramp ends.
const EDGE_MARGIN: f32 = 2.0;
/// Alpha above which a pixel belongs to the layer's shape (half an 8-bit step).
const INSIDE_EPS: f32 = 0.5 / 255.0;
/// The soft falloff of an effect of `size` pixels: two box passes of width `size` (a tent),
/// as Photoshop does. Fitted on psd-tools layer_effects.psd against the earlier Gaussian
/// (sigma = 0.4 × size): drop shadow coverage error 0.0064 → 0.0038, satin 0.014 → 0.002.
fn blur(m: &mut Map, size: f32) {
if size.is_finite() {
tent(m, size);
}
}
/// Grows the shape by `r` pixels (anti-aliased), keeping the original soft edge. Distances use
/// the 5 × 5 chamfer metric, as for strokes: Photoshop's spread of a group drop shadow around an
/// ellipse (ag-psd group-drop-shadows) is round at 0° and 45° and falls short in between, peaking
/// near 15° where the chamfer metric overestimates most (5/255 lighter with an exact Euclidean
/// dilation, within 2/255 with the chamfer).
fn dilate(s: &Map, r: f32) -> Map {
if r <= 0.0 {
return s.clone();
}
let d = dist_outside_by(s, Metric::Chamfer);
let mut out = s.clone();
for (o, d) in out.v.iter_mut().zip(d) {
// Partly covered pixels (distance sentinel < 0) grow by `r` from their own coverage, so a
// 2 % spread doesn't make every anti-aliased edge pixel opaque (ag-psd drop shadow).
*o = if d < 0.0 { (*o + r).min(1.0) } else { o.max((r + 0.5 - d).clamp(0.0, 1.0)) };
}
out
}
fn contour_lut(c: &Contour) -> Option<Vec<f32>> {
match c {
Contour::Linear => None,
Contour::Custom { points, corners, .. } => Some(crate::adjust::contour_curve_lut(points, corners)),
}
}
/// Satin's coverage transfer: the contour read from its far end, `|c(1 − v) − c(1)|`. Measured in
/// Photoshop 27.11: Cone and Cone − Inverted Satins are identical, and a Gaussian Satin runs under
/// the diagonal (0.36 at 0.5) while the same Gaussian on a glow or gloss runs over it (0.65).
/// `None` = identity.
pub fn satin_lut(c: &Contour) -> Option<Vec<f32>> {
let lut = contour_lut(c)?;
let end = lut.last().copied().unwrap_or(1.0);
Some(lut.iter().rev().map(|v| (v - end).abs()).collect())
}
/// Interpolated lookup in a 0..=1 table (as the GPU's `lut()`).
fn lut_at(l: &[f32], v: f32) -> f32 {
let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32;
let i = x.floor() as usize;
let j = (i + 1).min(l.len() - 1);
l[i] + (l[j] - l[i]) * (x - i as f32)
}
/// A glow's coverage transfer: its contour applied over the Range (Photoshop maps the glow's
/// coverage `v` through `contour(min(v / range, 1))`, so the default 50 % doubles a blurred
/// edge's 0.5 to full strength). `None` = identity.
pub fn glow_lut(g: &Glow) -> Option<Vec<f32>> {
ranged_lut(&g.contour, g.range)
}
/// A contour applied over a Range (`contour(min(v / range, 1))`); `None` = identity.
pub fn ranged_lut(contour: &Contour, range: f32) -> Option<Vec<f32>> {
let base = contour_lut(contour);
let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
if range >= 0.999 {
return base;
}
let n = 4096;
Some(
(0..n)
.map(|k| {
let v = (k as f32 / (n - 1) as f32 / range).min(1.0);
base.as_ref().map_or(v, |l| lut_at(l, v))
})
.collect(),
)
}
/// The bevel Contour element's height transfer: the contour alone. Unlike a glow's, its Range is
/// not a `v / range` stretch: that flattened a Linear contour at the default 50 % to a bevel half
/// as wide as Photoshop's (a real 18 px smooth inner bevel at 136 % depth: top-edge highlight gone
/// 15 px in, Photoshop's still 0.19 at 16 px), and dropping it lowers the bad pixels of every
/// contour oracle (bevel-inner-contour-ramp-peak 25.9 → 21.8 %, -double-ring-aa-r70 25.2 → 21.0 %,
/// bevel-pillow-chisel-hard-gloss-and-contour 11.4 → 7.2 %). What Range does instead is still open.
pub fn bevel_contour_lut(c: &photocraft_doc::BevelContour) -> Vec<f32> {
contour_lut(&c.contour).unwrap_or_else(|| (0..4096).map(|k| k as f32 / 4095.0).collect())
}
fn apply_lut(m: Map, l: Option<Vec<f32>>) -> Map {
match l {
None => m,
Some(l) => m.map(|v| lut_at(&l, v)),
}
}
/// Deterministic per-pixel value in 0..=1, hashed from document coordinates
/// (the same hash as the GPU's `dissolve_noise`), so the speckle is stable
/// across renders, frames and tiles.
pub fn hash_noise(x: i64, y: i64) -> f32 {
let mut h = (x as i32 as u32).wrapping_mul(0x8da6_b343) ^ (y as i32 as u32).wrapping_mul(0xd816_3841) ^ 0x9e37_79b9;
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
(h & 0xffff) as f32 / 65536.0
}
/// Effect noise (Photoshop's Noise slider): monochrome speckle in the coverage,
/// applied after the contour. `amount` is 0..=1; 0 leaves the map untouched.
fn noise(m: &mut Map, amount: f32, x0: i32, y0: i32) {
if !amount.is_finite() || amount <= 0.0 {
return;
}
for y in 0..m.h {
for x in 0..m.w {
let v = &mut m.v[y * m.w + x];
if *v <= 0.0 {
continue;
}
let n = hash_noise(i64::from(x0 + x as i32), i64::from(y0 + y as i32));
*v = (*v + (n - 0.5) * 2.0 * amount).clamp(0.0, 1.0);
}
}
}
fn apply_contour(m: Map, c: &Contour) -> Map {
match contour_lut(c) {
None => m,
Some(l) => m.map(|v| {
let x = v.clamp(0.0, 1.0) * (l.len() - 1) as f32;
let i = x.floor() as usize;
let j = (i + 1).min(l.len() - 1);
l[i] + (l[j] - l[i]) * (x - i as f32)
}),
}
}
fn rgb(c: &photocraft_color::Color) -> [f32; 3] {
c.to_rgb()
}
/// Offset of an effect lit from `angle` at `distance` (shadow falls away from
/// the light), rounded to whole pixels as Photoshop does.
fn offset(angle: f32, distance: f32) -> (f32, f32) {
let a = angle.to_radians();
((-a.cos() * distance).round(), (a.sin() * distance).round())
}
/// The lengths a 100 % gradient at `angle` spans in a `w` × `h` frame: the chord (Linear and
/// Reflected) and the ellipse-norm length (Radial, Angle, Diamond; its half is the radius).
pub fn gradient_units(angle: f32, w: f32, h: f32) -> (f32, f32) {
let (s, c) = angle.to_radians().sin_cos();
// Gradient length: the bounds' extent along the angle as an ellipse
// norm (a unit gradient scaled to the bounds); fitted on psd-tools
// gradient-styles.psd (cached Photoshop renderings of every style).
let len = ((c * w).powi(2) + (s * h).powi(2)).sqrt().max(1.0);
// Linear / Reflected span the chord of the bounds through their centre along the angle:
// min(w / |cos|, h / |sin|). A 45° gradient on a 29 px square runs 41 px (psd-tools
// shape-fx2), an 87° one on a 600 × 60 text line 60 px (layer_effects); axis-aligned angles
// span the width / height.
let chord = (w / c.abs().max(1e-6)).min(h / s.abs().max(1e-6)).max(1.0);
(chord, len)
}
/// Gradient parameter `t` in `0..=1` for pixel centre `(x, y)` inside `bounds`.
#[allow(clippy::too_many_arguments)]
pub fn gradient_t(style: GradientStyle, angle: f32, scale: f32, reverse: bool, offset: (f32, f32), bounds: Rect, x: f32, y: f32) -> f32 {
let w = bounds.width().max(1) as f32;
let h = bounds.height().max(1) as f32;
let cx = bounds.x0 as f32 + w / 2.0 + offset.0 * w;
let cy = bounds.y0 as f32 + h / 2.0 + offset.1 * h;
let (s, c) = angle.to_radians().sin_cos();
let (dx, dy) = (x - cx, y - cy);
// Distance along the gradient direction (y axis points down).
let along = dx * c - dy * s;
let across = dx * s + dy * c;
let (chord, len) = gradient_units(angle, w, h);
let (chord, len) = (chord * scale.max(1e-3), len * scale.max(1e-3));
// Linear / Reflected sample the pixel's top-left corner, half a pixel before its centre
// (with the whole-pixel end points of `fill_layout`: layer_effects' overlay 5.8 → 1.8/255,
// shape-fx2 2.8 → 1.1/255, gradient-fill.psd 1.9 → 0.5/255).
let corner = 0.5 * (c - s);
let mut t = match style {
GradientStyle::Linear => (along - corner) / chord + 0.5,
GradientStyle::Reflected => ((along - corner) / (chord / 2.0)).abs(),
GradientStyle::Radial => (dx * dx + dy * dy).sqrt() / (len / 2.0),
GradientStyle::Diamond => (along.abs() + across.abs()) / (len / 2.0),
// Clockwise sweep starting at the gradient angle.
GradientStyle::Angle => ((angle.to_radians() - (-dy).atan2(dx)) / std::f32::consts::TAU).rem_euclid(1.0),
};
t = t.clamp(0.0, 1.0);
if reverse { 1.0 - t } else { t }
}
/// Samples colour and opacity stops at `t`.
pub fn sample_gradient(g: &Gradient, t: f32) -> [f32; 4] {
PreparedGradient::new(g).sample(t)
}
// Prepared only for one paint call: CMYK conversions must use that call's active profile.
struct PreparedGradient {
color: Vec<(f32, [f32; 3])>,
opacity: Vec<(f32, [f32; 3])>,
}
impl PreparedGradient {
fn new(g: &Gradient) -> Self {
Self { color: g.stops.iter().map(|(p, c)| (*p, rgb(c))).collect(), opacity: g.opacity_stops.iter().map(|(p, a)| (*p, [*a; 3])).collect() }
}
fn sample(&self, t: f32) -> [f32; 4] {
let color = sample_stops(&self.color, t);
let alpha = if self.opacity.is_empty() { 1.0 } else { sample_stops(&self.opacity, t)[0] };
[color[0], color[1], color[2], alpha]
}
}
fn sample_stops(stops: &[(f32, [f32; 3])], t: f32) -> [f32; 3] {
match stops {
[] => [t; 3],
[s] => s.1,
_ => {
if t <= stops[0].0 {
return stops[0].1;
}
for w in stops.windows(2) {
if t <= w[1].0 {
let k = if w[1].0 > w[0].0 { (t - w[0].0) / (w[1].0 - w[0].0) } else { 0.0 };
return std::array::from_fn(|i| w[0].1[i] + (w[1].1[i] - w[0].1[i]) * k);
}
}
stops[stops.len() - 1].1
}
}
}
/// Composite `color × coverage` into `dst` (both over the same rectangle).
fn paint(dst: &mut Buffer, m: &Map, color: impl Fn(usize) -> [f32; 4], blend: BlendMode, opacity: f32) {
for (i, p) in dst.px.iter_mut().enumerate() {
let a = m.v[i] * opacity;
if a <= 0.0 {
continue;
}
let c = color(i);
*p = psblend::composite(blend, *p, [c[0], c[1], c[2], c[3] * a], 1.0);
}
}
fn paint_color(dst: &mut Buffer, m: &Map, c: [f32; 3], blend: BlendMode, opacity: f32) {
paint(dst, m, |_| [c[0], c[1], c[2], 1.0], blend, opacity);
}
/// Spread / choke of a soft effect: (dilation radius, blur width). The dilation is whole pixels
/// (`size × spread` rounded: a 2 % spread of a 5 px shadow doesn't grow it at all), the blur
/// keeps `size × (1 − spread)` (ag-psd effects 10.6 → 3.8 % of pixels off).
pub fn spread_split(size: f32, spread: f32) -> (f32, f32) {
((size * spread).round(), size * (1.0 - spread))
}
fn shadow_map(shape: &Map, s: &Shadow, light: &GlobalLight, inner: bool, origin: (i32, i32)) -> Map {
let angle = if s.use_global_light { light.angle } else { s.angle };
let (dx, dy) = offset(angle, s.distance);
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
let mut m = src.shifted(dx, dy, if inner { 1.0 } else { 0.0 });
let (r, bw) = spread_split(s.size, s.spread);
m = dilate(&m, r);
blur(&mut m, bw);
let mut m = apply_contour(m, &s.contour);
noise(&mut m, s.noise, origin.0, origin.1);
if inner {
for (v, a) in m.v.iter_mut().zip(&shape.v) {
*v *= a;
}
}
m
}
fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -> Map {
// An inner glow is built from the edge inwards; a centre-source glow is its inverse, taken
// after the contour and Range (Photoshop 27.11, measured: the centre glow is exactly
// 1 − the edge glow, for both techniques).
let src = if inner { shape.clone().map(|a| 1.0 - a) } else { shape.clone() };
let mut m = match g.technique {
GlowTechnique::Precise => {
// Coverage falls linearly from 0.5 at the edge to 0 at `size + 1` px (centre to
// centre), solid within the spread / choke: after the default 50 % Range that is
// Photoshop's 1 − D / (size + 1) beyond the solid part (measured: outer and inner,
// sizes 10 and 20, spread 0 and 40 %, choke 0 and 30 %: within 1 level; other Range
// values are unmeasured). The distances here are 0.5 px short of centre to centre.
let d = if inner { dist_inside(shape) } else { dist_outside(&src) };
let solid = g.size * g.spread;
let soft = (g.size + 1.0 - solid).max(1e-3);
let mut m = Map::new(shape.w, shape.h, 0.0);
for (o, d) in m.v.iter_mut().zip(d) {
let d = d + 0.5;
*o = if d <= solid { 1.0 } else { 0.5 * (1.0 - (d - solid) / soft).clamp(0.0, 1.0) };
}
m
}
GlowTechnique::Softer => {
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
m
}
};
m = apply_lut(m, glow_lut(g));
if inner && g.source == GlowSource::Center {
m = m.map(|v| 1.0 - v);
}
noise(&mut m, g.noise, origin.0, origin.1);
if inner {
for (v, a) in m.v.iter_mut().zip(&shape.v) {
*v *= a;
}
}
m
}
/// Paints a pattern (looked up in `patterns`) through coverage `m`; missing patterns paint nothing.
#[allow(clippy::too_many_arguments)]
fn paint_pattern(
dst: &mut Buffer,
m: &Map,
patterns: &PreparedPatterns<'_>,
name: &str,
id: &str,
place: Placement,
big: Rect,
blend: BlendMode,
opacity: f32,
) {
let Some(tile) = patterns.get(id, name) else { return };
let w = big.width() as usize;
paint(
dst,
m,
|i| {
let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5);
let (u, v) = place.map(x, y);
tile.sample(u, v)
},
blend,
opacity,
);
}
#[allow(clippy::too_many_arguments)]
fn paint_fx(
dst: &mut Buffer,
m: &Map,
p: &FxPaint,
shape_bounds: Rect,
anchor: (f64, f64),
big: Rect,
blend: BlendMode,
opacity: f32,
patterns: &PreparedPatterns<'_>,
) {
match p {
FxPaint::Color(c) => paint_color(dst, m, rgb(c), blend, opacity),
FxPaint::Gradient(g) => {
let prepared = PreparedGradient::new(g);
let (angle, scale, offset) = crate::fill_layout::gradient_layout(g.style, g.angle, g.scale, g.offset, shape_bounds);
let w = big.width() as usize;
paint(
dst,
m,
|i| {
let (x, y) = ((big.x0 + (i % w) as i32) as f32 + 0.5, (big.y0 + (i / w) as i32) as f32 + 0.5);
prepared.sample(gradient_t(g.style, angle, scale, g.reverse, offset, shape_bounds, x, y))
},
blend,
opacity,
)
}
FxPaint::Pattern { name, id, scale } => {
paint_pattern(dst, m, patterns, name, id, Placement::anchored(anchor, true, (0.0, 0.0), *scale, 0.0), big, blend, opacity)
}
}
}
/// The opacity gain of a gradient glow: it is opaque from strength `range²` on (see [`paint_glow`]).
pub fn glow_gradient_gain(range: f32) -> f32 {
let r = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
1.0 / (r * r)
}
/// A glow's paint over its map `m`. A gradient runs along the glow rather than across the
/// canvas: Photoshop colours the glow where its (ranged, contoured) strength is `v` with the
/// gradient at `1 - v`, so the first stop hugs the edge, and the glow is opaque from `v = range²`
/// on (photoshop corpus outer-glow-gradient.psd: 1.4/255 mean, against bands across the canvas;
/// psd-tools layer_params.psd). Glows have no gradient angle, style or Reverse.
#[allow(clippy::too_many_arguments)]
fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect, anchor: (f64, f64), big: Rect, patterns: &PreparedPatterns<'_>) {
let FxPaint::Gradient(gradient) = &g.paint else {
return paint_fx(dst, m, &g.paint, shape_bounds, anchor, big, g.common.blend, g.common.opacity, patterns);
};
let prepared = PreparedGradient::new(gradient);
let gain = glow_gradient_gain(g.range);
let strength = Map { w: m.w, h: m.h, v: m.v.iter().map(|v| (v * gain).min(1.0)).collect() };
paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity);
}
/// A box width as the blurs use it: at least 1, at most [`MAX_REACH`] (an effect never reaches
/// further, see [`margin`]), and 1 for NaN. A size or softness from a command or a file can be
/// anything, and an unbounded width sizes the kernel and its buffers (#1543).
fn box_width(w: f32) -> f32 {
if w.is_nan() { 1.0 } else { w.clamp(1.0, MAX_REACH) }
}
/// Normalised weights of a centred box of (fractional) width `w`: tap `i` gets the overlap of
/// `[i - 0.5, i + 0.5]` with `[-w/2, w/2]`.
fn box_weights(w: f32) -> Vec<f32> {
let w = box_width(w);
let half = w / 2.0;
let r = (half - 0.5).ceil().max(0.0) as i64;
let v: Vec<f32> = (-r..=r).map(|i| ((i as f32 + 0.5).min(half) - (i as f32 - 0.5).max(-half)).max(0.0)).collect();
let sum: f32 = v.iter().sum();
v.iter().map(|x| x / sum).collect()
}
/// The 1D kernel of two box passes of width `w` (a tent of half-width ≈ `w`): `(radius, weights)`.
/// Photoshop's bevel height maps are this blur of the shape (fitted on the corpus: a smooth
/// inner bevel of size 5 matches within 0.7 % mean, size 41 within 2.6 %).
pub fn tent_kernel(w: f32) -> (i32, Vec<f32>) {
let b = box_weights(w);
let n = b.len() * 2 - 1;
let mut t = vec![0.0f32; n];
for (i, x) in b.iter().enumerate() {
for (j, y) in b.iter().enumerate() {
t[i + j] += x * y;
}
}
((n as i32 - 1) / 2, t)
}
/// Box geometry for a (fractional) width: (`r`, end-tap weight `f`, 1 / width) — the
/// [`box_weights`] taps are `r - 1` full ones each side of the centre plus the two end taps at `f`.
fn box_geom(bw: f32) -> (i64, f64, f64) {
let bw = box_width(bw);
let half = bw / 2.0;
let r = (half - 0.5).ceil().max(0.0) as i64;
let f = f64::from((half - (r as f32 - 0.5)).clamp(0.0, 1.0));
(r, f, 1.0 / f64::from(bw))
}
/// One box pass over `src` (zero outside it) evaluated at `x0 .. x0 + dst.len()`, as a running
/// sum (O(1) per sample whatever the width).
fn box_line(src: &[f64], x0: i64, dst: &mut [f64], bw: f32) {
let n = src.len() as i64;
let (r, f, norm) = box_geom(bw);
let at = |i: i64| if i >= 0 && i < n { src[i as usize] } else { 0.0 };
if r == 0 {
for (k, d) in dst.iter_mut().enumerate() {
*d = at(x0 + k as i64);
}
return;
}
let mut inner: f64 = (x0 - (r - 1)..=x0 + (r - 1)).map(at).sum();
for (k, d) in dst.iter_mut().enumerate() {
let x = x0 + k as i64;
*d = (inner + f * (at(x - r) + at(x + r))) * norm;
inner += at(x + r) - at(x - r + 1);
}
}
/// Two box passes along a line, exactly the convolution with [`tent_kernel`] (zero outside): the
/// first pass is evaluated `r` samples beyond each end so the second sees what lies there.
fn tent_line(src: &[f32], dst: &mut [f32], bw: f32) {
let (r, _, _) = box_geom(bw);
let s: Vec<f64> = src.iter().map(|v| f64::from(*v)).collect();
let mut mid = vec![0.0f64; src.len() + 2 * r as usize];
box_line(&s, -r, &mut mid, bw);
let mut out = vec![0.0f64; src.len()];
// `mid[k]` holds position k - r: output position p reads index p + r.
box_line(&mid, r, &mut out, bw);
for (d, v) in dst.iter_mut().zip(out) {
*d = v as f32;
}
}
/// Rows of a `w`-wide row-major buffer, each through `f(src_row, dst_row)`, in parallel on
/// scoped OS threads. Not rayon: maps can be built from inside a rayon tile, where a waiting
/// worker steals other tiles. Map builds no longer block on each other (#276), but the blur
/// keeps out of the tile pool.
fn rows_par(src: &[f32], dst: &mut [f32], w: usize, f: impl Fn(&[f32], &mut [f32]) + Sync) {
if w == 0 {
return;
}
let threads =
if cfg!(target_arch = "wasm32") || src.len() < 1 << 16 { 1 } else { std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16) };
if threads == 1 {
dst.chunks_mut(w).zip(src.chunks(w)).for_each(|(d, s)| f(s, d));
return;
}
let rows = (src.len() / w).div_ceil(threads);
std::thread::scope(|sc| {
for (d, s) in dst.chunks_mut(rows * w).zip(src.chunks(rows * w)) {
let f = &f;
sc.spawn(move || d.chunks_mut(w).zip(s.chunks(w)).for_each(|(d, s)| f(s, d)));
}
});
}
fn transpose_map(v: &[f32], w: usize, h: usize) -> Vec<f32> {
let mut out = vec![0.0f32; v.len()];
const B: usize = 32;
for by in (0..h).step_by(B) {
for bx in (0..w).step_by(B) {
for y in by..(by + B).min(h) {
for x in bx..(bx + B).min(w) {
out[x * h + y] = v[y * w + x];
}
}
}
}
out
}
/// Two box passes of width `w` along each axis (the tent [`tent_kernel`] describes, which the GPU
/// convolves directly).
fn tent(m: &mut Map, w: f32) {
if tent_kernel(w).0 == 0 || m.v.is_empty() {
return;
}
tent_fast(m, w);
}
/// [`tent`] as a direct convolution with [`tent_kernel`] (the GPU's way; tests compare).
#[cfg(test)]
fn tent_direct(m: &mut Map, w: f32) {
{
let (r, k) = tent_kernel(w);
let (mw, mh) = (m.w as i64, m.h as i64);
let mut tmp = vec![0.0f32; m.v.len()];
for y in 0..mh {
for x in 0..mw {
let mut acc = 0.0;
for (i, kv) in k.iter().enumerate() {
let xx = x + i as i64 - r as i64;
if xx >= 0 && xx < mw {
acc += m.v[(y * mw + xx) as usize] * kv;
}
}
tmp[(y * mw + x) as usize] = acc;
}
}
for y in 0..mh {
for x in 0..mw {
let mut acc = 0.0;
for (i, kv) in k.iter().enumerate() {
let yy = y + i as i64 - r as i64;
if yy >= 0 && yy < mh {
acc += tmp[(yy * mw + x) as usize] * kv;
}
}
m.v[(y * mw + x) as usize] = acc;
}
}
}
}
fn tent_fast(m: &mut Map, w: f32) {
let (mw, mh) = (m.w, m.h);
let twice = |s: &[f32], d: &mut [f32]| tent_line(s, d, w);
let mut a = vec![0.0f32; m.v.len()];
rows_par(&m.v, &mut a, mw, twice);
let t = transpose_map(&a, mw, mh);
let mut b = vec![0.0f32; t.len()];
rows_par(&t, &mut b, mh, twice);
m.v = transpose_map(&b, mh, mw);
}
/// Where a bevel's maps paint: inside the shape (with the layer), outside it (onto the
/// backdrop), or both (emboss styles: maps 0–1 inside, 2–3 outside).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BevelPaint {
Inner,
Outer,
Both,
}
/// Height below which an outer bevel pixel counts as off the bevel (beyond the blur's reach):
/// above float noise of the running-sum blur (~3e-7), far below anything that shades.
pub const BEVEL_H_EPS: f32 = 1e-5;
/// Bevel geometry shared by the CPU maps and the GPU programs.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BevelGeom {
pub paint: BevelPaint,
/// Box width of the smooth height blur (size; half the size, rounded up, for emboss styles).
pub width: f32,
/// Height-map gradient scale: depth × width (smooth) or depth × size (chisel), signed by
/// direction.
pub depth: f32,
/// Pillow emboss: the outside slopes face the other way.
pub pillow: bool,
/// Chisel Soft: extra tent width over the chiselled height map.
pub chisel_soft: f32,
}
pub fn bevel_geom(b: &Bevel) -> BevelGeom {
let size = b.size.max(1.0);
let emboss = matches!(b.style, BevelStyle::Emboss | BevelStyle::PillowEmboss);
let paint = match b.style {
BevelStyle::OuterBevel => BevelPaint::Outer,
BevelStyle::Emboss | BevelStyle::PillowEmboss => BevelPaint::Both,
BevelStyle::InnerBevel | BevelStyle::StrokeEmboss => BevelPaint::Inner,
};
// Emboss styles straddle the edge with half the size, rounded up to whole pixels: a 41 px
// emboss blurs over 21 (psd-tools layer_effects: 20.5 left narrow letter parts up to
// 5.7/255 dark, 21 is within 2.2).
let width = if emboss { (size / 2.0).ceil() } else { size };
let smooth = b.technique == BevelTechnique::Smooth;
let sign = if b.up { 1.0 } else { -1.0 };
BevelGeom {
paint,
width,
// A bevel rises `depth × width / 2` px: Smooth with a slope falling from `depth` at the
// edge (the tent), a chisel with the constant slope of its ramp (see `bevel_chisel_h`).
depth: b.depth * if smooth { width } else { size / 2.0 } * sign,
pillow: b.style == BevelStyle::PillowEmboss,
chisel_soft: if b.technique == BevelTechnique::ChiselSoft { (size / 4.0).max(1.0) } else { 0.0 },
}
}
/// Bevel height map in `0..=1`. Smooth: the shape blurred by two box passes of the bevel
/// width. Chisel: linear ramps of the distance to the edge (size wide; emboss styles straddle the
/// edge), slightly blurred for Chisel Soft. The distance puts the edge by the anti-aliased
/// coverage of its pixels ([`bevel_dist_inside`]), so a facet is planar at any angle. Soften blurs
/// the result.
fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, patterns: &PreparedPatterns<'_>) -> Map {
let size = b.size.max(1.0);
let mut h = if b.technique == BevelTechnique::Smooth {
let mut h = shape.clone();
tent(&mut h, g.width);
h
} else {
let sharp = sharp_edges(shape);
let (din, dout) = (bevel_dist_inside(shape, &sharp, size), bevel_dist_outside(shape, &sharp, size));
let mut h = Map::new(shape.w, shape.h, 0.0);
let soft = g.chisel_soft > 0.0;
for i in 0..h.v.len() {
h.v[i] = bevel_chisel_h(g.paint, size, din[i], dout[i], soft);
}
// Inner and outer Chisel Soft are not blurred in Photoshop (their facets equal Chisel
// Hard's across a straight edge); emboss styles keep the blur until measured.
if soft && g.paint == BevelPaint::Both {
tent(&mut h, g.chisel_soft);
}
h
};
// Contour element: the height profile through the contour.
if let Some(c) = &b.contour {
h = apply_lut(h, Some(bevel_contour_lut(c)));
}
// Texture element: the pattern's luminance as extra height, scaled so a full-contrast step
// at 100 % depth slopes like the bevel at its depth.
if let Some(t) = &b.texture
&& let Some(tile) = patterns.get(&t.id, &t.name)
{
let unit = if b.depth.abs() > 1e-6 { (g.depth / b.depth).abs().max(1e-3) } else { g.width.max(1.0) };
let place = Placement::anchored(tex.anchor, t.link, t.phase, t.scale, 0.0);
let k = t.depth / unit;
let (x0, y0) = (tex.rect.x0, tex.rect.y0);
for y in 0..h.h {
for x in 0..h.w {
let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5);
let p = tile.sample(u, v);
let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3];
h.v[y * h.w + x] += k * if t.invert { 1.0 - l } else { l };
}
}
}
if b.soften >= 1.0 {
tent(&mut h, b.soften);
}
h
}
/// Where a bevel texture tiles: the maps' rectangle, the document's patterns and the linked
/// origin (the effects reference point, else the layer's top-left).
pub struct TextureCtx<'a> {
pub rect: Rect,
pub patterns: &'a [Pattern],
pub anchor: (f64, f64),
}
/// Chiselled height from the inside / outside distances (keep in sync with `fs_mbevelh`).
///
/// Photoshop 27.11, measured across straight edges (sizes 10 and 20, depth 100 and 300): a chisel
/// facet has the constant slope `depth × size / (2 (size + 1))`, i.e. one height step of
/// `1 / (size + 1)` per pixel at the `depth × size / 2` scale of [`bevel_geom`]. Inner Hard's ramp
/// runs from half a pixel beyond the edge to `size + ½` (the facet is flat right up to the edge
/// pixel); Inner Soft starts a pixel further out, so its edge pixel is steeper; Outer Hard ends a
/// pixel further out. All within 1 level of Photoshop on those profiles.
pub fn bevel_chisel_h(paint: BevelPaint, size: f32, din: f32, dout: f32, soft: bool) -> f32 {
let ramp = size + 1.0;
match paint {
BevelPaint::Outer if !soft => {
let top = (size + 2.0) / ramp;
if dout > 0.0 { ((size + 1.5 - dout) / ramp).clamp(0.0, top) } else { top }
}
// Outer Soft: Outer Hard's ramp a pixel shorter, with a steeper edge pixel.
BevelPaint::Outer => {
if dout > 0.0 {
1.0 - ((dout + 0.5) / ramp).clamp(0.0, 1.0)
} else {
(size + 2.0) / ramp
}
}
BevelPaint::Both => {
let half = size / 2.0 + 0.5;
if din > 0.0 { 0.5 + 0.5 * (din / half).clamp(0.0, 1.0) } else { 0.5 - 0.5 * (dout / half).clamp(0.0, 1.0) }
}
BevelPaint::Inner if soft => {
if din > 0.0 {
((din + 1.5) / ramp).clamp(0.0, (size + 2.0) / ramp)
} else {
0.0
}
}
BevelPaint::Inner => {
if din > 0.0 {
((din + 0.5) / ramp).clamp(0.0, 1.0)
} else {
0.0
}
}
}
}
/// Bevel highlight and shadow maps: `[hi, sh]` (inner or outer) or `[hi, sh, hi_out, sh_out]`
/// (emboss styles).
fn bevel_maps(shape: &Map, b: &Bevel, light: &GlobalLight, tex: &TextureCtx, patterns: &PreparedPatterns<'_>) -> (Vec<Map>, BevelPaint) {
let g = bevel_geom(b);
let hmap = bevel_height(shape, b, &g, tex, patterns);
let (angle, altitude) = if b.use_global_light { (light.angle, light.altitude) } else { (b.angle, b.altitude) };
let (sa, ca) = angle.to_radians().sin_cos();
let (se, ce) = altitude.to_radians().sin_cos();
let light_v = [ca * ce, -sa * ce, se];
// Region: 0 = inside (× the shape's alpha), 1 = under the layer's edge and outside.
// A pixel is on the bevel where its height is raised. Emboss styles also shade the last pixel
// past the blur's reach (a raised 4-neighbour: it still slopes; psd-tools layer_effects'
// emboss shadow runs one row past it), outer bevels don't (Photoshop oracle
// bevel-outer-smooth).
// Outer chisel bevels too: the pixel where their ramp reaches zero still slopes (Photoshop
// 27.11 shades it, half strength, at `size + 1½` px out).
let reach_past = g.paint == BevelPaint::Both || (g.paint == BevelPaint::Outer && b.technique != BevelTechnique::Smooth);
let on_bevel = |x: i64, y: i64| {
hmap.get(x, y) > BEVEL_H_EPS || (reach_past && [(1, 0), (-1, 0), (0, 1), (0, -1)].iter().any(|(dx, dy)| hmap.get(x + dx, y + dy) > BEVEL_H_EPS))
};
let shade_into = |depth: f32, region_kind: u8| -> (Map, Map) {
let (mut hi, mut sh) = (Map::new(shape.w, shape.h, 0.0), Map::new(shape.w, shape.h, 0.0));
for y in 0..shape.h as i64 {
for x in 0..shape.w as i64 {
let i = y as usize * shape.w + x as usize;
let gx = (hmap.get(x + 1, y) - hmap.get(x - 1, y)) * 0.5 * depth;
let gy = (hmap.get(x, y + 1) - hmap.get(x, y - 1)) * 0.5 * depth;
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let shade = (n[0] * light_v[0] + n[1] * light_v[1] + n[2] * light_v[2]) / len;
let region = if region_kind == 0 { shape.v[i] } else { f32::from(shape.v[i] < 1.0 - INSIDE_EPS && on_bevel(x, y)) };
let k = shade - se;
if k > 0.0 {
hi.v[i] = (k / (1.0 - se).max(1e-3)).clamp(0.0, 1.0) * region;
} else {
sh.v[i] = (-k / se.max(1e-3)).clamp(0.0, 1.0) * region;
}
}
}
(apply_contour(hi, &b.gloss_contour), apply_contour(sh, &b.gloss_contour))
};
let maps = match g.paint {
BevelPaint::Inner | BevelPaint::Outer => {
let (hi, sh) = shade_into(g.depth, u8::from(g.paint == BevelPaint::Outer));
vec![hi, sh]
}
BevelPaint::Both => {
// One map per colour over the whole bevel: the inside half where the shape is (× its
// alpha), the outside half (pillow: facing the other way) for the rest of each pixel.
// A 1 % edge pixel of a pillow emboss takes the outside shading (Photoshop oracle
// bevel-pillow-smooth), a 75 % one of an emboss mostly the inside (layer_effects).
let (hi, sh) = shade_into(g.depth, 0);
let (ho, so) = shade_into(if g.pillow { -g.depth } else { g.depth }, 1);
let mix = |a: Map, b: Map| -> Map {
let v = a.v.iter().zip(&b.v).zip(&shape.v).map(|((x, y), s)| if *s > INSIDE_EPS { *x } else { 0.0 } + y * (1.0 - s.min(1.0))).collect();
Map { w: a.w, h: a.h, v }
};
vec![mix(hi, ho), mix(sh, so)]
}
};
(maps, g.paint)
}
/// Effect maps derived from a layer's shape (its alpha), computed once over the layer's whole
/// region and cropped per render tile. They depend only on the shape, the effect settings and the
/// global light, never on the backdrop, so they can be cached across tiles and edits.
pub struct FxMaps {
rect: Rect,
shape: Map,
/// Per enabled effect (in `items` order): the maps it paints through.
per: Vec<Vec<Map>>,
bevel_paint: Vec<BevelPaint>,
din: Option<Vec<f32>>,
dout: Option<Vec<f32>>,
/// Shape layers: distance outside the vector outline (from local coverage).
vdout: 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`].
frames: Vec<(u32, Rect)>,
}
impl FxMaps {
/// The frame of a gradient stroke `st` of this layer (see [`stroke_frame`]), if built.
pub fn stroke_frame(&self, st: &photocraft_doc::StrokeFx) -> Option<Rect> {
self.frames.iter().find(|f| f.0 == stroke_widths(st).1.to_bits()).map(|f| f.1)
}
}
impl FxMaps {
/// Approximate heap size (for cache budgeting).
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
}
fn crop_vec(&self, v: &[f32], to: Rect, fill: f32) -> Vec<f32> {
let (w, h) = (to.width() as usize, to.height() as usize);
let mut out = vec![fill; w * h];
let r = self.rect;
let inter = r.intersect(&to);
if inter.is_empty() {
return out;
}
let sw = r.width() as usize;
for y in inter.y0..inter.y1 {
let s0 = (y - r.y0) as usize * sw + (inter.x0 - r.x0) as usize;
let d0 = (y - to.y0) as usize * w + (inter.x0 - to.x0) as usize;
let n = inter.width() as usize;
out[d0..d0 + n].copy_from_slice(&v[s0..s0 + n]);
}
out
}
fn crop(&self, m: &Map, to: Rect, fill: f32) -> Map {
Map { w: to.width() as usize, h: to.height() as usize, v: self.crop_vec(&m.v, to, fill) }
}
}
/// The distance the two satin copies are offset by. Photoshop draws Distance 0 as 1 px (an
/// edge band of 2/size, measured in Photoshop 27.11), not as two copies cancelling out.
pub fn satin_distance(s: &photocraft_doc::effects::Satin) -> f32 {
s.distance.max(1.0)
}
fn satin_map(shape: &Map, s: &photocraft_doc::effects::Satin) -> Map {
let (dx, dy) = offset(s.angle, satin_distance(s));
let mut a = shape.shifted(dx, dy, 0.0);
let mut b = shape.shifted(-dx, -dy, 0.0);
// Two box passes of the satin size (fitted: 0.2 % mean error on psd-tools layer_effects).
tent(&mut a, s.size);
tent(&mut b, s.size);
let mut m = Map { w: shape.w, h: shape.h, v: a.v.iter().zip(&b.v).map(|(x, y)| (x - y).abs()).collect() };
if s.invert {
m = m.map(|v| 1.0 - v);
}
let mut m = apply_lut(m, satin_lut(&s.contour));
for (v, a) in m.v.iter_mut().zip(&shape.v) {
*v *= a;
}
m
}
/// Build every effect map for `layer` from its alpha `shape` over `rect`.
pub fn build_maps(layer: &Layer, shape: Vec<f32>, rect: Rect, light: &GlobalLight, tex: &TextureCtx) -> FxMaps {
let prepared = PreparedPatterns::new(tex.patterns, PREPARED_PATTERN_BYTES);
build_maps_prepared(layer, shape, rect, light, tex, &prepared)
}
pub(crate) fn build_maps_prepared(
layer: &Layer,
shape: Vec<f32>,
rect: Rect,
light: &GlobalLight,
tex: &TextureCtx,
patterns: &PreparedPatterns<'_>,
) -> FxMaps {
let (w, h) = (rect.width() as usize, rect.height() as usize);
let shape = Map { w, h, v: shape };
let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
let mut bevel_paint = vec![BevelPaint::Inner; items.len()];
let per: Vec<Vec<Map>> = items
.iter()
.enumerate()
.map(|(i, e)| match e {
Effect::DropShadow(s) => vec![shadow_map(&shape, s, light, false, (rect.x0, rect.y0))],
Effect::InnerShadow(s) => vec![shadow_map(&shape, s, light, true, (rect.x0, rect.y0))],
Effect::OuterGlow(g) => vec![glow_map(&shape, g, false, (rect.x0, rect.y0))],
Effect::InnerGlow(g) => vec![glow_map(&shape, g, true, (rect.x0, rect.y0))],
Effect::Satin(s) => vec![satin_map(&shape, s)],
Effect::BevelEmboss(b) => {
let (maps, paint) = bevel_maps(&shape, b, light, tex, patterns);
bevel_paint[i] = paint;
maps
}
_ => Vec::new(),
})
.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.
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 mut frames: Vec<(u32, Rect)> = Vec::new();
for e in &items {
if let Effect::Stroke(st) = e
&& matches!(st.paint, FxPaint::Gradient(_))
{
let out_w = stroke_widths(st).1;
if !frames.iter().any(|f| f.0 == out_w.to_bits()) {
frames.push((out_w.to_bits(), stroke_frame(&shape, out_w, rect)));
}
}
}
FxMaps { rect, shape, per, bevel_paint, din, dout, vdout, outline, frames }
}
/// Far outside any shape (distance fill for cropped distance fields).
const FAR: f32 = 1.0e9;
/// Keep an exterior effect behind the layer's shape when Fill is partly or wholly
/// transparent. The visible part beneath a fully opaque fill is already covered by the
/// layer, so avoid attenuating anti-aliased edges twice in that case.
fn knock_out_exterior(map: &mut Map, shape: &Map, fill_opacity: f32) {
let see_through = 1.0 - fill_opacity.clamp(0.0, 1.0);
for (coverage, alpha) in map.v.iter_mut().zip(&shape.v) {
*coverage *= 1.0 - alpha * see_through;
}
}
/// Composites `content` (the layer's own pixels over `big`, alpha already
/// masked, clipped layers applied) plus its effects into `backdrop`.
pub fn composite_with_effects(layer: &Layer, content: &Buffer, backdrop: &mut Buffer, maps: &FxMaps, layer_bounds: Rect, patterns: &[Pattern]) {
let prepared = PreparedPatterns::new(patterns, PREPARED_PATTERN_BYTES);
composite_with_effects_prepared(layer, content, backdrop, maps, layer_bounds, &prepared, None, None);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn composite_with_effects_prepared(
layer: &Layer,
content: &Buffer,
backdrop: &mut Buffer,
maps: &FxMaps,
layer_bounds: Rect,
patterns: &PreparedPatterns<'_>,
vstroke: Option<VectorStroke<'_>>,
fill_map: Option<&[f32]>,
) {
let big = content.rect;
let (w, h) = (big.width() as usize, big.height() as usize);
// The effect shape: the content's alpha (with a split-off vector stroke, `content` is the
// unmasked fill and the mask applies to fill ∪ stroke), joined with a filled shape's outline.
let kmask = |i: usize| vstroke.as_ref().and_then(|v| v.mask).and_then(|m| m.get(i)).copied().unwrap_or(1.0);
let union = |i: usize, a: f32| vstroke.as_ref().and_then(|v| v.stroke.px.get(i)).map_or(a, |s| kmask(i) * (a + s[3] * (1.0 - a)));
// Transparency Shapes Layer off: the shape is the whole layer (its masks, as the maps were
// built), and the content's own transparency acts like fill opacity within it.
let shapeless = !layer.advanced.transparency_shapes;
let shape = if shapeless {
maps.crop(&maps.shape, big, 0.0)
} else if maps.outline {
let o = maps.crop(&maps.shape, big, 0.0);
Map { w, h, v: o.v.iter().zip(&content.px).enumerate().map(|(i, (o, p))| o.max(union(i, p[3]))).collect() }
} else {
Map { w, h, v: content.px.iter().enumerate().map(|(i, p)| union(i, p[3])).collect() }
};
let relative = shapeless || maps.outline || vstroke.is_some();
let fx = |i: usize, k: usize| maps.crop(&maps.per[i][k], big, 0.0);
// Layer bounds (gradients aligned with the layer use the whole layer,
// independent of the render rect).
let sb = layer_bounds;
// Linked patterns tile from the effects reference point (else the layer's top-left).
let anchor = layer.effects.reference.unwrap_or((f64::from(sb.x0), f64::from(sb.y0)));
let rect = backdrop.rect;
let before = backdrop.clone();
// Exterior effects are painted on a backdrop copy extended to `big`.
let mut work = Buffer::transparent(big);
for y in rect.y0.max(big.y0)..rect.y1.min(big.y1) {
for x in rect.x0.max(big.x0)..rect.x1.min(big.x1) {
work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] = before.get(x, y);
}
}
let items: Vec<&Effect> = layer.effects.items.iter().filter(|e| e.enabled()).collect();
let vector_shape = matches!(layer.content, photocraft_doc::LayerContent::Shape(_));
// Multiple instances: the first listed is on top, so paint in reverse.
let rev = || items.iter().copied().enumerate().rev();
for (i, e) in rev() {
if let Effect::DropShadow(s) = e {
let mut m = fx(i, 0);
if s.knocks_out {
// The layer hides the shadow beneath it only where its fill is see-through: at
// 100 % fill the layer covers it anyway (and anti-aliased edges are not
// attenuated twice), at 0 % the shape shows the bare backdrop.
knock_out_exterior(&mut m, &shape, layer.fill_opacity);
}
paint_color(&mut work, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
for (i, e) in rev() {
if let Effect::OuterGlow(g) = e {
let mut m = fx(i, 0);
// The glow is behind the shape, not painted across its interior when Fill is 0 %.
// Preserve the existing anti-aliased edge when the fill is fully opaque.
knock_out_exterior(&mut m, &shape, layer.fill_opacity);
paint_glow(&mut work, &m, g, sb, anchor, big, patterns);
}
}
// The layer: content at fill opacity, then interior effects. Interior effects are painted
// relative to the layer's shape (alpha = coverage within the shape), then the shape's alpha
// applies: a colour overlay at 100 % replaces the colour of a half-transparent edge pixel and
// keeps its alpha, as in Photoshop.
let fill = layer.fill_opacity;
// Blend Interior Effects as Group: the interior effects (overlays, satin, inner glow) are
// combined with the content first, and fill opacity applies to the combination.
let interior_group = layer.advanced.blend_interior && fill < 1.0;
let content_fill = if interior_group { 1.0 } else { fill };
let inside = |a: f32| a > INSIDE_EPS;
// Within an outline (or a split-off vector stroke) the content's own transparency (a fading
// gradient fill) acts like fill opacity: the effects still cover the whole shape.
// A base below 100 % fill with clipped layers: the clipping group's own alpha per pixel
// (the base at fill, its clipped layers at full strength inside its shape).
let fill_at = |i: usize| fill_map.and_then(|m| m.get(i)).copied().unwrap_or(content_fill);
let lay_alpha = |i: usize, p: &[f32; 4], a: f32| {
if !inside(a) {
0.0
} else if relative {
fill_at(i) * (kmask(i) * p[3] / a).min(1.0)
} else {
fill_at(i)
}
};
let mut lay = Buffer { rect: big, px: content.px.iter().zip(&shape.v).enumerate().map(|(i, (p, a))| [p[0], p[1], p[2], lay_alpha(i, p, *a)]).collect() };
let rel = |m: Map| -> Map {
let v = m.v.iter().zip(&shape.v).map(|(m, a)| if inside(*a) { (m / a).min(1.0) } else { 0.0 }).collect();
Map { w: m.w, h: m.h, v }
};
let full = Map { w, h, v: shape.v.iter().map(|a| if inside(*a) { 1.0 } else { 0.0 }).collect() };
for (_, e) in rev() {
if let Effect::PatternOverlay { common, name, id, scale, angle, link, phase } = e {
paint_pattern(&mut lay, &full, patterns, name, id, Placement::anchored(anchor, *link, *phase, *scale, *angle), big, common.blend, common.opacity);
}
}
for (_, e) in rev() {
if let Effect::GradientOverlay { common, gradient, .. } = e {
paint_fx(&mut lay, &full, &FxPaint::Gradient(gradient.clone()), sb, anchor, big, common.blend, common.opacity, patterns)
}
}
for (_, e) in rev() {
if let Effect::ColorOverlay { common, color } = e {
paint_color(&mut lay, &full, rgb(color), common.blend, common.opacity);
}
}
for (i, e) in rev() {
if let Effect::Satin(s) = e {
let m = rel(fx(i, 0));
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
for (i, e) in rev() {
if let Effect::InnerGlow(g) = e {
paint_glow(&mut lay, &rel(fx(i, 0)), g, sb, anchor, big, patterns);
}
}
if interior_group {
for p in &mut lay.px {
p[3] *= fill.max(0.0);
}
}
for (i, e) in rev() {
if let Effect::InnerShadow(s) = e {
let m = rel(fx(i, 0));
paint_color(&mut lay, &m, rgb(&s.color), s.common.blend, s.common.opacity);
}
}
// A stroked shape's vector stroke: Photoshop draws it above the fill's clipped layers and
// interior effects, below the stroke effect (psd-tools stroke-composite).
if let Some(vs) = &vstroke {
for (i, ((p, s), a)) in lay.px.iter_mut().zip(&vs.stroke.px).zip(&shape.v).enumerate() {
if s[3] > 0.0 && inside(*a) {
*p = psblend::composite(BlendMode::Normal, *p, [s[0], s[1], s[2], fill * (kmask(i) * s[3] / a).min(1.0)], 1.0);
}
}
}
// 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 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);
for st in strokes.iter().rev().copied() {
let (in_w, _) = widths(st);
if in_w <= 0.0 {
continue;
}
let mut m = Map::new(w, h, 0.0);
for ((mv, a), dv) in m.v.iter_mut().zip(&shape.v).zip(&din) {
*mv = if inside(*a) { (in_w + 0.5 - dv).clamp(0.0, 1.0) } else { 0.0 };
}
paint_fx(&mut lay, &m, &st.paint, frame(st), anchor, big, st.common.blend, st.common.opacity, patterns);
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Inner
{
let (hi, sh) = (rel(fx(i, 0)), rel(fx(i, 1)));
paint_color(&mut lay, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut lay, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
// The shape's own alpha.
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));
// 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
// coverage × opacity not yet taken by strokes above; for Normal strokes this equals stacking
// them top over bottom.
if strokes.iter().any(|st| widths(st).1 > 0.0) {
let base = work.clone();
let mut acc = vec![[0.0f32; 4]; w * h]; // premultiplied Σ share·blended
let mut cover = vec![0.0f32; w * h];
for st in strokes.iter().copied() {
let (_, out_w) = widths(st);
if out_w <= 0.0 {
continue;
}
// 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 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);
let k = if maps.outline {
band * outline_share(shape.v[i], lay.px[i][3])
} else if inside(shape.v[i]) {
if vector_shape {
0.0
} else {
// The stroke lies outside the layer's pixels. Beneath the layer it may show
// only through the part of the pixel the shape doesn't cover (1 - a); the
// layer composited on top covers c = a × fill, so the share beneath is
// (1 - a) / (1 - c). At 100 % fill that is 1 (the layer hides the rest, as
// before); at 0 % fill the interior stays clear (Fill 0 % + Outside stroke).
let c = lay.px[i][3].clamp(0.0, 1.0);
if c >= 1.0 { 1.0 } else { ((1.0 - shape.v[i].clamp(0.0, 1.0)) / (1.0 - c)).clamp(0.0, 1.0) }
}
} else {
band
};
*sh = k * st.common.opacity * (1.0 - cover[i]);
cover[i] += *sh;
}
// The stroke blended over the backdrop at full coverage.
let mut blended = base.clone();
let ones = Map { w, h, v: share.iter().map(|&s| if s > 0.0 { 1.0 } else { 0.0 }).collect() };
paint_fx(&mut blended, &ones, &st.paint, frame(st), anchor, big, st.common.blend, 1.0, patterns);
for ((a, b), s) in acc.iter_mut().zip(&blended.px).zip(&share) {
if *s > 0.0 {
for c in 0..3 {
a[c] += s * b[c] * b[3];
}
a[3] += s * b[3];
}
}
}
for ((p, a), c) in work.px.iter_mut().zip(&acc).zip(&cover) {
if *c <= 0.0 {
continue;
}
let k = 1.0 - c.min(1.0);
let alpha = p[3] * k + a[3];
if alpha > 0.0 {
*p = [(p[0] * p[3] * k + a[0]) / alpha, (p[1] * p[3] * k + a[1]) / alpha, (p[2] * p[3] * k + a[2]) / alpha, alpha.min(1.0)];
}
}
}
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Outer
{
let (hi, sh) = (fx(i, 0), fx(i, 1));
paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
// Layer (with interior effects) onto the exterior result, in the layer's mode.
let mode = if layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };
let gamma = crate::text_gamma(layer);
for (wp, lp) in work.px.iter_mut().zip(&lay.px) {
if lp[3] > 0.0 {
*wp = psblend::composite_gamma(mode, *wp, *lp, 1.0, gamma);
}
}
// Emboss styles shade the composited layer (their maps cover inside and outside). Painting
// them into the layer before its (text-gamma) composite left a type layer's lit top edges up
// to 6/255 dark (psd-tools layer_effects Emboss: 7.8 → 5.7/255).
for (i, e) in rev() {
if let Effect::BevelEmboss(b) = e
&& maps.bevel_paint[i] == BevelPaint::Both
{
paint_color(&mut work, &fx(i, 0), rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);
paint_color(&mut work, &fx(i, 1), rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);
}
}
// Layer opacity applies to the whole stack.
let op = layer.opacity;
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let i = ((y - rect.y0) as usize) * rect.width() as usize + (x - rect.x0) as usize;
let a = before.px[i];
let b = if x >= big.x0 && x < big.x1 && y >= big.y0 && y < big.y1 { work.px[((y - big.y0) as usize) * w + (x - big.x0) as usize] } else { a };
backdrop.px[i] = mix_premul(a, b, op);
}
}
}
/// Coverage of an outside stroke beneath a filled shape's edge pixel, for a pixel the outline
/// covers `cov` of and the layer (with its interior effects) ends at alpha `l`. Photoshop treats
/// the stroke and the shape as disjoint areas: the stroke fills the part outside the path and
/// the layer adds its own alpha (psd-tools stroke-effects: a 58 % covered edge pixel of an
/// opaque fill ends opaque, one of a faded fill at 42 % + its alpha). Composited beneath the
/// layer, `share + l (1 - share) = (1 - cov) + l`.
pub fn outline_share(cov: f32, l: f32) -> f32 {
let outside = (1.0 - cov.clamp(0.0, 1.0)).max(0.0);
let rest = 1.0 - l.clamp(0.0, 1.0);
if rest <= 1e-6 { 1.0 } else { (outside / rest).min(1.0) }
}
/// (inside width, outside width) of a stroke.
pub fn stroke_widths(st: &photocraft_doc::StrokeFx) -> (f32, f32) {
match st.position {
StrokePosition::Outside => (0.0, st.size),
StrokePosition::Inside => (st.size, 0.0),
StrokePosition::Center => (st.size / 2.0, st.size / 2.0),
}
}
/// The frame a gradient stroke aligned with the layer is laid out in: the pixel bounds of the
/// effect shape (over `rect`) grown by the stroke's outside width less one pixel. Photoshop
/// spans the gradient over the stroke's extent, not the layer's: a 4 px outside stroke around a
/// 22 px square runs its 90° gradient over 28 px (psd-tools stroke-effects), a 3 px one around
/// 36 px of type over 40 px (psd-tools effect-stroke-gradient).
fn stroke_frame(shape: &Map, out_w: f32, rect: Rect) -> Rect {
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0usize, 0usize);
for y in 0..shape.h {
for x in 0..shape.w {
if shape.v[y * shape.w + x] > INSIDE_EPS {
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
}
}
}
if x0 == usize::MAX {
return rect;
}
let grow = if out_w.is_finite() { (out_w.min(MAX_REACH).ceil() as i32 - 1).max(0) } else { 0 };
Rect::new(rect.x0 + x0 as i32, rect.y0 + y0 as i32, rect.x0 + x1 as i32, rect.y0 + y1 as i32).inflate(grow)
}
/// A stroked shape's vector stroke, composited apart from its fill (which the compositor then
/// passes unmasked): the stroke's pixels over the render rect and the layer's mask values.
pub(crate) struct VectorStroke<'a> {
pub stroke: &'a Buffer,
pub mask: Option<&'a [f32]>,
}
/// Premultiplied linear interpolation between two straight-alpha pixels.
fn mix_premul(a: [f32; 4], b: [f32; 4], k: f32) -> [f32; 4] {
let alpha = a[3] + (b[3] - a[3]) * k;
if alpha <= 0.0 {
return [0.0; 4];
}
let mut out = [0.0; 4];
for c in 0..3 {
out[c] = (a[c] * a[3] + (b[c] * b[3] - a[c] * a[3]) * k) / alpha;
}
out[3] = alpha;
out
}
/// A distance field of an effect shape, as the map builders above compute it (for the GPU
/// compositor, which builds the rest of the maps itself; keep in sync with `shadow_map`,
/// `glow_map`, `bevel_maps` and `build_maps`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum FieldKind {
/// `dist_outside` of the shape: precise outer glows.
Outside,
/// `dist_inside` of the shape: precise inner glows.
Inside,
/// `bevel_dist_outside` of the shape: the outside distance of chiselled bevels.
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`]).
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).
StrokeOutsideVector,
}
/// The `kind` distance field of the shape `alpha` (row-major `w × h`). Values past `reach` pixels
/// from the shape's edge are only meant to be large (the bevel fields skip refining them).
pub fn distance_field(kind: FieldKind, alpha: Vec<f32>, w: usize, h: usize, reach: f32) -> Vec<f32> {
let s = Map { w, h, v: alpha };
match kind {
FieldKind::Outside => dist_outside(&s),
FieldKind::Inside => dist_inside(&s),
FieldKind::BevelOutside => bevel_dist_outside(&s, &sharp_edges(&s), reach),
FieldKind::BevelInside => bevel_dist_inside(&s, &sharp_edges(&s), reach),
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),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Runs `f` with every parallel pass on this thread using `n` threads (1 = in order), whatever its size.
fn with_threads<R>(n: usize, f: impl FnOnce() -> R) -> R {
struct Restore(Option<usize>);
impl Drop for Restore {
fn drop(&mut self) {
THREADS.with(|t| t.set(self.0));
}
}
let _restore = Restore(THREADS.with(|t| t.replace(Some(n))));
f()
}
fn bits(v: &[f32]) -> Vec<u32> {
v.iter().map(|x| x.to_bits()).collect()
}
#[test]
fn edt_is_exact() {
let (w, h) = (7, 5);
let mut inside = vec![false; w * h];
inside[2 * w + 3] = true;
let d = edt(&inside, w, h);
assert_eq!(d[2 * w + 3], 0.0);
assert!((d[2 * w + 6] - 3.0).abs() < 1e-6);
assert!((d[0] - (9.0f32 + 4.0).sqrt()).abs() < 1e-5);
assert!((d[4 * w + 5] - (4.0f32 + 4.0).sqrt()).abs() < 1e-5);
}
#[test]
fn edt_two_seeds() {
let (w, h) = (10, 1);
let mut inside = vec![false; w];
inside[0] = true;
inside[9] = true;
let d = edt(&inside, w, h);
assert_eq!(d, vec![0.0, 1.0, 2.0, 3.0, 4.0, 4.0, 3.0, 2.0, 1.0, 0.0]);
}
#[test]
fn blur_preserves_mass() {
let mut m = Map::new(21, 21, 0.0);
m.v[10 * 21 + 10] = 1.0;
blur(&mut m, 6.0);
let sum: f32 = m.v.iter().sum();
assert!((sum - 1.0).abs() < 1e-4, "{sum}");
assert!(m.v[10 * 21 + 10] < 1.0);
}
#[test]
fn gradient_geometry() {
let b = Rect::new(0, 0, 100, 100);
let t = |s, x, y| gradient_t(s, 0.0, 1.0, false, (0.0, 0.0), b, x, y);
assert!(t(GradientStyle::Linear, 0.0, 50.0) < 0.01);
// Linear / reflected sample pixel corners: the centre is reached half a pixel later.
assert!((t(GradientStyle::Linear, 50.5, 50.0) - 0.5).abs() < 1e-6);
assert!(t(GradientStyle::Linear, 100.0, 50.0) > 0.99);
assert!(t(GradientStyle::Reflected, 50.5, 50.0) < 1e-6);
assert!((t(GradientStyle::Reflected, 0.0, 50.0) - 1.0).abs() < 1e-6);
assert!(t(GradientStyle::Radial, 50.0, 50.0) < 1e-6);
assert!((t(GradientStyle::Radial, 50.0, 0.0) - 1.0).abs() < 1e-6);
assert!((t(GradientStyle::Diamond, 75.0, 75.0) - 1.0).abs() < 1e-6);
let ang = t(GradientStyle::Angle, 50.0, 0.0);
assert!((ang - 0.75).abs() < 1e-3, "{ang}");
let rev = gradient_t(GradientStyle::Linear, 0.0, 1.0, true, (0.0, 0.0), b, 0.0, 50.0);
assert!(rev > 0.99);
}
#[test]
fn effect_gradients_snap_end_points_and_sample_corners() {
// 87° over 600 × 60 at (99, 422): end points (397, 482) and (400, 422), so the overlay runs
// along (3, -60) from its midpoint (398.5, 452), sampled at pixel corners.
let frame = Rect::new(99, 422, 699, 482);
let (a, sc, o) = crate::fill_layout::gradient_layout(GradientStyle::Linear, 87.0, 1.0, (0.0, 0.0), frame);
assert!((a - (60f32).atan2(3.0).to_degrees()).abs() < 1e-3, "{a}");
let t = |x: f32, y: f32| gradient_t(GradientStyle::Linear, a, sc, false, o, frame, x, y);
assert!((t(399.0, 452.5) - 0.5).abs() < 1e-4, "{}", t(399.0, 452.5));
assert!(t(400.5, 422.5) > 0.99 && t(397.5, 482.5) < 0.01);
}
#[test]
fn chamfer_uses_euclidean_step_lengths() {
let (w, h) = (9, 9);
let mut inside = vec![false; w * h];
inside[0] = true;
let (d, near) = chamfer_nearest(&inside, w, h);
let at = |x: usize, y: usize| d[y * w + x];
assert!((at(3, 0) - 3.0).abs() < 1e-5);
assert!((at(2, 2) - 2.0 * std::f32::consts::SQRT_2).abs() < 1e-5);
assert!((at(1, 2) - 5f32.sqrt()).abs() < 1e-5);
assert!((at(1, 3) - (5f32.sqrt() + 1.0)).abs() < 1e-5, "knight + straight, not √10");
assert!((at(2, 3) - (5f32.sqrt() + std::f32::consts::SQRT_2)).abs() < 1e-5);
assert!(near.iter().all(|&n| n == 0));
}
#[test]
fn spread_dilates_with_the_chamfer_metric() {
// One opaque pixel grown by 3 px: at offset (1, 3) the chamfer distance is √5 + 1 = 3.236
// (Euclidean 3.162), so the anti-aliased rim keeps 3 + 0.5 − (3.236 − 0.5) = 0.764.
let mut m = Map::new(9, 9, 0.0);
m.v[4 * 9 + 4] = 1.0;
let d = dilate(&m, 3.0);
assert!((d.v[7 * 9 + 5] - 0.764).abs() < 1e-3, "{}", d.v[7 * 9 + 5]);
// (2, 3): √5 + √2 = 3.650 (Euclidean 3.606) leaves 0.350; axis steps are exact.
assert!((d.v[7 * 9 + 6] - (4.0 - 5f32.sqrt() - std::f32::consts::SQRT_2)).abs() < 1e-4, "{}", d.v[7 * 9 + 6]);
assert_eq!(d.v[4 * 9 + 7], 1.0);
}
#[test]
fn running_sum_tent_matches_the_kernel() {
let (w, h) = (37usize, 23usize);
let src: Vec<f32> = (0..w * h).map(|i| ((i * 7919) % 101) as f32 / 100.0).collect();
for width in [1.0f32, 2.0, 3.5, 5.0, 8.25, 41.0] {
let mut m = Map { w, h, v: src.clone() };
tent(&mut m, width);
let mut want = Map { w, h, v: src.clone() };
tent_direct(&mut want, width);
for (a, b) in m.v.iter().zip(&want.v) {
assert!((a - b).abs() < 1e-5, "width {width}: {a} vs {b}");
}
}
}
#[test]
fn tent_kernel_is_two_boxes() {
let (r, k) = tent_kernel(5.0);
assert_eq!(r, 4);
let sum: f32 = k.iter().sum();
assert!((sum - 1.0).abs() < 1e-6);
assert!((k[4] - 5.0 / 25.0).abs() < 1e-6 && (k[0] - 1.0 / 25.0).abs() < 1e-6);
// Fractional widths taper the end taps; width 1 is the identity.
let (r, k) = tent_kernel(4.0);
assert_eq!(r, 4);
assert!(k[0] > 0.0 && k[0] < 1.0 / 25.0);
assert_eq!(tent_kernel(1.0), (0, vec![1.0]));
// #1543: a huge, infinite or NaN width is capped, not turned into a kernel of 2^62 taps.
for w in [1e30, f32::INFINITY, f32::MAX] {
assert_eq!(tent_kernel(w), tent_kernel(MAX_REACH), "{w}");
}
assert_eq!(tent_kernel(f32::NAN), (0, vec![1.0]));
}
fn no_tex() -> TextureCtx<'static> {
TextureCtx { rect: Rect::new(0, 0, 1, 1), patterns: &[], anchor: (0.0, 0.0) }
}
fn square(n: usize, x0: usize, x1: usize) -> Map {
let mut m = Map::new(n, n, 0.0);
for y in x0..x1 {
for x in x0..x1 {
m.v[y * n + x] = 1.0;
}
}
m
}
fn bevel_of(style: BevelStyle, technique: BevelTechnique) -> Bevel {
Bevel {
enabled: true,
style,
technique,
depth: 1.0,
up: true,
size: 5.0,
soften: 0.0,
angle: 90.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: Contour::Linear,
highlight: photocraft_doc::FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: photocraft_color::Color::WHITE,
shadow: photocraft_doc::FxCommon::new(BlendMode::Multiply, 0.75),
shadow_color: photocraft_color::Color::BLACK,
contour: None,
texture: None,
}
}
#[test]
fn smooth_inner_bevel_lights_the_top_edge_inside_only() {
let shape = square(40, 10, 30);
let (maps, paint) = bevel_maps(
&shape,
&bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth),
&GlobalLight::default(),
&no_tex(),
&PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES),
);
assert_eq!(paint, BevelPaint::Inner);
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
// Light from the top: highlight along the top edge, shadow along the bottom, flat middle.
assert!(at(&maps[0], 20, 10) > 0.5 && at(&maps[1], 20, 10) == 0.0);
assert!(at(&maps[1], 20, 29) > 0.5);
assert!(at(&maps[0], 20, 20) < 1e-3 && at(&maps[1], 20, 20) < 1e-3);
// Nothing outside the shape; the ramp reaches about `size` inside.
assert_eq!(at(&maps[0], 20, 8), 0.0);
assert!(at(&maps[0], 20, 15) < 0.05);
}
/// Photoshop 27.11, measured across a straight edge (size 10, depth 100 %, light from the top
/// at 30°): a chisel facet has the slope `depth × size / (2 (size + 1))`, flat right up to the
/// edge pixel for Inner Hard; Inner Soft's edge pixel is half as steep again; Outer Hard
/// still shades, partly, the pixel where its ramp reaches zero `size + 1½` px out.
#[test]
fn chisel_facets_match_photoshop() {
let amount = |g: f32| {
let (se, ce) = 30f32.to_radians().sin_cos();
((g * ce + se) / (1.0 + g * g).sqrt() - se) / (1.0 - se)
};
let n = 80;
let shape = square(n, 20, 60);
let maps = |style, technique| {
let mut b = bevel_of(style, technique);
b.size = 10.0;
bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES)).0
};
let at = |m: &Map, y: usize| m.v[y * n + 40];
let facet = amount(10.0 / 22.0);
let hard = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
for y in 20..30 {
assert!((at(&hard[0], y) - facet).abs() < 0.01, "inner hard facet at row {y}: {} vs {facet}", at(&hard[0], y));
}
assert!(at(&hard[0], 30) > 0.05 && at(&hard[0], 30) < facet, "one partial pixel at the end");
let soft = maps(BevelStyle::InnerBevel, BevelTechnique::ChiselSoft);
assert!((at(&soft[0], 20) - amount(1.5 * 10.0 / 22.0)).abs() < 0.01, "steeper edge pixel: {}", at(&soft[0], 20));
assert!((at(&soft[0], 24) - facet).abs() < 0.01, "then Chisel Hard's facet");
let outer = maps(BevelStyle::OuterBevel, BevelTechnique::ChiselHard);
// Below the shape (rows 60..): shadow facet through 10½ px, partial at 11½.
assert!((at(&outer[1], 70) - at(&outer[1], 61)).abs() < 1e-4);
assert!(at(&outer[1], 71) > 0.05 && at(&outer[1], 71) < at(&outer[1], 70));
assert_eq!(at(&outer[1], 72), 0.0);
}
#[test]
fn emboss_paints_both_sides_and_pillow_flips_the_outside() {
let shape = square(40, 10, 30);
let l = GlobalLight::default();
let (e, paint) =
bevel_maps(&shape, &bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
assert_eq!((paint, e.len()), (BevelPaint::Both, 2));
let at = |m: &Map, x: usize, y: usize| m.v[y * 40 + x];
// Emboss: the slope across the top edge faces the light on both sides.
assert!(at(&e[0], 20, 10) > 0.3 && at(&e[0], 20, 9) > 0.3);
let (p, _) =
bevel_maps(&shape, &bevel_of(BevelStyle::PillowEmboss, BevelTechnique::Smooth), &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
assert!(at(&p[0], 20, 10) > 0.3 && at(&p[1], 20, 9) > 0.3, "pillow: outside top edge in shadow");
}
#[test]
fn emboss_width_rounds_up_and_shades_past_the_blur() {
let mut b = bevel_of(BevelStyle::Emboss, BevelTechnique::Smooth);
b.size = 41.0;
let g = bevel_geom(&b);
assert_eq!((g.width, g.depth), (21.0, 21.0));
b.size = 5.0;
assert_eq!(bevel_geom(&b).width, 3.0);
// The first row past the height blur's reach still slopes (its upper neighbour is raised),
// so it shades; the row after it does not.
let shape = square(60, 20, 40);
b.size = 9.0;
b.angle = 90.0;
let (m, _) = bevel_maps(&shape, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
let reach = tent_kernel(bevel_geom(&b).width).0 as usize;
let row = 39 + reach + 1; // last shape row + reach + 1: height 0, neighbour above raised
assert!(m[1].v[row * 60 + 30] > 0.0, "{}", m[1].v[row * 60 + 30]);
assert_eq!(m[1].v[(row + 1) * 60 + 30], 0.0);
}
/// A `len` x `wid` bar through the middle of a `w` x `h` map at `deg` degrees, anti-aliased by
/// 16 x 16 supersampling (the coverage a scanline rasteriser gives).
fn aa_bar(w: usize, h: usize, deg: f32, len: f32, wid: f32) -> Map {
let (sn, cs) = deg.to_radians().sin_cos();
let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0);
let mut m = Map::new(w, h, 0.0);
const N: usize = 16;
for y in 0..h {
for x in 0..w {
let mut hit = 0;
for sy in 0..N {
for sx in 0..N {
let (px, py) = (x as f32 + (sx as f32 + 0.5) / N as f32 - cx, y as f32 + (sy as f32 + 0.5) / N as f32 - cy);
let (u, v) = (px * cs + py * sn, -px * sn + py * cs);
hit += usize::from(u.abs() <= len / 2.0 && v.abs() <= wid / 2.0);
}
}
m.v[y * w + x] = hit as f32 / (N * N) as f32;
}
}
m
}
/// Ripple and bias of a distance field on an `aa_bar` (`inside` or outside it): the worst
/// standard deviation of the error among pixels at the same true distance from a long edge
/// (0.5 px bins, 0.6..8 px, away from the ends), and the worst mean error of a bin. A constant
/// offset is no ripple: only a field that varies along the edge shades in stripes.
fn bar_ripple(f: &[f32], w: usize, h: usize, deg: f32, wid: f32, inside: bool) -> (f32, f32) {
let (sn, cs) = deg.to_radians().sin_cos();
let mut bins: Vec<Vec<f32>> = vec![Vec::new(); 16];
for y in 0..h {
for x in 0..w {
let (px, py) = (x as f32 + 0.5 - w as f32 / 2.0, y as f32 + 0.5 - h as f32 / 2.0);
let (u, v) = (px * cs + py * sn, -px * sn + py * cs);
let truth = if inside { wid / 2.0 - v.abs() } else { v.abs() - wid / 2.0 };
if u.abs() < 40.0 && (0.6..8.0).contains(&truth) {
bins[(truth * 2.0) as usize].push(f[y * w + x] - truth);
}
}
}
let (mut ripple, mut bias) = (0.0f32, 0.0f32);
for b in bins.iter().filter(|b| b.len() > 8) {
let mean = b.iter().sum::<f32>() / b.len() as f32;
let sd = (b.iter().map(|e| (e - mean) * (e - mean)).sum::<f32>() / b.len() as f32).sqrt();
ripple = ripple.max(sd);
bias = bias.max(mean.abs());
}
(ripple, bias)
}
/// The worst standard deviation of the chisel shading (highlight minus shadow) along lines
/// parallel to an `aa_bar` 2..6 px in from its edges, the way Issue #1926 measured stripes.
fn bar_shade_ripple(deg: f32, light: f32) -> f32 {
let (w, h) = (220usize, 220usize);
let bar = aa_bar(w, h, deg, 150.0, 16.0);
let mut b = bevel_of(BevelStyle::InnerBevel, BevelTechnique::ChiselHard);
(b.size, b.depth, b.angle, b.altitude) = (8.0, 2.2, light, 32.0);
let (m, _) = bevel_maps(&bar, &b, &GlobalLight::default(), &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
let (sn, cs) = deg.to_radians().sin_cos();
let mut worst = 0.0f32;
for o in [-6.0f32, -5.0, -4.0, -3.0, -2.0, 2.0, 3.0, 4.0, 5.0, 6.0] {
let line: Vec<f32> = (-60..=60)
.map(|k| {
let t = k as f32 * 0.5;
let (x, y) = (w as f32 / 2.0 + t * cs - o * sn, h as f32 / 2.0 + t * sn + o * cs);
let i = y.floor() as usize * w + x.floor() as usize;
m[0].v[i] - m[1].v[i]
})
.collect();
let mean = line.iter().sum::<f32>() / line.len() as f32;
worst = worst.max((line.iter().map(|v| (v - mean) * (v - mean)).sum::<f32>() / line.len() as f32).sqrt());
}
worst
}
/// Coverage of a unit pixel by the half plane `n . p + s >= 0` (`p` from the pixel's centre),
/// by 64 x 64 supersampling.
fn half_plane_coverage(n: [f32; 2], s: f32) -> f32 {
const N: usize = 64;
let mut hit = 0;
for y in 0..N {
for x in 0..N {
let (px, py) = ((x as f32 + 0.5) / N as f32 - 0.5, (y as f32 + 0.5) / N as f32 - 0.5);
hit += usize::from(n[0] * px + n[1] * py + s >= 0.0);
}
}
hit as f32 / (N * N) as f32
}
#[test]
fn edge_offset_places_a_straight_edge_by_its_coverage() {
for step in 0..=12 {
let (sn, cs) = (step as f32 * 7.5).to_radians().sin_cos();
for k in -12..=12 {
let s = k as f32 * 0.05;
let a = half_plane_coverage([cs, sn], s);
// Where the edge only just touches the pixel the coverage hardly moves with it.
if !(0.03..=0.97).contains(&a) {
continue;
}
let got = edge_offset([cs, sn], a);
assert!((got - s).abs() < 0.03, "{}deg s {s}: coverage {a} gave {got}", step as f32 * 7.5);
}
}
// Axis-aligned edges keep `alpha - 1/2`; a pixel no coverage says anything about is 0.
assert!((edge_offset([1.0, 0.0], 0.25) + 0.25).abs() < 1e-6);
assert!((edge_offset([0.0, 1.0], 0.9) - 0.4).abs() < 1e-6);
assert_eq!(edge_offset([0.6, 0.8], f32::NAN), 0.0);
}
#[test]
fn bevel_distances_equal_the_plain_ones_without_anti_aliasing() {
// Hard edges (no partly covered pixel) and soft ramps (no sharp one) keep the plain fields.
let (w, h) = (96usize, 80usize);
let mut maps = Vec::new();
for kind in 0..4 {
let mut m = Map::new(w, h, 0.0);
let mut seed = 12345u32;
for y in 0..h {
for x in 0..w {
let (dx, dy) = (x as f32 - 48.0, y as f32 - 40.0);
m.v[y * w + x] = match kind {
0 => f32::from((50.0..90.0).contains(&(x as f32 * 0.6 + y as f32))),
1 => f32::from(dx * dx + dy * dy < 30.0 * 30.0),
2 => {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
f32::from(seed >> 28 < 5 && x > 10 && x < 80)
}
_ => ((20.0 - (dx * dx + dy * dy).sqrt()) / 8.0).clamp(0.0, 1.0),
};
}
}
maps.push(m);
}
for (i, m) in maps.iter().enumerate() {
let sharp = sharp_edges(m);
assert!(sharp.is_empty(), "map {i} has no anti-aliased edge");
assert_eq!(bevel_dist_inside(m, &sharp, 1e9), dist_inside(m), "inside, map {i}");
assert_eq!(bevel_dist_outside(m, &sharp, 1e9), dist_outside(m), "outside, map {i}");
}
}
#[test]
fn chisel_distance_has_no_ripple_along_oblique_edges() {
// The plain distance to a pixel's centre ripples with the staircase of an oblique edge (the
// stripes of Issue #1926); at 0 and 45 degrees the staircase is regular and it does not.
let (w, h) = (160, 160);
for deg in [0.0f32, 30.0, 45.0, 60.0, 75.0, 120.0, 150.0] {
let bar = aa_bar(w, h, deg, 120.0, 40.0);
let sharp = sharp_edges(&bar);
let inside = bar_ripple(&bevel_dist_inside(&bar, &sharp, 40.0), w, h, deg, 40.0, true);
let outside = bar_ripple(&bevel_dist_outside(&bar, &sharp, 40.0), w, h, deg, 40.0, false);
for (what, (ripple, bias)) in [("inside", inside), ("outside", outside)] {
assert!(ripple < 0.008 && bias < 0.01, "{deg} deg {what}: ripple {ripple}, bias {bias}");
}
}
let plain = aa_bar(w, h, 60.0, 120.0, 40.0);
let (ripple, _) = bar_ripple(&dist_inside(&plain), w, h, 60.0, 40.0, true);
assert!(ripple > 0.1, "the plain field still ripples ({ripple}), so the test above means something");
}
#[test]
fn edge_normals_follow_straight_edges_to_a_fraction_of_a_degree() {
// The 3x3 gradient is 1-3 degrees off and the error is systematic; the line through the edge
// points of the pixels around one is not.
let (w, h, wid) = (160usize, 160usize, 40.0f32);
for deg in [30.0f32, 60.0, 75.0, 120.0, 150.0] {
let sharp = sharp_edges(&aa_bar(w, h, deg, 120.0, wid));
let (sn, cs) = deg.to_radians().sin_cos();
let (mut worst, mut seen) = (0.0f32, 0);
for (i, e) in sharp.iter() {
let (px, py) = ((i % w) as f32 + 0.5 - w as f32 / 2.0, (i / w) as f32 + 0.5 - h as f32 / 2.0);
let (u, v) = (px * cs + py * sn, -px * sn + py * cs);
if u.abs() > 40.0 || (wid / 2.0 - v.abs()).abs() > 1.5 {
continue;
}
let into = if v > 0.0 { [sn, -cs] } else { [-sn, cs] };
worst = worst.max((e.normal[0] * into[0] + e.normal[1] * into[1]).clamp(-1.0, 1.0).acos().to_degrees());
seen += 1;
}
assert!(seen > 100 && worst < 0.6, "{deg} deg: {seen} edge pixels, normal off by up to {worst} deg");
}
}
#[test]
fn chisel_distance_is_close_on_round_shapes() {
// Circles (a hard stretch of edge at each pole, where the edge lies on a pixel boundary) and
// rounded rectangles on whole pixels (straight hard edges joined to anti-aliased arcs): the
// distance to the sharp pixels alone is up to 0.3 px too long there, so the plain distance
// caps it. Errors are against the exact distance to the shape, 0.6..8 px from its edge.
let worst = |f: &[f32], w: usize, h: usize, sdf: &dyn Fn(f32, f32) -> f32, inside: bool| {
let mut mx = 0.0f32;
for y in 0..h {
for x in 0..w {
let t = sdf(x as f32 + 0.5, y as f32 + 0.5);
let truth = if inside { -t } else { t };
if (0.6..8.0).contains(&truth) {
mx = mx.max((f[y * w + x] - truth).abs());
}
}
}
mx
};
for r in [20.0f32, 40.0, 70.0] {
let (w, h) = (200usize, 200usize);
let m = aa_disc(w, h, r);
let sharp = sharp_edges(&m);
let sdf = |x: f32, y: f32| (x - 100.0).hypot(y - 100.0) - r;
let (i, o) = (worst(&bevel_dist_inside(&m, &sharp, 12.0), w, h, &sdf, true), worst(&bevel_dist_outside(&m, &sharp, 12.0), w, h, &sdf, false));
assert!(i < 0.05 && o < 0.07, "disc {r}: inside off by {i}, outside by {o}");
}
for rad in [6.0f32, 12.0, 24.0] {
let (w, h) = (200usize, 160usize);
let m = aa_round_rect(w, h, 20.0, 20.0, 180.0, 140.0, rad);
let sharp = sharp_edges(&m);
let sdf = |x: f32, y: f32| {
let (qx, qy) = ((x - 100.0).abs() - (80.0 - rad), (y - 80.0).abs() - (60.0 - rad));
qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - rad
};
let (i, o) = (worst(&bevel_dist_inside(&m, &sharp, 12.0), w, h, &sdf, true), worst(&bevel_dist_outside(&m, &sharp, 12.0), w, h, &sdf, false));
assert!(i < 0.08 && o < 0.2, "rounded rect, radius {rad}: inside off by {i}, outside by {o}");
}
}
#[test]
fn chisel_facets_are_flat_along_oblique_edges() {
// Issue #1926: the facets of a chiselled bar at 30, 60, 120, 150 degrees were striped.
for deg in [0.0f32, 30.0, 45.0, 60.0, 75.0, 120.0, 150.0] {
for light in [-90.0f32, 0.0, 45.0, 120.0] {
let ripple = bar_shade_ripple(deg, light);
assert!(ripple < 0.03, "{deg} deg bar, light {light}: shading ripple {ripple}");
}
}
}
#[test]
fn bevel_distances_survive_degenerate_maps() {
let cases: [(usize, usize, f32); 7] = [(0, 0, 0.0), (1, 1, 0.5), (1, 1, 1.0), (2, 2, 0.5), (3, 1, 0.3), (5, 5, f32::NAN), (4, 4, 1.0)];
for (w, h, a) in cases {
let m = Map::new(w, h, a);
let sharp = sharp_edges(&m);
for reach in [0.0, 8.0, f32::NAN, f32::INFINITY, -3.0] {
assert_eq!(bevel_dist_inside(&m, &sharp, reach).len(), w * h);
assert_eq!(bevel_dist_outside(&m, &sharp, reach).len(), w * h);
}
}
// A single partly covered pixel in an empty map, and an edge on the map's border.
let mut m = Map::new(5, 5, 0.0);
m.v[12] = 0.4;
let _ = (bevel_dist_inside(&m, &sharp_edges(&m), 4.0), bevel_dist_outside(&m, &sharp_edges(&m), 4.0));
let mut m = Map::new(8, 8, 0.0);
for y in 0..8 {
m.v[y * 8 + 7] = 1.0;
m.v[y * 8 + 6] = 0.5;
}
let _ = (bevel_dist_inside(&m, &sharp_edges(&m), 4.0), bevel_dist_outside(&m, &sharp_edges(&m), 4.0));
}
#[test]
fn par_units_visits_every_unit_once_with_its_own_slices() {
// Lengths that leave a short last unit, no second slice, units of different sizes, and nothing at all.
let cases: [(usize, usize, usize, usize); 8] =
[(0, 3, 0, 0), (1, 1, 1, 1), (10, 3, 0, 0), (64, 8, 64, 8), (65, 8, 13, 1), (200, 1, 200, 1), (7, 7, 14, 2), (9, 4, 0, 5)];
for (la, ua, lb, ub) in cases {
for threads in [1, 2, 3, 8, 64] {
let (mut a, mut b) = (vec![0u32; la], vec![0u32; lb]);
let calls = std::sync::atomic::AtomicUsize::new(0);
with_threads(threads, || {
par_units(
la,
&mut a,
ua,
&mut b,
ub,
|| (),
|_, u, sa, sb| {
calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
assert!(sa.len() <= ua && sb.len() <= ub);
for (k, v) in sa.iter_mut().enumerate() {
*v = (u * ua + k) as u32 + 1;
}
for (k, v) in sb.iter_mut().enumerate() {
*v = (u * ub + k) as u32 + 1;
}
},
);
});
assert_eq!(calls.into_inner(), la.div_ceil(ua), "{la} {ua} {lb} {ub} on {threads}");
assert!(a.iter().enumerate().all(|(i, &v)| v == i as u32 + 1), "a: {la} {ua} {lb} {ub} on {threads}");
// `b` is written for the units that exist in `a` and no further.
let written = (la.div_ceil(ua) * ub).min(lb);
assert!(b.iter().enumerate().all(|(i, &v)| v == if i < written { i as u32 + 1 } else { 0 }), "b: {la} {ua} {lb} {ub} on {threads}");
}
}
// A unit size of 0 does nothing.
let mut a = vec![1u8; 4];
par_units(4, &mut a, 0, &mut [] as &mut [()], 0, || (), |_, _, _, _| panic!("no units"));
}
#[test]
fn nearest_distances_do_not_depend_on_the_threads() {
// Sizes that do not divide into the thread groups, lines of 1 pixel, and a noisy map where
// many pixels are equally far from two seeds (the nearest one must resolve the same way).
for (w, h) in [(37usize, 23usize), (1, 40), (40, 1), (2, 2), (101, 3), (64, 64)] {
let mut seed = 7u32;
let inside: Vec<bool> = (0..w * h)
.map(|_| {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(seed >> 24).is_multiple_of(11)
})
.collect();
let (d1, n1) = with_threads(1, || edt_nearest(&inside, w, h));
for threads in [2, 3, 8] {
let (d, n) = with_threads(threads, || edt_nearest(&inside, w, h));
assert_eq!((bits(&d), n), (bits(&d1), n1.clone()), "{w}x{h} on {threads} threads");
}
// And the nearest pixel is a seed at exactly that distance.
for (i, (&d, &q)) in d1.iter().zip(&n1).enumerate() {
if inside.iter().any(|&b| b) {
let (dx, dy) = ((i % w) as f32 - (q as usize % w) as f32, (i / w) as f32 - (q as usize / w) as f32);
assert!(inside[q as usize] && (dx.hypot(dy) - d).abs() < 1e-3, "{w}x{h} at {i}: {d} vs seed {q}");
}
}
}
}
#[test]
fn nearest_distances_survive_degenerate_sizes() {
// The slice does not match the size, or there is nothing: no panic, and a result per input pixel.
for (len, w, h) in [(0usize, 0usize, 0usize), (0, 5, 0), (0, 0, 5), (6, 2, 2), (6, 4, 2), (1, 0, 1)] {
let (d, n) = edt_nearest(&vec![true; len], w, h);
assert_eq!((d.len(), n.len()), (len, len), "{len} px as {w}x{h}");
}
}
#[test]
fn chisel_fields_do_not_depend_on_the_threads() {
// Edge pixels at every angle, curves, patchy opacity and an edge along the border, on sizes that
// leave short last groups; one thread (in order) is the reference.
let mut maps = vec![
aa_disc(97, 83, 30.0),
aa_round_rect(131, 77, 9.0, 11.0, 120.0, 66.0, 14.0),
aa_bar(120, 120, 33.0, 100.0, 30.0),
aa_bar(90, 130, 60.0, 150.0, 25.0),
];
let mut patchy = aa_disc(101, 101, 40.0);
for (i, v) in patchy.v.iter_mut().enumerate() {
if (i % 101 / 13 + i / 101 / 17) % 5 == 0 {
*v *= 0.6;
}
}
maps.push(patchy);
maps.push(aa_bar(64, 64, 40.0, 220.0, 30.0));
for (k, m) in maps.iter().enumerate() {
for reach in [3.0f32, 12.0] {
let one = with_threads(1, || (sharp_edges(m), distance_field_of(m, reach)));
for threads in [2, 5, 16] {
let many = with_threads(threads, || (sharp_edges(m), distance_field_of(m, reach)));
assert_eq!(one.0.slot, many.0.slot, "map {k} sharp slots on {threads} threads");
let edges = |s: &SharpEdges| {
s.pixels.iter().map(|p| (p.at, p.cov.to_bits(), p.edge.offset.to_bits(), p.edge.normal.map(f32::to_bits))).collect::<Vec<_>>()
};
assert_eq!(edges(&one.0), edges(&many.0), "map {k} sharp edges on {threads} threads");
for (a, b) in one.1.iter().zip(&many.1) {
assert_eq!(bits(a), bits(b), "map {k}, reach {reach} on {threads} threads");
}
}
}
}
}
/// The four fields that use the distance transform, of `m`.
fn distance_field_of(m: &Map, reach: f32) -> Vec<Vec<f32>> {
[FieldKind::Inside, FieldKind::Outside, FieldKind::BevelInside, FieldKind::BevelOutside]
.map(|k| distance_field(k, m.v.clone(), m.w, m.h, reach))
.to_vec()
}
fn aa_disc(w: usize, h: usize, r: f32) -> Map {
let mut m = Map::new(w, h, 0.0);
const N: usize = 16;
for y in 0..h {
for x in 0..w {
let mut hit = 0;
for sy in 0..N {
for sx in 0..N {
let (px, py) = (x as f32 + (sx as f32 + 0.5) / N as f32 - w as f32 / 2.0, y as f32 + (sy as f32 + 0.5) / N as f32 - h as f32 / 2.0);
hit += usize::from(px * px + py * py <= r * r);
}
}
m.v[y * w + x] = hit as f32 / (N * N) as f32;
}
}
m
}
/// Integer-aligned rounded rectangle (x0..x1 x y0..y1, corner radius `rad`), anti-aliased by supersampling.
fn aa_round_rect(w: usize, h: usize, x0: f32, y0: f32, x1: f32, y1: f32, rad: f32) -> Map {
let sdf = |px: f32, py: f32| {
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (qx, qy) = ((px - cx).abs() - ((x1 - x0) / 2.0 - rad), (py - cy).abs() - ((y1 - y0) / 2.0 - rad));
qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - rad
};
let mut m = Map::new(w, h, 0.0);
const N: usize = 16;
for y in 0..h {
for x in 0..w {
let mut hit = 0;
for sy in 0..N {
for sx in 0..N {
hit += usize::from(sdf(x as f32 + (sx as f32 + 0.5) / N as f32, y as f32 + (sy as f32 + 0.5) / N as f32) <= 0.0);
}
}
m.v[y * w + x] = hit as f32 / (N * N) as f32;
}
}
m
}
#[test]
fn chisel_hard_has_flat_facets() {
let shape = square(48, 10, 38);
let (m, _) = bevel_maps(
&shape,
&bevel_of(BevelStyle::InnerBevel, BevelTechnique::ChiselHard),
&GlobalLight::default(),
&no_tex(),
&PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES),
);
let at = |x: usize, y: usize| m[0].v[y * 48 + x];
// A constant slope: equal highlight along the top facet.
assert!(at(24, 12) > 0.1 && (at(24, 12) - at(24, 13)).abs() < 1e-3);
}
#[test]
fn linear_bevel_contour_leaves_the_height_alone_at_any_range() {
for range in [0.5, 0.7, 1.0] {
let lut = bevel_contour_lut(&photocraft_doc::BevelContour { contour: Contour::Linear, range, anti_alias: false });
for v in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
assert!((lut_at(&lut, v) - v).abs() < 1e-3, "range {range}: {v} -> {}", lut_at(&lut, v));
}
}
}
#[test]
fn bevel_contour_and_texture_shape_the_height() {
let shape = square(40, 6, 34);
let l = GlobalLight::default();
let plain = bevel_of(BevelStyle::InnerBevel, BevelTechnique::Smooth);
let (a, _) = bevel_maps(&shape, &plain, &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
// A contour reshapes the profile (here a ramp folded at its middle).
let mut c = plain.clone();
let fold = vec![
photocraft_doc::adjust::CurvePoint { input: 0.0, output: 0.0 },
photocraft_doc::adjust::CurvePoint { input: 0.5, output: 1.0 },
photocraft_doc::adjust::CurvePoint { input: 1.0, output: 0.0 },
];
c.contour = Some(photocraft_doc::BevelContour {
contour: Contour::Custom { name: "fold".into(), points: fold, corners: vec![] },
range: 1.0,
anti_alias: false,
});
let (b, _) = bevel_maps(&shape, &c, &l, &no_tex(), &PreparedPatterns::new(&[], PREPARED_PATTERN_BYTES));
let diff: f32 = a[1].v.iter().zip(&b[1].v).map(|(x, y)| (x - y).abs()).sum();
assert!(diff > 5.0, "{diff}");
// A striped texture lights the flat middle.
let mut sf = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
sf.fill_rect(Rect::new(0, 0, 4, 2), &[0.0, 0.0, 0.0, 1.0]);
sf.fill_rect(Rect::new(0, 2, 4, 4), &[1.0, 1.0, 1.0, 1.0]);
let pats = [Pattern::new("stripes", sf, 4, 4)];
let mut t = plain.clone();
t.texture = Some(photocraft_doc::BevelTexture {
name: "stripes".into(),
id: String::new(),
scale: 1.0,
depth: 1.0,
invert: false,
link: true,
phase: (0.0, 0.0),
});
let tex = TextureCtx { rect: Rect::new(0, 0, 40, 40), patterns: &pats, anchor: (0.0, 0.0) };
let (m, _) = bevel_maps(&shape, &t, &l, &tex, &PreparedPatterns::new(&pats, PREPARED_PATTERN_BYTES));
let mid: f32 = (16..24).map(|y| m[0].v[y * 40 + 20] + m[1].v[y * 40 + 20]).sum();
let flat: f32 = (16..24).map(|y| a[0].v[y * 40 + 20] + a[1].v[y * 40 + 20]).sum();
assert!(mid > flat + 0.5, "{mid} vs {flat}");
}
#[test]
fn glow_range_stretches_the_contour() {
let mut g = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 10.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let l = glow_lut(&g).unwrap();
assert!((lut_at(&l, 0.25) - 0.5).abs() < 1e-3 && (lut_at(&l, 0.75) - 1.0).abs() < 1e-6);
g.range = 1.0;
assert!(glow_lut(&g).is_none());
}
/// Photoshop 27.11, measured on a 128 px square (Normal, black, 100 %, Range 50 %): a precise
/// glow's coverage across the edge, and a centre-source inner glow as 1 − the edge glow.
#[test]
fn glows_match_photoshop() {
let glow = |technique, size: f32, spread: f32, source| Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
paint: FxPaint::Color(photocraft_color::Color::BLACK),
technique,
spread,
size,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source,
};
let n = 60;
let mut shape = Map::new(n, n, 0.0);
for y in 20..40 {
for x in 20..40 {
shape.v[y * n + x] = 1.0;
}
}
let at = |m: &Map, x: usize| m.v[30 * n + x];
// Outer, precise, size 10: Photoshop's black glow over white reads 47, 93, 140, 186, 232
// at 2, 4, 6, 8 and 10 px out from the edge pixel (alpha 1 − D / 11).
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.0, GlowSource::Edge), false, (0, 0));
for (d, ps) in [(2, 47.0f32), (4, 93.0), (6, 140.0), (8, 186.0), (10, 232.0)] {
let got = 255.0 * (1.0 - at(&m, 20 - d));
assert!((got - ps).abs() <= 1.5, "outer precise at {d} px: {got} vs Photoshop {ps}");
}
// Spread 40 %: solid for the first 4 px.
let m = glow_map(&shape, &glow(GlowTechnique::Precise, 10.0, 0.4, GlowSource::Edge), false, (0, 0));
assert!((at(&m, 16) - 1.0).abs() < 1e-6 && at(&m, 14) < 1.0);
// Centre source = 1 − edge, for both techniques.
for t in [GlowTechnique::Precise, GlowTechnique::Softer] {
let e = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Edge), true, (0, 0));
let c = glow_map(&shape, &glow(t, 6.0, 0.0, GlowSource::Center), true, (0, 0));
for x in 20..40 {
assert!((at(&c, x) - (1.0 - at(&e, x))).abs() < 1e-5, "{t:?} at {x}");
}
}
}
#[test]
fn offsets_follow_light() {
let (dx, dy) = offset(120.0, 10.0);
assert_eq!((dx, dy), (5.0, 9.0));
}
fn square_shape(n: usize) -> Map {
let mut m = Map::new(n, n, 0.0);
for y in 5..n - 5 {
for x in 5..n - 5 {
m.v[y * n + x] = 1.0;
}
}
m
}
#[test]
fn satin_at_distance_zero_matches_photoshop() {
// Photoshop 27.11, Satin black 100 %, angle 90°, Linear contour, distance 0, size 5 over a
// #808080 square: the rows from the top edge read 82, 102, 123, 128.
let s = photocraft_doc::effects::Satin {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
color: photocraft_color::Color::BLACK,
angle: 90.0,
distance: 0.0,
size: 5.0,
contour: Contour::Linear,
anti_alias: false,
invert: false,
};
let m = satin_map(&square_shape(60), &s);
for (row, gray) in [(5, 82.0), (7, 102.0), (9, 123.0), (11, 128.0), (30, 128.0)] {
let want = 1.0 - gray / 128.0;
let got = m.v[row * 60 + 30];
assert!((got - want).abs() < 0.02, "row {row}: {got} vs {want}");
}
}
fn shadow(noise: f32, origin: (i32, i32)) -> Map {
let s = Shadow {
common: photocraft_doc::FxCommon::new(BlendMode::Multiply, 1.0),
color: photocraft_color::Color::BLACK,
angle: 0.0,
use_global_light: false,
distance: 0.0,
spread: 0.0,
size: 6.0,
contour: Contour::Linear,
anti_alias: false,
noise,
knocks_out: true,
};
shadow_map(&square_shape(40), &s, &GlobalLight::default(), false, origin)
}
#[test]
fn shadow_noise_speckles_soft_coverage_deterministically() {
let plain = shadow(0.0, (0, 0));
let noisy = shadow(0.5, (0, 0));
let again = shadow(0.5, (0, 0));
let (mut diff, mut identical) = (0.0f32, true);
for i in 0..plain.v.len() {
diff += (plain.v[i] - noisy.v[i]).abs();
if noisy.v[i] != again.v[i] {
identical = false;
}
}
assert!(diff > 5.0, "noise changes the soft band: {diff}");
assert!(identical, "same coords, same speckle");
// Every value stays a coverage.
assert!(noisy.v.iter().all(|v| (0.0..=1.0).contains(v)));
// The speckle moves with the document origin, not the tile.
let moved = shadow(0.5, (100, 100));
assert_ne!(noisy.v, moved.v, "hashed from document coordinates");
// Full noise can punch holes even in the solid part.
let heavy = shadow(1.0, (0, 0));
assert!(heavy.v.iter().any(|v| *v < 0.5) && heavy.v.iter().any(|v| *v > 0.5), "heavy noise spans the range",);
}
#[test]
fn glow_noise_follows_the_contour_and_range() {
let g = |noise: f32| Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 6.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise,
source: GlowSource::Edge,
};
let plain = glow_map(&square_shape(40), &g(0.0), false, (0, 0));
let noisy = glow_map(&square_shape(40), &g(0.6), false, (0, 0));
let diff: f32 = plain.v.iter().zip(&noisy.v).map(|(a, b)| (a - b).abs()).sum();
assert!(diff > 5.0, "glow noise changes the coverage: {diff}");
}
}