Files
figcraft/crates/render/src/shadow.rs
BFlatandClaude Opus 5.5 f9b4609631 M3: effects fidelity: backdrop blur in clipped frames, exact shadow spread, glass, noise and texture (#69)
- 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>
2026-10-10 11:59:59 +07:00

206 lines
9.3 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.
//! Drop and inner shadows, with spread that offsets the shape's outline.
//!
//! Spread moves a shadow's outline out (or in) by the spread: the shape is filled and its outline
//! stroked twice the spread wide with mitred joins, drawn over it to grow and cut away to shrink.
//! Ellipses stay ellipses, stars keep their points, text keeps its letterforms, and a rectangle's
//! corner radii grow with the spread. Layers whose pixels aren't one outline (groups, frames that
//! don't clip, ...) spread their pixels instead (a square neighbourhood).
//!
//! The canvas, PNG/JPG/WebP exports and the PDF export's shadow images all come from here.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_doc::{Document, Node, NodeKind, SPREAD_MITER_LIMIT, Shadow};
use figcraft_geom::{Affine, BezPath};
use tiny_skia::{FillRule, Pixmap, PixmapPaint, Transform};
use crate::draw::{hash_path, node_outline, stroke_area, stroke_rule, ts_path, ts_transform};
use crate::fx;
/// The largest spread drawn, in device pixels.
const MAX_SPREAD_PX: f64 = 1e6;
/// A shape in local space: an outline and the rule it fills with.
pub type Outline = (BezPath, FillRule);
/// The outlines a node's shadows are cast by when they spread, in local space: its fill outline
/// (when a fill shows) and its stroke area. `None` when the layer's pixels aren't those outlines:
/// groups, frames that don't clip their children or show no fill, text with a stroke.
pub fn silhouette(doc: &Document, n: &Node) -> Option<Vec<Outline>> {
let has_fill = n.fills.iter().any(|f| f.visible && f.opacity > 0.0);
let is_bool = matches!(n.kind, NodeKind::BooleanOp { .. });
let is_text = matches!(n.kind, NodeKind::Text(_));
let kids = !is_bool && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| c.visible));
if matches!(n.kind, NodeKind::Group | NodeKind::Page { .. })
|| (kids && !(n.kind.is_frame_like() && n.clip && has_fill))
|| (is_text && n.has_visible_stroke())
{
return None;
}
let closed = is_bool || is_text || figcraft_doc::geometry::is_closed(n);
let outline = node_outline(doc, n);
let mut out = Vec::new();
if has_fill
&& closed
&& let Some(o) = outline.clone()
{
out.push(o);
}
if n.has_visible_stroke() {
let geom = if is_bool { outline.map(|(p, _)| p) } else { figcraft_doc::geometry::stroke_geometry(n) };
if let Some(g) = geom {
out.push(((*stroke_area(n, &g, closed)).clone(), stroke_rule(n, closed)));
}
}
(!out.is_empty()).then_some(out)
}
/// Cache of spread bands: (outline, width, direction, resolution) → the stroked band.
fn band_cache() -> &'static Mutex<HashMap<u64, Option<Arc<tiny_skia::Path>>>> {
static C: OnceLock<Mutex<HashMap<u64, Option<Arc<tiny_skia::Path>>>>> = OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
/// The band within `half` of `path`'s outline, mitred at corners, in local space. Shrinking first
/// resolves overlapping non-zero contours (overlapping glyph parts, vector regions), whose inner
/// edges aren't outline and must not be cut away.
fn band(path: &BezPath, rule: FillRule, half: f64, shrink: bool, res: f32) -> Option<Arc<tiny_skia::Path>> {
let mut h = std::collections::hash_map::DefaultHasher::new();
hash_path(path, &mut h);
(rule == FillRule::EvenOdd, half.to_bits(), shrink, res.to_bits()).hash(&mut h);
let key = h.finish();
if let Some(b) = band_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return b.clone();
}
let simplified = (shrink && rule == FillRule::Winding).then(|| figcraft_pathops::simplify(path).ok()).flatten();
let stroke = tiny_skia::Stroke {
width: (half * 2.0) as f32,
line_join: tiny_skia::LineJoin::Miter,
miter_limit: SPREAD_MITER_LIMIT as f32,
line_cap: tiny_skia::LineCap::Butt,
..Default::default()
};
let made = ts_path(simplified.as_ref().unwrap_or(path)).and_then(|p| p.stroke(&stroke, res)).map(Arc::new);
let mut c = band_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if c.len() > 1024 {
c.clear();
}
c.insert(key, made.clone());
made
}
fn white(blend: tiny_skia::BlendMode) -> tiny_skia::Paint<'static> {
tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), anti_alias: true, blend_mode: blend, ..Default::default() }
}
/// The coverage of `outlines` grown (`spread > 0`) or shrunk (`spread < 0`) by `spread` local
/// units, drawn through `xf` (local → pixels) into a `w` × `h` pixmap (white, premultiplied).
pub fn spread_alpha(outlines: &[Outline], spread: f64, xf: Affine, w: u32, h: u32) -> Option<Pixmap> {
let mut p = Pixmap::new(w, h)?;
let t = ts_transform(xf);
let fill = white(tiny_skia::BlendMode::SourceOver);
for (path, rule) in outlines {
if let Some(tp) = ts_path(path) {
p.fill_path(&tp, &fill, *rule, t, None);
}
}
// Past a million pixels every spread looks the same, and the rasteriser needs sane coordinates.
let px = xf.determinant().abs().sqrt();
let half = if px.is_finite() && px > 0.0 { spread.abs().min(MAX_SPREAD_PX / px) } else { 0.0 };
if !(half.is_finite() && half > 1e-6) {
return Some(p);
}
// Curves flatten for the pixel size, bucketed to powers of two so zooming reuses bands.
let res = tiny_skia::PathStroker::compute_resolution_scale(&t);
let res = if res.is_finite() && res > 0.0 { 2f32.powi(res.log2().ceil().clamp(-8.0, 8.0) as i32) } else { 1.0 };
let paint = white(if spread > 0.0 { tiny_skia::BlendMode::SourceOver } else { tiny_skia::BlendMode::Clear });
for (path, rule) in outlines {
if let Some(b) = band(path, *rule, half, spread < 0.0, res) {
p.fill_path(&b, &paint, FillRule::Winding, t, None);
}
}
Some(p)
}
/// A device distance as a whole pixel offset (non-finite → 0, capped).
pub(crate) fn whole_px(v: f64) -> i32 {
if v.is_finite() { v.round().clamp(-100_000.0, 100_000.0) as i32 } else { 0 }
}
fn color4(c: figcraft_color::Color) -> [f32; 4] {
let c = c.sanitized();
[c.r, c.g, c.b, c.a]
}
/// What casts a node's shadows.
pub struct Caster<'a> {
/// The node's pixels (content and inner shadows, with the effects drawn on top of them).
pub layer: &'a Pixmap,
/// Local → layer pixels.
pub xf: Affine,
/// Device pixels per document unit.
pub scale: f64,
/// See [`silhouette`]; needed only for spread.
pub silhouette: Option<&'a [Outline]>,
}
/// A drop shadow in the layer's pixels: offset, spread, blurred, tinted and (unless it shows
/// behind the layer) hidden behind the layer's own pixels.
pub fn drop_shadow(c: &Caster, s: &Shadow) -> Option<Pixmap> {
let (w, h) = (c.layer.width(), c.layer.height());
let (dx, dy) = (s.offset.0 * c.scale, s.offset.1 * c.scale);
let spread = if s.spread.is_finite() { s.spread } else { 0.0 };
let mut sh = match c.silhouette {
Some(sil) if spread != 0.0 && dx.is_finite() && dy.is_finite() => spread_alpha(sil, spread, Affine::translate((dx, dy)) * c.xf, w, h)?,
_ => {
let mut base = c.layer.clone();
fx::morph(&mut base, whole_px(spread * c.scale));
let mut out = Pixmap::new(w, h)?;
out.draw_pixmap(whole_px(dx), whole_px(dy), base.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
out
}
};
fx::blur(&mut sh, s.radius / 2.0 * c.scale);
fx::tint(&mut sh, color4(s.color));
if !s.show_behind {
fx::mask_by(&mut sh, c.layer, true);
}
Some(sh)
}
/// An inner shadow in a `w` × `h` layer whose `shape` maps through `xf`: everything outside the
/// shape (shrunk by the spread) at the offset, blurred, tinted and clipped to the shape.
pub fn inner_shadow(shape: &Outline, w: u32, h: u32, xf: Affine, scale: f64, s: &Shadow) -> Option<Pixmap> {
let (dx, dy) = (s.offset.0 * scale, s.offset.1 * scale);
let spread = if s.spread.is_finite() { s.spread } else { 0.0 };
if !(dx.is_finite() && dy.is_finite()) {
return None;
}
// A buffer padded by the shadow's reach, with the shape drawn at its offset: everything outside
// the shape casts shadow inwards, from every edge. The shape itself (not the layer's box)
// decides what is outside: the layer can be cropped (to a render band or the view) where the
// shape carries on.
let reach = s.radius.abs() / 2.0 * scale * 3.0 + spread.abs() * scale * SPREAD_MITER_LIMIT + dx.abs().max(dy.abs());
let pad = if reach.is_finite() { reach.ceil().clamp(0.0, 2048.0) as u32 + 2 } else { 2 };
let at = Affine::translate((f64::from(pad) + dx, f64::from(pad) + dy)) * xf;
let mut big = spread_alpha(std::slice::from_ref(shape), -spread, at, w + pad * 2, h + pad * 2)?;
fx::invert_alpha(&mut big);
fx::blur(&mut big, s.radius / 2.0 * scale);
let mut out = Pixmap::new(w, h)?;
let pad = i32::try_from(pad).ok()?;
out.draw_pixmap(
-pad,
-pad,
big.as_ref(),
&PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() },
Transform::identity(),
None,
);
let sa = spread_alpha(std::slice::from_ref(shape), 0.0, xf, w, h)?;
fx::mask_by(&mut out, &sa, false);
fx::tint(&mut out, color4(s.color));
Some(out)
}