//! 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, EffectKind, 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> { outlines(doc, n, |f| f.visible && f.opacity > 0.0) } /// The area a node's paints cover, in local space: the outline of its visible fills (translucent /// or at 0% too) and its stroke area; what hides its drop shadows unless they show behind /// transparent areas. `None` where [`silhouette`] is. pub fn cover(doc: &Document, n: &Node) -> Option> { outlines(doc, n, |f| f.visible) } fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) -> bool) -> Option> { let has_fill = n.fills.iter().any(fill_counts); 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>>> { static C: OnceLock>>>> = 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> { 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 { 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`]; only when a shadow spreads. pub silhouette: Option>, /// The node's [`cover`] in the layer's pixels; only when a shadow hides behind the layer. pub cover: Option, } fn drop_shadows(n: &Node) -> impl Iterator { n.effects.iter().filter(|e| e.visible).filter_map(|e| if let EffectKind::DropShadow(s) = &e.kind { Some(s) } else { None }) } /// What hides node `n`'s drop shadows that don't show behind transparent areas: its [`cover`], /// or `None` when its pixels do that (layers that aren't one outline; effects that move the /// pixels past the paints) or no shadow hides. pub fn hiding_cover(doc: &Document, n: &Node) -> Option> { let moved = n.effects.iter().any(|e| matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_)) && e.reach() > 0.0); if moved || !drop_shadows(n).any(|s| !s.show_behind) { return None; } cover(doc, n) } impl<'a> Caster<'a> { /// What casts node `n`'s drop shadows from its `layer` (local → pixels through `xf`). pub fn new(doc: &Document, n: &Node, layer: &'a Pixmap, xf: Affine, scale: f64) -> Self { let silhouette = if drop_shadows(n).any(|s| s.spread != 0.0) { silhouette(doc, n) } else { None }; let cover = hiding_cover(doc, n).and_then(|c| spread_alpha(&c, 0.0, xf, layer.width(), layer.height())); Caster { layer, xf, scale, silhouette, cover } } } /// A drop shadow in the layer's pixels: offset, spread, blurred, tinted and (unless it shows /// behind the layer) hidden behind the layer: behind what its paints cover, translucent parts /// included, or (layers that aren't one outline) behind its pixels. pub fn drop_shadow(c: &Caster, s: &Shadow) -> Option { 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.as_deref() { 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.cover.as_ref().unwrap_or(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 { 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 big = spread_alpha(std::slice::from_ref(shape), -spread, at, w + pad * 2, h + pad * 2)?; inner_from(big, pad, &spread_alpha(std::slice::from_ref(shape), 0.0, xf, w, h)?, scale, s) } /// An inner shadow for a layer that isn't one outline (a group): cast inwards from everything /// around what its pixels `cover` (the layers it draws, together), over them. Offsets and spreads /// go by whole pixels. pub fn inner_shadow_of(cover: &Pixmap, scale: f64, s: &Shadow) -> Option { let (dx, dy) = (s.offset.0 * scale, s.offset.1 * scale); let spread = if s.spread.is_finite() { s.spread * scale } else { 0.0 }; if !(dx.is_finite() && dy.is_finite()) { return None; } let reach = s.radius.abs() / 2.0 * scale * 3.0 + spread.abs() + 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 = i32::try_from(pad).ok()?; let mut big = Pixmap::new(cover.width() + pad * 2, cover.height() + pad * 2)?; big.draw_pixmap(at + whole_px(dx), at + whole_px(dy), cover.as_ref(), &PixmapPaint::default(), Transform::identity(), None); fx::morph(&mut big, -whole_px(spread)); inner_from(big, pad, cover, scale, s) } /// The inner shadow `s` from `big`: what casts it (the shape at the shadow's offset, less its /// spread), `pad` pixels larger each way than the layer, whose pixels `cover` the shape. fn inner_from(mut big: Pixmap, pad: u32, cover: &Pixmap, scale: f64, s: &Shadow) -> Option { fx::invert_alpha(&mut big); fx::blur(&mut big, s.radius / 2.0 * scale); let mut out = Pixmap::new(cover.width(), cover.height())?; 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, ); fx::mask_by(&mut out, cover, false); fx::tint(&mut out, color4(s.color)); Some(out) }