mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 22:36:51 -06:00
M7: performance: retained canvas renders, virtual layers panel, shared text layouts, cargo xtask bench (#35)
Large documents stay interactive: the canvas re-renders only what a pan uncovers or an edit touches, the layers panel lays out only the rows in view, and per-frame work no longer walks the whole page. - Benchmark: `cargo xtask bench [SIZES]` builds synthetic pages (`figcraft_engine::synthetic`: screens with an auto layout header, a card group, button instances and shapes/text) at 1k/10k/50k layers and prints engine/render timings (`figcraft-cli perf --layers 1000,10000`) and headless whole-UI frame timings (an ignored ui-egui test). - Retained canvas rendering (`figcraft_render::Retained`, used by the render worker): views snap to whole device pixels, so a pan shifts the previous image in place and renders only the uncovered strips; an edit renders only where changed layers were and are (`Document::changed_ids` diffs the two versions skipping shared structure; ancestors that paint from their children's pixels - booleans, effects, masks - count whole; background blur reach and noise are allowed for). Zoom, page, options, font or page-level changes, or large changes, render the whole view as before. `ui.inspect` reports `canvas.renderWork`. - The texture image is converted on the render thread, not the UI thread. - Layers panel: only visible rows are laid out (stable ids while scrolling); rows are cached per document version, page, expanded set and filter. - Frame labels and layout grids of off-screen top-level frames are skipped; the Prototype tab's connection sources are cached per document version. - Text: the layout cache keys by a cheap fingerprint confirmed by `TextData` equality (no Debug formatting per lookup, 32k entries) and hands out shared layouts; a layout generation marks renders stale when fonts arrive. Overflowing text is culled by its glyphs, so it no longer vanishes while its box is off screen. Before -> after (release, Windows, 1440x900 view, ms; 10k / 50k layers): - move one layer, re-render @100%: 7.65 / 6.49 -> 0.18 / 0.81 - move one layer, re-render zoomed out: 11.95 / 24.10 -> 0.16 / 0.77 - pan 40 px @100%: 7.53 / 6.82 -> 0.69 / 0.81 - pan 40 px zoomed out: 12.07 / 24.30 -> 1.62 / 4.17 - full render @100% / zoomed to fit: 7.78 / 44.22 -> 6.56 / 38.83 (10k @100%, 50k fit) - UI frame, every screen expanded in layers: 6.16 / 39.07 -> 0.24 / 0.57 - UI frame, scrolling that panel: 6.11 / 40.18 -> 0.43 / 0.40 - UI frame, layers filtered by name: 1.03 / 6.92 -> 0.17 / 0.36 - UI frame, Prototype tab: 0.89 / 6.45 -> 0.18 / 0.80 - UI frame, idle: 0.31 / 1.43 -> 0.11 / 0.39 Unchanged and within budget: single-layer edits through the engine ~0.01 ms, normalize after a one-node edit < 0.01 ms, hit test 26 / 93 us. Left: save/load at 50k (~0.7 s each), zoomed-out renders at 50k (~40 ms). Tests: retained renders match full renders after pans, moves, recolours, hide/remove/add, undo, masks, overflowing text, edits under a background blur and an inner-shadow/noise layer, a drop- shadowed group, and a blur panned in from the view's edge; zoom/page/options/page background render full; junk views don't panic. Layer-row and prototype-source caches follow edits, undo, page switches, expansion and the filter. `changed_ids`, the text cache, and the perf CLI. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+30
-10
@@ -250,6 +250,7 @@ fn device_rect(xf: Affine, r: Rect) -> 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.
|
||||
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect {
|
||||
let reach = n.paint_reach();
|
||||
let mut r = n.local_rect().inflate(reach, reach);
|
||||
@@ -266,10 +267,38 @@ fn local_paint_bounds(doc: &Document, n: &Node, depth: usize) -> Rect {
|
||||
// Text can overflow its box.
|
||||
if let NodeKind::Text(_) = n.kind {
|
||||
r = r.inflate(n.height.max(10.0), n.height.max(10.0) * 0.5);
|
||||
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 and
|
||||
/// drop shadows. 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::BackgroundBlur { radius } | EffectKind::LayerBlur { radius } => *radius * 2.0,
|
||||
EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs(),
|
||||
_ => 0.0,
|
||||
})
|
||||
.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);
|
||||
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)
|
||||
}
|
||||
@@ -299,16 +328,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
let local = local_paint_bounds(ctx.doc, n, 0);
|
||||
let dev = device_rect(xf, local);
|
||||
// Effects that read outside (blur) need a margin when culling.
|
||||
let blur_margin = n
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.map(|e| match &e.kind {
|
||||
EffectKind::BackgroundBlur { radius } | EffectKind::LayerBlur { radius } => *radius * 2.0 * ctx.scale,
|
||||
EffectKind::DropShadow(s) => (s.radius * 2.0 + s.spread.abs() + s.offset.0.abs() + s.offset.1.abs()) * ctx.scale,
|
||||
_ => 0.0,
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
let blur_margin = effect_margin(n) * ctx.scale;
|
||||
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;
|
||||
|
||||
@@ -9,8 +9,10 @@ pub mod draw;
|
||||
pub mod fx;
|
||||
pub mod images;
|
||||
pub mod paint;
|
||||
pub mod retained;
|
||||
|
||||
pub use draw::{node_outline, subtree_outline, ts_path};
|
||||
pub use draw::{node_outline, paint_bounds, subtree_outline, ts_path};
|
||||
pub use retained::{Retained, Work};
|
||||
pub use tiny_skia::{FillRule, Pixmap};
|
||||
|
||||
use figcraft_color::Color;
|
||||
@@ -53,18 +55,20 @@ fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
|
||||
Pixmap::new(w, h)
|
||||
}
|
||||
|
||||
/// How far (device pixels) background blurs read their backdrop: an upper bound over the document.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn backdrop_reach(doc: &Document, scale: f64) -> f64 {
|
||||
let radius = doc
|
||||
.nodes()
|
||||
/// The largest visible background blur radius in the document (0 without any).
|
||||
pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 {
|
||||
doc.nodes()
|
||||
.flat_map(|n| n.effects.iter())
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| match e.kind {
|
||||
figcraft_doc::EffectKind::BackgroundBlur { radius } if radius.is_finite() => Some(radius),
|
||||
_ => None,
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`.
|
||||
pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 {
|
||||
// The three box passes of a blur with sigma = radius / 2 reach at most 3 sigma + 3 pixels.
|
||||
if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 }
|
||||
}
|
||||
@@ -93,7 +97,7 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) ->
|
||||
fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option<Pixmap> {
|
||||
use rayon::prelude::*;
|
||||
let band_h = view.height.div_ceil(bands.max(1));
|
||||
let overlap = backdrop_reach(doc, view.scale).ceil();
|
||||
let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil();
|
||||
if !overlap.is_finite() || overlap > f64::from(band_h) {
|
||||
return None;
|
||||
}
|
||||
@@ -241,4 +245,6 @@ pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
|
||||
#[cfg(test)]
|
||||
mod band_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Retained canvas rendering: keep the last rendered view and, for the next one, re-render only
|
||||
//! what changed: the strips a pan uncovers and the areas of layers that were edited.
|
||||
//!
|
||||
//! Views are snapped to whole device pixels so that, at one zoom, the pixels of every render line
|
||||
//! up and a pan is an integer shift of the previous image. Anything the partial path can't vouch
|
||||
//! for (another zoom, page or set of options, new fonts, a page-level edit, a large change) is a
|
||||
//! full render, the same as [`render_view`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, EffectKind, NodeId, NodeKind};
|
||||
use figcraft_geom::Rect;
|
||||
use tiny_skia::{IntRect, Pixmap, PixmapPaint, Transform};
|
||||
|
||||
use crate::{Options, View, draw, render_view};
|
||||
|
||||
/// What a render had to do.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Work {
|
||||
/// Nothing changed: the previous image as is.
|
||||
Reused,
|
||||
/// The previous image, shifted and/or patched where things changed.
|
||||
Partial {
|
||||
/// Device pixels rendered afresh.
|
||||
pixels: u64,
|
||||
},
|
||||
/// The whole view.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Re-rendered areas beyond this many get merged into their bounding box.
|
||||
const MAX_RECTS: usize = 8;
|
||||
/// A change covering more than this share of the view renders it whole.
|
||||
const MAX_PARTIAL_SHARE: f64 = 0.6;
|
||||
/// More changed layers than this render the view whole.
|
||||
const MAX_CHANGED: usize = 4096;
|
||||
/// Effect margins beyond this many device pixels render the view whole.
|
||||
const MAX_MARGIN_PX: f64 = 1024.0;
|
||||
|
||||
/// What a retained image was rendered from.
|
||||
struct Frame {
|
||||
doc: Arc<Document>,
|
||||
page: NodeId,
|
||||
outline: bool,
|
||||
skip: Vec<NodeId>,
|
||||
background: Option<figcraft_color::Color>,
|
||||
scale: u64,
|
||||
/// The view's top-left in device pixels (page origin × scale).
|
||||
px: (i64, i64),
|
||||
fonts: u64,
|
||||
/// Areas (page coordinates) of layers whose look depends on where a render cuts them, and the
|
||||
/// largest background blur (what backdrops read around).
|
||||
sensitive: Arc<Sensitive>,
|
||||
pix: Pixmap,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Sensitive {
|
||||
areas: Vec<Rect>,
|
||||
backdrop_blur: f64,
|
||||
}
|
||||
|
||||
/// Noise is seeded by its layer's pixels, which start where a render cuts the layer: noisy layers
|
||||
/// are redrawn whole.
|
||||
fn sensitive(doc: &Document) -> Sensitive {
|
||||
let noisy = doc.nodes().filter(|n| n.effects.iter().any(|e| e.visible && matches!(e.kind, EffectKind::Noise { .. })));
|
||||
Sensitive { areas: noisy.filter_map(|n| draw::paint_bounds(doc, n.id)).collect(), backdrop_blur: crate::max_backdrop_blur(doc) }
|
||||
}
|
||||
|
||||
/// A view snapped outwards to whole device pixels: the origin on its zoom's pixel grid and one
|
||||
/// more pixel each way. Returns the view and its origin in device pixels.
|
||||
pub fn snap(view: &View) -> Option<(View, (i64, i64))> {
|
||||
let s = view.scale;
|
||||
let (fx, fy) = ((view.origin.0 * s).floor(), (view.origin.1 * s).floor());
|
||||
if !(s.is_finite() && s > 0.0 && fx.is_finite() && fy.is_finite() && fx.abs() < 1e15 && fy.abs() < 1e15) {
|
||||
return None;
|
||||
}
|
||||
let v = View {
|
||||
origin: (fx / s, fy / s),
|
||||
scale: s,
|
||||
width: view.width.saturating_add(1).clamp(1, 16_384),
|
||||
height: view.height.saturating_add(1).clamp(1, 16_384),
|
||||
};
|
||||
Some((v, (fx as i64, fy as i64)))
|
||||
}
|
||||
|
||||
/// Device-pixel rectangle `[x0, x1) × [y0, y1)` in the frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Px {
|
||||
x0: i64,
|
||||
y0: i64,
|
||||
x1: i64,
|
||||
y1: i64,
|
||||
}
|
||||
|
||||
impl Px {
|
||||
fn area(&self) -> u64 {
|
||||
(self.x1 - self.x0).max(0) as u64 * (self.y1 - self.y0).max(0) as u64
|
||||
}
|
||||
fn union(&self, o: &Px) -> Px {
|
||||
Px { x0: self.x0.min(o.x0), y0: self.y0.min(o.y0), x1: self.x1.max(o.x1), y1: self.y1.max(o.y1) }
|
||||
}
|
||||
fn clamp(&self, w: i64, h: i64) -> Option<Px> {
|
||||
let r = Px { x0: self.x0.max(0), y0: self.y0.max(0), x1: self.x1.min(w), y1: self.y1.min(h) };
|
||||
(r.x1 > r.x0 && r.y1 > r.y0).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the last rendered view; see the module docs.
|
||||
#[derive(Default)]
|
||||
pub struct Retained {
|
||||
last: Option<Frame>,
|
||||
}
|
||||
|
||||
impl Retained {
|
||||
/// Forget the kept image (the next render is full).
|
||||
pub fn clear(&mut self) {
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
/// Render `view` of `page`, reusing the previous render where nothing changed. Returns the
|
||||
/// view actually drawn (snapped to device pixels, see [`snap`]), the image and what it took.
|
||||
pub fn render(&mut self, doc: &Arc<Document>, page: NodeId, view: &View, opts: &Options) -> Option<(View, &Pixmap, Work)> {
|
||||
// A view that can't be put on a pixel grid (non-finite or absurd) draws nothing.
|
||||
let Some((snapped, px)) = snap(view) else {
|
||||
self.last = None;
|
||||
return None;
|
||||
};
|
||||
let fonts = figcraft_text::layout_generation();
|
||||
let mut work = Work::Full;
|
||||
let mut sens = None;
|
||||
if let Some(last) = self.last.take()
|
||||
&& last.page == page
|
||||
&& last.outline == opts.outline_mode
|
||||
&& last.skip == opts.skip
|
||||
&& last.background == opts.background
|
||||
&& last.scale == snapped.scale.to_bits()
|
||||
&& last.pix.width() == snapped.width
|
||||
&& last.pix.height() == snapped.height
|
||||
&& last.fonts == fonts
|
||||
{
|
||||
let same_doc = Arc::ptr_eq(&last.doc, doc);
|
||||
let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) };
|
||||
if let Some((pix, pixels)) = patch(last, doc, page, &snapped, px, opts, &s) {
|
||||
work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } };
|
||||
let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
|
||||
return Some((snapped, &frame.pix, work));
|
||||
}
|
||||
sens = Some(s);
|
||||
}
|
||||
let pix = render_view(doc, page, &snapped, opts)?;
|
||||
let s = sens.unwrap_or_else(|| Arc::new(sensitive(doc)));
|
||||
let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s);
|
||||
Some((snapped, &frame.pix, work))
|
||||
}
|
||||
|
||||
/// Keep `pix`, the render of `at`: the snapped view, its top-left device pixel and the font
|
||||
/// generation the render started with (fonts arriving meanwhile leave it stale).
|
||||
fn keep(
|
||||
&mut self,
|
||||
doc: &Arc<Document>,
|
||||
page: NodeId,
|
||||
at: (&View, (i64, i64), u64),
|
||||
opts: &Options,
|
||||
pix: Pixmap,
|
||||
sensitive: Arc<Sensitive>,
|
||||
) -> &Frame {
|
||||
let (view, px, fonts) = at;
|
||||
self.last.insert(Frame {
|
||||
doc: doc.clone(),
|
||||
page,
|
||||
outline: opts.outline_mode,
|
||||
skip: opts.skip.clone(),
|
||||
background: opts.background,
|
||||
scale: view.scale.to_bits(),
|
||||
px,
|
||||
fonts,
|
||||
sensitive,
|
||||
pix,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Page rectangle → device pixels of a frame whose top-left is `px`, rounded outwards plus a pixel
|
||||
/// of anti-aliasing.
|
||||
fn to_px(r: Rect, scale: f64, px: (i64, i64)) -> Option<Px> {
|
||||
let (x0, y0, x1, y1) = ((r.x0 * scale).floor() - 1.0, (r.y0 * scale).floor() - 1.0, (r.x1 * scale).ceil() + 1.0, (r.y1 * scale).ceil() + 1.0);
|
||||
if !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite())
|
||||
|| x0.abs() > 1e15
|
||||
|| y0.abs() > 1e15
|
||||
|| x1.abs() > 1e15
|
||||
|| y1.abs() > 1e15
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Px { x0: x0 as i64 - px.0, y0: y0 as i64 - px.1, x1: x1 as i64 - px.0, y1: y1 as i64 - px.1 })
|
||||
}
|
||||
|
||||
/// Page areas a change to `id` in `doc` affects on `page`; `None` when only a full render will do.
|
||||
fn changed_areas(doc: &Document, page: NodeId, id: NodeId, out: &mut Vec<Rect>) -> Option<()> {
|
||||
let Some(n) = doc.node(id) else { return Some(()) };
|
||||
if n.is_page() {
|
||||
// Page background, page order or top-level layers.
|
||||
return if id == page { None } else { Some(()) };
|
||||
}
|
||||
if doc.page_of(id) != Some(page) {
|
||||
return Some(());
|
||||
}
|
||||
out.push(draw::paint_bounds(doc, id)?);
|
||||
// A mask clips the layers above it.
|
||||
if n.is_mask {
|
||||
let parent = n.parent?;
|
||||
if parent == page {
|
||||
return None;
|
||||
}
|
||||
out.push(draw::paint_bounds(doc, parent)?);
|
||||
}
|
||||
// Ancestors that paint from their children's pixels (booleans, effects, masks): all of them.
|
||||
for a in doc.ancestors(id) {
|
||||
let Some(an) = doc.node(a) else { break };
|
||||
if an.is_page() {
|
||||
break;
|
||||
}
|
||||
if matches!(an.kind, NodeKind::BooleanOp { .. }) || an.is_mask || an.effects.iter().any(|e| e.visible) {
|
||||
out.push(draw::paint_bounds(doc, a)?);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The previous frame brought up to date for `doc` at `px`, rendering only what changed; `None`
|
||||
/// when a full render is the better (or only correct) choice. Also returns the pixels rendered.
|
||||
fn patch(last: Frame, doc: &Arc<Document>, page: NodeId, view: &View, px: (i64, i64), opts: &Options, s: &Sensitive) -> Option<(Pixmap, u64)> {
|
||||
let (w, h) = (i64::from(view.width), i64::from(view.height));
|
||||
let (dx, dy) = (px.0.checked_sub(last.px.0)?, px.1.checked_sub(last.px.1)?);
|
||||
if dx.abs() >= w || dy.abs() >= h {
|
||||
return None;
|
||||
}
|
||||
// Background blurs read the backdrop around them, so a change shows that far off: under a blur
|
||||
// beside a changed layer, and at the old view's edge (where the backdrop used to end).
|
||||
let reach = crate::backdrop_reach(s.backdrop_blur.max(last.sensitive.backdrop_blur), view.scale).ceil();
|
||||
if !reach.is_finite() || reach > MAX_MARGIN_PX {
|
||||
return None;
|
||||
}
|
||||
let reach = reach as i64;
|
||||
let mut damage: Vec<Px> = Vec::new();
|
||||
// Strips a pan uncovers.
|
||||
if dx > 0 {
|
||||
damage.push(Px { x0: w - dx - reach, y0: 0, x1: w, y1: h });
|
||||
} else if dx < 0 {
|
||||
damage.push(Px { x0: 0, y0: 0, x1: reach - dx, y1: h });
|
||||
}
|
||||
if dy > 0 {
|
||||
damage.push(Px { x0: 0, y0: h - dy - reach, x1: w, y1: h });
|
||||
} else if dy < 0 {
|
||||
damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach - dy });
|
||||
}
|
||||
// Layers that changed, where they were and where they are now.
|
||||
if !Arc::ptr_eq(&last.doc, doc) {
|
||||
let mut areas = Vec::new();
|
||||
for id in doc.changed_ids(&last.doc, MAX_CHANGED)? {
|
||||
changed_areas(&last.doc, page, id, &mut areas)?;
|
||||
changed_areas(doc, page, id, &mut areas)?;
|
||||
}
|
||||
for r in areas {
|
||||
if let Some(p) = to_px(r, view.scale, px) {
|
||||
damage.push(Px { x0: p.x0 - reach, y0: p.y0 - reach, x1: p.x1 + reach, y1: p.y1 + reach });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut damage: Vec<Px> = damage.iter().filter_map(|d| d.clamp(w, h)).collect();
|
||||
if damage.is_empty() && dx == 0 && dy == 0 {
|
||||
return Some((last.pix, 0));
|
||||
}
|
||||
// Layers whose look depends on where they are cut are redrawn whole.
|
||||
let sensitive: Vec<Px> = s.areas.iter().chain(last.sensitive.areas.iter()).filter_map(|r| to_px(*r, view.scale, px)).collect();
|
||||
for _ in 0..4 {
|
||||
let mut grew = false;
|
||||
for d in damage.iter_mut() {
|
||||
for a in &sensitive {
|
||||
let hits = a.x0 < d.x1 && d.x0 < a.x1 && a.y0 < d.y1 && d.y0 < a.y1;
|
||||
if hits && a.union(d) != *d {
|
||||
*d = a.union(d);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !grew {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut damage = merge(damage.iter().filter_map(|d| d.clamp(w, h)).collect());
|
||||
if damage.len() > MAX_RECTS {
|
||||
let all = damage.iter().skip(1).fold(*damage.first()?, |a, b| a.union(b));
|
||||
damage = vec![all];
|
||||
}
|
||||
let pixels: u64 = damage.iter().map(Px::area).sum();
|
||||
if pixels as f64 > MAX_PARTIAL_SHARE * (w * h) as f64 {
|
||||
return None;
|
||||
}
|
||||
// Render with the backdrop reach as a margin (so blurs see their whole backdrop), keep the inside.
|
||||
let margin = reach + 1;
|
||||
let mut pix = last.pix;
|
||||
shift(&mut pix, dx, dy)?;
|
||||
for d in &damage {
|
||||
render_into(doc, page, view, px, d, margin, opts, &mut pix)?;
|
||||
}
|
||||
Some((pix, pixels))
|
||||
}
|
||||
|
||||
/// Move the image's content by `(-dx, -dy)` pixels in place (what was at `(x, y)` goes to
|
||||
/// `(x - dx, y - dy)`); the uncovered strips keep stale pixels for the caller to re-render.
|
||||
fn shift(pix: &mut Pixmap, dx: i64, dy: i64) -> Option<()> {
|
||||
if dx == 0 && dy == 0 {
|
||||
return Some(());
|
||||
}
|
||||
let (w, h) = (i64::from(pix.width()), i64::from(pix.height()));
|
||||
let row = usize::try_from(w * 4).ok()?;
|
||||
let data = pix.data_mut();
|
||||
// Columns that survive the horizontal move, as byte ranges within a row.
|
||||
let (src_x, dst_x, n) = if dx >= 0 { (dx, 0, w - dx) } else { (0, -dx, w + dx) };
|
||||
let (src_x, dst_x, n) = (usize::try_from(src_x * 4).ok()?, usize::try_from(dst_x * 4).ok()?, usize::try_from(n * 4).ok()?);
|
||||
let ys: Box<dyn Iterator<Item = i64>> = if dy >= 0 { Box::new(0..h - dy) } else { Box::new((-dy..h).rev()) };
|
||||
for y in ys {
|
||||
let (src, dst) = (usize::try_from(y + dy).ok()? * row, usize::try_from(y).ok()? * row);
|
||||
if src + src_x + n > data.len() || dst + dst_x + n > data.len() {
|
||||
return None;
|
||||
}
|
||||
data.copy_within(src + src_x..src + src_x + n, dst + dst_x);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Merge rectangles that overlap so much that their bounding box is no bigger than the two
|
||||
/// (where a layer was and where it is now, a frame and its child…): no pixel renders twice.
|
||||
fn merge(mut rects: Vec<Px>) -> Vec<Px> {
|
||||
let mut i = 0;
|
||||
while i < rects.len() {
|
||||
let mut j = i + 1;
|
||||
let mut merged = false;
|
||||
while j < rects.len() {
|
||||
let (Some(a), Some(b)) = (rects.get(i).copied(), rects.get(j).copied()) else { break };
|
||||
let u = a.union(&b);
|
||||
if u.area() <= a.area() + b.area() {
|
||||
if let Some(slot) = rects.get_mut(i) {
|
||||
*slot = u;
|
||||
}
|
||||
rects.swap_remove(j);
|
||||
merged = true;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
if !merged {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
rects
|
||||
}
|
||||
|
||||
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_into(doc: &Document, page: NodeId, view: &View, px: (i64, i64), d: &Px, margin: i64, opts: &Options, pix: &mut Pixmap) -> Option<()> {
|
||||
let s = view.scale;
|
||||
let (ox, oy) = (d.x0 - margin, d.y0 - margin);
|
||||
let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?);
|
||||
// The same pixel grid as the frame: origin (px + offset) / scale.
|
||||
let sub = View { origin: ((px.0 + ox) as f64 / s, (px.1 + oy) as f64 / s), scale: s, width: rw, height: rh };
|
||||
let part = render_view(doc, page, &sub, opts)?;
|
||||
let inner = IntRect::from_xywh(
|
||||
i32::try_from(margin).ok()?,
|
||||
i32::try_from(margin).ok()?,
|
||||
u32::try_from(d.x1 - d.x0).ok()?,
|
||||
u32::try_from(d.y1 - d.y0).ok()?,
|
||||
)?;
|
||||
let inner = part.clone_rect(inner)?;
|
||||
let copy = PixmapPaint { blend_mode: tiny_skia::BlendMode::Source, ..Default::default() };
|
||||
pix.draw_pixmap(i32::try_from(d.x0).ok()?, i32::try_from(d.y0).ok()?, inner.as_ref(), ©, Transform::identity(), None);
|
||||
Some(())
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! The retained renderer: every partial render must match a full render of the same view.
|
||||
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, EffectKind, Node, NodeId, NodeKind, Shadow, TextAutoResize, TextData};
|
||||
use figcraft_geom::Affine;
|
||||
|
||||
use crate::{Options, Pixmap, Retained, View, Work, render_view, retained::snap};
|
||||
|
||||
/// System fonts load once, before any render here, so text has glyphs and fonts never change
|
||||
/// mid-test (a font change makes the next render full).
|
||||
fn fonts() {
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(figcraft_text::scan_system_fonts);
|
||||
}
|
||||
|
||||
struct Scene {
|
||||
doc: Arc<Document>,
|
||||
page: NodeId,
|
||||
rect: NodeId,
|
||||
text: NodeId,
|
||||
frame: NodeId,
|
||||
child: NodeId,
|
||||
}
|
||||
|
||||
fn solid(n: &mut Node, hex: &str) {
|
||||
n.fills = vec![Paint::solid(Color::from_hex(hex).unwrap())];
|
||||
}
|
||||
|
||||
/// Shapes, a clipping frame with children, a group with a drop shadow and fixed-width text; with
|
||||
/// `cut_sensitive`, also layers whose look depends on where a render cuts them (inner shadow and
|
||||
/// noise, a background blur). A full render cuts those at the view's edge, so tests compare them
|
||||
/// only in views that contain them whole.
|
||||
fn scene(cut_sensitive: bool) -> Scene {
|
||||
fonts();
|
||||
let mut d = Document::new("retained");
|
||||
let page = d.first_page().unwrap();
|
||||
let rect = d.create(page, NodeKind::Rectangle, "R", 20.0, 20.0, 80.0, 50.0).unwrap();
|
||||
solid(d.get_mut(rect).unwrap(), "E03030");
|
||||
let ell = d.create(page, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "E", 140.0, 30.0, 60.0, 60.0).unwrap();
|
||||
solid(d.get_mut(ell).unwrap(), "3070E0");
|
||||
let frame = d.create(page, NodeKind::Frame, "F", 40.0, 120.0, 200.0, 120.0).unwrap();
|
||||
let child = d.create(frame, NodeKind::Rectangle, "C", 150.0, 20.0, 90.0, 40.0).unwrap();
|
||||
solid(d.get_mut(child).unwrap(), "20A060");
|
||||
let group = d.create(page, NodeKind::Group, "G", 280.0, 40.0, 60.0, 60.0).unwrap();
|
||||
let g1 = d.create(group, NodeKind::Rectangle, "G1", 0.0, 0.0, 60.0, 60.0).unwrap();
|
||||
solid(d.get_mut(g1).unwrap(), "F0B020");
|
||||
d.get_mut(group)
|
||||
.unwrap()
|
||||
.effects
|
||||
.push(Effect { kind: EffectKind::DropShadow(Shadow { offset: (12.0, 16.0), radius: 6.0, ..Shadow::default() }), visible: true });
|
||||
let mut td = TextData::new("A line of text that overflows its box");
|
||||
td.auto_resize = TextAutoResize::None;
|
||||
td.style.font_size = 14.0;
|
||||
let text = d.create(page, NodeKind::Text(td), "T", 20.0, 270.0, 60.0, 20.0).unwrap();
|
||||
if cut_sensitive {
|
||||
let inner = d.create(page, NodeKind::Rectangle, "Inner", 180.0, 150.0, 70.0, 50.0).unwrap();
|
||||
solid(d.get_mut(inner).unwrap(), "FFFFFF");
|
||||
d.get_mut(inner).unwrap().effects.push(Effect::inner_shadow());
|
||||
d.get_mut(inner).unwrap().effects.push(Effect { kind: EffectKind::Noise { size: 1.0, density: 0.3, color: Color::BLACK }, visible: true });
|
||||
let glass = d.create(page, NodeKind::Rectangle, "Glass", 120.0, 280.0, 120.0, 40.0).unwrap();
|
||||
d.get_mut(glass).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.3))];
|
||||
d.get_mut(glass).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur { radius: 8.0 }, visible: true });
|
||||
}
|
||||
Scene { doc: Arc::new(d), page, rect, text, frame, child }
|
||||
}
|
||||
|
||||
fn view(x: f64, y: f64, scale: f64) -> View {
|
||||
View { origin: (x, y), scale, width: 300, height: 220 }
|
||||
}
|
||||
|
||||
fn edit(doc: &Arc<Document>, f: impl FnOnce(&mut Document)) -> Arc<Document> {
|
||||
let mut d = (**doc).clone();
|
||||
f(&mut d);
|
||||
Arc::new(d)
|
||||
}
|
||||
|
||||
/// Render through `r` and check the image against a full render of the same (snapped) view.
|
||||
fn check(r: &mut Retained, doc: &Arc<Document>, page: NodeId, v: &View) -> Work {
|
||||
let (shown, pix, work) = r.render(doc, page, v, &Options::default()).unwrap();
|
||||
let pix = pix.clone();
|
||||
let key = |v: &View| (v.origin, v.scale, v.width, v.height);
|
||||
if let Some((snapped, _)) = snap(v) {
|
||||
assert_eq!(key(&shown), key(&snapped), "the snapped view is what's shown");
|
||||
}
|
||||
let full = render_view(doc, page, &shown, &Options::default()).unwrap();
|
||||
assert_same(&pix, &full, &format!("{work:?}"));
|
||||
work
|
||||
}
|
||||
|
||||
/// Equal up to anti-aliasing: a part rendered on its own sees its paths translated by a whole
|
||||
/// number of pixels, and float rounding can move an edge sample (a fraction of one pixel's
|
||||
/// coverage along a curve). Stale or missing areas differ by whole colours, over many pixels.
|
||||
fn assert_same(a: &Pixmap, b: &Pixmap, what: &str) {
|
||||
assert_eq!((a.width(), a.height()), (b.width(), b.height()));
|
||||
let (mut slight, mut big, mut first) = (0usize, 0usize, None);
|
||||
for (i, (x, y)) in a.data().iter().zip(b.data()).enumerate() {
|
||||
let d = x.abs_diff(*y);
|
||||
slight += usize::from(d > 2);
|
||||
big += usize::from(d > 96);
|
||||
if d > 2 && first.is_none() {
|
||||
first = Some(((i / 4) as u32 % a.width(), (i / 4) as u32 / a.width(), d));
|
||||
}
|
||||
}
|
||||
assert!(slight <= 200 && big == 0, "{what}: partial render differs from a full one ({slight} channels over 2, {big} over 96, first {first:?})");
|
||||
}
|
||||
|
||||
fn partial(w: Work) -> bool {
|
||||
matches!(w, Work::Partial { .. })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_view_is_reused() {
|
||||
let s = scene(false);
|
||||
let mut r = Retained::default();
|
||||
assert_eq!(check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.0)), Work::Full);
|
||||
assert_eq!(check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.0)), Work::Reused);
|
||||
// A sub-pixel move of the view stays on the same pixel grid.
|
||||
assert_eq!(check(&mut r, &s.doc, s.page, &view(0.3, 0.2, 1.0)), Work::Reused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pans_render_only_the_uncovered_strips() {
|
||||
let s = scene(false);
|
||||
let mut r = Retained::default();
|
||||
check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.0));
|
||||
for (x, y) in [(37.0, 0.0), (37.0, 23.0), (-12.5, 41.0), (40.0, 80.0), (10.0, 30.0), (0.0, 0.0)] {
|
||||
eprintln!("pan to {x} {y}");
|
||||
let w = check(&mut r, &s.doc, s.page, &view(x, y, 1.0));
|
||||
assert!(partial(w), "pan to ({x}, {y}): {w:?}");
|
||||
}
|
||||
// At another zoom too (the grid is that zoom's).
|
||||
check(&mut r, &s.doc, s.page, &view(10.0, 10.0, 1.7));
|
||||
assert!(partial(check(&mut r, &s.doc, s.page, &view(25.0, -8.0, 1.7))));
|
||||
// A jump further than the view renders it whole.
|
||||
assert_eq!(check(&mut r, &s.doc, s.page, &view(2000.0, 0.0, 1.7)), Work::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_render_only_what_changed() {
|
||||
let s = scene(false);
|
||||
let mut r = Retained::default();
|
||||
let v = view(0.0, 0.0, 1.0);
|
||||
check(&mut r, &s.doc, s.page, &v);
|
||||
let moved = edit(&s.doc, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((60.0, 40.0)));
|
||||
let w = check(&mut r, &moved, s.page, &v);
|
||||
let Work::Partial { pixels } = w else { panic!("move: {w:?}") };
|
||||
assert!(pixels < 300 * 220 / 4, "only around the layer: {pixels}");
|
||||
let recolored = edit(&moved, |d| solid(d.get_mut(s.child).unwrap(), "000000"));
|
||||
assert!(partial(check(&mut r, &recolored, s.page, &v)));
|
||||
// Out of the clipping frame and back.
|
||||
let out = edit(&recolored, |d| d.get_mut(s.child).unwrap().transform = Affine::translate((180.0, 90.0)));
|
||||
assert!(partial(check(&mut r, &out, s.page, &v)));
|
||||
let hidden = edit(&out, |d| d.get_mut(s.child).unwrap().visible = false);
|
||||
assert!(partial(check(&mut r, &hidden, s.page, &v)));
|
||||
let removed = edit(&hidden, |d| d.remove(s.child).unwrap());
|
||||
assert!(partial(check(&mut r, &removed, s.page, &v)));
|
||||
let added = edit(&removed, |d| {
|
||||
let k = d.create(s.frame, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "K", 10.0, 10.0, 30.0, 30.0).unwrap();
|
||||
solid(d.get_mut(k).unwrap(), "8040C0");
|
||||
});
|
||||
assert!(partial(check(&mut r, &added, s.page, &v)));
|
||||
// Undo: back to an older version of the document.
|
||||
assert!(partial(check(&mut r, &s.doc, s.page, &v)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_under_and_beside_effects_match() {
|
||||
let s = scene(true);
|
||||
let mut r = Retained::default();
|
||||
// Holds the inner shadow + noise layer (180, 150) and the background blur (120, 280) whole.
|
||||
let v = view(0.0, 100.0, 1.0);
|
||||
check(&mut r, &s.doc, s.page, &v);
|
||||
// Under the background blur, then across the inner shadow + noise layer.
|
||||
let under = edit(&s.doc, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((150.0, 260.0)));
|
||||
assert!(partial(check(&mut r, &under, s.page, &v)));
|
||||
// Panning the blur (over the red layer) in from the view's edge, where a full render cut its
|
||||
// backdrop off (so only the view after it compares).
|
||||
let mut fresh = Retained::default();
|
||||
assert_eq!(fresh.render(&under, s.page, &view(0.0, 75.0, 1.0), &Options::default()).map(|r| r.2), Some(Work::Full));
|
||||
assert!(partial(check(&mut fresh, &under, s.page, &v)));
|
||||
let across = edit(&under, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((200.0, 170.0)));
|
||||
assert!(partial(check(&mut r, &across, s.page, &v)));
|
||||
let beside = edit(&across, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((10.0, 110.0)));
|
||||
assert!(partial(check(&mut r, &beside, s.page, &v)));
|
||||
// A child of a group with a drop shadow: the shadow moves too.
|
||||
let g1 = s.doc.find_by_name("G1", None)[0];
|
||||
let v = View { origin: (260.0, 0.0), scale: 1.0, width: 300, height: 220 };
|
||||
check(&mut r, &beside, s.page, &v);
|
||||
let shadowed = edit(&beside, |d| d.get_mut(g1).unwrap().transform = Affine::translate((30.0, 20.0)));
|
||||
assert!(partial(check(&mut r, &shadowed, s.page, &v)));
|
||||
let shadowed2 = edit(&shadowed, |d| d.get_mut(g1).unwrap().transform = Affine::translate((10.0, 60.0)));
|
||||
assert!(partial(check(&mut r, &shadowed2, s.page, &v)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_mask_edits_match() {
|
||||
let s = scene(false);
|
||||
let mut r = Retained::default();
|
||||
let v = view(0.0, 120.0, 1.5);
|
||||
check(&mut r, &s.doc, s.page, &v);
|
||||
// Longer text overflows further than its box.
|
||||
let longer = edit(&s.doc, |d| {
|
||||
if let NodeKind::Text(t) = &mut d.get_mut(s.text).unwrap().kind {
|
||||
t.characters = "Overflowing text, much longer than before, runs on and on".into();
|
||||
}
|
||||
});
|
||||
assert!(partial(check(&mut r, &longer, s.page, &v)));
|
||||
// Pans whose new strips hold only the overflowing part of the text.
|
||||
for x in [30.0, 60.0, 90.0] {
|
||||
assert!(partial(check(&mut r, &longer, s.page, &View { origin: (x, 120.0), ..v.clone() })));
|
||||
}
|
||||
check(&mut r, &longer, s.page, &v);
|
||||
// A mask clips the layers above it, wherever they are.
|
||||
let masked = edit(&longer, |d| {
|
||||
let m = d.create(s.frame, NodeKind::Rectangle, "M", 0.0, 0.0, 60.0, 60.0).unwrap();
|
||||
d.reorder(m, 0).unwrap();
|
||||
d.get_mut(m).unwrap().is_mask = true;
|
||||
});
|
||||
check(&mut r, &masked, s.page, &v);
|
||||
let unmasked = edit(&masked, |d| {
|
||||
let m = d.find_by_name("M", None)[0];
|
||||
d.get_mut(m).unwrap().is_mask = false;
|
||||
});
|
||||
check(&mut r, &unmasked, s.page, &v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_page_and_options_render_full() {
|
||||
let s = scene(false);
|
||||
let mut r = Retained::default();
|
||||
let v = view(0.0, 0.0, 1.0);
|
||||
check(&mut r, &s.doc, s.page, &v);
|
||||
assert_eq!(check(&mut r, &s.doc, s.page, &view(0.0, 0.0, 1.25)), Work::Full);
|
||||
// Page background: the whole page changes.
|
||||
let bg = edit(&s.doc, |d| {
|
||||
if let NodeKind::Page { background, .. } = &mut d.get_mut(s.page).unwrap().kind {
|
||||
*background = Color::BLACK;
|
||||
}
|
||||
});
|
||||
assert_eq!(check(&mut r, &bg, s.page, &view(0.0, 0.0, 1.25)), Work::Full);
|
||||
// Another page.
|
||||
let two = edit(&bg, |d| {
|
||||
d.add_page("Two");
|
||||
});
|
||||
let p2 = two.pages[1];
|
||||
check(&mut r, &two, s.page, &view(0.0, 0.0, 1.25));
|
||||
assert_eq!(check(&mut r, &two, p2, &view(0.0, 0.0, 1.25)), Work::Full);
|
||||
// Outline mode.
|
||||
let (_, _, w) = r.render(&two, p2, &view(0.0, 0.0, 1.25), &Options { outline_mode: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(w, Work::Full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_views_never_panic() {
|
||||
let s = scene(false);
|
||||
let mut r = Retained::default();
|
||||
for v in [view(f64::NAN, 0.0, 1.0), view(0.0, 0.0, f64::INFINITY), view(1e300, -1e300, 1.0), view(0.0, 0.0, 0.0), view(0.0, 0.0, 1.0)] {
|
||||
let _ = r.render(&s.doc, s.page, &v, &Options::default());
|
||||
}
|
||||
let tiny = View { origin: (0.0, 0.0), scale: 1.0, width: 0, height: 0 };
|
||||
assert!(r.render(&s.doc, s.page, &tiny, &Options::default()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_text_is_drawn_while_its_box_is_out_of_view() {
|
||||
fonts();
|
||||
let mut d = Document::new("overflow");
|
||||
let page = d.first_page().unwrap();
|
||||
// An auto-width box whose stored width is stale (a file saved where fonts measured narrower):
|
||||
// one line, far wider than the box.
|
||||
let mut td = TextData::new("Overflowing far to the right of a one pixel box");
|
||||
td.auto_resize = TextAutoResize::WidthAndHeight;
|
||||
td.style.font_size = 16.0;
|
||||
let t = d.create(page, NodeKind::Text(td), "T", 100.0, 10.0, 1.0, 20.0).unwrap();
|
||||
let Some((layout, _)) = figcraft_text::layout_node(d.node(t).unwrap()) else { return };
|
||||
if layout.glyphs.is_empty() || layout.width < 120.0 {
|
||||
return; // no fonts on this machine
|
||||
}
|
||||
// The box (x 100..101) is left of the view; the glyphs run on into it.
|
||||
let v = View { origin: (150.0, 0.0), scale: 1.0, width: 100, height: 40 };
|
||||
let pix = render_view(&d, page, &v, &Options { background: Some(Color::WHITE), ..Default::default() }).unwrap();
|
||||
let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
|
||||
assert!(dark > 20, "glyphs drawn: {dark}");
|
||||
}
|
||||
Reference in New Issue
Block a user