From a5d5c265fdea09abe4ceca64070eb2c4195fcdbb Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 05:19:05 +0700 Subject: [PATCH] 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 --- Cargo.lock | 1 + ROADMAP.md | 11 +- apps/figcraft-cli/Cargo.toml | 1 + apps/figcraft-cli/src/args.rs | 10 +- apps/figcraft-cli/src/main.rs | 9 +- apps/figcraft-cli/src/perf.rs | 377 +++++++++++++++++---------- crates/doc/src/diff_tests.rs | 27 ++ crates/doc/src/lib.rs | 19 ++ crates/engine/src/lib.rs | 1 + crates/engine/src/synthetic.rs | 127 ++++++++++ crates/render/src/draw.rs | 40 ++- crates/render/src/lib.rs | 22 +- crates/render/src/retained.rs | 380 ++++++++++++++++++++++++++++ crates/render/src/retained_tests.rs | 286 +++++++++++++++++++++ crates/text/src/cache_tests.rs | 36 +++ crates/text/src/layout.rs | 14 + crates/text/src/lib.rs | 96 +++++-- crates/ui-egui/src/canvas.rs | 19 +- crates/ui-egui/src/layers.rs | 89 +++++-- crates/ui-egui/src/lib.rs | 12 +- crates/ui-egui/src/perf_tests.rs | 199 +++++++++++++++ crates/ui-egui/src/proto_canvas.rs | 35 ++- crates/ui-egui/src/render_worker.rs | 38 ++- docs/cli.md | 13 +- docs/control-protocol.md | 4 +- xtask/src/main.rs | 21 +- 26 files changed, 1657 insertions(+), 230 deletions(-) create mode 100644 crates/doc/src/diff_tests.rs create mode 100644 crates/engine/src/synthetic.rs create mode 100644 crates/render/src/retained.rs create mode 100644 crates/render/src/retained_tests.rs create mode 100644 crates/text/src/cache_tests.rs create mode 100644 crates/ui-egui/src/perf_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0168c5c..f4b705b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1307,6 +1307,7 @@ dependencies = [ "figcraft-mcp", "figcraft-render", "figcraft-svg", + "figcraft-text", "serde_json", ] diff --git a/ROADMAP.md b/ROADMAP.md index f501ca5..dd4da2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,8 +82,15 @@ dimension measured side by side. landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel); previews on the canvas and autosave checkpoints are missing. Multiplayer and plugins are not started. 25–55 h. -9. **Hardening and performance**: real-world file corpus, perf budgets at 50k layers (tile cache, - incremental re-render), Windows/Linux/browser QA, accessibility. 40–60 h. +9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer + pages: engine, render and headless UI-frame timings). The canvas keeps its last image and + re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders + in ~0.2 ms instead of ~8 ms (0.8 vs 24 ms at 50k zoomed out), a 40 px pan in ~0.7 ms instead of + ~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 → + 0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is + culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit + tests ~26 µs. *Left:* zoomed-out renders at 50k (~40 ms: tiles or levels of detail), save/load + at 50k (~0.7 s each), a real-world file corpus, Windows/Linux/browser QA, accessibility. 40–60 h. ### Where we're going diff --git a/apps/figcraft-cli/Cargo.toml b/apps/figcraft-cli/Cargo.toml index 07ecf84..0b356c2 100644 --- a/apps/figcraft-cli/Cargo.toml +++ b/apps/figcraft-cli/Cargo.toml @@ -17,6 +17,7 @@ workspace = true figcraft-engine = { workspace = true } figcraft-doc = { workspace = true } figcraft-render = { workspace = true } +figcraft-text = { workspace = true } figcraft-svg = { workspace = true } figcraft-format = { workspace = true } figcraft-mcp = { workspace = true } diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index b497dd2..cdaaed0 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -71,10 +71,14 @@ impl Args { } } - pub fn usize(&self, k: &str, default: usize) -> CliResult { + /// A comma-separated list of whole numbers (`--layers 1000,10_000`). + pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult> { match self.values.get(k) { - None => Ok(default), - Some(s) => s.replace('_', "").parse::().map_err(|_| CliError::Usage(format!("--{k} must be a whole number, got `{s}`"))), + None => Ok(default.to_vec()), + Some(s) => s + .split(',') + .map(|v| v.trim().replace('_', "").parse::().map_err(|_| CliError::Usage(format!("--{k} must be whole numbers, got `{s}`")))) + .collect(), } } } diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index bcc96f7..e87326a 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -22,7 +22,7 @@ USAGE: figcraft-cli run [--in ] [--out ] figcraft-cli commands [--json] [--filter TEXT] figcraft-cli mcp [--attach PORT] - figcraft-cli perf [--shapes N] [--frames N] + figcraft-cli perf [--layers N[,N...]] figcraft-cli help | --help | --version Files: .figcraft (native), .json (FigCraft JSON), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image @@ -118,10 +118,9 @@ fn run(argv: &[String]) -> CliResult { mcp(a.value("attach").as_deref()) } "perf" => { - let a = Args::parse(rest, &["shapes", "frames"], &[])?; - let shapes = a.usize("shapes", 20_000)?.clamp(1, 2_000_000); - let frames = a.usize("frames", 200)?.clamp(1, 100_000); - perf::run(shapes, frames) + let a = Args::parse(rest, &["layers"], &[])?; + let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); + perf::run(&sizes) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 874660c..a7bd3d9 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -1,161 +1,269 @@ -//! `figcraft-cli perf`: a synthetic large document and timings of the hot paths. +//! `figcraft-cli perf`: synthetic large documents and timings of the hot paths. +//! +//! The documents come from [`figcraft_engine::synthetic`] (screens with an auto layout header, a +//! card group, button instances and a grid of shapes and text). Every row is the mean of several +//! runs; use a release build for meaningful numbers (`cargo xtask bench`). use std::process::ExitCode; use std::time::Instant; -use figcraft_doc::color::{Color, Paint}; use figcraft_doc::geom::Point; use figcraft_doc::{Document, NodeId, NodeKind}; +use figcraft_engine::{PointerEvent, PointerKind, Session, ViewState}; use serde_json::json; use crate::{CliError, CliResult}; -const FRAME_W: f64 = 400.0; -const FRAME_H: f64 = 300.0; -const GAP: f64 = 100.0; - -/// `shapes` layers spread over `frames` frames in a grid; every tenth layer is text. -pub fn build(shapes: usize, frames: usize) -> CliResult<(Document, NodeId)> { - let mut doc = Document::new("Perf"); - let page = doc.first_page().ok_or_else(|| CliError::Failed("new document has no page".into()))?; - let cols = (frames as f64).sqrt().ceil().max(1.0) as usize * 2; - let per = shapes.div_ceil(frames).max(1); - let grid = (per as f64).sqrt().ceil().max(1.0) as usize; - let (cw, ch) = (FRAME_W / grid as f64, FRAME_H / grid as f64); - let mut made = 0usize; - let err = |e: figcraft_doc::DocError| CliError::Failed(e.to_string()); - for f in 0..frames { - let (fx, fy) = ((f % cols) as f64 * (FRAME_W + GAP), (f / cols) as f64 * (FRAME_H + GAP)); - let frame = doc.create(page, NodeKind::Frame, &format!("Frame {}", f + 1), fx, fy, FRAME_W, FRAME_H).map_err(err)?; - if let Some(n) = doc.node_mut(frame) { - n.fills = vec![Paint::solid(Color::WHITE)]; - } - for k in 0..per { - if made >= shapes { - break; - } - let (x, y) = ((k % grid) as f64 * cw + 2.0, (k / grid) as f64 * ch + 2.0); - let hue = (made % 360) as f32 / 360.0; - let (kind, name) = match made % 10 { - 0 => { - let (mut kind, _) = figcraft_engine::cmd::create::kind_for("text").ok_or_else(|| CliError::Failed("no text kind".into()))?; - if let NodeKind::Text(t) = &mut kind { - t.characters = format!("Item {made}"); - } - (kind, "Text") - } - 1 | 4 | 7 => (figcraft_engine::cmd::create::kind_for("ellipse").map(|k| k.0).unwrap_or(NodeKind::Rectangle), "Ellipse"), - _ => (NodeKind::Rectangle, "Rectangle"), - }; - let id = doc.create(frame, kind, name, x, y, (cw - 4.0).max(1.0), (ch - 4.0).max(1.0)).map_err(err)?; - if let Some(n) = doc.node_mut(id) - && name != "Text" - { - n.fills = vec![Paint::solid(Color::new(hue, 0.5, 1.0 - hue, 1.0))]; - n.corners.radii = [4.0; 4]; - } - made += 1; - } - } - Ok((doc, page)) -} +/// Canvas size of the measured viewport (a typical laptop window). +const VIEW_W: u32 = 1440; +const VIEW_H: u32 = 900; fn ms(t: Instant) -> f64 { t.elapsed().as_secs_f64() * 1000.0 } -pub fn run(shapes: usize, frames: usize) -> CliResult { - let mut rows: Vec<(String, f64, String)> = Vec::new(); - +/// Mean milliseconds of `n` runs of `f`. +fn mean(n: usize, mut f: impl FnMut(usize)) -> f64 { let t = Instant::now(); - let (doc, page) = build(shapes, frames)?; - rows.push(("build document (doc API)".into(), ms(t), format!("{} nodes", doc.node_count()))); + for i in 0..n { + f(i); + } + ms(t) / n.max(1) as f64 +} - let bounds = doc.page_content_bounds(page).ok_or_else(|| CliError::Failed("empty page".into()))?; - let t = Instant::now(); - let full = crate::files::render_page(&doc, page, 1.0)?; - rows.push(("render full page @1x".into(), ms(t), format!("{}×{} px", full.width(), full.height()))); - drop(full); +/// Set when a measured command fails (the numbers would be meaningless). +static FAILED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); - let view = figcraft_render::View { origin: (bounds.x0 + 200.0, bounds.y0 + 150.0), scale: 2.0, width: 1920, height: 1080 }; - let t = Instant::now(); - let mut n = 0; - for _ in 0..5 { - if figcraft_render::render_view(&doc, page, &view, &Default::default()).is_some() { - n += 1; +fn exec(s: &mut Session, id: &str, p: serde_json::Value) { + if let Err(e) = s.execute(id, &p) { + eprintln!("{id}: {e}"); + FAILED.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + +fn failed(e: impl std::fmt::Display) -> CliError { + CliError::Failed(e.to_string()) +} + +/// The `k`-th layer of `kind` among the descendants of `root` (pre-order). +fn find(doc: &Document, root: NodeId, k: usize, pred: impl Fn(&figcraft_doc::Node) -> bool) -> Option { + doc.subtree(root).into_iter().filter(|i| doc.node(*i).is_some_and(&pred)).nth(k) +} + +fn render(doc: &Document, page: NodeId, v: &ViewState) -> bool { + let (ox, oy) = v.origin(); + let view = figcraft_render::View { origin: (ox, oy), scale: v.zoom, width: VIEW_W, height: VIEW_H }; + figcraft_render::render_view(doc, page, &view, &Default::default()).is_some() +} + +/// Render `v` panned right by `dx` page units through the retained renderer. +fn retained(r: &mut figcraft_render::Retained, doc: &std::sync::Arc, page: NodeId, v: &ViewState, dx: f64) -> bool { + let (ox, oy) = v.origin(); + let view = figcraft_render::View { origin: (ox + dx, oy), scale: v.zoom, width: VIEW_W, height: VIEW_H }; + r.render(doc, page, &view, &Default::default()).is_some() +} + +struct Table { + rows: Vec<(String, Vec)>, +} + +impl Table { + fn put(&mut self, col: usize, name: &str, v: f64) { + let i = match self.rows.iter().position(|r| r.0 == name) { + Some(i) => i, + None => { + self.rows.push((name.to_string(), Vec::new())); + self.rows.len() - 1 + } + }; + if let Some(r) = self.rows.get_mut(i) { + r.1.resize(col + 1, f64::NAN); + if let Some(c) = r.1.get_mut(col) { + *c = v; + } } } - rows.push(("render 1920×1080 view @2x (avg of 5)".into(), ms(t) / f64::from(n.max(1)), "zoomed into the first frames".into())); +} - let fit = figcraft_render::View { - origin: (bounds.x0, bounds.y0), - scale: (1920.0 / bounds.width()).min(1080.0 / bounds.height()), - width: 1920, - height: 1080, - }; +/// Measure one document size into column `col`. +fn measure(table: &mut Table, col: usize, layers: usize) -> CliResult<()> { let t = Instant::now(); - let _ = figcraft_render::render_view(&doc, page, &fit, &Default::default()); - rows.push(("render 1920×1080 zoom-to-fit".into(), ms(t), format!("scale {:.3}", fit.scale))); - - let hits = 10_000; - let mut found = 0; - let t = Instant::now(); - for i in 0..hits { - let fx = (i * 7919 % 1000) as f64 / 1000.0; - let fy = (i * 104_729 % 1000) as f64 / 1000.0; - let p = Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy); - if doc.hit_test(page, p, 1.0).is_some() { - found += 1; - } - } - let total = ms(t); - rows.push((format!("hit test ×{hits}"), total, format!("{:.1} µs each, {found} hits", total * 1000.0 / hits as f64))); + let (doc, page) = figcraft_engine::synthetic::large_document(layers).map_err(failed)?; + table.put(col, "build + normalize (like open)", ms(t)); let t = Instant::now(); - let bytes = figcraft_format::save_bytes(&doc, None).map_err(|e| CliError::Failed(e.to_string()))?; - rows.push(("save .figcraft".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6))); + let bytes = figcraft_format::save_bytes(&doc, None).map_err(failed)?; + table.put(col, "save .figcraft", ms(t)); let t = Instant::now(); - let (back, _) = figcraft_format::load_bytes(&bytes).map_err(|e| CliError::Failed(e.to_string()))?; - rows.push(("load .figcraft".into(), ms(t), format!("{} nodes", back.node_count()))); + let (back, _) = figcraft_format::load_bytes(&bytes).map_err(failed)?; + table.put(col, "load .figcraft", ms(t)); drop(back); - let kids = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); - let t = Instant::now(); - let svg = figcraft_svg::export_svg(&doc, &kids, &Default::default()).unwrap_or_default(); - rows.push(("export page as SVG".into(), ms(t), format!("{:.1} MB", svg.len() as f64 / 1e6))); - drop(svg); + // Rendering a 1440×900 viewport: 100% on the first screen, and zoomed out to fit the page. + let bounds = doc.page_content_bounds(page).ok_or_else(|| failed("empty page"))?; + let mut at100 = ViewState { vw: f64::from(VIEW_W), vh: f64::from(VIEW_H), ..Default::default() }; + at100.cx = f64::from(VIEW_W) / 2.0; + at100.cy = f64::from(VIEW_H) / 2.0; + table.put(col, "render 1440×900 @100%", mean(5, |_| _ = render(&doc, page, &at100))); + let mut fit = at100.clone(); + fit.fit(bounds, 1.0); + table.put(col, "render 1440×900 zoomed to fit", mean(3, |_| _ = render(&doc, page, &fit))); - // Command throughput through the engine (undo snapshots included). - let mut s = figcraft_engine::Session::with_document(doc); - let first = kids.first().copied(); - let t = Instant::now(); - let cmds = 200; - let mut ok = 0; - for i in 0..cmds { - ok += - usize::from(s.execute("node.create", &json!({"type": "rectangle", "parent": first, "x": i, "y": i, "width": 10, "height": 10})).is_ok()); + // The canvas keeps its last image: a pan renders the uncovered strips, an edit the layers' + // areas. + let doc = std::sync::Arc::new(doc); + let pans = 10; + // Zoomed out, but with content right up to the edges (a fitted page has margins). + let mut out = fit.clone(); + out.zoom *= 2.0; + table.put(col, "render 1440×900 zoomed out (2× fit)", mean(3, |_| _ = render(&doc, page, &out))); + for (name, v) in [("pan 40 px @100% (retained)", &at100), ("pan 40 px zoomed out (retained)", &out)] { + let mut r = figcraft_render::Retained::default(); + let _ = retained(&mut r, &doc, page, v, 0.0); + table.put(col, name, mean(pans, |i| _ = retained(&mut r, &doc, page, v, if i % 2 == 0 { 40.0 / v.zoom } else { 0.0 }))); } - let total = ms(t); - rows.push((format!("node.create ×{cmds} via Session::execute"), total, format!("{:.2} ms each, {ok} ok", total / f64::from(cmds)))); - let _ = s.execute("select.none", &json!({})); - let t = Instant::now(); - let _ = s.execute("select.all", &json!({})); - let _ = s.execute("transform.move", &json!({"dx": 10, "dy": 0})); - rows.push(("select all + move top-level frames".into(), ms(t), format!("{} selected", s.selection().len()))); - let t = Instant::now(); - let _ = s.undo(); - rows.push(("undo".into(), ms(t), String::new())); + for (name, v) in [("move one layer, re-render @100% (retained)", &at100), ("move one layer, re-render zoomed out (retained)", &out)] { + // A shape in the screen nearest the view's centre. + let centre = Point::new(v.cx, v.cy); + let screens = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); + let nearest = + screens.into_iter().filter_map(|c| doc.world_bounds(c).map(|b| (c, (b.center() - centre).hypot2()))).min_by(|a, b| a.1.total_cmp(&b.1)); + let target = nearest.and_then(|(c, _)| doc.node(c)?.children.last().copied()).ok_or_else(|| failed("no screen to edit"))?; + let moved: Vec> = (0..2) + .map(|k| { + let mut d = (*doc).clone(); + if let Some(n) = d.node_mut(target) { + n.transform = figcraft_doc::geom::Affine::translate((f64::from(k) * 6.0, 0.0)) * n.transform; + } + std::sync::Arc::new(d) + }) + .collect(); + let mut r = figcraft_render::Retained::default(); + let _ = retained(&mut r, &doc, page, v, 0.0); + table.put(col, name, mean(10, |i| _ = moved.get(i % 2).map(|d| retained(&mut r, d, page, v, 0.0)))); + } + let doc = std::sync::Arc::unwrap_or_clone(doc); + // Hit testing (a hover under the pointer). + let hits = 2000; + let pt = |i: usize| { + let (fx, fy) = ((i * 7919 % 1000) as f64 / 1000.0, (i * 104_729 % 1000) as f64 / 1000.0); + Point::new(bounds.x0 + bounds.width() * fx, bounds.y0 + bounds.height() * fy) + }; + table.put(col, "hit test (µs each)", mean(hits, |i| _ = doc.hit_test(page, pt(i), 1.0)) * 1000.0); + + let mut s = Session::with_document(doc); + let doc = s.doc().map_err(failed)?.clone(); + let rect = + find(&doc, page, layers / 2 / 50, |n| matches!(n.kind, NodeKind::Rectangle) && n.name == "Rectangle").ok_or_else(|| failed("no rect"))?; + let title = find(&doc, page, 0, |n| n.name == "Title").ok_or_else(|| failed("no title"))?; + let button = find(&doc, page, 0, |n| matches!(n.kind, NodeKind::Component { .. })).ok_or_else(|| failed("no component"))?; + let button_bg = doc.node(button).and_then(|b| b.children.first().copied()).ok_or_else(|| failed("no button layers"))?; + + // Hover with the Move tool: hit test + select target, as the canvas does per pointer move. + let zoom = at100.zoom; + table.put( + col, + "hover pointer move (µs each)", + mean(hits, |i| { + let p = pt(i); + let ev = + PointerEvent { kind: PointerKind::Hover, x: p.x, y: p.y, sx: 0.0, sy: 0.0, shift: false, alt: false, cmd: false, ctrl: false, zoom }; + let _ = s.pointer(&ev); + }) * 1000.0, + ); + + // Single-layer edits through the engine (command + normalize + undo step). + let colors = ["#FF0000", "#00FF00"]; + table.put(col, "node.set fill on one layer", mean(20, |i| exec(&mut s, "node.set", json!({"ids": [rect], "fill": colors[i % 2]})))); + exec(&mut s, "select.set", json!({"ids": [rect]})); + table.put(col, "transform.move one layer", mean(20, |i| exec(&mut s, "transform.move", json!({"dx": if i % 2 == 0 { 1 } else { -1 }, "dy": 0})))); + table.put( + col, + "edit text in an auto layout frame", + mean(20, |i| exec(&mut s, "node.set", json!({"ids": [title], "characters": format!("Title {i}")}))), + ); + table.put( + col, + "edit main component (all instances follow)", + mean(5, |i| exec(&mut s, "node.set", json!({"ids": [button_bg], "fill": colors[i % 2]}))), + ); + table.put(col, "undo", mean(10, |_| _ = s.undo())); + table.put(col, "redo", mean(10, |_| _ = s.redo())); + + // A drag: every pointer move re-applies the move to the gesture's snapshot. + exec(&mut s, "select.set", json!({"ids": [rect]})); + let _ = s.begin_interaction("Move"); + table.put( + col, + "drag preview frame (one layer)", + mean(20, |i| { + let _ = s.preview(|d, sel| { + for id in sel.iter() { + if let Some(n) = d.node_mut(*id) { + n.transform = figcraft_doc::geom::Affine::translate((i as f64, 0.0)) * n.transform; + } + } + Ok(()) + }); + }), + ); + let _ = s.commit_interaction(); + + // Raw normalisation after a one-node edit (no command machinery). + let before = s.doc().map_err(failed)?.clone(); + table.put( + col, + "normalize after a one-node edit", + mean(50, |i| { + let mut d = before.clone(); + if let Some(n) = d.node_mut(rect) { + n.opacity = if i % 2 == 0 { 0.5 } else { 1.0 }; + } + figcraft_engine::normalize(&mut d, &before); + }), + ); + + table.put(col, "document.inspect (depth 8)", mean(3, |_| _ = figcraft_engine::inspect::document(&s, 8, false))); + exec(&mut s, "select.none", json!({})); + let t = Instant::now(); + exec(&mut s, "select.all", json!({})); + exec(&mut s, "transform.move", json!({"dx": 10, "dy": 0})); + table.put(col, "select all + move every screen", ms(t)); + Ok(()) +} + +/// Time the hot paths for each document size in `sizes` (layers) and print a table. +pub fn run(sizes: &[usize]) -> CliResult { + // Text renders with system fonts too, like the desktop app. + figcraft_text::scan_system_fonts(); + let mut table = Table { rows: Vec::new() }; + for (col, n) in sizes.iter().enumerate() { + eprintln!("measuring {n} layers…"); + measure(&mut table, col, *n)?; + } + if FAILED.load(std::sync::atomic::Ordering::Relaxed) { + return Err(failed("a measured command failed")); + } println!( - "figcraft perf — {shapes} shapes in {frames} frames ({} {})", + "figcraft perf ({} {}; ms unless noted; mean of several runs)", std::env::consts::OS, if cfg!(debug_assertions) { "debug build" } else { "release build" } ); - let w = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(10); - println!("{:10} notes", "operation", "ms"); - println!("{}", "-".repeat(w + 30)); - for (name, t, note) in rows { - println!("{name:10.1} {note}"); + let w = table.rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(10); + let mut head = format!("| {:10} |", format!("{n} layers"))); + rule.push_str(&format!("{}:|", "-".repeat(11))); + } + println!("{head}\n{rule}"); + for (name, vals) in &table.rows { + let mut line = format!("| {name:10} |", if v.is_finite() { format!("{v:.2}") } else { "-".into() })); + } + println!("{line}"); } Ok(ExitCode::SUCCESS) } @@ -163,10 +271,21 @@ pub fn run(shapes: usize, frames: usize) -> CliResult { #[cfg(test)] mod tests { #[test] - fn builds_the_requested_size() { - let (doc, page) = super::build(1000, 10).unwrap(); - assert_eq!(doc.node(page).unwrap().children.len(), 10); - // page + 10 frames + 1000 shapes - assert_eq!(doc.node_count(), 1 + 10 + 1000); + fn layer_sizes_parse_as_a_list() { + use crate::args::Args; + let argv = |s: &[&str]| s.iter().map(|x| x.to_string()).collect::>(); + let a = Args::parse(&argv(&["--layers", "1000,10_000, 50000"]), &["layers"], &[]).unwrap(); + assert_eq!(a.usizes("layers", &[1]).unwrap(), vec![1000, 10_000, 50_000]); + assert_eq!(Args::parse(&[], &["layers"], &[]).unwrap().usizes("layers", &[7]).unwrap(), vec![7]); + let bad = Args::parse(&argv(&["--layers", "10,x"]), &["layers"], &[]).unwrap(); + assert!(bad.usizes("layers", &[1]).is_err()); + } + + #[test] + fn measures_a_small_document() { + let mut t = super::Table { rows: Vec::new() }; + super::measure(&mut t, 0, 300).unwrap(); + assert!(t.rows.len() > 10); + assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows); } } diff --git a/crates/doc/src/diff_tests.rs b/crates/doc/src/diff_tests.rs new file mode 100644 index 0000000..ed0b5eb --- /dev/null +++ b/crates/doc/src/diff_tests.rs @@ -0,0 +1,27 @@ +//! `Document::changed_ids`: what differs between two versions of a document. + +use crate::{Document, NodeKind}; + +#[test] +fn changed_ids_lists_added_removed_and_edited_nodes() { + let mut a = Document::new("a"); + let page = a.first_page().unwrap(); + let keep = a.create(page, NodeKind::Rectangle, "Keep", 0.0, 0.0, 10.0, 10.0).unwrap(); + let edit = a.create(page, NodeKind::Rectangle, "Edit", 0.0, 0.0, 10.0, 10.0).unwrap(); + let gone = a.create(page, NodeKind::Rectangle, "Gone", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert_eq!(a.changed_ids(&a, 10), Some(vec![]), "a version against itself"); + let mut b = a.clone(); + assert_eq!(b.changed_ids(&a, 10), Some(vec![]), "a copy shares everything"); + b.get_mut(edit).unwrap().opacity = 0.5; + b.remove(gone).unwrap(); + let new = b.create(page, NodeKind::Ellipse { start: 0.0, sweep: 1.0, inner: 0.0 }, "New", 0.0, 0.0, 5.0, 5.0).unwrap(); + // The page changed too (its children). + assert_eq!(b.changed_ids(&a, 10), Some(vec![page, edit, gone, new])); + assert_eq!(a.changed_ids(&b, 10), Some(vec![page, edit, gone, new]), "symmetric"); + assert!(!b.changed_ids(&a, 10).unwrap().contains(&keep)); + // A node copied on write but equal in value isn't a change. + let mut c = a.clone(); + let _ = c.get_mut(keep).unwrap(); + assert_eq!(c.changed_ids(&a, 10), Some(vec![])); + assert_eq!(b.changed_ids(&a, 3), None, "more than the limit"); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 14faa86..a3575bb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -193,6 +193,23 @@ impl Document { self.nodes.len() } + /// Ids of the nodes added, removed or changed (by value) since `before`, ascending, or `None` + /// when more than `limit` differ. Structure the two versions share is skipped, so comparing an + /// edit with the version it came from costs about the size of the edit, not of the document. + pub fn changed_ids(&self, before: &Document, limit: usize) -> Option> { + let mut out = Vec::new(); + for d in before.nodes.diff(&self.nodes) { + if out.len() >= limit { + return None; + } + out.push(match d { + imbl::ordmap::DiffItem::Add(k, _) | imbl::ordmap::DiffItem::Remove(k, _) => *k, + imbl::ordmap::DiffItem::Update { new: (k, _), .. } => *k, + }); + } + Some(out) + } + /// Every node id (pages included), ascending. pub fn ids(&self) -> impl Iterator + '_ { self.nodes.keys().copied() @@ -732,5 +749,7 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap) { } } +#[cfg(test)] +mod diff_tests; #[cfg(test)] mod tests; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ebb46d0..713591e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,6 +17,7 @@ pub mod inspect; pub mod measure; pub mod params; pub mod snap; +pub mod synthetic; pub mod textedit; pub mod tools; pub mod vectoredit; diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/synthetic.rs new file mode 100644 index 0000000..0cb3e14 --- /dev/null +++ b/crates/engine/src/synthetic.rs @@ -0,0 +1,127 @@ +//! 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()); + } + } +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 8b0d1b6..f9d5f20 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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 { + 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; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6e6c14d..b845867 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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::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 { 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, String> { #[cfg(test)] mod band_tests; #[cfg(test)] +mod retained_tests; +#[cfg(test)] mod tests; diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs new file mode 100644 index 0000000..3a6e2b0 --- /dev/null +++ b/crates/render/src/retained.rs @@ -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, + page: NodeId, + outline: bool, + skip: Vec, + background: Option, + 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, + pix: Pixmap, +} + +#[derive(Default)] +struct Sensitive { + areas: Vec, + 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 { + 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, +} + +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, 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, + page: NodeId, + at: (&View, (i64, i64), u64), + opts: &Options, + pix: Pixmap, + sensitive: Arc, + ) -> &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 { + 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) -> 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, 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 = 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 = 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 = 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> = 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) -> Vec { + 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(()) +} diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs new file mode 100644 index 0000000..3919273 --- /dev/null +++ b/crates/render/src/retained_tests.rs @@ -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, + 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, f: impl FnOnce(&mut Document)) -> Arc { + 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, 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}"); +} diff --git a/crates/text/src/cache_tests.rs b/crates/text/src/cache_tests.rs new file mode 100644 index 0000000..b701397 --- /dev/null +++ b/crates/text/src/cache_tests.rs @@ -0,0 +1,36 @@ +//! The text layout cache: equal texts share a layout, unequal ones never do. + +use std::sync::Arc; + +use figcraft_doc::{LetterSpacing, TextData}; + +use crate::{clear_layout_cache, layout_cached, layout_generation}; + +#[test] +fn equal_texts_share_a_layout_and_unequal_ones_never_do() { + let g0 = layout_generation(); + let t1 = TextData::new("Cached text"); + let a = layout_cached(&t1, Some(100.0), None); + let b = layout_cached(&t1.clone(), Some(100.0), None); + // Fonts loading elsewhere may clear the cache in between; then a fresh layout is right too. + assert!(Arc::ptr_eq(&a, &b) || layout_generation() != g0, "a cache hit"); + // Same characters, family, style and size (the cheap fingerprint), different spacing. + let mut t2 = t1.clone(); + t2.style.letter_spacing = LetterSpacing::Pixels(5.0); + assert!(!Arc::ptr_eq(&a, &layout_cached(&t2, Some(100.0), None))); + // Another box is another layout. + assert!(!Arc::ptr_eq(&a, &layout_cached(&t1, Some(50.0), None))); + // Texts that never equal themselves (NaN) are laid out each time without piling up. + let mut nan = t1.clone(); + nan.paragraph_spacing = f64::NAN; + for _ in 0..50 { + let _ = layout_cached(&nan, None, None); + } +} + +#[test] +fn clearing_bumps_the_generation() { + let g = layout_generation(); + clear_layout_cache(); + assert!(layout_generation() > g); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3b4b5ca..7555111 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -722,6 +722,20 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe } impl TextLayout { + /// A box (layout coordinates) holding everything the text paints: each glyph's advance box, + /// grown by half its size for ascenders, descenders and overhangs, plus decorations. None when + /// nothing is laid out. + pub fn ink_bounds(&self) -> Option { + let lines = self.lines.iter().map(|l| (l, self.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]))); + let glyphs = lines.flat_map(|(l, gs)| { + gs.iter().map(move |g| { + let pad = g.size.abs() * 0.5; + Rect::new(g.x.min(g.x + g.advance) - pad, l.top - pad, g.x.max(g.x + g.advance) + pad, l.top + l.height + pad) + }) + }); + glyphs.chain(self.decorations.iter().map(|d| d.rect)).filter(|r| r.x0.is_finite() && r.y1.is_finite()).reduce(|a, b| a.union(b)) + } + /// The caret position (x, line index) before char `index`. pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 86080fa..a098ec5 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,41 +16,95 @@ pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; use figcraft_doc::{Node, NodeKind, TextAlignV, TextAutoResize}; -type CacheKey = (u64, u64, u64); - -fn layout_cache() -> &'static std::sync::Mutex>> { - static C: std::sync::OnceLock>>> = std::sync::OnceLock::new(); - C.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +/// One cached layout: the exact text and box it was made for. +struct Cached { + text: figcraft_doc::TextData, + w: Option, + h: Option, + layout: std::sync::Arc, } -/// Forget cached layouts (after fonts were added). +/// Cached layouts by a cheap fingerprint; entries are confirmed with `TextData`'s full equality. +#[derive(Default)] +struct LayoutCache { + buckets: std::collections::HashMap>, + len: usize, +} + +/// Layouts kept before the cache starts over (a page of distinct texts stays cached while it +/// renders, pans and zooms). +const LAYOUT_CACHE_MAX: usize = 32_768; +/// Entries per fingerprint (texts that compare unequal to themselves, e.g. NaN sizes, can't pile up). +const BUCKET_MAX: usize = 8; + +fn layout_cache() -> &'static std::sync::Mutex { + static C: std::sync::OnceLock> = std::sync::OnceLock::new(); + C.get_or_init(Default::default) +} + +static LAYOUT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// Forget cached layouts (after fonts were added). Bumps [`layout_generation`]. pub fn clear_layout_cache() { - layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear(); + *layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner) = LayoutCache::default(); + LAYOUT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// Changes whenever text may lay out differently than before (fonts were added), so renders kept +/// from before are stale. +pub fn layout_generation() -> u64 { + LAYOUT_GENERATION.load(std::sync::atomic::Ordering::Relaxed) +} + +/// The parts of a text that usually tell texts apart, hashed cheaply (no formatting). +fn fingerprint(t: &figcraft_doc::TextData, w: Option, h: Option) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hs = std::collections::hash_map::DefaultHasher::new(); + t.characters.hash(&mut hs); + t.style.font_family.hash(&mut hs); + t.style.font_style.hash(&mut hs); + t.style.font_size.to_bits().hash(&mut hs); + t.runs.len().hash(&mut hs); + (w, h).hash(&mut hs); + hs.finish() } /// Layout with a process-wide cache keyed by the text content/style and box size. pub fn layout_cached(t: &figcraft_doc::TextData, w: Option, h: Option) -> std::sync::Arc { - use std::hash::{Hash, Hasher}; - let mut hs = std::collections::hash_map::DefaultHasher::new(); - format!("{t:?}").hash(&mut hs); - let key = (hs.finish(), w.map(f64::to_bits).unwrap_or(1), h.map(f64::to_bits).unwrap_or(1)); - if let Some(l) = layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) { - return l.clone(); + let (wb, hb) = (w.map(f64::to_bits), h.map(f64::to_bits)); + let key = fingerprint(t, wb, hb); + let hit = |c: &Cached| c.w == wb && c.h == hb && c.text == *t; + if let Some(l) = + layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).buckets.get(&key).and_then(|b| b.iter().find(|c| hit(c))) + { + return l.layout.clone(); } let l = std::sync::Arc::new(layout(t, w, h)); let mut c = layout_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if c.len() > 4096 { - c.clear(); + if c.len >= LAYOUT_CACHE_MAX { + *c = LayoutCache::default(); } - c.insert(key, l.clone()); + let bucket = c.buckets.entry(key).or_default(); + if bucket.iter().any(hit) { + // Another thread laid it out meanwhile. + return l; + } + let dropped = if bucket.len() >= BUCKET_MAX { + bucket.remove(0); + 1 + } else { + 0 + }; + bucket.push(Cached { text: t.clone(), w: wb, h: hb, layout: l.clone() }); + c.len = c.len + 1 - dropped; l } -/// Lay out a text node at its current box, returning the layout and the vertical offset that -/// applies its vertical alignment. -pub fn layout_node(n: &Node) -> Option<(TextLayout, f64)> { +/// Lay out a text node at its current box, returning the (shared, cached) layout and the vertical +/// offset that applies its vertical alignment. +pub fn layout_node(n: &Node) -> Option<(std::sync::Arc, f64)> { let NodeKind::Text(t) = &n.kind else { return None }; - let l = (*layout_cached(t, Some(n.width), Some(n.height))).clone(); + let l = layout_cached(t, Some(n.width), Some(n.height)); let dy = match (t.auto_resize, t.align_v) { (TextAutoResize::WidthAndHeight | TextAutoResize::Height, _) => 0.0, (_, TextAlignV::Top) => 0.0, @@ -76,5 +130,7 @@ pub fn auto_size(n: &Node) -> Option<(f64, f64)> { } } +#[cfg(test)] +mod cache_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 689a4e0..b31f5d0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -19,6 +19,8 @@ pub struct CanvasState { tex_view: Option, submitted: Option, generation: u64, + /// What the last canvas render had to do (shown by `ui.inspect`). + pub render_work: Option, dragging: bool, space_pan: bool, middle_pan: Option<(Pos2, f64, f64)>, @@ -34,6 +36,7 @@ impl Default for CanvasState { tex_view: None, submitted: None, generation: 0, + render_work: None, dragging: false, space_pan: false, middle_pan: None, @@ -89,6 +92,8 @@ struct JobKey { size: (u32, u32), outline: bool, skip: Vec, + /// Text lays out anew when fonts arrive (system fonts load in the background). + fonts: u64, } impl FigApp { @@ -134,12 +139,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { if let Some(done) = worker.poll() && done.generation == app.canvas.generation { - let img = egui::ColorImage::from_rgba_premultiplied([done.view.width as usize, done.view.height as usize], &done.rgba); match &mut app.canvas.texture { - Some(t) => t.set(img, egui::TextureOptions::LINEAR), - None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", img, egui::TextureOptions::LINEAR)), + Some(t) => t.set(done.image, egui::TextureOptions::LINEAR), + None => app.canvas.texture = Some(ctx.load_texture("figcraft-canvas", done.image, egui::TextureOptions::LINEAR)), } app.canvas.tex_view = Some(done.view); + app.canvas.render_work = Some(done.work); app.ui.render_ms = done.ms; } let painter = ui.painter_at(rect); @@ -216,6 +221,7 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { size: (w, h), outline: ds.view.outlines, skip: vec![], + fonts: figcraft_text::layout_generation(), }; if app.canvas.submitted.as_ref() == Some(&key) { return; @@ -469,6 +475,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { if !n.visible { continue; } + let Some(b) = doc.world_bounds(*c) else { continue }; + // Off-screen frames draw neither grids nor a label (labels sit just above the frame). + let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand2(vec2(0.0, 20.0)); + if !on_screen.intersects(app.canvas.rect) { + continue; + } if view.layout_grids && !n.grids.is_empty() { let w = doc.world_transform(*c); for g in n.grids.iter().filter(|g| g.visible) { @@ -487,7 +499,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } let labelled = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); if labelled { - let Some(b) = doc.world_bounds(*c) else { continue }; let tl = app.to_screen(b.x0, b.y0); let selected = sel.contains(c); let hovered = ds.hover == Some(*c); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 03b96be..52425af 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -1,5 +1,8 @@ //! The left panel: file header, File/Assets tabs, pages and the layers tree. +use std::collections::BTreeSet; +use std::sync::Arc; + use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2}; use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::json; @@ -240,13 +243,51 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { crate::widgets::separator(ui); } -struct Row { - id: NodeId, +#[derive(Clone)] +pub(crate) struct Row { + pub(crate) id: NodeId, depth: usize, has_kids: bool, } -fn collect(doc: &Document, id: NodeId, depth: usize, expanded: &std::collections::BTreeSet, out: &mut Vec) { +/// The layer rows of the last frame and what they were built from, so a frame where neither the +/// document, the page, the expanded set nor the filter changed doesn't walk the tree again. +#[derive(Clone, Default)] +struct RowCache { + doc: Option>, + page: Option, + expanded: u64, + filter: String, + rows: Arc>, +} + +fn hash_expanded(expanded: &BTreeSet) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + expanded.hash(&mut h); + h.finish() +} + +/// The rows to list: the expanded tree, or the layers matching `filter` (lowercase). +pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { + let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; + ui.data_mut(|d| { + let c = d.get_temp_mut_or_default::(Id::new("layer_rows")); + let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; + if !fresh { + let mut rows = Vec::new(); + if filter.is_empty() { + collect(doc, page, 0, expanded, &mut rows); + } else { + matching(doc, page, filter, &mut rows); + } + *c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) }; + } + c.rows.clone() + }) +} + +fn collect(doc: &Document, id: NodeId, depth: usize, expanded: &BTreeSet, out: &mut Vec) { let Some(n) = doc.node(id) else { return }; // Layers panel lists top of the stack first. for c in n.children.iter().rev() { @@ -275,7 +316,7 @@ fn matching(doc: &Document, id: NodeId, q: &str, out: &mut Vec) { } /// Expand or collapse `id` and everything inside it (Alt-click on a disclosure triangle). -fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut std::collections::BTreeSet) { +fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTreeSet) { let mut stack = vec![id]; while let Some(n) = stack.pop() { if open { @@ -356,13 +397,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { app.ui.layers_seen_sel = sel.clone(); } let filter = app.ui.layer_filter.trim().to_lowercase(); - let mut rows = Vec::new(); - if filter.is_empty() { - collect(&doc, page, 0, &app.ui.expanded, &mut rows); - } else { - matching(&doc, page, &filter, &mut rows); - } - let scroll_to = if reveal { sel.first().copied() } else { None }; + let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter); + let scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None }; let mut clicked: Option<(NodeId, egui::Modifiers)> = None; let mut toggle: Option = None; let mut deep: Option<(NodeId, bool)> = None; @@ -373,7 +409,15 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let mut hovered_row: Option = None; let drag_id = Id::new("layer_drag"); let mut drop: Option<(NodeId, i8)> = None; - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + // Only the rows in view are laid out and drawn; the rest is empty scroll space. + egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let pitch = ROW + ui.spacing().item_spacing.y; + let top = ui.max_rect().top(); + let total = rows.len() as f32 * pitch; + ui.set_height(total.max(viewport.height())); + if let Some(i) = scroll_to { + ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top + i as f32 * pitch), vec2(w, ROW)), None); + } if rows.is_empty() { ui.add_space(8.0); ui.horizontal(|ui| { @@ -383,12 +427,16 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { }); } let dragging: Option = ui.data(|d| d.get_temp(drag_id)); - for (ri, row) in rows.iter().enumerate() { + let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len()); + let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len()); + let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch)); + let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band)); + // Stable widget ids for a row wherever the list is scrolled. + band_ui.skip_ahead_auto_ids(first); + let ui = &mut band_ui; + for (ri, row) in rows.iter().enumerate().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); - if scroll_to == Some(row.id) { - ui.scroll_to_rect(r, None); - } let selected = sel.contains(&row.id); let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a)); let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); @@ -533,12 +581,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } // Empty space below clears selection. - let rest = ui.available_rect_before_wrap(); - if rest.height() > 0.0 { - let resp = ui.allocate_rect(rest, Sense::click()); - if resp.clicked() { - app.run("select.none", json!({})); - } + let rest = Rect::from_x_y_ranges(band.x_range(), (top + total)..=(top + total.max(viewport.max.y))); + if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::click()).clicked() { + app.run("select.none", json!({})); } }); if ui.input(|i| i.pointer.any_released()) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 03b00de..8f30d51 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,9 @@ pub mod vector_panel; pub mod versions; pub mod widgets; +#[cfg(test)] +mod perf_tests; + use std::collections::BTreeSet; use std::path::PathBuf; @@ -435,7 +438,14 @@ impl FigApp { "comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved}, "frameMs": self.ui.frame_ms, "renderMs": self.ui.render_ms, - "canvas": {"rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y]}, + "canvas": { + "rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y], + "renderWork": self.canvas.render_work.map(|w| match w { + figcraft_render::Work::Reused => json!({"kind": "reused"}), + figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), + figcraft_render::Work::Full => json!({"kind": "full"}), + }), + }, "tool": self.session.tool_info(), "status": self.session.status(), "lastError": self.ui.last_error, diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs new file mode 100644 index 0000000..3c56454 --- /dev/null +++ b/crates/ui-egui/src/perf_tests.rs @@ -0,0 +1,199 @@ +//! UI frame benchmarks on large documents (ignored by default; `cargo xtask bench` runs them in +//! release): the whole UI drawn headless through egui, timing idle frames, hovering, panning and a +//! layers panel with every screen expanded. Also the per-document-version caches that keep those +//! frames cheap: they must follow edits, undo, page switches and the layers panel's state. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::Instant; + +use egui::{Event, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::{Document, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +const SCREEN: (f32, f32) = (1920.0, 1080.0); + +struct Bench { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +impl Bench { + fn new(layers: usize) -> Self { + figcraft_text::scan_system_fonts(); + let (doc, _) = figcraft_engine::synthetic::large_document(layers).unwrap(); + let mut b = Self { ctx: egui::Context::default(), app: FigApp::new(Session::with_document(doc), Box::new(NoServices)), time: 0.0 }; + // Fonts install on the first frame; the next ones lay out and settle. + for _ in 0..4 { + b.frame(vec![]); + } + b + } + + /// One UI frame with `events`; returns its wall time in ms. + fn frame(&mut self, events: Vec) -> f64 { + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(SCREEN.0, SCREEN.1))), + time: Some(self.time), + events, + ..Default::default() + }; + let t = Instant::now(); + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + let ms = t.elapsed().as_secs_f64() * 1000.0; + // No GPU here to take the texture uploads. + out.textures_delta.clear(); + ms + } + + /// Mean frame ms over `n` frames, events from `ev(i)`. + fn mean(&mut self, n: usize, ev: impl Fn(usize) -> Vec) -> f64 { + (0..n).map(|i| self.frame(ev(i))).sum::() / n as f64 + } + + fn canvas_point(&self, i: usize) -> Pos2 { + let r = self.app.canvas.rect; + let (fx, fy) = ((i * 7919 % 1000) as f32 / 1000.0, (i * 104_729 % 1000) as f32 / 1000.0); + Pos2::new(r.min.x + 20.0 + (r.width() - 40.0) * fx, r.min.y + 20.0 + (r.height() - 40.0) * fy) + } +} + +fn report(layers: usize) { + let mut b = Bench::new(layers); + let mut rows: Vec<(&str, f64)> = Vec::new(); + rows.push(("idle frame, nothing selected", b.mean(30, |_| vec![]))); + let hover = |b: &Bench, i: usize| vec![Event::PointerMoved(b.canvas_point(i))]; + let pts: Vec> = (0..60).map(|i| hover(&b, i)).collect(); + rows.push(("hover over the canvas", b.mean(60, |i| pts[i].clone()))); + let wheel = |d: f32| Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, d), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + let centre = b.app.canvas.rect.center(); + rows.push(("pan (wheel) over the canvas", b.mean(60, |i| vec![Event::PointerMoved(centre), wheel(if i % 20 < 10 { -40.0 } else { 40.0 })]))); + // One layer selected: the design panel shows its properties. + let screen = b.app.session.doc().ok().and_then(|d| d.node(b.app.session.page().ok()?)?.children.get(1).copied()); + let first = screen.and_then(|s| b.app.session.doc().ok()?.node(s)?.children.last().copied()); + b.app.session.set_selection(first.into_iter().collect()); + rows.push(("idle frame, one layer selected", b.mean(30, |_| vec![]))); + b.app.session.set_selection(vec![]); + b.app.ui.right_tab = crate::RightTab::Prototype; + rows.push(("idle frame, Prototype tab", b.mean(30, |_| vec![]))); + b.app.ui.right_tab = crate::RightTab::Design; + // Every screen expanded in the layers panel. + let page = b.app.session.page().unwrap(); + let screens = b.app.session.doc().unwrap().node(page).unwrap().children.clone(); + b.app.ui.expanded.extend(screens); + b.frame(vec![]); + rows.push(("idle frame, every screen expanded in layers", b.mean(30, |_| vec![]))); + let panel = Pos2::new(150.0, 600.0); + rows.push(("scroll the expanded layers panel", b.mean(60, |i| vec![Event::PointerMoved(panel), wheel(if i % 30 < 15 { -60.0 } else { 60.0 })]))); + b.app.ui.layer_search = true; + b.app.ui.layer_filter = "item".into(); + b.frame(vec![]); + rows.push(("idle frame, layers filtered by name", b.mean(30, |_| vec![]))); + println!("UI frame ms — {layers} layers ({})", if cfg!(debug_assertions) { "debug" } else { "release" }); + for (name, v) in rows { + println!("| {name:<44} | {v:>8.2} |"); + } +} + +#[test] +#[ignore = "benchmark: cargo xtask bench"] +fn perf_ui_frames() { + let sizes: Vec = std::env::var("FIGCRAFT_BENCH_LAYERS") + .ok() + .map(|s| s.split(',').filter_map(|v| v.trim().parse().ok()).collect()) + .unwrap_or_else(|| vec![1000, 10_000]); + for n in sizes { + report(n); + } +} + +#[test] +fn bench_harness_draws_frames() { + let mut b = Bench::new(200); + assert!(b.frame(vec![]) >= 0.0); + assert!(b.app.canvas.rect.width() > 100.0, "the canvas was laid out"); +} + +/// Layer rows through the panel's cache, in a frame of `ctx` (the cache lives in its memory). +fn rows(ctx: &egui::Context, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { + let mut out = Arc::new(Vec::new()); + ctx.run_ui(RawInput::default(), |ui| out = crate::layers::rows_for(ui, doc, page, expanded, filter)).textures_delta.clear(); + out +} + +fn ids(rows: &[crate::layers::Row]) -> Vec { + rows.iter().map(|r| r.id).collect() +} + +#[test] +fn layer_rows_follow_edits_undo_pages_expansion_and_filter() { + let ctx = egui::Context::default(); + let mut s = Session::new(); + let page = s.page().unwrap(); + let frame = s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let frame = NodeId(frame); + let rect = NodeId( + s.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + let mut expanded = BTreeSet::new(); + let doc1 = s.ds().unwrap().doc.clone(); + let first = rows(&ctx, &doc1, page, &expanded, ""); + assert_eq!(ids(&first), vec![frame]); + assert!(Arc::ptr_eq(&first, &rows(&ctx, &doc1, page, &expanded, "")), "same inputs: the cached rows"); + // Expanding shows the children. + expanded.insert(frame); + assert_eq!(ids(&rows(&ctx, &doc1, page, &expanded, "")), vec![frame, rect]); + // An edit adds a row; undo takes it away again. + let ell = + NodeId(s.execute("node.create", &json!({"type": "ellipse", "x": 200, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()); + let doc2 = s.ds().unwrap().doc.clone(); + assert_eq!(ids(&rows(&ctx, &doc2, page, &expanded, "")), vec![ell, frame, rect]); + s.undo().unwrap(); + let undone = s.ds().unwrap().doc.clone(); + assert_eq!(ids(&rows(&ctx, &undone, page, &expanded, "")), vec![frame, rect]); + // The filter lists matches only. + assert_eq!(ids(&rows(&ctx, &doc2, page, &expanded, "ellipse")), vec![ell]); + assert_eq!(ids(&rows(&ctx, &doc2, page, &expanded, "")), vec![ell, frame, rect]); + // Another page (added after the undo: no ellipse). + s.execute("page.add", &json!({})).unwrap(); + let p2 = s.page().unwrap(); + assert_ne!(p2, page); + let doc3 = s.ds().unwrap().doc.clone(); + assert!(rows(&ctx, &doc3, p2, &expanded, "").is_empty()); + assert_eq!(ids(&rows(&ctx, &doc3, page, &expanded, "")), vec![frame, rect]); + // Collapsing hides the children again. + expanded.clear(); + assert_eq!(ids(&rows(&ctx, &doc3, page, &expanded, "")), vec![frame]); +} + +#[test] +fn prototype_sources_follow_edits_and_undo() { + let ctx = egui::Context::default(); + let mut s = Session::new(); + let page = s.page().unwrap(); + let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap()); + let a = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let b = id(s.execute("node.create", &json!({"type": "frame", "x": 200, "y": 0, "width": 100, "height": 100})).unwrap()); + let before = s.ds().unwrap().doc.clone(); + assert!(crate::proto_canvas::sources(&ctx, &before, page).is_empty()); + s.execute("proto.interaction.add", &json!({"ids": [a], "dest": b})).unwrap(); + let after = s.ds().unwrap().doc.clone(); + assert_eq!(*crate::proto_canvas::sources(&ctx, &after, page), vec![a]); + assert!(Arc::ptr_eq(&crate::proto_canvas::sources(&ctx, &after, page), &crate::proto_canvas::sources(&ctx, &after, page))); + s.undo().unwrap(); + let undone = s.ds().unwrap().doc.clone(); + assert!(crate::proto_canvas::sources(&ctx, &undone, page).is_empty()); +} diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 9c1e5bf..a183a6a 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -2,6 +2,8 @@ //! act on, a "+" connection handle on selected layers that drags out a new Navigate interaction, //! and clicking a noodle to edit its interaction. +use std::sync::Arc; + use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2}; use figcraft_doc::{Document, NodeId}; use serde_json::{Value, json}; @@ -49,13 +51,30 @@ fn curve_between(a: Rect, b: Rect) -> [Pos2; 4] { [p0, p0 + vec2(dx, 0.0), p3 - vec2(dx, 0.0), p3] } -fn noodles(app: &FigApp, doc: &Document, page: NodeId) -> Vec { - let mut out = Vec::new(); - for id in doc.subtree(page) { - let Some(n) = doc.node(id) else { continue }; - if n.interactions.is_empty() { - continue; +/// The layers on `page` with interactions, in tree order, found once per document version (the +/// tab redraws every frame; walking a large page each time would cost milliseconds). +#[derive(Clone, Default)] +struct Sources { + doc: Option>, + page: Option, + ids: Arc>, +} + +pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { + ctx.data_mut(|d| { + let c = d.get_temp_mut_or_default::(Id::new("proto_canvas_sources")); + if !(c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page)) { + let ids = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| !n.interactions.is_empty())).collect(); + *c = Sources { doc: Some(doc.clone()), page: Some(page), ids: Arc::new(ids) }; } + c.ids.clone() + }) +} + +fn noodles(app: &FigApp, ctx: &egui::Context, doc: &Arc, page: NodeId) -> Vec { + let mut out = Vec::new(); + for &id in sources(ctx, doc, page).iter() { + let Some(n) = doc.node(id) else { continue }; let Some(from) = screen_rect(app, doc, id) else { continue }; for (k, it) in n.interactions.iter().enumerate() { let Some(dest) = it.actions.iter().find_map(|a| a.dest()) else { continue }; @@ -136,7 +155,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let open = crate::proto_panel::editor(ctx); let hover = ctx.input(|i| i.pointer.hover_pos()); let dragging: Option = ctx.data(|d| d.get_temp(drag_id())); - for nd in noodles(app, doc, page) { + for nd in noodles(app, ctx, doc, page) { let selected = sel.contains(&nd.source); let is_open = open == Some((nd.source, nd.index)); let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0); @@ -239,7 +258,7 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { ctx.data_mut(|m| m.insert_temp(drag_id(), Drag { source, from, pos })); return true; } - let hit = noodles(app, &doc, page) + let hit = noodles(app, &ctx, &doc, page) .into_iter() .filter(|n| bezier_distance(&n.pts, pos) < 5.0) .min_by(|a, b| bezier_distance(&a.pts, pos).total_cmp(&bezier_distance(&b.pts, pos))); diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs index 645071c..45afbc3 100644 --- a/crates/ui-egui/src/render_worker.rs +++ b/crates/ui-egui/src/render_worker.rs @@ -1,10 +1,12 @@ //! Canvas rendering off the UI thread (desktop) or inline (web). The latest request wins; the UI //! keeps showing the previous image, re-projected to the current view, until the new one arrives. +//! The renderer keeps its last image ([`figcraft_render::Retained`]): a pan renders only the +//! uncovered strips and an edit only the areas of the layers that changed. use std::sync::Arc; use figcraft_doc::{Document, NodeId}; -use figcraft_render::View; +use figcraft_render::{Retained, View, Work}; #[derive(Clone)] pub struct Job { @@ -17,22 +19,26 @@ pub struct Job { } pub struct Done { - pub rgba: Vec, + /// The image, ready for a texture (converted off the UI thread). + pub image: egui::ColorImage, + /// The view the image shows (the requested one snapped outwards to whole device pixels). pub view: View, pub generation: u64, pub ms: f64, + pub work: Work, } -fn run(job: &Job) -> Option { +fn run(job: &Job, retained: &mut Retained) -> Option { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); let opts = figcraft_render::Options { outline_mode: job.outline, skip: job.skip.clone(), ..Default::default() }; - let pix = figcraft_render::render_view(&job.doc, job.page, &job.view, &opts)?; + let (view, pix, work) = retained.render(&job.doc, job.page, &job.view, &opts)?; + let image = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data()); #[cfg(not(target_arch = "wasm32"))] let ms = t0.elapsed().as_secs_f64() * 1000.0; #[cfg(target_arch = "wasm32")] let ms = 0.0; - Some(Done { rgba: pix.data().to_vec(), view: job.view.clone(), generation: job.generation, ms }) + Some(Done { image, view, generation: job.generation, ms, work }) } #[cfg(not(target_arch = "wasm32"))] @@ -48,13 +54,22 @@ impl Worker { let (tx, jobs) = std::sync::mpsc::channel::(); let (done_tx, rx) = std::sync::mpsc::channel::(); let spawned = std::thread::Builder::new().name("figcraft-render".into()).spawn(move || { + let mut retained = Retained::default(); while let Ok(mut job) = jobs.recv() { // Skip to the newest queued job. while let Ok(newer) = jobs.try_recv() { job = newer; } - let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job))); - if let Ok(Some(d)) = res + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&job, &mut retained))); + let done = match res { + Ok(d) => d, + Err(_) => { + // Don't build on an image a panic may have left half drawn. + retained.clear(); + None + } + }; + if let Some(d) = done && done_tx.send(d).is_err() { break; @@ -89,13 +104,14 @@ impl Worker { #[cfg(target_arch = "wasm32")] pub struct Worker { pending: Option, + retained: Retained, pub in_flight: bool, } #[cfg(target_arch = "wasm32")] impl Worker { pub fn new(_ctx: egui::Context) -> Self { - Self { pending: None, in_flight: false } + Self { pending: None, retained: Retained::default(), in_flight: false } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); @@ -104,11 +120,11 @@ impl Worker { pub fn poll(&mut self) -> Option { let job = self.pending.take()?; self.in_flight = false; - run(&job) + run(&job, &mut self.retained) } } -/// Render synchronously (tests, screenshots). +/// Render synchronously from scratch (tests, screenshots). pub fn render_now(job: &Job) -> Option { - run(job) + run(job, &mut Retained::default()) } diff --git a/docs/cli.md b/docs/cli.md index ee61cf2..f4a2b47 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -87,9 +87,12 @@ machines. Serve MCP on stdio: headless by default, or attached to a running app started with `figcraft --control PORT`. See [mcp.md](mcp.md). -### `perf [--shapes N] [--frames N]` +### `perf [--layers N[,N...]]` -Builds a synthetic document (default 20 000 shapes in 200 frames: rectangles, ellipses and text) and -times building, a full-page render at 1x, 1920×1080 renders (zoomed in and zoom-to-fit), 10 000 hit -tests, save/load, SVG export, `node.create` through the engine and select-all + move + undo. Use a -release build for meaningful numbers. +Builds synthetic documents of each size (default `1000,10000` layers: screens with an auto layout +header, a card group, button instances and a grid of shapes and text) and prints a Markdown table of +the hot paths: build + normalise, save/load, 1440×900 renders at 100% and zoomed to fit, hit testing +and hover, single-layer edits through the engine (fill, move, text in auto layout, a main component +with all its instances), undo/redo, a drag preview frame, `normalize` after a one-node edit, +`document.inspect` and select-all + move. Use a release build for meaningful numbers; +`cargo xtask bench` runs it at 1k/10k/50k layers together with the UI frame benchmarks. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1241993..c2034bb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,7 +60,9 @@ that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-kn ### `ui.inspect {}` -UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings. +UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings (`frameMs`, +`renderMs`) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with +the `pixels` re-rendered after a pan or an edit, or `reused`). ### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index a36e688..68d6d9c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -7,6 +7,7 @@ //! - `version` print the version, or `version set X.Y.Z` //! - `icon` regenerate the app icon PNGs/ICO from code (original artwork) //! - `wasm` check the core crates and web app for wasm32 +//! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) mod icon; mod layers; @@ -37,8 +38,9 @@ fn main() -> ExitCode { "version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)), "icon" => icon::generate(&root()), "wasm" => wasm(), + "bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)), _ => { - eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm"); + eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, bench [SIZES]"); task == "help" } }; @@ -58,6 +60,23 @@ fn wasm() -> bool { run(cargo().args(["check", "--target", "wasm32-unknown-unknown", "-p", "figcraft-engine", "-p", "figcraft-ui-egui", "-p", "figcraft-web"])) } +/// Engine/render timings (`figcraft-cli perf`) and UI frame timings (an ignored test driving the +/// whole UI headless), release builds, for each comma-separated document size in layers. +fn bench(sizes: &str) -> bool { + run(cargo().args(["run", "--release", "-p", "figcraft-cli", "--", "perf", "--layers", sizes])) + && run(cargo().env("FIGCRAFT_BENCH_LAYERS", sizes).args([ + "test", + "--release", + "-p", + "figcraft-ui-egui", + "--lib", + "perf_ui_frames", + "--", + "--ignored", + "--nocapture", + ])) +} + /// Every asset file must be listed in ATTRIBUTION.md. fn assets() -> bool { let root = root();