Files
figcraft/crates/engine/src/synthetic.rs
T
BFlatandClaude Opus 5.5 a5d5c265fd 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>
2026-10-10 05:19:05 +07:00

128 lines
5.8 KiB
Rust

//! Synthetic large documents for benchmarks (`figcraft-cli perf`) and scale tests.
//!
//! A page of "screens" (top-level frames) laid out in a grid. Each screen holds an auto layout
//! header (text and icons), a card group, three instances of a shared button component and a grid
//! of rectangles, ellipses and text, so every layer kind the hot paths care about is present.
use figcraft_color::{Color, Paint};
use figcraft_doc::{AutoLayout, DocResult, Document, NodeId, NodeKind};
/// Screen size and spacing (page units).
pub const SCREEN_W: f64 = 360.0;
pub const SCREEN_H: f64 = 240.0;
pub const SCREEN_GAP: f64 = 80.0;
/// Layers per screen (the last screen may hold fewer).
pub const PER_SCREEN: usize = 50;
fn text(content: &str) -> NodeKind {
NodeKind::Text(figcraft_doc::TextData::new(content))
}
fn ellipse() -> NodeKind {
NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }
}
fn fill(doc: &mut Document, id: NodeId, c: Color) {
if let Some(n) = doc.node_mut(id) {
n.fills = vec![Paint::solid(c)];
}
}
/// A page of about `layers` layers (pages not counted), normalised like an opened file. Returns
/// the document and its page.
pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
let mut doc = Document::new("Synthetic");
let page = doc.first_page().ok_or(figcraft_doc::DocError::Invalid("new document has no page".into()))?;
let layers = layers.clamp(10, 2_000_000);
// The shared button component (3 layers), above the screens.
let button =
doc.create(page, NodeKind::Component { description: String::new(), props: Default::default() }, "Button", 0.0, -120.0, 96.0, 32.0)?;
let bg = doc.create(button, NodeKind::Rectangle, "Background", 0.0, 0.0, 96.0, 32.0)?;
fill(&mut doc, bg, Color::from_rgb8_const(0x0D, 0x99, 0xFF));
if let Some(n) = doc.node_mut(bg) {
n.corners.radii = [6.0; 4];
}
doc.create(button, text("Button"), "Label", 12.0, 8.0, 72.0, 16.0)?;
let screens = (layers.saturating_sub(3)).div_ceil(PER_SCREEN).max(1);
let cols = ((screens as f64).sqrt().ceil() as usize).max(1);
let mut made = 3usize;
for s in 0..screens {
let (x, y) = ((s % cols) as f64 * (SCREEN_W + SCREEN_GAP), (s / cols) as f64 * (SCREEN_H + SCREEN_GAP));
let screen = doc.create(page, NodeKind::Frame, &format!("Screen {}", s + 1), x, y, SCREEN_W, SCREEN_H)?;
made += 1;
let budget = |made: usize| layers.saturating_sub(made);
// Auto layout header: title + two icons (4 layers).
if budget(made) >= 4 {
let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?;
if let Some(n) = doc.node_mut(header) {
n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() });
n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))];
}
doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?;
for k in 0..2 {
let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?;
fill(&mut doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99));
}
made += 4;
}
// A card group (5 layers).
if budget(made) >= 5 {
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
// Children are in the group's space; normalising fits the group around them.
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
fill(&mut doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF));
doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?;
doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?;
doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?;
made += 5;
}
// Three button instances (3 layers each).
for k in 0..3 {
if budget(made) < 3 {
break;
}
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
made += 3;
}
// Fill the rest of the screen with a grid of shapes and text.
let rest = PER_SCREEN.saturating_sub(4 + 5 + 9 + 1).min(budget(made));
for k in 0..rest {
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
let id = match k % 5 {
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
};
if k % 5 != 0 {
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
if let Some(n) = doc.node_mut(id) {
n.corners.radii = [3.0; 4];
}
}
made += 1;
}
if budget(made) == 0 {
break;
}
}
// Settle derived state (text sizes, group bounds, auto layout) as after opening a file.
let empty = Document::new("");
crate::normalize(&mut doc, &empty);
Ok((doc, page))
}
#[cfg(test)]
mod tests {
#[test]
fn builds_about_the_requested_size() {
for n in [10, 100, 1000, 2500] {
let (doc, page) = super::large_document(n).unwrap();
let layers = doc.node_count() - 1;
assert!(layers >= n && layers <= n + 3, "{n} → {layers}");
assert!(doc.validate().is_empty());
assert!(doc.page_content_bounds(page).is_some());
}
}
}