mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
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>
128 lines
5.8 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|