//! 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 { 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, 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, inner_shadow: Option, outer_glow: Option, inner_glow: Option, bevel: Option, satin: Option, color: Option, gradient: Option, stroke: Option, } 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); 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)) }); }