//! 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::{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, /// 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 { 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() }; pix.fill_path(path, &paint, rule, ts_transform(xf), mask); } } /// Stroke outline cache: (geometry hash) → outline path. fn stroke_cache() -> &'static Mutex { static C: OnceLock> = 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 { 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 { 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); 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(path, props, closed)); 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, 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)> { 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, 0)?; 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(doc, n), } } 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) -> 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(), _ => 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 { 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) } /// `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 { 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 > 256 || ctx.skip.contains(&id) { return; } let Some(n) = ctx.doc.node(id) else { return }; if !n.visible || n.opacity <= 0.0 || n.is_mask { 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 ctx.outline_mode || ctx.outlined.contains(&id) { 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. if let Some(sh) = &shape && backdrop::has_effects(n) { let pad = backdrop::pad(n, ctx.scale); let p = i32::try_from(pad).unwrap_or(0); if let Some(back) = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2) && let Some(b) = backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale) { 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`. let under = Under { opacity: op, blend: ts_blend(n.blend), ..Under::new(pix, (lx, ly), ctx.under) }; draw_content(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth, has_inner.then_some(&inner as &dyn Fn(&mut Pixmap))); } // Layer blur, noise and texture, in list order, over everything else. for e in n.effects.iter().filter(|e| e.visible) { match &e.kind { EffectKind::LayerBlur(b) => blur::apply(&mut layer, b, (n.width, n.height), lxf, ctx.scale), EffectKind::Noise(nz) => grain::noise(&mut layer, nz, lxf, id.0 as u32), EffectKind::Texture(t) => grain::texture(&mut layer, t, lxf, ctx.scale, id.0 as u32), _ => {} } } // Drop shadows go under the layer, in effect-list order. let spreads = n.effects.iter().any(|e| e.visible && matches!(&e.kind, EffectKind::DropShadow(s) if s.spread != 0.0)); let sil = if spreads { shadow::silhouette(ctx.doc, n) } else { None }; let caster = shadow::Caster { layer: &layer, xf: lxf, scale: ctx.scale, silhouette: sil.as_deref() }; 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) { let paint = PixmapPaint { opacity: op, blend_mode: ts_blend(s.blend), ..Default::default() }; pix.draw_pixmap(lx, ly, sh.as_ref(), &paint, Transform::identity(), None); } } let paint = PixmapPaint { opacity: op, blend_mode: ts_blend(n.blend), ..Default::default() }; pix.draw_pixmap(lx, ly, layer.as_ref(), &paint, Transform::identity(), None); } fn draw_outline(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) { if let Some((path, _)) = node_outline(ctx.doc, n) && !matches!(n.kind, NodeKind::Group) && let Some(tp) = ts_path(&path) { let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::BLACK), anti_alias: true, ..Default::default() }; let stroke = tiny_skia::Stroke { width: 1.0 / ctx.scale.max(1e-6) as f32, ..Default::default() }; pix.stroke_path(&tp, &paint, &stroke, ts_transform(xf), None); } if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.hides_contents() { for c in &n.children { 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 = 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 { let mut r = d.rect.to_path(0.1); r.apply_affine(Affine::IDENTITY); by_style.entry(d.style).or_default().extend(r.elements().iter().copied()); } let mut keys: Vec = 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; let mut i = 0; while i < kids.len() { let Some(&c) = kids.get(i) else { break }; let is_mask = ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible); if is_mask { // Everything above the mask (until the next mask) is clipped by its alpha. let mut j = i + 1; while kids.get(j).is_some_and(|k| !ctx.doc.node(*k).is_some_and(|kn| kn.is_mask)) { j += 1; } draw_masked(ctx, pix, c, kids.get(i + 1..j).unwrap_or(&[]), xf, depth); i = j; continue; } draw_node(ctx, pix, c, xf, depth + 1); i += 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()); let Some(mut layer) = Pixmap::new(ir.width(), ir.height()) 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) }) }; { let under = Under { clip: mask.as_ref(), ..Under::new(pix, (lx, ly), ctx.under) }; draw_children(&Ctx { under: Some(&under), ..*ctx }, &mut layer, n, lxf, depth); } if rect_clip { if ctx.lod { lod::blit_over(pix, lx, ly, &layer); } else { pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } return; } if let Some(m) = &mask { layer.apply_mask(m); } pix.draw_pixmap(lx, ly, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), None); } 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 }