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