mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
With drop shadows the backdrop was read twice: before the content and again after the shadows. The first read is only what backdrop effects inside the content see, so it now runs only when the layer has such descendants, and the content is copied for the shadow caster only when it gets a layer blur, noise or texture. The output is unchanged.
739 lines
34 KiB
Rust
739 lines
34 KiB
Rust
//! Recursive scene drawing.
|
|
|
|
use std::collections::HashMap;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
use figcraft_color::{BlendMode, Paint};
|
|
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign};
|
|
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
|
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
|
|
|
|
use crate::backdrop::{self, Under};
|
|
use crate::boolean::{PathCache, locked};
|
|
use crate::paint::{composite, prepare_at, ts_blend};
|
|
use crate::{blur, grain, lod, shadow};
|
|
|
|
/// Shared state for one render.
|
|
#[derive(Clone, Copy)]
|
|
pub struct Ctx<'a> {
|
|
pub doc: &'a Document,
|
|
/// Device pixels per document unit (for effect radii).
|
|
pub scale: f64,
|
|
pub outline_mode: bool,
|
|
/// What outline mode draws besides shapes, and its line colour.
|
|
pub outline: crate::Outline,
|
|
/// Layers drawn in outline mode with their contents (outlines for the selection).
|
|
pub outlined: &'a [NodeId],
|
|
/// Nodes not to draw (e.g. text being edited draws through the editor).
|
|
pub skip: &'a [NodeId],
|
|
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
|
|
pub lod: bool,
|
|
/// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops).
|
|
pub under: Option<&'a Under<'a>>,
|
|
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
|
|
pub time: f64,
|
|
}
|
|
|
|
pub fn ts_transform(a: Affine) -> Transform {
|
|
let c = a.as_coeffs();
|
|
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
|
|
}
|
|
|
|
/// kurbo path → tiny-skia path (None when empty or degenerate).
|
|
pub fn ts_path(p: &BezPath) -> Option<tiny_skia::Path> {
|
|
use figcraft_geom::kurbo::PathEl;
|
|
let mut b = tiny_skia::PathBuilder::new();
|
|
for el in p.elements() {
|
|
match *el {
|
|
PathEl::MoveTo(p) => b.move_to(p.x as f32, p.y as f32),
|
|
PathEl::LineTo(p) => b.line_to(p.x as f32, p.y as f32),
|
|
PathEl::QuadTo(c, p) => b.quad_to(c.x as f32, c.y as f32, p.x as f32, p.y as f32),
|
|
PathEl::CurveTo(c1, c2, p) => b.cubic_to(c1.x as f32, c1.y as f32, c2.x as f32, c2.y as f32, p.x as f32, p.y as f32),
|
|
PathEl::ClosePath => b.close(),
|
|
}
|
|
}
|
|
b.finish()
|
|
}
|
|
|
|
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
|
|
let scale = xf.determinant().abs().sqrt();
|
|
for p in paints {
|
|
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
|
|
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
|
|
if p.blend == BlendMode::PlusDarker {
|
|
// No tiny-skia mode: the paint alone over its pixels, then blended in.
|
|
let area = path
|
|
.bounds()
|
|
.transform(ts_transform(xf))
|
|
.and_then(|b| int_rect(Rect::new(b.left().into(), b.top().into(), b.right().into(), b.bottom().into()), (pix.width(), pix.height())));
|
|
let Some(mut alone) = area.and_then(|r| Pixmap::new(r.width(), r.height())) else { continue };
|
|
let Some(r) = area else { continue };
|
|
let t = ts_transform(Affine::translate((-f64::from(r.x()), -f64::from(r.y()))) * xf);
|
|
alone.fill_path(path, &tiny_skia::Paint { blend_mode: tiny_skia::BlendMode::SourceOver, ..paint }, rule, t, None);
|
|
crate::paint::composite(pix, (r.x(), r.y()), &alone, 1.0, p.blend);
|
|
continue;
|
|
}
|
|
pix.fill_path(path, &paint, rule, ts_transform(xf), mask);
|
|
}
|
|
}
|
|
|
|
/// Stroke outline cache: (geometry hash) → outline path.
|
|
fn stroke_cache() -> &'static Mutex<PathCache> {
|
|
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
|
|
C.get_or_init(|| Mutex::new(PathCache::new(4096)))
|
|
}
|
|
|
|
pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
|
|
for el in p.elements() {
|
|
use figcraft_geom::kurbo::PathEl::*;
|
|
match *el {
|
|
MoveTo(a) | LineTo(a) => (0u8, a.x.to_bits(), a.y.to_bits()).hash(h),
|
|
QuadTo(a, b) => (1u8, a.x.to_bits(), a.y.to_bits(), b.x.to_bits(), b.y.to_bits()).hash(h),
|
|
CurveTo(a, b, c) => (2u8, a.x.to_bits(), a.y.to_bits(), b.x.to_bits(), b.y.to_bits(), c.x.to_bits(), c.y.to_bits()).hash(h),
|
|
ClosePath => 3u8.hash(h),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The stroke area of a node in local coordinates.
|
|
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
|
let props = &n.stroke;
|
|
// Per-side strokes on rectangles/frames.
|
|
if let Some(sides) = props.sides
|
|
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
|
|
&& n.corners.uniform() == Some(0.0)
|
|
{
|
|
return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align));
|
|
}
|
|
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
|
|
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
|
|
let w = props.weight;
|
|
let ring = |inset: f64| -> Option<BezPath> {
|
|
let (x0, y0, ww, hh) = (inset, inset, n.width - 2.0 * inset, n.height - 2.0 * inset);
|
|
if ww <= 0.0 || hh <= 0.0 {
|
|
return None;
|
|
}
|
|
match n.kind {
|
|
NodeKind::Rectangle
|
|
| NodeKind::Frame
|
|
| NodeKind::Component { .. }
|
|
| NodeKind::ComponentSet { .. }
|
|
| NodeKind::Instance { .. }
|
|
| NodeKind::Section { .. }
|
|
if n.corners.smoothing == 0.0 =>
|
|
{
|
|
let radii = n.corners.radii.map(|r| if r > 0.0 { (r - inset).max(0.0) } else { 0.0 });
|
|
let mut p = figcraft_geom::rounded_rect(ww, hh, radii, 0.0);
|
|
p.apply_affine(Affine::translate((x0, y0)));
|
|
Some(p)
|
|
}
|
|
NodeKind::Ellipse { sweep, inner, .. } if sweep.abs() >= std::f64::consts::TAU - 1e-9 && inner == 0.0 => {
|
|
Some(figcraft_geom::kurbo::Ellipse::from_rect(Rect::new(x0, y0, x0 + ww, y0 + hh)).to_path(0.05))
|
|
}
|
|
_ => None,
|
|
}
|
|
};
|
|
let (outer, inner) = match props.align {
|
|
StrokeAlign::Inside => (ring(0.0), ring(w)),
|
|
_ => (ring(-w), ring(0.0)),
|
|
};
|
|
if let Some(mut o) = outer {
|
|
if let Some(i) = inner {
|
|
o.extend(i.elements().iter().copied());
|
|
}
|
|
return Arc::new(o);
|
|
}
|
|
}
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
hash_path(path, &mut h);
|
|
(props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed)
|
|
.hash(&mut h);
|
|
for d in &props.dashes {
|
|
d.to_bits().hash(&mut h);
|
|
}
|
|
props.dashes.len().hash(&mut h);
|
|
// Points' own caps and joins (vector networks).
|
|
let net = match &n.kind {
|
|
NodeKind::Vector { network } if network.has_point_styles() => Some(network),
|
|
_ => None,
|
|
};
|
|
for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() {
|
|
(v.cap.map(|c| c as u8), v.join.map(|j| j as u8)).hash(&mut h);
|
|
}
|
|
for p in props.profile().map(|p| p.points.as_slice()).unwrap_or_default() {
|
|
(p.t.to_bits(), p.scale.to_bits(), p.bias.to_bits()).hash(&mut h);
|
|
}
|
|
if let Some(b) = &props.brush {
|
|
(&b.preset, b.spacing.to_bits(), b.size_jitter.to_bits(), b.rotation_jitter.to_bits(), b.scatter.to_bits(), b.seed).hash(&mut h);
|
|
}
|
|
let key = h.finish();
|
|
if let Some(p) = locked(stroke_cache()).get(key) {
|
|
return p;
|
|
}
|
|
let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, net));
|
|
locked(stroke_cache()).insert(key, out.clone());
|
|
out
|
|
}
|
|
|
|
/// A node's painted stroke area in local coordinates with the rule to fill it by (`None` without a
|
|
/// visible stroke or geometry). Text strokes follow its glyphs and aren't covered.
|
|
pub fn stroke_fill(doc: &Document, n: &Node) -> Option<(Arc<BezPath>, FillRule)> {
|
|
if !n.has_visible_stroke() {
|
|
return None;
|
|
}
|
|
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
|
|
let geom = match &n.kind {
|
|
NodeKind::BooleanOp { .. } => node_outline(doc, n).map(|(p, _)| p),
|
|
NodeKind::Text(_) | NodeKind::Group | NodeKind::Page { .. } => None,
|
|
_ => figcraft_doc::geometry::stroke_geometry(n),
|
|
}?;
|
|
Some((stroke_area(n, &geom, closed), stroke_rule(n, closed)))
|
|
}
|
|
|
|
/// Even-odd ring paths need the even-odd rule; everything else non-zero.
|
|
pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
|
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) {
|
|
FillRule::EvenOdd
|
|
} else {
|
|
FillRule::Winding
|
|
}
|
|
}
|
|
|
|
/// The geometry a node fills, in local space: shapes, booleans (computed from children) and text
|
|
/// (glyph outlines). Groups and pages have none.
|
|
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
|
node_outline_at(doc, n, 0)
|
|
}
|
|
|
|
/// [`node_outline`] `depth` levels into a boolean or group nesting (one budget for both).
|
|
fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
|
|
match &n.kind {
|
|
NodeKind::BooleanOp { op } => {
|
|
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
|
|
.children
|
|
.iter()
|
|
.filter_map(|c| doc.node(*c))
|
|
.filter(|c| c.visible)
|
|
.filter_map(|c| {
|
|
let (mut p, _) = subtree_outline(doc, c, depth + 1)?;
|
|
p.apply_affine(c.transform);
|
|
Some((p, c.bool_op.unwrap_or(*op)))
|
|
})
|
|
.collect();
|
|
Some((crate::boolean::fold_cached(&operands), FillRule::EvenOdd))
|
|
}
|
|
NodeKind::Text(_) => {
|
|
let (l, dy) = figcraft_text::layout_node(n)?;
|
|
let mut p = l.to_path();
|
|
p.apply_affine(Affine::translate((0.0, dy)));
|
|
Some((p, FillRule::Winding))
|
|
}
|
|
NodeKind::Vector { network } => {
|
|
let rule =
|
|
if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
|
|
Some((network.fill_path(), rule))
|
|
}
|
|
_ => figcraft_doc::geometry::shape_path(n).map(|p| (p, FillRule::Winding)),
|
|
}
|
|
}
|
|
|
|
/// The union outline of a node and its descendants (for booleans of groups/frames).
|
|
pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
|
|
if depth > 64 {
|
|
return None;
|
|
}
|
|
match &n.kind {
|
|
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } if !n.children.is_empty() => {
|
|
let mut out = BezPath::new();
|
|
if n.kind.is_frame_like()
|
|
&& let Some(p) = figcraft_doc::geometry::shape_path(n)
|
|
&& n.fills.iter().any(|f| f.visible)
|
|
{
|
|
out.extend(p.elements().iter().copied());
|
|
}
|
|
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
|
if !c.visible {
|
|
continue;
|
|
}
|
|
if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) {
|
|
p.apply_affine(c.transform);
|
|
out.extend(p.elements().iter().copied());
|
|
}
|
|
}
|
|
Some((out, FillRule::Winding))
|
|
}
|
|
_ => node_outline_at(doc, n, depth),
|
|
}
|
|
}
|
|
|
|
fn device_rect(xf: Affine, r: Rect) -> Rect {
|
|
figcraft_geom::transform_rect_bbox(xf, r)
|
|
}
|
|
|
|
/// The area text may paint without its glyphs being looked at: its box, generously grown.
|
|
fn text_box_bounds(n: &Node) -> Rect {
|
|
n.local_rect().inflate(n.height.max(10.0), n.height.max(10.0) * 0.5)
|
|
}
|
|
|
|
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
|
|
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
|
|
/// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
|
|
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
|
|
let reach = n.paint_reach();
|
|
let mut r = n.local_rect().inflate(reach, reach);
|
|
let clips = n.clip && n.kind.is_frame_like();
|
|
if (!clips || n.kind.bounds_follow_children()) && depth < 64 && !n.hides_contents() {
|
|
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
|
if !c.visible {
|
|
continue;
|
|
}
|
|
let cb = local_paint_bounds(doc, c, depth + 1, greek_scale);
|
|
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
|
|
}
|
|
}
|
|
// Text can overflow its box.
|
|
if let NodeKind::Text(_) = n.kind {
|
|
r = r.union(text_box_bounds(n).inflate(reach, reach));
|
|
if greek_scale.is_some_and(|s| lod::greeked(n, s)) {
|
|
return r;
|
|
}
|
|
if let Some((l, dy)) = figcraft_text::layout_node(n)
|
|
&& let Some(ink) = l.ink_bounds()
|
|
{
|
|
r = r.union(ink + figcraft_geom::kurbo::Vec2::new(0.0, dy));
|
|
}
|
|
}
|
|
r
|
|
}
|
|
|
|
/// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass,
|
|
/// drop shadows and texture. Culling and partial renders keep this much margin.
|
|
pub fn effect_margin(n: &Node) -> f64 {
|
|
n.effects
|
|
.iter()
|
|
.filter(|e| e.visible)
|
|
.map(|e| match &e.kind {
|
|
EffectKind::LayerBlur(b) => b.max_radius() * 2.0,
|
|
EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0,
|
|
EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(),
|
|
EffectKind::Texture(t) => t.radius.abs(),
|
|
// A group's inner shadows are cast from around its pixels: a layer cut off at the
|
|
// view would cast one from the cut.
|
|
EffectKind::InnerShadow(s) if matches!(n.kind, NodeKind::Group) => s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs(),
|
|
_ => 0.0,
|
|
})
|
|
.filter(|m| m.is_finite())
|
|
.fold(0.0, f64::max)
|
|
}
|
|
|
|
/// Everything node `id` can paint, in page coordinates: its paint bounds (glyphs of overflowing
|
|
/// text included) grown by its effects' margin. What a change to the node can affect on screen.
|
|
pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
|
|
let n = doc.node(id)?;
|
|
let m = effect_margin(n).abs();
|
|
let local = local_paint_bounds(doc, n, 0, None);
|
|
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m))
|
|
}
|
|
|
|
fn needs_layer(n: &Node) -> bool {
|
|
n.opacity < 0.999
|
|
|| !matches!(n.blend, BlendMode::Normal | BlendMode::PassThrough)
|
|
|| n.effects.iter().any(|e| e.visible)
|
|
|| n.isolates_children()
|
|
}
|
|
|
|
/// `v` on the whole pixel that only float noise keeps it off: an edge on a pixel boundary comes out
|
|
/// a hair either side of it depending on the view's origin, and rounding outwards would then
|
|
/// take in a whole extra row or column in one render and not in another.
|
|
fn snap_px(v: f64) -> f64 {
|
|
let r = v.round();
|
|
if (v - r).abs() < 1e-6 { r } else { v }
|
|
}
|
|
|
|
fn int_rect(r: Rect, limit: (u32, u32)) -> Option<IntRect> {
|
|
let x0 = snap_px(r.x0).floor().max(0.0);
|
|
let y0 = snap_px(r.y0).floor().max(0.0);
|
|
let x1 = snap_px(r.x1).ceil().min(f64::from(limit.0));
|
|
let y1 = snap_px(r.y1).ceil().min(f64::from(limit.1));
|
|
if !(x1 > x0 && y1 > y0) {
|
|
return None;
|
|
}
|
|
IntRect::from_xywh(x0 as i32, y0 as i32, (x1 - x0) as u32, (y1 - y0) as u32)
|
|
}
|
|
|
|
/// The most a layer reaches past the view for its effects, in device pixels.
|
|
const MAX_EFFECT_MARGIN_PX: f64 = 2048.0;
|
|
|
|
/// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels.
|
|
pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) {
|
|
if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) {
|
|
return;
|
|
}
|
|
let Some(n) = ctx.doc.node(id) else { return };
|
|
let outline = ctx.outline_mode || ctx.outlined.contains(&id);
|
|
if n.is_mask || n.opacity <= 0.0 && n.visible {
|
|
return;
|
|
}
|
|
if !n.visible {
|
|
// Outline mode can show hidden layers too (nothing of them is culled: a hidden layer
|
|
// isn't counted in its parent's bounds).
|
|
if outline && ctx.outline.hidden {
|
|
draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, parent_xf * n.transform, depth);
|
|
}
|
|
return;
|
|
}
|
|
let xf = parent_xf * n.transform;
|
|
let (pw, ph) = (pix.width(), pix.height());
|
|
let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale));
|
|
let dev = device_rect(xf, local);
|
|
// Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or
|
|
// blur of millions of pixels) keep a sane layer: what lies further off reads as empty.
|
|
let blur_margin = (effect_margin(n) * ctx.scale).min(MAX_EFFECT_MARGIN_PX);
|
|
let view = Rect::new(0.0, 0.0, f64::from(pw), f64::from(ph)).inflate(blur_margin, blur_margin);
|
|
if dev.intersect(view).is_zero_area() && !(dev.width() == 0.0 || dev.height() == 0.0) {
|
|
return;
|
|
}
|
|
if outline {
|
|
draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth);
|
|
return;
|
|
}
|
|
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {
|
|
lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth);
|
|
return;
|
|
}
|
|
if !needs_layer(n) {
|
|
draw_content(ctx, pix, n, xf, depth, None);
|
|
return;
|
|
}
|
|
// Isolated layer covering the node's paint bounds, clipped to the visible area + margin. Its
|
|
// origin can be negative (inside the margin); the size counts from there, or a layer starting
|
|
// above or left of the pixmap came out short by the margin (a band missing at its far edge).
|
|
let vis = dev.intersect(view);
|
|
let (x0, y0) = (vis.x0.floor(), vis.y0.floor());
|
|
let (w, h) = (vis.x1.ceil() - x0, vis.y1.ceil() - y0);
|
|
if !(x0.is_finite() && y0.is_finite() && w >= 1.0 && h >= 1.0) {
|
|
return;
|
|
}
|
|
let (lx, ly) = (x0 as i32, y0 as i32);
|
|
let lw = w.min(16_384.0) as u32;
|
|
let lh = h.min(16_384.0) as u32;
|
|
let Some(mut layer) = Pixmap::new(lw, lh) else { return };
|
|
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
|
|
let shape = node_outline(ctx.doc, n);
|
|
// Background blur and glass: the whole backdrop under the layer (every surface below this
|
|
// one, composited), through the shape, under the content.
|
|
let back_shape = if backdrop::has_effects(n) { backdrop::outline(ctx.doc, n) } else { None };
|
|
let read_backdrop = |pix: &Pixmap| back_shape.as_ref().and_then(|sh| backdrop_layer(ctx, pix, n, sh, (lx, ly), (lw, lh), lxf));
|
|
// With drop shadows too, the layer casts them from what it paints, not from the backdrop, and
|
|
// the backdrop is read again once they lie under it (a shadow showing behind is seen through
|
|
// it). Until then, the layer's content sees the backdrop read first under it (#255).
|
|
let casts = back_shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::DropShadow(_)));
|
|
// Kept apart, the first read is only what backdrop effects inside the content see: without
|
|
// any, the backdrop (blur or glass, the costly part) is computed once, after the shadows.
|
|
let nested = || ctx.doc.subtree(id).iter().skip(1).any(|c| ctx.doc.node(*c).is_some_and(backdrop::has_effects));
|
|
let first_back = if !casts || nested() { read_backdrop(pix) } else { None };
|
|
if !casts && let Some(b) = &first_back {
|
|
layer.draw_pixmap(0, 0, b.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
|
}
|
|
let op = n.opacity.clamp(0.0, 1.0);
|
|
{
|
|
// Inner shadows paint over the fills, under the children and strokes.
|
|
let inner = |l: &mut Pixmap| {
|
|
for e in n.effects.iter().filter(|e| e.visible) {
|
|
if let EffectKind::InnerShadow(s) = &e.kind
|
|
&& let Some(sh) = &shape
|
|
&& let Some(inner) = shadow::inner_shadow(sh, lw, lh, lxf, ctx.scale, s)
|
|
{
|
|
l.draw_pixmap(0, 0, inner.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
|
}
|
|
}
|
|
};
|
|
let has_inner = shape.is_some() && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_)));
|
|
// Backdrops further down see this layer composited onto `pix` (over its backdrop, when
|
|
// that is kept apart for now).
|
|
let under = Under { opacity: op, blend: n.blend, ..Under::new(pix, (lx, ly), ctx.under) };
|
|
let over_back = first_back.as_ref().filter(|_| casts).map(|b| Under::new(b, (0, 0), Some(&under)));
|
|
let content_under = over_back.as_ref().unwrap_or(&under);
|
|
draw_content(&Ctx { under: Some(content_under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap)));
|
|
}
|
|
// A group is no outline of its own: its inner shadows are cast from around what its layers
|
|
// draw together, over them.
|
|
if matches!(n.kind, NodeKind::Group) && n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::InnerShadow(_))) {
|
|
let cover = layer.clone();
|
|
for e in n.effects.iter().filter(|e| e.visible) {
|
|
if let EffectKind::InnerShadow(s) = &e.kind
|
|
&& let Some(sh) = shadow::inner_shadow_of(&cover, ctx.scale, s)
|
|
{
|
|
layer.draw_pixmap(0, 0, sh.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
|
}
|
|
}
|
|
}
|
|
// Layer blur, noise and texture, in list order, over everything else.
|
|
if !casts {
|
|
layer_effects(ctx, &mut layer, n, id, lxf);
|
|
}
|
|
// Drop shadows go under the layer, in effect-list order.
|
|
{
|
|
// Kept apart, the backdrop isn't in the layer yet: the shadows are cast from the content
|
|
// with the layer effects it will get (a copy only when it gets some).
|
|
let layer_fx =
|
|
n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::LayerBlur(_) | EffectKind::Noise(_) | EffectKind::Texture(_)));
|
|
let content = (casts && layer_fx).then(|| {
|
|
let mut c = layer.clone();
|
|
layer_effects(ctx, &mut c, n, id, lxf);
|
|
c
|
|
});
|
|
let caster = shadow::Caster::new(ctx.doc, n, content.as_ref().unwrap_or(&layer), lxf, ctx.scale);
|
|
for e in n.effects.iter().filter(|e| e.visible) {
|
|
if let EffectKind::DropShadow(s) = &e.kind
|
|
&& let Some(sh) = shadow::drop_shadow(&caster, s)
|
|
{
|
|
composite(pix, (lx, ly), &sh, op, s.blend);
|
|
}
|
|
}
|
|
}
|
|
if casts {
|
|
// The backdrop with the shadows under it, the content over it, then the layer effects.
|
|
if let Some(mut b) = read_backdrop(pix) {
|
|
b.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None);
|
|
layer = b;
|
|
}
|
|
layer_effects(ctx, &mut layer, n, id, lxf);
|
|
}
|
|
composite(pix, (lx, ly), &layer, op, n.blend);
|
|
}
|
|
|
|
/// Node `n`'s backdrop effects through its backdrop outline `sh`, for its `size` layer at `at` on
|
|
/// `pix` (local → layer pixels through `lxf`): what `pix` holds there now, over what lies under it.
|
|
fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (i32, i32), size: (u32, u32), lxf: Affine) -> Option<Pixmap> {
|
|
let ((lx, ly), (lw, lh)) = (at, size);
|
|
let pad = backdrop::pad(n, ctx.scale);
|
|
let p = i32::try_from(pad).unwrap_or(0);
|
|
let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?;
|
|
backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
|
|
}
|
|
|
|
/// Node `n`'s layer blur, noise and texture on its `layer`, in list order.
|
|
fn layer_effects(ctx: &Ctx, layer: &mut Pixmap, n: &Node, id: NodeId, lxf: Affine) {
|
|
for e in n.effects.iter().filter(|e| e.visible) {
|
|
match &e.kind {
|
|
EffectKind::LayerBlur(b) => blur::apply(layer, b, (n.width, n.height), lxf, ctx.scale),
|
|
EffectKind::Noise(nz) => grain::noise(layer, nz, lxf, id.0 as u32),
|
|
EffectKind::Texture(t) => grain::texture(layer, t, lxf, ctx.scale, id.0 as u32),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
|
|
let color = if ctx.outline.light { tiny_skia::Color::WHITE } else { tiny_skia::Color::BLACK };
|
|
let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(color), anti_alias: true, ..Default::default() };
|
|
let stroke = tiny_skia::Stroke { width: 1.0 / ctx.scale.max(1e-6) as f32, ..Default::default() };
|
|
if let Some((path, _)) = node_outline(ctx.doc, n)
|
|
&& !matches!(n.kind, NodeKind::Group)
|
|
&& let Some(tp) = ts_path(&path)
|
|
{
|
|
pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None);
|
|
}
|
|
// Object bounds: the box of every layer whose outline isn't its box already.
|
|
let boxed = matches!(n.kind, NodeKind::Rectangle) && n.corners.uniform() == Some(0.0) || n.kind.is_frame_like();
|
|
if ctx.outline.bounds
|
|
&& !boxed
|
|
&& let Some(tp) = ts_path(&figcraft_geom::kurbo::Shape::to_path(&n.local_rect(), 0.1))
|
|
{
|
|
pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None);
|
|
}
|
|
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.hides_contents() {
|
|
for c in n.paint_order() {
|
|
draw_node(ctx, pix, c, xf, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// What paints over a node's fills, under its children and strokes (its inner shadows).
|
|
type OverFills<'a> = Option<&'a dyn Fn(&mut Pixmap)>;
|
|
|
|
/// Fills, `over_fills`, children (clipped), strokes and text of a node, without opacity/effects.
|
|
fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, over_fills: OverFills) {
|
|
let doc = ctx.doc;
|
|
let (w, h) = (n.width, n.height);
|
|
match &n.kind {
|
|
NodeKind::Page { .. } => {
|
|
draw_children(ctx, pix, n, xf, depth);
|
|
}
|
|
NodeKind::Group => draw_children(ctx, pix, n, xf, depth),
|
|
NodeKind::Text(_) if ctx.lod && lod::greeked(n, ctx.scale) => {
|
|
lod::greek_text(pix, n, xf, text_box_bounds(n));
|
|
if let Some(f) = over_fills {
|
|
f(pix);
|
|
}
|
|
}
|
|
NodeKind::Text(t) => {
|
|
let Some((layout, dy)) = figcraft_text::layout_node(n) else { return };
|
|
let gxf = xf * Affine::translate((0.0, dy));
|
|
// Group glyphs by style so runs with their own fills paint with them.
|
|
let mut by_style: HashMap<usize, BezPath> = HashMap::new();
|
|
for g in &layout.glyphs {
|
|
by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied());
|
|
}
|
|
for d in &layout.decorations {
|
|
by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied());
|
|
}
|
|
let mut keys: Vec<usize> = by_style.keys().copied().collect();
|
|
keys.sort_unstable();
|
|
// Every run's fills, then what paints over the fills, then the strokes on top.
|
|
for k in &keys {
|
|
let Some(tp) = by_style.get(k).and_then(ts_path) else { continue };
|
|
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
|
|
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
|
|
}
|
|
if let Some(f) = over_fills {
|
|
f(pix);
|
|
}
|
|
if n.has_visible_stroke() {
|
|
let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
|
|
for path in keys.iter().filter_map(|k| by_style.get(k)) {
|
|
let s = figcraft_pathops::stroke_center(path, props.weight, &props);
|
|
if let Some(ts) = ts_path(&s) {
|
|
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, ctx, None);
|
|
}
|
|
}
|
|
}
|
|
let _ = t;
|
|
}
|
|
_ => {
|
|
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. });
|
|
let splatted = ctx.lod && closed && lod::splat_fills(pix, n, xf);
|
|
let clips = n.clip && n.kind.is_frame_like() && !n.children.is_empty();
|
|
// The outline fills, clips children to rounded corners and strokes booleans.
|
|
let outline = if splatted && !(clips && n.corners.uniform() != Some(0.0)) { None } else { node_outline(doc, n) };
|
|
if !splatted
|
|
&& let Some((path, rule)) = &outline
|
|
&& closed
|
|
&& let Some(tp) = ts_path(path)
|
|
{
|
|
fill_with(pix, &tp, &n.fills, w, h, xf, *rule, ctx, None);
|
|
}
|
|
if let Some(f) = over_fills {
|
|
f(pix);
|
|
}
|
|
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() && !n.hides_contents() {
|
|
if clips {
|
|
draw_children_clipped(ctx, pix, n, xf, depth, outline.as_ref().map(|(p, _)| p));
|
|
} else {
|
|
draw_children(ctx, pix, n, xf, depth);
|
|
}
|
|
}
|
|
if n.has_visible_stroke() {
|
|
let geom = match &n.kind {
|
|
NodeKind::BooleanOp { .. } => outline.as_ref().map(|(p, _)| p.clone()),
|
|
_ => figcraft_doc::geometry::stroke_geometry(n),
|
|
};
|
|
if let Some(g) = geom {
|
|
let area = stroke_area(n, &g, closed);
|
|
if let Some(tp) = ts_path(&area) {
|
|
fill_with(pix, &tp, &n.strokes, w, h, xf, stroke_rule(n, closed), ctx, None);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
|
|
let kids = &n.children;
|
|
for run in ctx.doc.paint_runs(n) {
|
|
let Some(&c) = kids.get(run.start) else { continue };
|
|
if ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
|
|
// Everything above the mask (until the next mask) is clipped by its alpha.
|
|
draw_masked(ctx, pix, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth);
|
|
} else {
|
|
draw_node(ctx, pix, c, xf, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
|
|
let (w, h) = (pix.width(), pix.height());
|
|
let Some(mut mask_px) = Pixmap::new(w, h) else { return };
|
|
let mask_type = ctx.doc.node(mask_id).map(|m| m.mask_type).unwrap_or_default();
|
|
// Draw the mask node as if it weren't a mask (a vector mask: its outline, fully opaque).
|
|
if let Some(m) = ctx.doc.node(mask_id) {
|
|
let mut m2 = m.clone();
|
|
m2.is_mask = false;
|
|
let mxf = xf * m2.transform;
|
|
if mask_type == figcraft_doc::MaskType::Vector {
|
|
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
|
|
&& let Some(path) = ts_path(&outline)
|
|
{
|
|
let mut paint = tiny_skia::Paint::default();
|
|
paint.set_color_rgba8(255, 255, 255, 255);
|
|
paint.anti_alias = true;
|
|
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
|
|
}
|
|
} else if needs_layer(&m2) {
|
|
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
|
|
let mut tmp = Pixmap::new(w, h);
|
|
if let Some(tmp) = tmp.as_mut() {
|
|
draw_content(ctx, tmp, &m2, mxf, depth + 1, None);
|
|
mask_px.draw_pixmap(0, 0, tmp.as_ref(), &paint, Transform::identity(), None);
|
|
}
|
|
} else {
|
|
draw_content(ctx, &mut mask_px, &m2, mxf, depth + 1, None);
|
|
}
|
|
}
|
|
let Some(mut layer) = Pixmap::new(w, h) else { return };
|
|
let kind = if mask_type == figcraft_doc::MaskType::Luminance { MaskType::Luminance } else { MaskType::Alpha };
|
|
let mask = Mask::from_pixmap(mask_px.as_ref(), kind);
|
|
{
|
|
let under = Under { clip: Some(&mask), ..Under::new(pix, (0, 0), ctx.under) };
|
|
let sub = Ctx { under: Some(&under), ..*ctx };
|
|
for c in content {
|
|
draw_node(&sub, &mut layer, *c, xf, depth + 1);
|
|
}
|
|
}
|
|
pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
|
|
}
|
|
|
|
fn draw_children_clipped(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, clip: Option<&BezPath>) {
|
|
let (pw, ph) = (pix.width(), pix.height());
|
|
let dev = device_rect(xf, n.local_rect());
|
|
let Some(ir) = int_rect(dev, (pw, ph)) else { return };
|
|
let (lx, ly) = (ir.x(), ir.y());
|
|
// The layer starts as what lies under it and goes back in its place through the clip:
|
|
// clipping cuts the children off without isolating them, so their blend modes reach the
|
|
// layers behind the frame.
|
|
let Some(mut layer) = pix.clone_rect(ir) else { return };
|
|
let lxf = Affine::translate((-f64::from(lx), -f64::from(ly))) * xf;
|
|
let rect_clip = n.corners.uniform() == Some(0.0) && is_axis_aligned(xf);
|
|
// The clip (a rectangular one is the layer's own box) is known before the children draw, so
|
|
// their backdrops are clipped the way the layer will be.
|
|
let mask = if rect_clip {
|
|
None
|
|
} else {
|
|
clip.and_then(ts_path).and_then(|cp| {
|
|
let mut m = Mask::new(ir.width(), ir.height())?;
|
|
m.fill_path(&cp, FillRule::Winding, true, ts_transform(lxf));
|
|
Some(m)
|
|
})
|
|
};
|
|
if !rect_clip && mask.is_none() {
|
|
return;
|
|
}
|
|
{
|
|
let under = Under { clip: mask.as_ref(), replaces: true, ..Under::new(pix, (lx, ly), ctx.under) };
|
|
draw_children(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth);
|
|
}
|
|
backdrop::replace(pix, (lx, ly), &layer, mask.as_ref().map(|m| (m, (0, 0))));
|
|
}
|
|
|
|
fn is_axis_aligned(xf: Affine) -> bool {
|
|
let c = xf.as_coeffs();
|
|
c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && fract_ok(c[4]) && fract_ok(c[5])
|
|
}
|
|
|
|
fn fract_ok(_v: f64) -> bool {
|
|
true
|
|
}
|