mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
Colour emoji came out as the placeholder rectangles their outlines carry (Apple Color Emoji) or as .notdef when the face has no outline at all: nothing read sbix, CBDT/EBDT or COLR. A glyph now asks its face for colour ink before its outline: an embedded bitmap strike decoded to premultiplied RGBA and placed by the strike's bearings the way Skia (and CoreText) places it, scaled to the size the run asks for, or a COLR v0 glyph's layers filled from the font's palette. Faces that carry only colour tables are indexed again: Apple Color Emoji is no longer skipped for being 190 MB (the scan still reads headers and name tables only), and "Segoe UI Emoji" and "Noto Color Emoji" join "Apple Color Emoji" as colour fallbacks, read only for a text that nothing else covers. The png crate decodes the strikes; skrifa keeps shaping, metrics and outlines. Left for later: COLR v1 paint graphs, alpha-only strikes and the SVG and PDF exports (text is outlines there). Tests: 8 cases in the text crate (placement, PNG and raw-BGRA decoding, scaling, COLR layers and bitmaps of the machine's own colour font) and a render case gated on that font; the font-free ones run everywhere. Verified on Windows with Segoe UI Emoji (COLR) and with Noto Color Emoji (CBDT) read from a downloaded file; the sbix path runs through the same code and needs a macOS run to be seen. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
744 lines
34 KiB
Rust
744 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.
|
|
if backdrop::has_effects(n)
|
|
&& let Some(sh) = &backdrop::outline(ctx.doc, 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: 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)));
|
|
}
|
|
// 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.
|
|
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 caster = shadow::Caster::new(ctx.doc, n, &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);
|
|
}
|
|
}
|
|
composite(pix, (lx, ly), &layer, op, n.blend);
|
|
}
|
|
|
|
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));
|
|
// Colour glyphs (emoji) carry their own ink, which draws instead of their outline: a
|
|
// bitmap the font ships, or COLR layers with a colour each.
|
|
let gs = gxf.determinant().abs().sqrt();
|
|
let mut ink: Vec<(usize, Ink)> = Vec::new();
|
|
// 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 {
|
|
match g.color(gs) {
|
|
Some(figcraft_text::ColorInk::Bitmap(bitmap, place)) => ink.push((g.style, Ink::Bitmap(bitmap, place))),
|
|
Some(figcraft_text::ColorInk::Layers(layers)) => {
|
|
for layer in layers.iter() {
|
|
ink.push((g.style, Ink::Layer(g.layer_path(layer.gid), layer.rgba)));
|
|
}
|
|
}
|
|
None => 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());
|
|
}
|
|
// A run can have ink and no outline at all (bitmap-only fonts).
|
|
let mut keys: Vec<usize> = by_style.keys().copied().chain(ink.iter().map(|(style, _)| *style)).collect();
|
|
keys.sort_unstable();
|
|
keys.dedup();
|
|
// Every run's fills (its ink included), then what paints over the fills, then the
|
|
// strokes on top.
|
|
for k in &keys {
|
|
let fills = layout.styles.get(*k).and_then(|s| s.fills.as_ref()).unwrap_or(&n.fills);
|
|
if let Some(tp) = by_style.get(k).and_then(ts_path) {
|
|
fill_with(pix, &tp, fills, w, h, gxf, FillRule::Winding, ctx, None);
|
|
}
|
|
for (_, item) in ink.iter().filter(|(style, _)| style == k) {
|
|
match item {
|
|
Ink::Bitmap(bitmap, place) => blit(pix, bitmap, ts_transform(gxf * *place)),
|
|
Ink::Layer(path, rgba) => {
|
|
if let Some(tp) = ts_path(path) {
|
|
let mut paint = tiny_skia::Paint::default();
|
|
paint.set_color_rgba8(rgba[0], rgba[1], rgba[2], rgba[3]);
|
|
paint.anti_alias = true;
|
|
pix.fill_path(&tp, &paint, FillRule::Winding, ts_transform(gxf), 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))));
|
|
}
|
|
|
|
/// Colour ink of a text run, in the order the run paints it. Strokes follow the outlines only: a
|
|
/// bitmap-strike glyph has no outline to stroke.
|
|
enum Ink {
|
|
/// An embedded bitmap and the transform from its pixels into layout coordinates.
|
|
Bitmap(Arc<figcraft_text::ColorBitmap>, Affine),
|
|
/// One COLR layer: its outline in layout coordinates and the colour to fill it with.
|
|
Layer(BezPath, [u8; 4]),
|
|
}
|
|
|
|
/// Draw a colour bitmap (premultiplied RGBA) under a transform, sampled smoothly when the
|
|
/// transform scales it.
|
|
fn blit(pix: &mut Pixmap, bitmap: &figcraft_text::ColorBitmap, transform: Transform) {
|
|
let Some(image) = tiny_skia::PixmapRef::from_bytes(&bitmap.rgba, bitmap.width, bitmap.height) else { return };
|
|
let paint = PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..Default::default() };
|
|
pix.draw_pixmap(0, 0, image, &paint, transform, 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
|
|
}
|