effectcraft/crates/render/src/styles.rs

742 lines
28 KiB
Rust
Raw Permalink 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.

//! Layer Styles rendering (Photoshop-style layer effects, as After Effects renders them).
//!
//! Styles run in layer space after masks and effects and before the transform. The layer's
//! processed pixels (the *content*) become:
//!
//! * **exterior passes** — Drop Shadow, then Outer Glow — separate buffers composited onto the
//! comp *below* the layer, each with its own blend mode (so a Multiply shadow multiplies the
//! layers underneath, as in AE);
//! * the **body** — the content scaled by Fill Opacity, then the interior styles in Photoshop's
//! stacking order (Gradient Overlay, Color Overlay, Satin, Inner Glow, Inner Shadow), each
//! clipped to the content's alpha and blended with its mode, then Stroke and Bevel and Emboss.
//! With *Blend Interior Styles as Group* the interior styles are faded by Fill Opacity too.
//!
//! The compositor applies the layer's opacity to the whole stack, and Advanced Blending's
//! Knockout and R/G/B channel switches at composite time (see `Renderer::composite_styled`).
//! Every distance is in layer pixels and scales with the render resolution (Half, Quarter…).
//!
//! Shapes come from a signed distance field of the content alpha (exact Euclidean distance
//! transform, Felzenszwalb–Huttenlocher) for spread/choke, strokes, precise glows and bevel
//! profiles, and from the shared separable Gaussian (`effects::util::gauss_plane`) for soft
//! shadows and glows. All per-pixel work is parallel (rayon).
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
use effectcraft_effects::util::{Plane, gauss_plane};
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_project::styles::{self as st, style_blend_mode};
use effectcraft_project::{Layer, PropGroup};
use effectcraft_raster::{Image, hash_noise};
use rayon::prelude::*;
use crate::eval::EvalCtx;
/// Largest padding (buffer pixels) styles may add on each side.
const MAX_PAD: f64 = 2048.0;
/// A styled layer: exterior passes (bottom first, each with its blend mode) and the body.
#[derive(Clone, Debug)]
pub struct Styled {
pub passes: Vec<(Buf, BlendMode)>,
pub body: Buf,
}
impl Styled {
/// Everything composited into one buffer (for 3D layers, track mattes and layer references).
pub fn flatten(&self) -> Buf {
if self.passes.is_empty() {
return self.body.clone();
}
let mut img = Image::new(self.body.img.width, self.body.img.height);
for (p, m) in &self.passes {
img.blend_from(&p.img, *m, 1.0, 0);
}
img.blend_from(&self.body.img, BlendMode::Normal, 1.0, 0);
Buf { img, offset: self.body.offset, scale: self.body.scale }
}
}
/// Advanced Blending options evaluated at a time.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Blending {
pub fill: f32,
/// Red, green, blue channel switches.
pub channels: [bool; 3],
/// 0 None, 1 Shallow, 2 Deep.
pub knockout: u32,
pub interior_group: bool,
}
impl Default for Blending {
fn default() -> Self {
Blending { fill: 1.0, channels: [true; 3], knockout: 0, interior_group: false }
}
}
/// The layer's Layer Styles group when styles are switched on.
pub fn group(layer: &Layer) -> Option<&PropGroup> {
layer.layer_styles().filter(|g| g.enabled && layer.can_have_styles())
}
/// Evaluated Advanced Blending (defaults when the layer has no styles).
pub fn blending(ctx: &EvalCtx, layer: &Layer) -> Blending {
let Some(adv) = group(layer).and_then(|g| g.sub(st::BLENDING)).and_then(|b| b.sub("advancedBlending")) else { return Blending::default() };
Blending {
fill: (ctx.f(layer, adv, "fillOpacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32,
channels: [ctx.b(layer, adv, "red"), ctx.b(layer, adv, "green"), ctx.b(layer, adv, "blue")],
knockout: ctx.e(layer, adv, "knockout"),
interior_group: ctx.b(layer, adv, "blendInteriorAsGroup"),
}
}
/// Global light (angle, altitude) in degrees for the layer.
fn global_light(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> (f64, f64) {
match g.sub(st::BLENDING) {
Some(bo) => (ctx.f(layer, bo, "globalLightAngle", st::DEFAULT_ANGLE), ctx.f(layer, bo, "globalLightAltitude", st::DEFAULT_ALTITUDE)),
None => (st::DEFAULT_ANGLE, st::DEFAULT_ALTITUDE),
}
}
/// Does the layer need the styled path (any style on, or non-default blending)?
pub fn active(ctx: &EvalCtx, layer: &Layer) -> bool {
let Some(g) = group(layer) else { return false };
g.groups().any(|s| s.enabled && s.match_id != st::BLENDING) || blending(ctx, layer) != Blending::default()
}
/// Blend modes of the exterior passes [`render`] produces (Drop Shadow, then Outer Glow).
pub fn pass_modes(ctx: &EvalCtx, layer: &Layer) -> Vec<BlendMode> {
let Some(g) = group(layer) else { return vec![] };
["dropShadow", "outerGlow"].iter().filter_map(|id| g.sub(id).filter(|s| s.enabled)).map(|s| style_blend_mode(ctx.e(layer, s, "blendMode"))).collect()
}
// ------------------------------------------------------------------------------------------
// Evaluated styles
struct Shadow {
mode: BlendMode,
color: [f32; 3],
opacity: f32,
offset: [f64; 2],
spread: f64,
size: f64,
noise: f32,
knocks_out: bool,
}
struct Glow {
mode: BlendMode,
opacity: f32,
noise: f32,
gradient: Option<Gradient>,
color: [f32; 3],
precise: bool,
center: bool,
spread: f64,
size: f64,
range: f64,
jitter: f32,
}
struct Bevel {
style: u32,
technique: u32,
depth: f64,
down: bool,
size: f64,
soften: f64,
angle: f64,
altitude: f64,
hl: (BlendMode, [f32; 3], f32),
sh: (BlendMode, [f32; 3], f32),
}
struct Satin {
mode: BlendMode,
color: [f32; 3],
opacity: f32,
offset: [f64; 2],
size: f64,
invert: bool,
}
struct Overlay {
mode: BlendMode,
color: [f32; 3],
opacity: f32,
}
struct GradOverlay {
mode: BlendMode,
opacity: f32,
gradient: Gradient,
angle: f64,
style: u32,
reverse: bool,
align: bool,
scale: f64,
offset: [f64; 2],
}
struct Stroke {
mode: BlendMode,
color: [f32; 3],
size: f64,
opacity: f32,
position: u32,
}
#[derive(Default)]
struct Styles {
drop: Option<Shadow>,
inner_shadow: Option<Shadow>,
outer_glow: Option<Glow>,
inner_glow: Option<Glow>,
bevel: Option<Bevel>,
satin: Option<Satin>,
color: Option<Overlay>,
gradient: Option<GradOverlay>,
stroke: Option<Stroke>,
}
fn rgb(c: [f32; 4]) -> [f32; 3] {
[c[0], c[1], c[2]]
}
/// Light direction → shadow offset (the shadow falls away from the light).
fn offset(angle: f64, distance: f64) -> [f64; 2] {
let a = angle.to_radians();
[-distance * a.cos(), distance * a.sin()]
}
fn evaluate(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> Styles {
let (ga, galt) = global_light(ctx, layer, g);
let mut s = Styles::default();
for sg in g.groups().filter(|x| x.enabled) {
let f = |m: &str, d: f64| ctx.f(layer, sg, m, d);
let pct = |m: &str, d: f64| (ctx.f(layer, sg, m, d) / 100.0).clamp(0.0, 1.0);
let mode = |m: &str| style_blend_mode(ctx.e(layer, sg, m));
let col = |m: &str| rgb(ctx.color(layer, sg, m));
let ang = || if ctx.b(layer, sg, "useGlobalLight") { ga } else { f("angle", st::DEFAULT_ANGLE) };
let grad = || match ctx.group_value(layer, sg, "colors") {
Some(Value::Gradient(g)) => g,
_ => Gradient::default(),
};
match sg.match_id.as_str() {
"dropShadow" | "innerShadow" => {
let inner = sg.match_id == "innerShadow";
let sh = Shadow {
mode: mode("blendMode"),
color: col("color"),
opacity: pct("opacity", 75.0) as f32,
offset: offset(ang(), f("distance", 5.0).max(0.0)),
spread: pct(if inner { "choke" } else { "spread" }, 0.0),
size: f("size", 5.0).clamp(0.0, 250.0),
noise: pct("noise", 0.0) as f32,
knocks_out: !inner && ctx.b(layer, sg, "knocksOut"),
};
if inner {
s.inner_shadow = Some(sh);
} else {
s.drop = Some(sh);
}
}
"outerGlow" | "innerGlow" => {
let inner = sg.match_id == "innerGlow";
let gl = Glow {
mode: mode("blendMode"),
opacity: pct("opacity", 75.0) as f32,
noise: pct("noise", 0.0) as f32,
gradient: (ctx.e(layer, sg, "colorType") == 1).then(grad),
color: col("color"),
precise: ctx.e(layer, sg, "technique") == 1,
center: inner && ctx.e(layer, sg, "source") == 0,
spread: pct(if inner { "choke" } else { "spread" }, 0.0),
size: f("size", 5.0).clamp(0.0, 250.0),
range: pct("range", 50.0).max(0.01),
jitter: pct("jitter", 0.0) as f32,
};
if inner {
s.inner_glow = Some(gl);
} else {
s.outer_glow = Some(gl);
}
}
"bevelEmboss" => {
let gl = ctx.b(layer, sg, "useGlobalLight");
s.bevel = Some(Bevel {
style: ctx.e(layer, sg, "style"),
technique: ctx.e(layer, sg, "technique"),
depth: f("depth", 100.0).clamp(1.0, 1000.0) / 100.0,
down: ctx.e(layer, sg, "direction") == 1,
size: f("size", 5.0).clamp(0.0, 250.0),
soften: f("soften", 0.0).clamp(0.0, 16.0),
angle: if gl { ga } else { f("angle", st::DEFAULT_ANGLE) },
altitude: if gl { galt } else { f("altitude", st::DEFAULT_ALTITUDE) }.clamp(0.0, 90.0),
hl: (mode("highlightMode"), col("highlightColor"), pct("highlightOpacity", 75.0) as f32),
sh: (mode("shadowMode"), col("shadowColor"), pct("shadowOpacity", 75.0) as f32),
});
}
"satin" => {
s.satin = Some(Satin {
mode: mode("blendMode"),
color: col("color"),
opacity: pct("opacity", 50.0) as f32,
offset: offset(f("angle", 19.0), f("distance", 11.0).clamp(0.0, 250.0)),
size: f("size", 14.0).clamp(0.0, 250.0),
invert: ctx.b(layer, sg, "invert"),
});
}
"colorOverlay" => s.color = Some(Overlay { mode: mode("blendMode"), color: col("color"), opacity: pct("opacity", 100.0) as f32 }),
"gradientOverlay" => {
s.gradient = Some(GradOverlay {
mode: mode("blendMode"),
opacity: pct("opacity", 100.0) as f32,
gradient: grad(),
angle: f("angle", 90.0),
style: ctx.e(layer, sg, "style"),
reverse: ctx.b(layer, sg, "reverse"),
align: ctx.b(layer, sg, "alignWithLayer"),
scale: f("scale", 100.0).clamp(10.0, 150.0) / 100.0,
offset: ctx.v2(layer, sg, "offset", [0.0; 2]),
});
}
"stroke" => {
s.stroke = Some(Stroke {
mode: mode("blendMode"),
color: col("color"),
size: f("size", 3.0).clamp(0.0, 250.0),
opacity: pct("opacity", 100.0) as f32,
position: ctx.e(layer, sg, "position"),
});
}
_ => {}
}
}
s
}
/// Layer-space margin the styles reach beyond the content.
fn reach(s: &Styles) -> f64 {
let soft = |size: f64| size * 1.6;
let mut r: f64 = 0.0;
if let Some(d) = &s.drop {
r = r.max(d.offset[0].abs().max(d.offset[1].abs()) + soft(d.size));
}
if let Some(d) = &s.inner_shadow {
r = r.max(d.offset[0].abs().max(d.offset[1].abs()) + soft(d.size));
}
if let Some(g) = &s.outer_glow {
r = r.max(soft(g.size));
}
if let Some(g) = &s.inner_glow {
r = r.max(soft(g.size));
}
if let Some(b) = &s.bevel {
r = r.max(b.size + b.soften * 2.0);
}
if let Some(st) = &s.stroke {
r = r.max(st.size);
}
if let Some(sa) = &s.satin {
r = r.max(sa.offset[0].abs().max(sa.offset[1].abs()) + soft(sa.size));
}
r
}
// ------------------------------------------------------------------------------------------
// Distance fields
/// "Infinite" squared distance (finite so parabola intersections stay exact in f64).
const FAR: f64 = 1e10;
/// 1D squared Euclidean distance transform (Felzenszwalb & Huttenlocher) of `f` into `d`.
fn dt1d(f: &[f32], d: &mut [f32]) {
let n = f.len();
if n == 0 {
return;
}
let fv = |i: usize| (f[i] as f64).min(FAR);
let mut v = vec![0usize; n];
let mut z = vec![0f64; n + 1];
let mut k = 0usize;
z[0] = f64::NEG_INFINITY;
z[1] = f64::INFINITY;
let cross = |q: usize, p: usize| ((fv(q) + (q * q) as f64) - (fv(p) + (p * p) as f64)) / (2.0 * (q as f64 - p as f64));
for q in 1..n {
let mut s = cross(q, v[k]);
while k > 0 && s <= z[k] {
k -= 1;
s = cross(q, v[k]);
}
k += 1;
v[k] = q;
z[k] = s;
z[k + 1] = f64::INFINITY;
}
k = 0;
for (q, dq) in d.iter_mut().enumerate() {
while z[k + 1] < q as f64 {
k += 1;
}
let dx = q as f64 - v[k] as f64;
*dq = (dx * dx + fv(v[k])) as f32;
}
}
/// Euclidean distance (pixels) from every pixel to the nearest pixel where `seed` is true.
fn edt(w: usize, h: usize, seed: impl Fn(usize) -> bool + Sync) -> Plane {
let f = Plane { w, h, data: (0..w * h).into_par_iter().map(|i| if seed(i) { 0.0 } else { FAR as f32 }).collect() };
let rows = f.map_rows(dt1d);
let cols = rows.transpose().map_rows(dt1d).transpose();
cols.map(|v| v.sqrt())
}
/// Signed distance to the content edge in pixels (negative inside), anti-aliased at the edge:
/// a partially covered pixel with alpha `a` sits at `0.5 - a`.
fn sdf(alpha: &Plane) -> Plane {
let (w, h) = (alpha.w, alpha.h);
let to_in = edt(w, h, |i| alpha.data[i] >= 0.5);
let to_out = edt(w, h, |i| alpha.data[i] < 0.5);
Plane {
w,
h,
data: (0..w * h)
.into_par_iter()
.map(|i| {
let a = alpha.data[i];
if a > 0.0 && a < 1.0 {
0.5 - a
} else if a >= 0.5 {
-(to_out.data[i] - 0.5)
} else {
to_in.data[i] - 0.5
}
})
.collect(),
}
}
/// Coverage of the content grown by `r` pixels (negative `r` shrinks).
#[inline]
fn grown(sdf: f32, r: f32) -> f32 {
(r + 0.5 - sdf).clamp(0.0, 1.0)
}
// ------------------------------------------------------------------------------------------
// Helpers
/// Soft shape: content grown by `spread` of `size`, blurred by the rest (`inverse` = the
/// outside of the content, for inner shadows/glows).
fn soft_shape(alpha: &Plane, d: &Lazy, size: f64, spread: f64, inverse: bool) -> Plane {
let hard = size * spread;
let base = if hard > 0.01 {
let sd = d.get(alpha);
let r = hard as f32;
if inverse { sd.map(|v| grown(-v, r)) } else { sd.map(|v| grown(v, r)) }
} else if inverse {
alpha.map(|a| 1.0 - a)
} else {
alpha.clone()
};
let sigma = (size - hard).max(0.0) / 2.0;
if sigma > 0.05 { gauss_plane(&base, sigma, sigma) } else { base }
}
/// Bilinear read of `p` displaced by `off` pixels (the plane moves by `off`).
#[inline]
fn shifted(p: &Plane, x: usize, y: usize, off: [f64; 2]) -> f32 {
p.sample(x as f64 + 0.5 - off[0], y as f64 + 0.5 - off[1])
}
struct Lazy(std::sync::OnceLock<Plane>);
impl Lazy {
fn get(&self, alpha: &Plane) -> &Plane {
self.0.get_or_init(|| sdf(alpha))
}
}
#[inline]
fn noisy(a: f32, noise: f32, x: usize, y: usize, seed: u32) -> f32 {
if noise <= 0.0 { a } else { a * (1.0 - noise * hash_noise(x as u32, y as u32, seed)) }
}
/// Blend a straight colour with coverage `a` onto every pixel of `img` (`f(x, y)` → (colour, a)).
fn blend_into(img: &mut Image, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) {
let w = img.width as usize;
img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let (c, a) = f(x, y);
if a <= 0.0 {
continue;
}
let src = [c[0] * a, c[1] * a, c[2] * a, a];
let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 };
*d = blend_pixel(mode, *d, src, n);
}
});
}
/// A pass image filled with a straight colour at coverage `f(x, y)`.
fn pass_image(w: usize, h: usize, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) -> Image {
let mut img = Image::new(w as u32, h as u32);
img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
for (x, d) in row.iter_mut().enumerate() {
let (c, a) = f(x, y);
let a = a.clamp(0.0, 1.0);
*d = [c[0] * a, c[1] * a, c[2] * a, a];
}
});
img
}
/// Glow intensity → (colour, alpha) for colour or gradient glows.
fn glow_color(g: &Glow, v: f32, x: usize, y: usize) -> ([f32; 3], f32) {
let v = v.clamp(0.0, 1.0).powf((0.5 / g.range) as f32);
match &g.gradient {
None => (g.color, v),
Some(gr) => {
let j = if g.jitter > 0.0 { (hash_noise(x as u32, y as u32, 77) - 0.5) * g.jitter } else { 0.0 };
let c = gr.sample((1.0 - v + j) as f64);
([c[0], c[1], c[2]], c[3] * (v * 8.0).min(1.0))
}
}
}
/// Glow intensity (0..1) from the content (`inner` glows read the inverse).
fn glow_intensity(alpha: &Plane, d: &Lazy, g: &Glow, s: f64, inner: bool) -> Plane {
let size = g.size * s;
if g.precise {
let sd = d.get(alpha);
let hard = (size * g.spread) as f32;
let soft = (size as f32 - hard).max(0.5);
return sd.map(|v| {
let v = if inner { -v } else { v };
(1.0 - (v - hard).max(0.0) / soft).clamp(0.0, 1.0)
});
}
soft_shape(alpha, d, size, g.spread, inner).map(|v| (v * 2.0).min(1.0))
}
// ------------------------------------------------------------------------------------------
// Rendering
/// Render the layer's styles around its processed `content` (layer size `layer_size` in layer
/// pixels, for Gradient Overlay without Align with Layer).
pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2]) -> Styled {
let Some(g) = group(layer) else { return Styled { passes: vec![], body: content.clone() } };
let styles = evaluate(ctx, layer, g);
let bl = blending(ctx, layer);
let s = content.scale.max(1e-6);
let pad = ((reach(&styles) * s).ceil() + 2.0).min(MAX_PAD) as u32;
let mut buf = content.clone();
if content.img.is_empty() {
return Styled { passes: vec![], body: buf };
}
buf.pad(pad);
let (w, h) = (buf.img.width as usize, buf.img.height as usize);
let alpha = Plane::alpha(&buf.img);
let d = Lazy(std::sync::OnceLock::new());
let seed = layer.id.0 as u32;
// ---- exterior passes
let mut passes = vec![];
if let Some(ds) = &styles.drop {
let shape = soft_shape(&alpha, &d, ds.size * s, ds.spread, false);
let off = [ds.offset[0] * s, ds.offset[1] * s];
let img = pass_image(w, h, |x, y| {
let mut a = shifted(&shape, x, y, off) * ds.opacity;
if ds.knocks_out {
a *= 1.0 - alpha.data[y * w + x];
}
(ds.color, noisy(a, ds.noise, x, y, seed))
});
passes.push((Buf { img, offset: buf.offset, scale: buf.scale }, ds.mode));
}
if let Some(gl) = &styles.outer_glow {
let v = glow_intensity(&alpha, &d, gl, s, false);
let img = pass_image(w, h, |x, y| {
let i = y * w + x;
let (c, a) = glow_color(gl, v.data[i], x, y);
// The glow sits outside the content (knocked out under it).
(c, noisy(a * gl.opacity * (1.0 - alpha.data[i]), gl.noise, x, y, seed ^ 0x5a5a))
});
passes.push((Buf { img, offset: buf.offset, scale: buf.scale }, gl.mode));
}
// ---- body
let mut body = buf.img.clone();
if !bl.interior_group && bl.fill < 1.0 {
body.scale_alpha(bl.fill);
}
let cov = |i: usize| alpha.data[i];
if let Some(go) = &styles.gradient {
let (cx, cy, hw, hh) = gradient_frame(go, &buf, &alpha, layer_size);
let (ux, uy) = (go.angle.to_radians().cos(), -go.angle.to_radians().sin());
let ext = ((hw * ux).abs() + (hh * uy).abs()).max(1e-3) * go.scale;
let radius = hw.max(hh).max(1e-3) * go.scale;
blend_into(&mut body, go.mode, seed, |x, y| {
let (px, py) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy);
let along = px * ux + py * uy;
let across = -px * uy + py * ux;
let mut t = match go.style {
1 => (px * px + py * py).sqrt() / radius,
2 => {
let a = (-py).atan2(px) - go.angle.to_radians();
(a / std::f64::consts::TAU).rem_euclid(1.0)
}
3 => along.abs() / ext,
4 => (along.abs() + across.abs()) / ext,
_ => 0.5 + along / (2.0 * ext),
};
if go.reverse {
t = 1.0 - t;
}
let c = go.gradient.sample(t.clamp(0.0, 1.0));
([c[0], c[1], c[2]], c[3] * go.opacity * cov(y * w + x))
});
}
if let Some(co) = &styles.color {
blend_into(&mut body, co.mode, seed, |x, y| (co.color, co.opacity * cov(y * w + x)));
}
if let Some(sa) = &styles.satin {
let b = gauss_plane(&alpha, sa.size * s / 2.0, sa.size * s / 2.0);
let off = [sa.offset[0] * s, sa.offset[1] * s];
blend_into(&mut body, sa.mode, seed, |x, y| {
let v = (shifted(&b, x, y, off) - shifted(&b, x, y, [-off[0], -off[1]])).abs().min(1.0);
let v = if sa.invert { 1.0 - v } else { v };
(sa.color, v * sa.opacity * cov(y * w + x))
});
}
if let Some(gl) = &styles.inner_glow {
let v = glow_intensity(&alpha, &d, gl, s, true);
blend_into(&mut body, gl.mode, seed, |x, y| {
let i = y * w + x;
let e = if gl.center { 1.0 - v.data[i] } else { v.data[i] };
let (c, a) = glow_color(gl, e, x, y);
(c, noisy(a * gl.opacity * cov(i), gl.noise, x, y, seed ^ 0x3c3c))
});
}
if let Some(is) = &styles.inner_shadow {
let shape = soft_shape(&alpha, &d, is.size * s, is.spread, true);
let off = [is.offset[0] * s, is.offset[1] * s];
blend_into(&mut body, is.mode, seed, |x, y| {
let a = shifted(&shape, x, y, off).min(1.0) * is.opacity * cov(y * w + x);
(is.color, noisy(a, is.noise, x, y, seed ^ 0x1234))
});
}
if bl.interior_group && bl.fill < 1.0 {
body.scale_alpha(bl.fill);
}
if let Some(sk) = &styles.stroke {
let sd = d.get(&alpha);
let size = (sk.size * s) as f32;
let (r_out, r_in) = match sk.position {
1 => (0.0, size),
2 => (size / 2.0, size / 2.0),
_ => (size, 0.0),
};
// Inside part: blended over the body within the content.
if r_in > 0.0 {
blend_into(&mut body, sk.mode, seed, |x, y| {
let i = y * w + x;
let ring = (alpha.data[i] - grown(sd.data[i], -r_in)).max(0.0);
(sk.color, ring * sk.opacity)
});
}
// Outside part: covers exactly what the content leaves uncovered.
if r_out > 0.0 {
body.data.par_iter_mut().enumerate().for_each(|(i, p)| {
let ring = (grown(sd.data[i], r_out) - alpha.data[i]).max(0.0) * sk.opacity;
if ring > 0.0 {
let src = [sk.color[0] * ring, sk.color[1] * ring, sk.color[2] * ring, ring];
for c in 0..4 {
p[c] += src[c];
}
}
});
}
}
if let Some(bv) = &styles.bevel {
bevel(&mut body, &alpha, &d, bv, s, seed);
}
Styled { passes, body: Buf { img: body, offset: buf.offset, scale: buf.scale } }
}
/// Gradient Overlay frame in buffer pixels: centre and half extents.
fn gradient_frame(go: &GradOverlay, buf: &Buf, alpha: &Plane, layer_size: [f64; 2]) -> (f64, f64, f64, f64) {
let s = buf.scale;
let (x0, y0, x1, y1) = if go.align {
let img = Image { width: alpha.w as u32, height: alpha.h as u32, data: alpha.data.iter().map(|&a| [0.0, 0.0, 0.0, a]).collect() };
match img.alpha_bounds() {
Some((a, b, c, d)) => (a as f64, b as f64, c as f64, d as f64),
None => (0.0, 0.0, 1.0, 1.0),
}
} else {
let (a, b) = buf.to_px([0.0, 0.0]);
let (c, d) = buf.to_px(layer_size);
(a, b, c, d)
};
((x0 + x1) / 2.0 + go.offset[0] * s, (y0 + y1) / 2.0 + go.offset[1] * s, (x1 - x0) / 2.0, (y1 - y0) / 2.0)
}
/// Bevel and Emboss: a height field from the content edge, lit by a directional light.
fn bevel(body: &mut Image, alpha: &Plane, d: &Lazy, bv: &Bevel, s: f64, seed: u32) {
let size = (bv.size * s).max(0.5) as f32;
let sd = d.get(alpha);
let w = alpha.w;
// Height profile 0..1 and the region the bevel paints.
let style = bv.style;
let mut height = sd.map(|v| match style {
0 => (1.0 - v.max(0.0) / size).clamp(0.0, 1.0),
2 | 4 => (0.5 - v / (2.0 * size)).clamp(0.0, 1.0),
3 => (v.abs() / size).clamp(0.0, 1.0),
_ => (-v / size + 0.5 / size).clamp(0.0, 1.0),
});
// The distance field is exact at pixel centres only; smoothing the profile removes the
// stair-steps on diagonal edges that lighting would otherwise amplify. Smooth rounds the
// profile over the bevel size; Chisel Hard keeps it crisp, Chisel Soft slightly softer.
let sigma = match bv.technique {
0 => (size as f64 / 4.0).max(1.0),
2 => 1.5,
_ => 0.75,
};
height = gauss_plane(&height, sigma, sigma);
if bv.soften > 0.0 {
let sg = bv.soften * s / 2.0;
height = gauss_plane(&height, sg, sg);
}
let sign = if bv.down { -1.0 } else { 1.0 };
let k = (size as f64 * bv.depth) as f32 * sign;
let (th, al) = (bv.angle.to_radians(), bv.altitude.to_radians());
let light = [(al.cos() * th.cos()) as f32, (-al.cos() * th.sin()) as f32, al.sin() as f32];
let flat = light[2];
let region = |i: usize| -> f32 {
let a = alpha.data[i];
match style {
0 => (grown(sd.data[i], size) - a).max(0.0),
1 => a,
_ => grown(sd.data[i], size),
}
};
let shade = |x: usize, y: usize| -> (f32, f32) {
let hx = |xx: i64| height.get_clamped(xx, y as i64);
let hy = |yy: i64| height.get_clamped(x as i64, yy);
let gx = (hx(x as i64 + 1) - hx(x as i64 - 1)) * 0.5 * k;
let gy = (hy(y as i64 + 1) - hy(y as i64 - 1)) * 0.5 * k;
let n = [-gx, -gy, 1.0];
let len = (n[0] * n[0] + n[1] * n[1] + 1.0).sqrt();
let dot = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]) / len;
let hl = ((dot - flat) / (1.0 - flat).max(1e-4)).clamp(0.0, 1.0);
let shd = ((flat - dot) / flat.max(1e-4)).clamp(0.0, 1.0);
(hl, shd)
};
blend_into(body, bv.sh.0, seed, |x, y| {
let (_, shd) = shade(x, y);
(bv.sh.1, shd * bv.sh.2 * region(y * w + x))
});
blend_into(body, bv.hl.0, seed, |x, y| {
let (hl, _) = shade(x, y);
(bv.hl.1, hl * bv.hl.2 * region(y * w + x))
});
}