Merge fix/667 into batch 13

This commit is contained in:
bflatastic
2026-10-17 17:19:51 +07:00
7 changed files with 278 additions and 34 deletions
+19 -6
View File
@@ -77,17 +77,19 @@ pub enum DocError {
pub type DocResult<T> = Result<T, DocError>;
/// The set of touched node ids, and whether any layer is a mask (worked out when first asked
/// after a change: hit testing skips its mask checks without one). Never part of equality or
/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks
/// without one) and the largest background blur radius (renders keep that much overlap between
/// their bands), both worked out when first asked after a change. Never part of equality or
/// serialisation.
#[derive(Clone, Debug, Default)]
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>);
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>, std::sync::OnceLock<f64>);
impl Dirty {
/// Record node `id` as touched (edited, added or removed).
fn touch(&mut self, id: NodeId) {
self.0.insert(id);
self.1 = std::sync::OnceLock::new();
self.2 = std::sync::OnceLock::new();
}
}
@@ -254,6 +256,11 @@ impl Document {
*self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask))
}
/// The largest background blur or glass radius of any layer's effects (0 without any).
pub fn max_backdrop_radius(&self) -> f64 {
*self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max))
}
/// Node ids touched so far (without clearing).
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
&self.dirty.0
@@ -1117,8 +1124,14 @@ impl Document {
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
let kids = &n.children;
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
let kids: Vec<Option<&Node>> = n.children.iter().map(|k| self.node(*k)).collect();
Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z))
}
/// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run
/// first when `reverse_z`.
pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec<std::ops::Range<usize>> {
let node = |i: usize| kids.get(i).copied().flatten();
let mut runs = Vec::new();
let mut i = 0;
while i < kids.len() {
@@ -1131,7 +1144,7 @@ impl Document {
runs.push(i..j);
i = j;
}
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
if reverse_z {
runs.reverse();
}
runs
+92
View File
@@ -0,0 +1,92 @@
//! Paint bounds worked out once per render: the bounds kept for a layer are the ones worked out
//! without keeping any, and the largest background blur follows the document's edits.
use super::*;
use figcraft_doc::{Effect, EffectKind};
#[test]
fn kept_paint_bounds_are_the_bounds_worked_out_afresh() {
// Unclipped frames nested past the 64-level walk, a rectangle at the bottom: bounds asked for
// deep first and then from the top, and the other way round, are those worked out alone.
let mut d = Document::new("deep");
let p = d.first_page().unwrap();
let mut chain = Vec::new();
let mut parent = p;
for k in 0..80 {
let f = d.create(parent, NodeKind::Frame, &format!("F{k}"), 1.0, 1.0, 10.0, 10.0).unwrap();
d.node_mut(f).unwrap().clip = false;
chain.push(f);
parent = f;
}
d.create(parent, NodeKind::Rectangle, "R", 500.0, 0.0, 10.0, 10.0).unwrap();
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
let memo = draw::Bounds::default();
for id in &order {
let n = d.node(*id).unwrap();
for depth in [0, 20] {
let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0;
assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name);
}
}
}
}
#[test]
fn the_largest_background_blur_follows_edits() {
let mut d = Document::new("blur");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
assert_eq!(max_backdrop_blur(&d), 0.0);
let mut blur = Effect::background_blur();
if let EffectKind::BackgroundBlur(b) = &mut blur.kind {
b.radius = 12.0;
}
d.node_mut(r).unwrap().effects.push(blur);
assert_eq!(max_backdrop_blur(&d), 12.0);
// A copy of the document keeps it, and an edit of the copy changes only the copy's.
let mut copy = d.clone();
if let Some(EffectKind::BackgroundBlur(b)) = copy.node_mut(r).unwrap().effects.first_mut().map(|e| &mut e.kind) {
b.radius = 30.0;
}
assert_eq!(max_backdrop_blur(&copy), 30.0);
assert_eq!(max_backdrop_blur(&d), 12.0);
copy.node_mut(r).unwrap().effects.clear();
assert_eq!(max_backdrop_blur(&copy), 0.0);
// A deleted layer's blur no longer counts.
d.remove(r).unwrap();
assert_eq!(max_backdrop_blur(&d), 0.0);
}
#[test]
fn kept_splat_answers_are_the_answers_worked_out_afresh() {
use figcraft_color::{ImageScaleMode, Paint};
// Groups nested past the 64-level walk, with an image-filled rectangle at the bottom of one
// branch and a plain one at the bottom of the other: answers asked for deep-first and
// top-first, at depths 0 and 20, are those worked out alone.
let mut d = Document::new("deep");
let p = d.first_page().unwrap();
let mut chain = Vec::new();
for image in [false, true] {
let mut parent = p;
for k in 0..80 {
let g = d.create(parent, NodeKind::Frame, &format!("{image} {k}"), 0.0, 0.0, 10.0, 10.0).unwrap();
d.node_mut(g).unwrap().clip = false;
chain.push(g);
parent = g;
}
let r = d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
if image {
d.node_mut(r).unwrap().fills = vec![Paint::image("missing", ImageScaleMode::Fill)];
}
}
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
let memo = draw::Bounds::default();
for id in &order {
let n = d.node(*id).unwrap();
for depth in [0, 20] {
let kept = lod::splattable_within(&d, n, depth, Some(&memo)).0;
assert_eq!(kept, lod::splattable(&d, n, depth), "{} at depth {depth}", n.name);
}
}
}
}
+121 -21
View File
@@ -16,8 +16,9 @@ use crate::{blur, grain, lod, shadow};
/// Shared state for one render.
#[derive(Clone, Copy)]
pub struct Ctx<'a> {
pub doc: &'a Document,
/// `'d`: the document's (layers looked up for the render are kept that long), `'a`: the rest.
pub struct Ctx<'a, 'd> {
pub doc: &'d Document,
/// Device pixels per document unit (for effect radii).
pub scale: f64,
pub outline_mode: bool,
@@ -33,8 +34,67 @@ pub struct Ctx<'a> {
pub under: Option<&'a Under<'a>>,
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
pub time: f64,
/// Paint bounds already worked out in this render.
pub bounds: &'a Bounds<'d>,
}
/// What one render works out about layers with children, which a walk down their subtree finds:
/// their local paint bounds by greeking scale, and whether they can be splatted (see
/// [`lod::splattable`]). Culling asks for a node's and then for each of its children's, so without
/// them every layer's subtree was walked again at each level above it. Each answer keeps how many
/// levels its walk went down.
/// It also keeps the layers it looked up: the walks for culling, splatting and drawing go down the
/// same layers, and in a large document a lookup is a good part of a layer's cost.
#[derive(Default)]
pub struct Bounds<'a> {
paint: std::cell::RefCell<IdMap<BoundsKey, (Rect, usize)>>,
pub(crate) splat: std::cell::RefCell<IdMap<NodeId, (bool, usize)>>,
nodes: std::cell::RefCell<IdMap<NodeId, Option<&'a Node>>>,
}
impl<'a> Bounds<'a> {
/// Node `id` of `doc`, looked up once for the render.
pub(crate) fn node(&self, doc: &'a Document, id: NodeId) -> Option<&'a Node> {
if let Some(n) = self.nodes.borrow().get(&id) {
return *n;
}
let n = doc.node(id);
self.nodes.borrow_mut().insert(id, n);
n
}
}
/// A hash map keyed by node ids, which need no SipHash: they are counters (or a peer's range of
/// them), and SipHash took a good part of a render.
type IdMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<IdHash>>;
/// Mixes each word in with a multiply (Fibonacci hashing), so ids that differ in low bits spread.
#[derive(Default)]
pub(crate) struct IdHash(u64);
impl Hasher for IdHash {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for b in bytes {
self.write_u64(u64::from(*b));
}
}
fn write_u64(&mut self, v: u64) {
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x9E37_79B9_7F4A_7C15);
}
fn write_usize(&mut self, v: usize) {
self.write_u64(v as u64);
}
}
/// A layer, and the scale its text is greeked at (none: drawn in full).
type BoundsKey = (NodeId, Option<u64>);
/// Deepest nesting the paint bounds walk follows.
const MAX_BOUNDS_DEPTH: usize = 64;
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)
@@ -279,32 +339,63 @@ fn text_box_bounds(n: &Node) -> Rect {
/// 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 {
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
bounds_within(doc, n, depth, greek_scale, None).0
}
/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many
/// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on
/// the depth they were asked at and aren't kept, and kept bounds are read only where their levels
/// still fit under the cap.
pub(crate) fn bounds_within<'a>(
doc: &'a Document,
n: &Node,
depth: usize,
greek_scale: Option<f64>,
memo: Option<&Bounds<'a>>,
) -> (Rect, Option<usize>) {
// A layer without children is quicker to work out again than to look up.
let memo = memo.filter(|_| !n.children.is_empty());
let key = (n.id, greek_scale.map(f64::to_bits));
if let Some((r, levels)) = memo.and_then(|m| m.paint.borrow().get(&key).copied())
&& depth + levels <= MAX_BOUNDS_DEPTH
{
return (r, Some(levels));
}
let reach = n.paint_reach();
let mut r = n.local_rect().inflate(reach, reach);
let mut levels = Some(0);
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;
if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() {
if depth < MAX_BOUNDS_DEPTH {
let mut below = Some(0);
for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))) {
if !c.visible {
continue;
}
let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo);
below = below.zip(c_levels).map(|(b, l)| b.max(l));
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
}
let cb = local_paint_bounds(doc, c, depth + 1, greek_scale);
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
levels = below.map(|b| b + 1);
} else if !n.children.is_empty() {
levels = None;
}
}
// 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)
if !greek_scale.is_some_and(|s| lod::greeked(n, s))
&& 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
if let (Some(m), Some(l)) = (memo, levels) {
m.paint.borrow_mut().insert(key, (r, l));
}
(r, levels)
}
/// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass,
@@ -367,10 +458,17 @@ 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 let Some(n) = ctx.doc.node(id) {
draw_layer(ctx, pix, n, parent_xf, depth);
}
}
/// [`draw_node`] for a node already looked up.
fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: usize) {
let id = n.id;
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;
@@ -385,7 +483,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
}
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 local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
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.
@@ -398,7 +496,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
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) {
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable_within(ctx.doc, n, 0, Some(ctx.bounds)).0 {
lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth);
return;
}
@@ -672,13 +770,15 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
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) {
// Each child looked up once: in a large document a lookup is a good part of a layer's cost.
let nodes: Vec<Option<&Node>> = kids.iter().map(|c| ctx.bounds.node(ctx.doc, *c)).collect();
for run in Document::paint_runs_of(&nodes, n.layout.as_ref().is_some_and(|l| l.reverse_z)) {
let (Some(&c), Some(Some(cn))) = (kids.get(run.start), nodes.get(run.start)) else { continue };
if 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);
draw_layer(ctx, pix, cn, xf, depth + 1);
}
}
}
+7 -1
View File
@@ -90,7 +90,7 @@ fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
/// The largest backdrop radius (background blur, glass: see [`figcraft_doc::Effect::backdrop_radius`])
/// in the document (0 without any).
pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 {
doc.nodes().flat_map(|n| n.effects.iter()).map(figcraft_doc::Effect::backdrop_radius).fold(0.0, f64::max)
doc.max_backdrop_radius()
}
/// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`.
@@ -170,6 +170,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
pix.fill(paint::ts_color(bg));
}
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
let bounds = draw::Bounds::default();
let ctx = draw::Ctx {
doc,
scale: view.scale,
@@ -180,6 +181,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
lod,
under: None,
time: 0.0,
bounds: &bounds,
};
let xf = view.transform();
let Some(p) = doc.node(page) else { return Some(pix) };
@@ -201,6 +203,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut
/// [`render_nodes_into`] `time` seconds into animated GIFs.
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
let bounds = draw::Bounds::default();
let ctx = draw::Ctx {
doc,
scale: view.scale,
@@ -211,6 +214,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
lod: false,
under: None,
time,
bounds: &bounds,
};
for id in ids {
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
@@ -434,6 +438,8 @@ mod band_tests;
#[cfg(test)]
mod boolean_cache_tests;
#[cfg(test)]
mod bounds_memo_tests;
#[cfg(test)]
mod effect_order_tests;
#[cfg(test)]
mod effects_behaviour_tests;
+34 -5
View File
@@ -60,18 +60,47 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Pai
/// Whether the subtree of `n` can be splatted: no image or pattern paints (their colours aren't known
/// without sampling them) and no masks (which change what their siblings show).
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool {
splattable_within(doc, n, depth, None).0
}
/// [`splattable`], kept in and read from `memo` when there is one (layers with children only).
/// Also returns how many levels the walk went down, or `None` when the nesting cap decided it: such
/// answers depend on the depth they were asked at and aren't kept, and kept answers are read only
/// where their levels still fit under the cap.
pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, memo: Option<&crate::draw::Bounds<'a>>) -> (bool, Option<usize>) {
if depth > MAX_DEPTH {
return false;
return (false, None);
}
let memo = memo.filter(|_| !n.children.is_empty());
if let Some((ok, levels)) = memo.and_then(|m| m.splat.borrow().get(&n.id).copied())
&& depth + levels <= MAX_DEPTH
{
return (ok, Some(levels));
}
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_));
let own = match &n.kind {
NodeKind::Text(t) => text_paints(n, t).any(image),
_ => n.fills.iter().any(image),
};
if own || n.strokes.iter().any(image) {
return false;
let (mut ok, mut below) = (!(own || n.strokes.iter().any(image)), Some(0));
if ok {
for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))).filter(|c| c.visible) {
if c.is_mask {
ok = false;
break;
}
let (c_ok, c_levels) = splattable_within(doc, c, depth + 1, memo);
below = below.zip(c_levels).map(|(b, l)| b.max(l + 1));
if !c_ok {
ok = false;
break;
}
}
}
n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1))
if let (Some(m), Some(l)) = (memo, below) {
m.splat.borrow_mut().insert(n.id, (ok, l));
}
(ok, below)
}
/// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for
@@ -199,7 +228,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op
} else {
clip
};
for c in n.paint_order().filter_map(|c| ctx.doc.node(c)) {
for c in n.paint_order().filter_map(|c| ctx.bounds.node(ctx.doc, c)) {
splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1);
}
}
+2
View File
@@ -154,6 +154,7 @@ fn exports_never_use_lod() {
crate::render_nodes_into(&d, &[card], &v, &mut exact);
assert_eq!(png.data(), exact.data());
let mut preview = Pixmap::new(v.width, v.height).unwrap();
let bounds = crate::draw::Bounds::default();
let ctx = crate::draw::Ctx {
doc: &d,
scale,
@@ -164,6 +165,7 @@ fn exports_never_use_lod() {
lod: true,
under: None,
time: 0.0,
bounds: &bounds,
};
let parent = d.world_transform(p);
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
+3 -1
View File
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId};
use figcraft_geom::Affine;
use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform};
use crate::draw::{Ctx, draw_node};
use crate::draw::{Bounds, Ctx, draw_node};
use crate::paint::Prepared;
/// The largest side of a cell's pixmap; a cell showing larger is drawn coarser.
@@ -46,6 +46,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX));
let (kx, ky) = (pw / cell.size.0, ph / cell.size.1);
let mut pix = Pixmap::new(pw as u32, ph as u32)?;
let bounds = Bounds::default();
let ctx = Ctx {
doc,
scale: kx.max(ky) * cell.scale,
@@ -56,6 +57,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
lod: false,
under: None,
time,
bounds: &bounds,
};
let unrotated = src.transform.inverse();
nested(|| {