mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -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>
206 lines
9.3 KiB
Rust
206 lines
9.3 KiB
Rust
//! 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)
|
||
}
|