diff --git a/AGENTS.md b/AGENTS.md index 665ad060..8df13b0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ - `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`. - **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `drawcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests). -- **Performance:** `drawcraft-cli bench FILE [--size WxH] [--iters N]` prints ms/frame (multi- and single-threaded). Check it before and after renderer changes. +- **Performance:** `drawcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `drawcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise). - MCP: `drawcraft-cli mcp` (see `docs/mcp.md`). - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). diff --git a/CLAUDE.md b/CLAUDE.md index 665ad060..8df13b0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ - `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`. - **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `drawcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests). -- **Performance:** `drawcraft-cli bench FILE [--size WxH] [--iters N]` prints ms/frame (multi- and single-threaded). Check it before and after renderer changes. +- **Performance:** `drawcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `drawcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise). - MCP: `drawcraft-cli mcp` (see `docs/mcp.md`). - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`). diff --git a/apps/drawcraft-cli/Cargo.toml b/apps/drawcraft-cli/Cargo.toml index 980866a9..b2908fa9 100644 --- a/apps/drawcraft-cli/Cargo.toml +++ b/apps/drawcraft-cli/Cargo.toml @@ -14,7 +14,12 @@ path = "src/main.rs" workspace = true [dependencies] +drawcraft-color = { workspace = true } +drawcraft-doc = { workspace = true } +drawcraft-engine = { workspace = true } +drawcraft-format = { workspace = true } drawcraft-geom = { workspace = true } +drawcraft-svg = { workspace = true } drawcraft-mcp = { workspace = true } drawcraft-render = { workspace = true } serde_json = { workspace = true } diff --git a/apps/drawcraft-cli/src/main.rs b/apps/drawcraft-cli/src/main.rs index ddea5fc5..2deab0b7 100644 --- a/apps/drawcraft-cli/src/main.rs +++ b/apps/drawcraft-cli/src/main.rs @@ -5,12 +5,15 @@ //! drawcraft-cli run [--in file.drawcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.drawcraft]... [--scale 2] //! drawcraft-cli commands //! drawcraft-cli bench FILE [--size 2880x1800] [--iters 5] +//! drawcraft-cli perf [--paths 50000] //! ``` #![forbid(unsafe_code)] use std::io::Write; use std::process::ExitCode; +mod perf; + use drawcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; use serde_json::{Value, json}; @@ -32,6 +35,10 @@ USAGE: drawcraft-cli bench FILE [--size WxH] [--iters N] Render FILE (.drawcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame (warm), multithreaded and single-threaded. + + drawcraft-cli perf [--paths N] + Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a + synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded. "; fn main() -> ExitCode { @@ -41,6 +48,7 @@ fn main() -> ExitCode { Some("run") => run(&args[1..]), Some("commands") => commands(), Some("bench") => bench(&args[1..]), + Some("perf") => perf::run(&args[1..]), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) diff --git a/apps/drawcraft-cli/src/perf.rs b/apps/drawcraft-cli/src/perf.rs new file mode 100644 index 00000000..8cfa4149 --- /dev/null +++ b/apps/drawcraft-cli/src/perf.rs @@ -0,0 +1,151 @@ +//! `drawcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document. +//! +//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load +//! average above the core count) they are noise, and the report says so. + +use std::time::Instant; + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, Document, Node, hit}; +use drawcraft_geom::{Affine, Point, Rect, shapes}; +use drawcraft_render::{RenderOptions, Renderer}; +use serde_json::json; + +/// Deterministic xorshift in 0..1. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % 1_000_000) as f64 / 1_000_000.0 + } +} + +/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page. +pub fn synthetic(n: usize) -> Document { + let mut d = Document::new(1600.0, 1200.0); + let l = d.layers[0].id; + let mut r = Rng(42); + for i in 0..n { + let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0); + let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32); + let id = d.alloc_id(); + let mut node = match i % 3 { + 0 => Node::path( + id, + shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * s, 2.0 * s))), + Appearance::basic(Paint::solid(c), Paint::solid(Color::BLACK), 0.5), + ), + 1 => Node::path(id, shapes::rectangle(Rect::new(x, y, x + 2.0 * s, y + s)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)), + _ => Node::path(id, shapes::star(Point::new(x, y), s, s / 2.0, 5, 0.0), Appearance::basic(Paint::None, Paint::solid(c), 2.0)), + }; + if i % 3 == 1 { + node.opacity = 0.8; + } + d.insert(Some(l), usize::MAX, node).expect("insert"); + } + d +} + +fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 { + let mut v: Vec = (0..runs) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(); + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +/// 1-minute load average (macOS / Linux), if available. +fn load_average() -> Option { + let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success()); + let text = match out { + Some(o) => String::from_utf8_lossy(&o.stdout).to_string(), + None => std::fs::read_to_string("/proc/loadavg").ok()?, + }; + text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok()) +} + +pub fn run(args: &[String]) -> Result<(), String> { + let mut n = 50_000usize; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?, + other => return Err(format!("unknown perf option `{other}`")), + } + } + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + let load = load_average(); + println!("DrawCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}"))); + let noisy = load.is_some_and(|l| l > cores as f64 * 0.75); + if noisy { + println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy."); + } + let t = Instant::now(); + let doc = synthetic(n); + println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0); + + let mut rows: Vec<(&str, f64, f64)> = vec![]; + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let fit = Affine::scale(1.6); + r.render(&doc, 2880, 1800, fit, &opts); + rows.push(("render: fit page, 2880×1800", median_ms(5, || drop(r.render(&doc, 2880, 1800, fit, &opts))), 16.0)); + let mut dx = 0.0; + rows.push(( + "render: pan frame (view moves 7 px)", + median_ms(9, || { + dx += 7.0; + drop(r.render(&doc, 2880, 1800, Affine::translate((dx, 0.0)) * fit, &opts)); + }), + 16.0, + )); + let zoom = Affine::scale(6.4) * Affine::translate((-600.0, -400.0)); + r.render(&doc, 2880, 1800, zoom, &opts); + rows.push(("render: 400% zoom, 2880×1800", median_ms(5, || drop(r.render(&doc, 2880, 1800, zoom, &opts))), 16.0)); + + let mut rng = Rng(7); + let points: Vec = (0..200).map(|_| Point::new(rng.next() * 1600.0, rng.next() * 1200.0)).collect(); + let hits = median_ms(3, || { + for p in &points { + drop(hit::hit_test(&doc, *p, hit::HitOptions::default())); + } + }) / points.len() as f64; + rows.push(("hit test (per click)", hits, 2.0)); + + let mut bytes = vec![]; + rows.push(("save .drawcraft", median_ms(3, || bytes = drawcraft_format::save_file(&doc)), 300.0)); + rows.push(("open .drawcraft", median_ms(3, || drop(drawcraft_format::load(&bytes).expect("load"))), 300.0)); + let svg_opts = drawcraft_svg::ExportOptions::default(); + rows.push(("export SVG", median_ms(3, || drop(drawcraft_svg::export(&doc, &svg_opts))), 500.0)); + + // Pathfinder Unite on 1,000 overlapping paths. + let unite = median_ms(3, || { + let mut s = drawcraft_engine::Session::new(); + s.add_document(synthetic(1000), None); + s.execute("select.all", &json!({})).expect("select"); + s.execute("object.pathfinder.unite", &json!({})).expect("unite"); + }); + rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0)); + + let mut over = 0; + println!(" {:<40} {:>10} {:>10}", "", "measured", "budget"); + for (name, ms, budget) in &rows { + let ok = ms <= budget; + over += usize::from(!ok); + println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" }); + } + match (over, noisy) { + (0, _) => Ok(()), + (_, true) => { + println!("{over} over budget, but the machine is busy: re-run when idle."); + Ok(()) + } + _ => Err(format!("{over} budget(s) exceeded")), + } +}