From c06fad7b2b8f51267ae35d9dca4bc0a997309310 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 23:38:38 -0400 Subject: [PATCH] M5: drawcraft-cli bench; canvas keyed by per-document uid (no stale frame from another tab); brushed strokes keep brush art under effects Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 ++ apps/drawcraft-cli/Cargo.toml | 2 ++ apps/drawcraft-cli/src/main.rs | 52 ++++++++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 5 ++++ crates/render/src/fx.rs | 4 +++ crates/ui-egui/src/canvas.rs | 11 +++++-- 6 files changed, 73 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69e187b8..75d41ec4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,9 @@ dependencies = [ name = "drawcraft-cli" version = "0.1.0" dependencies = [ + "drawcraft-geom", "drawcraft-mcp", + "drawcraft-render", "drawcraft-testkit", "serde_json", ] diff --git a/apps/drawcraft-cli/Cargo.toml b/apps/drawcraft-cli/Cargo.toml index 09436fb4..980866a9 100644 --- a/apps/drawcraft-cli/Cargo.toml +++ b/apps/drawcraft-cli/Cargo.toml @@ -14,7 +14,9 @@ path = "src/main.rs" workspace = true [dependencies] +drawcraft-geom = { workspace = true } drawcraft-mcp = { workspace = true } +drawcraft-render = { workspace = true } serde_json = { workspace = true } [dev-dependencies] diff --git a/apps/drawcraft-cli/src/main.rs b/apps/drawcraft-cli/src/main.rs index ac36bfcf..4b5637a7 100644 --- a/apps/drawcraft-cli/src/main.rs +++ b/apps/drawcraft-cli/src/main.rs @@ -4,6 +4,7 @@ //! drawcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] //! drawcraft-cli run [--in file.drawcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.drawcraft]... [--scale 2] //! drawcraft-cli commands +//! drawcraft-cli bench FILE [--size 2880x1800] [--iters 5] //! ``` #![forbid(unsafe_code)] @@ -27,6 +28,10 @@ USAGE: drawcraft-cli commands Print the command catalogue as JSON. + + 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. "; fn main() -> ExitCode { @@ -35,6 +40,7 @@ fn main() -> ExitCode { Some("mcp") => mcp(&args[1..]), Some("run") => run(&args[1..]), Some("commands") => commands(), + Some("bench") => bench(&args[1..]), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) @@ -150,3 +156,49 @@ fn run(args: &[String]) -> Result<(), String> { } Ok(()) } + +fn bench(args: &[String]) -> Result<(), String> { + let mut file = None; + let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32); + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--size" => { + let v = it.next().ok_or("--size needs WxH")?; + let (a, b) = v.split_once('x').ok_or("--size needs WxH")?; + w = a.parse().map_err(|_| "bad width")?; + h = b.parse().map_err(|_| "bad height")?; + } + "--iters" => iters = it.next().and_then(|v| v.parse().ok()).ok_or("--iters needs a number")?, + f if file.is_none() => file = Some(f.to_string()), + other => return Err(format!("unknown bench option `{other}`")), + } + } + let file = file.ok_or("bench needs a FILE")?; + let mut hl = Headless::new(); + hl.call("app.open", json!({ "path": file })).map_err(|e| format!("open {file}: {e}"))?; + let doc = hl.session.doc().map_err(|e| e.to_string())?.doc.clone(); + let b = doc.artboards.first().map(|a| a.rect).ok_or("document has no artboard")?; + let z = (w as f64 / b.width()).min(h as f64 / b.height()) * 0.95; + let view = drawcraft_geom::Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) + * drawcraft_geom::Affine::scale(z) + * drawcraft_geom::Affine::translate(-b.center().to_vec2()); + let opts = drawcraft_render::RenderOptions::default(); + println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::()); + for threads in [drawcraft_render::default_threads(), 0] { + let mut r = drawcraft_render::Renderer::new(); + r.threads = threads; + r.render(&doc, w, h, view, &opts); + let t = std::time::Instant::now(); + for _ in 0..iters { + r.render(&doc, w, h, view, &opts); + } + println!( + " threads {threads}: {:.1} ms/frame (drawn {}, culled {})", + t.elapsed().as_secs_f64() * 1000.0 / iters as f64, + r.stats.drawn, + r.stats.culled + ); + } + Ok(()) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf623653..398872cd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -94,8 +94,12 @@ pub struct DocState { pub last_transform: Option<(Affine, bool)>, /// Selection saved by Select → Reselect. pub last_selection_cmd: Option<(String, Value)>, + /// Process-unique id of this open document (tab indices shift when tabs close). + pub uid: u64, } +static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + impl DocState { pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.default_layer(); @@ -111,6 +115,7 @@ impl DocState { interaction: None, last_transform: None, last_selection_cmd: None, + uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), } } pub fn is_dirty(&self) -> bool { diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 154e00ab..8aebaabe 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -235,6 +235,10 @@ impl Renderer { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } + // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). + if st.brush.is_some() && self.draw_brush(ctx, f, n, &ig, st) { + continue; + } self.draw_stroke(ctx, f, &ig, rule, st, ib); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c5e8bc2d..6ad33b80 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -167,7 +167,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let ppp = ui.ctx().pixels_per_point(); let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32); let key = CacheKey { - doc: app.session.active_index().unwrap_or(0), + doc: st.uid as usize, revision: st.revision, zoom: v.zoom, cx: v.center.x, @@ -186,7 +186,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } } // Upload finished background renders. - if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) { + if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) + && done.key.doc == key.doc + { upload(app, ui.ctx(), &done.img); app.canvas.key = Some(done.key); app.perf.render_ms = done.ms; @@ -204,7 +206,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { ..opts }; // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. - let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); + // A document switch renders synchronously so another document's frame is never shown. + let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc); + let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some() && same_doc; match (&mut app.canvas.worker, heavy) { (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { @@ -218,6 +222,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } } if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) + && k.doc == key.doc && (k.rot - v.rotation).abs() < 1e-9 { // Reproject the last frame if it was rendered for a different view.