Files
figcraft/crates/render/src/grain.rs
T
BFlatandClaude Opus 5.5 70b2666c90 M3: progressive blur, inner shadows under strokes, noise and texture in SVG (#86)
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas,
  PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes).
- Layer and background blur gain a progressive type: the radius ramps linearly from a start radius
  at a start point to the end radius at an end point (unit-box points, like gradient handles).
  Rendered by blending a few geometric blur levels, each blurred only around the pixels that use
  it; bands and partial renders match one piece. Design panel type menu and Start/End fields,
  on-canvas handles while the settings are open, effect.add/effect.set params, startRadius
  variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset),
  Inspect row; CSS leaves progressive blurs out.
- SVG export embeds renders for what filters can't match: noise grains over the vector layer
  (crisp, blended), texture and progressive layer blur whole, background blur and glass from what
  lies under the layer among the exported layers. PDF and SVG share the renderer's helpers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:20:07 +07:00

145 lines
6.0 KiB
Rust

//! Grain effects: noise (grains over the layer's pixels) and texture (the layer's pixels scattered
//! into a rough edge).
//!
//! Grains are cells of the effect's size in the layer's own coordinates, each with a random value
//! hashed from its position and the layer: they stay put when the canvas pans or renders in pieces,
//! scale with the layer when zooming, and every export draws the same grains.
use figcraft_doc::{Noise, NoiseMode, Texture};
use figcraft_geom::Affine;
use tiny_skia::{Pixmap, PixmapPaint, Transform};
use crate::paint::ts_blend;
/// A well-mixed 64-bit hash of a grain cell.
fn hash(ix: i64, iy: i64, seed: u32) -> u64 {
let mut h = (ix as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ (iy as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F)
^ u64::from(seed).wrapping_mul(0x1656_67B1_9E37_79F9);
h ^= h >> 33;
h = h.wrapping_mul(0xFF51_AFD7_ED55_8CCD);
h ^= h >> 33;
h = h.wrapping_mul(0xC4CE_B9FE_1A85_EC53);
h ^ (h >> 33)
}
/// 16 bits of `h` from bit `at`, as 0..1 (1 excluded: a density of 1 draws every grain).
fn unit(h: u64, at: u32) -> f32 {
f32::from(((h >> at) & 0xFFFF) as u16) / 65536.0
}
/// Maps layer pixels to the grain cells under their centres.
struct Cells {
/// Layer pixels → local units, divided by the grain size: `[a, b, c, d, e, f]`.
m: [f64; 6],
}
impl Cells {
/// `xf` maps local units to layer pixels; `grain` is the cell size in local units.
fn new(xf: Affine, grain: (f64, f64)) -> Option<Cells> {
let inv = xf.inverse();
let c = inv.as_coeffs();
let (gx, gy) = grain;
let m = [c[0] / gx, c[1] / gy, c[2] / gx, c[3] / gy, c[4] / gx, c[5] / gy];
m.iter().all(|v| v.is_finite()).then_some(Cells { m })
}
fn at(&self, x: u32, y: u32) -> (i64, i64) {
let (px, py) = (f64::from(x) + 0.5, f64::from(y) + 0.5);
let [a, b, c, d, e, f] = self.m;
((a * px + c * py + e).floor() as i64, (b * px + d * py + f).floor() as i64)
}
}
/// Draw `nz`'s grains over the pixels of `layer` (local → layer pixels `xf`), each grain at the
/// alpha of the pixel under it, blended with the noise's blend mode. Grains are twice the noise
/// size across.
pub fn noise(layer: &mut Pixmap, nz: &Noise, xf: Affine, seed: u32) {
if let Some(g) = grains(layer, nz, xf, seed) {
let paint = PixmapPaint { blend_mode: ts_blend(nz.blend), ..Default::default() };
layer.draw_pixmap(0, 0, g.as_ref(), &paint, Transform::identity(), None);
}
}
/// `nz`'s grains for the pixels of `layer` (see [`noise`]), to draw over it with the noise's blend
/// mode; `None` when there are none.
pub fn grains(layer: &Pixmap, nz: &Noise, xf: Affine, seed: u32) -> Option<Pixmap> {
let (sx, sy) = nz.grain();
let cells = Cells::new(xf, (sx * 2.0, sy * 2.0))?;
let density = if nz.density.is_finite() { nz.density.clamp(0.0, 1.0) as f32 } else { 0.0 };
if density <= 0.0 {
return None;
}
let (w, h) = (layer.width(), layer.height());
let mut grains = Pixmap::new(w, h)?;
let (c1, c2) = (nz.color.sanitized(), nz.secondary.sanitized());
let multi_alpha = if nz.opacity.is_finite() { nz.opacity.clamp(0.0, 1.0) } else { 0.0 };
let src = layer.data();
let dst = grains.data_mut();
for y in 0..h {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
let a = f32::from(src.get(i + 3).copied().unwrap_or(0)) / 255.0;
if a <= 0.0 {
continue;
}
let (ix, iy) = cells.at(x, y);
let hv = hash(ix, iy, seed);
if unit(hv, 0) >= density {
continue;
}
let [r, g, b, ca] = match nz.mode {
NoiseMode::Mono => [c1.r, c1.g, c1.b, c1.a],
NoiseMode::Duo if hv >> 63 == 1 => [c2.r, c2.g, c2.b, c2.a],
NoiseMode::Duo => [c1.r, c1.g, c1.b, c1.a],
NoiseMode::Multi => [unit(hv, 16), unit(hv, 32), unit(hv, 48), multi_alpha],
};
let al = ca * a;
let q = |v: f32| (v * al * 255.0).round().clamp(0.0, 255.0) as u8;
if let Some(px) = dst.get_mut(i..i + 4) {
px.copy_from_slice(&[q(r), q(g), q(b), (al * 255.0).round().clamp(0.0, 255.0) as u8]);
}
}
}
Some(grains)
}
/// Scatter `layer`'s pixels by up to the texture's radius (`scale` device pixels per unit), one
/// random move per grain (twice the texture size across): edges turn rough and grainy. Clipped to
/// the shape, the result stays within the layer's own pixels.
pub fn texture(layer: &mut Pixmap, t: &Texture, xf: Affine, scale: f64, seed: u32) {
let r = t.radius * scale;
if !(r.is_finite() && r >= 0.5) {
return;
}
let r = r.min(2048.0) as f32;
let (sx, sy) = t.grain();
let Some(cells) = Cells::new(xf, (sx * 2.0, sy * 2.0)) else { return };
let (w, h) = (layer.width(), layer.height());
let src = layer.data().to_vec();
let dst = layer.data_mut();
for y in 0..h {
for x in 0..w {
let (ix, iy) = cells.at(x, y);
let hv = hash(ix, iy, seed ^ 0x7E57_u32);
// A uniform point in the disc of radius r.
let (ang, dist) = (unit(hv, 0) * std::f32::consts::TAU, unit(hv, 16).sqrt() * r);
let (fx, fy) = (x as f32 + 0.5 + ang.cos() * dist, y as f32 + 0.5 + ang.sin() * dist);
let i = ((y * w + x) * 4) as usize;
let mut px = [0u8; 4];
if fx >= 0.0 && fy >= 0.0 && (fx as u32) < w && (fy as u32) < h {
let j = (((fy as u32) * w + fx as u32) * 4) as usize;
if let Some(s) = src.get(j..j + 4) {
px.copy_from_slice(s);
}
}
if t.clip_to_shape {
let keep = u32::from(src.get(i + 3).copied().unwrap_or(0));
px = px.map(|c| ((u32::from(c) * keep + 127) / 255) as u8);
}
if let Some(d) = dst.get_mut(i..i + 4) {
d.copy_from_slice(&px);
}
}
}
}