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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 23:38:38 -04:00
parent acb2566d89
commit c06fad7b2b
6 changed files with 73 additions and 3 deletions

2
Cargo.lock generated
View File

@ -853,7 +853,9 @@ dependencies = [
name = "drawcraft-cli"
version = "0.1.0"
dependencies = [
"drawcraft-geom",
"drawcraft-mcp",
"drawcraft-render",
"drawcraft-testkit",
"serde_json",
]

View File

@ -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]

View File

@ -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::<usize>());
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(())
}

View File

@ -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<String>) -> 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 {

View File

@ -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);
}
}

View File

@ -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.