mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
**A paint and effects behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (fill stacking and opacity, paint vs layer opacity, every blend mode per paint and per layer, pass-through and Normal containers, the four gradient kinds and their handles, stops, image scale modes, rotation and adjustments, the colour models and hex entry, the document colour profile, drop and inner shadows, showing shadows behind transparent areas, layer and background blurs, effects on groups, effect order, what exports include) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. Scenes are small and synthetic; pixel cases use tolerances (blurs are checked against the normal distribution with sigma = radius / 2).
**Fixes (13 render cases and 8 command/export cases fail on the old code)**
- **Pass-through and Normal containers**: a group or frame set to Normal now blends its children among themselves first (it never isolated), and a **clipping frame no longer isolates** its children: they were drawn on a fresh layer, so a Multiply child inside a frame (frames clip by default) met neither the frame's fill nor what is behind it. The clipped layer now starts as a copy of what lies under it and goes back in its place through the clip (a memcpy for rectangular clips, so no slower); backdrop effects see the right composite, also for a clipping frame inside a faded group. Same in exports: SVG gives Normal groups `isolation:isolate` and puts a clipping frame's clip on what blends instead of a clipped `<g>` (a clipped group is a stacking context, which isolates); PDF wraps Normal groups in an isolated group (`Node::isolates_children`, shared).
- **Plus darker** (linear burn) is drawn by its formula on the canvas and in pixel exports for paints, layers, shadows and noise (it was multiply); tiny-skia lacks the mode, so `fx::plus_darker` composites it and `paint::composite` routes every layer, shadow and noise composite through it.
- **Shadows hidden behind the layer**: a drop shadow that doesn't show behind transparent areas is now hidden behind what the layer covers, translucent and 0% fills included (it was cut out by the layer's alpha, so it showed through a 50% fill anyway); groups and layers whose pixels a layer blur or texture moves still hide it behind their pixels. One `shadow::Caster::new` serves the canvas, PNG and the PDF's shadow rasters. **SVG** never cut shadows out: its filters now composite them `out` of an image of the layer's outline (or `SourceAlpha` for groups), and the filter region reaches as far as the effects (a fixed 200% box cut off a small layer's long shadow).
- **Fill images cover the box to its edges**: an upscaled Fill (or covering Crop) image faded to transparent over a source pixel at the box's edge.
- **"Normal" meant pass-through** when named: `BlendMode::from_name("normal")` found pass-through first (its CSS keyword is also `normal`), so `node.set {blend: "normal"}` and the blend menu's Normal set pass-through. Labels now win, and the menu sends labels.
**Added**
- A blend mode menu for each paint in the colour picker (no pass-through for paints), sharing `right::blend_menu` with the Design panel's layer menu; strings already translated.
- Inspect (`node.get`, MCP) reports a solid paint's `blend` when it isn't normal.
244 lines
11 KiB
Rust
244 lines
11 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, 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<Vec<Outline>> {
|
||
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<Vec<Outline>> {
|
||
outlines(doc, n, |f| f.visible)
|
||
}
|
||
|
||
fn outlines(doc: &Document, n: &Node, fill_counts: fn(&figcraft_color::Paint) -> bool) -> Option<Vec<Outline>> {
|
||
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<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`]; only when a shadow spreads.
|
||
pub silhouette: Option<Vec<Outline>>,
|
||
/// The node's [`cover`] in the layer's pixels; only when a shadow hides behind the layer.
|
||
pub cover: Option<Pixmap>,
|
||
}
|
||
|
||
fn drop_shadows(n: &Node) -> impl Iterator<Item = &Shadow> {
|
||
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<Vec<Outline>> {
|
||
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<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.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<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)
|
||
}
|