mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
- Background blur sees the whole backdrop under the layer: the renderer keeps the stack of isolated surfaces it draws into (clipping frames, rounded clips, faded groups, masks) and flattens it for the layer's area, reading past the layer's own box. A blur inside a clipping frame now shows the frame's fill instead of the sharp layers below, on the canvas and in PNG exports. The PDF export draws background blur and glass through the same code from a render of what lies under the node (`backdrop::render_beneath`, which the PDF's backdrop now shares), and its images match the PNG's pixels. - Shadow spread offsets the outline: the shape filled and stroked twice the spread wide with mitred joins (grow) or cut away (shrink), the stroked bands cached per outline. Ellipses stay ellipses, stars keep sharp points, text keeps its letterforms, rounded corners grow their radius. Inner shadows shrink the outline the same way. Canvas, PNG and PDF share the shadow code; SVG writes spread shadows as the same geometry and now exports inner shadows. - Glass effect (light angle and intensity, refraction, depth, dispersion, frost, splay): the frosted backdrop bent towards the inside within `depth` of the edge, colour fringes, light on the rim, from an exact distance transform with smoothed normals. - Noise: mono, duo and multi colour modes, grain size, density, opacity and blend mode; grains live in the layer's coordinates (stable when panning and zooming). Texture is now drawn (pixels scattered by the radius, grain size, clip to shape). - Design panel rows for noise mode/colours/opacity, texture clip to shape and every glass value; `effect.add` takes `glass`; REST import of NOISE, TEXTURE and GLASS; Inspect rows and CSS; the scale tool scales grain sizes, texture radius, glass depth and frost. - Tests in new modules: render backdrop/spread/grain tests, PDF backdrop tests, SVG, doc, engine and REST effect tests. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
11 KiB
Rust
256 lines
11 KiB
Rust
//! The glass effect: the backdrop seen through a pane whose edges curve down like a lens.
|
||
//!
|
||
//! Within `depth` of the outline the surface curves: the backdrop there is bent towards the inside
|
||
//! (strongest at the very edge, `refraction` × `depth` far), each colour channel by a slightly
|
||
//! different amount (`dispersion`, a rainbow fringe), and the rim catches the light: brightest
|
||
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
|
||
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
|
||
//! distance transform of the shape's coverage.
|
||
|
||
use figcraft_doc::Glass;
|
||
use figcraft_geom::Affine;
|
||
use tiny_skia::Pixmap;
|
||
|
||
use crate::fx;
|
||
use crate::shadow::{Outline, spread_alpha};
|
||
|
||
/// Squared distance transform of one row or column (Felzenszwalb & Huttenlocher): `f` holds 0 at
|
||
/// sources and a large value elsewhere; `d` gets each sample's squared distance to the nearest
|
||
/// source. `v` and `z` are scratch space (`f.len()` and `f.len() + 1` long).
|
||
fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) {
|
||
let n = f.len();
|
||
// Every index below stays under these lengths (k <= q < n), checked once here.
|
||
if n == 0 || d.len() < n || v.len() < n || z.len() < n + 1 {
|
||
return;
|
||
}
|
||
let mut k = 0usize;
|
||
v[0] = 0;
|
||
z[0] = f64::NEG_INFINITY;
|
||
z[1] = f64::INFINITY;
|
||
for q in 1..n {
|
||
let qf = q as f64;
|
||
let mut s;
|
||
loop {
|
||
let vk = v[k] as f64;
|
||
s = ((f[q] + qf * qf) - (f[v[k]] + vk * vk)) / (2.0 * qf - 2.0 * vk);
|
||
if s <= z[k] && k > 0 {
|
||
k -= 1;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
if s <= z[k] {
|
||
// Only the first parabola is left and this one hides it.
|
||
v[0] = q;
|
||
z[1] = f64::INFINITY;
|
||
} else {
|
||
k += 1;
|
||
v[k] = q;
|
||
z[k] = s;
|
||
z[k + 1] = f64::INFINITY;
|
||
}
|
||
}
|
||
let mut k = 0usize;
|
||
for q in 0..n {
|
||
let qf = q as f64;
|
||
while z[k + 1] < qf {
|
||
k += 1;
|
||
}
|
||
let dv = qf - v[k] as f64;
|
||
d[q] = dv * dv + f[v[k]];
|
||
}
|
||
}
|
||
|
||
/// For a `w` × `h` coverage pixmap: each pixel's distance (pixels) from its centre to the edge of
|
||
/// the shape (pixels at least half covered), 0 outside.
|
||
pub fn inside_distance(cov: &Pixmap) -> Vec<f32> {
|
||
let (w, h) = (cov.width() as usize, cov.height() as usize);
|
||
const FAR: f64 = 1e20;
|
||
let mut g: Vec<f64> = cov.data().chunks_exact(4).map(|p| if p.get(3).copied().unwrap_or(0) >= 128 { FAR } else { 0.0 }).collect();
|
||
let n = w.max(h);
|
||
let (mut f, mut d, mut v, mut z) = (vec![0f64; n], vec![0f64; n], vec![0usize; n], vec![0f64; n + 1]);
|
||
for x in 0..w {
|
||
for y in 0..h {
|
||
if let (Some(fy), Some(gv)) = (f.get_mut(y), g.get(y * w + x)) {
|
||
*fy = *gv;
|
||
}
|
||
}
|
||
edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z);
|
||
for y in 0..h {
|
||
if let (Some(gv), Some(dy)) = (g.get_mut(y * w + x), d.get(y)) {
|
||
*gv = *dy;
|
||
}
|
||
}
|
||
}
|
||
for y in 0..h {
|
||
let row = y * w;
|
||
if let (Some(src), Some(dst)) = (g.get(row..row + w), f.get_mut(..w)) {
|
||
dst.copy_from_slice(src);
|
||
}
|
||
edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z);
|
||
if let (Some(dst), Some(src)) = (g.get_mut(row..row + w), d.get(..w)) {
|
||
dst.copy_from_slice(src);
|
||
}
|
||
}
|
||
// Centre-to-centre distance to the nearest outside pixel → distance to the edge between.
|
||
g.iter().map(|s| if *s > 0.0 { (s.sqrt() - 0.5).max(0.0) as f32 } else { 0.0 }).collect()
|
||
}
|
||
|
||
/// Running-sum box blur of a `w` × `h` field along rows (`stride` 1) or columns (`stride` `w`),
|
||
/// radius `r`, zero outside.
|
||
fn box_pass(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, rows: bool) {
|
||
let (lines, len, step, across) = if rows { (h, w, 1, w) } else { (w, h, w, 1) };
|
||
let norm = 1.0 / (2 * r + 1) as f32;
|
||
for l in 0..lines {
|
||
let base = l * across;
|
||
let at = |i: usize| src.get(base + i * step).copied().unwrap_or(0.0);
|
||
let mut acc: f32 = (0..=r.min(len.saturating_sub(1))).map(at).sum();
|
||
for i in 0..len {
|
||
if let Some(o) = dst.get_mut(base + i * step) {
|
||
*o = acc * norm;
|
||
}
|
||
if i + r + 1 < len {
|
||
acc += at(i + r + 1);
|
||
}
|
||
if i >= r {
|
||
acc -= at(i - r);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Unit inward normals of the shape from its inside `dist`ance field (`w` × `h`): the distance's
|
||
/// gradient, smoothed over `r` pixels so curved edges bend the backdrop smoothly instead of in the
|
||
/// steps of the pixel grid. Zero where there is no direction (outside, or a flat middle).
|
||
fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)> {
|
||
let at = |x: usize, y: usize| dist.get(y * w + x).copied().unwrap_or(0.0);
|
||
let mut gx = vec![0f32; w * h];
|
||
let mut gy = vec![0f32; w * h];
|
||
for y in 1..h.saturating_sub(1) {
|
||
for x in 1..w.saturating_sub(1) {
|
||
if let (Some(a), Some(b)) = (gx.get_mut(y * w + x), gy.get_mut(y * w + x)) {
|
||
*a = (at(x + 1, y) - at(x - 1, y)) * 0.5;
|
||
*b = (at(x, y + 1) - at(x, y - 1)) * 0.5;
|
||
}
|
||
}
|
||
}
|
||
let mut tmp = vec![0f32; w * h];
|
||
for g in [&mut gx, &mut gy] {
|
||
for _ in 0..2 {
|
||
box_pass(g, &mut tmp, w, h, r, true);
|
||
box_pass(&tmp, g, w, h, r, false);
|
||
}
|
||
}
|
||
gx.iter()
|
||
.zip(&gy)
|
||
.map(|(x, y)| {
|
||
let len = (x * x + y * y).sqrt();
|
||
if len > 1e-4 { (x / len, y / len) } else { (0.0, 0.0) }
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each
|
||
/// channel 0..=255; transparent outside.
|
||
fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] {
|
||
let (w, h) = (p.width() as i64, p.height() as i64);
|
||
let (x, y) = (x - 0.5, y - 0.5);
|
||
let (x0, y0) = (x.floor(), y.floor());
|
||
let (tx, ty) = (x - x0, y - y0);
|
||
let (x0, y0) = (x0 as i64, y0 as i64);
|
||
let data = p.data();
|
||
let px = |xi: i64, yi: i64| -> [f32; 4] {
|
||
if xi < 0 || yi < 0 || xi >= w || yi >= h {
|
||
return [0.0; 4];
|
||
}
|
||
let i = ((yi * w + xi) * 4) as usize;
|
||
match data.get(i..i + 4) {
|
||
Some(s) => [f32::from(s[0]), f32::from(s[1]), f32::from(s[2]), f32::from(s[3])],
|
||
None => [0.0; 4],
|
||
}
|
||
};
|
||
let (a, b, c, d) = (px(x0, y0), px(x0 + 1, y0), px(x0, y0 + 1), px(x0 + 1, y0 + 1));
|
||
std::array::from_fn(|k| {
|
||
let top = a[k] + (b[k] - a[k]) * tx;
|
||
let bot = c[k] + (d[k] - c[k]) * tx;
|
||
top + (bot - top) * ty
|
||
})
|
||
}
|
||
|
||
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
|
||
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option<Pixmap> {
|
||
let g = g.sanitized();
|
||
let depth = (g.depth * scale).clamp(1.0, 1024.0);
|
||
// Coverage with a margin, so distances aren't cut short where the layer is cropped (past the
|
||
// margin the edge is further than `depth` away and nothing bends).
|
||
let m = depth.ceil() as u32 + 2;
|
||
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
|
||
let dist = inside_distance(&cov);
|
||
let (cw, ch) = (cov.width() as usize, cov.height() as usize);
|
||
let normals = smooth_normals(&dist, cw, ch, (depth * 0.1).clamp(1.0, 8.0) as usize);
|
||
let mut frosted = back.clone();
|
||
fx::blur(&mut frosted, g.frost / 2.0 * scale);
|
||
let ang = g.light_angle.to_radians();
|
||
let light = (ang.sin() as f32, -ang.cos() as f32);
|
||
let bend_max = (g.refraction * depth) as f32;
|
||
let fringe = (g.dispersion * 0.25) as f32;
|
||
let power = (6.0 - 5.0 * g.splay) as f32;
|
||
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0) as f32;
|
||
let intensity = g.light_intensity as f32;
|
||
let depth = depth as f32;
|
||
let mut out = Pixmap::new(w, h)?;
|
||
let dst = out.data_mut();
|
||
let cd = cov.data();
|
||
let d_at = |x: usize, y: usize| dist.get(y * cw + x).copied().unwrap_or(0.0);
|
||
for y in 0..h as usize {
|
||
for x in 0..w as usize {
|
||
let (mx, my) = (x + m as usize, y + m as usize);
|
||
let c = f32::from(cd.get((my * cw + mx) * 4 + 3).copied().unwrap_or(0)) / 255.0;
|
||
if c <= 0.0 {
|
||
continue;
|
||
}
|
||
let d = d_at(mx, my);
|
||
let inward = normals.get(my * cw + mx).copied().unwrap_or((0.0, 0.0));
|
||
let t = (d / depth).min(1.0);
|
||
let bend = bend_max * (1.0 - t) * (1.0 - t);
|
||
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
|
||
let at = |k: f32| sample(&frosted, bx + inward.0 * bend * k, by + inward.1 * bend * k);
|
||
let (rs, gs, bs) = (at(1.0 - fringe), at(1.0), at(1.0 + fringe));
|
||
let a = rs[3].max(gs[3]).max(bs[3]);
|
||
let base = [rs[0].min(a), gs[1].min(a), bs[2].min(a), a];
|
||
// The rim catches the light where it faces it, and half as much opposite.
|
||
let facing = -(inward.0 * light.0 + inward.1 * light.1);
|
||
let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power);
|
||
let hl = (intensity * spec * (-d / band).exp()).clamp(0.0, 1.0);
|
||
let i = (y * w as usize + x) * 4;
|
||
if let Some(p) = dst.get_mut(i..i + 4) {
|
||
for (o, v) in p.iter_mut().zip(base) {
|
||
*o = ((v * (1.0 - hl) + 255.0 * hl) * c).round().clamp(0.0, 255.0) as u8;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn distance_of_a_square() {
|
||
let mut p = Pixmap::new(21, 21).unwrap();
|
||
let r = tiny_skia::Rect::from_xywh(5.0, 5.0, 11.0, 11.0).unwrap();
|
||
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), ..Default::default() };
|
||
p.fill_rect(r, &paint, tiny_skia::Transform::identity(), None);
|
||
let d = inside_distance(&p);
|
||
// Outside: 0. The centre pixel (10, 10) is 5.5 px from each edge.
|
||
assert_eq!(d[0], 0.0);
|
||
assert!((d[10 * 21 + 10] - 5.5).abs() < 1e-4, "{}", d[10 * 21 + 10]);
|
||
// A pixel on the edge is half a pixel from it.
|
||
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
|
||
}
|
||
}
|