M1.9: headless CLI/MCP export PDF/JPG/WebP; app JPEG/WebP export; ui.screenshot errors instead of hanging without a presented frame; docs (roadmap re-estimate, agent guide: bench, locked screens, disk)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 00:30:38 -04:00
1 parent 5e15994012
commit abe2ed7080
10 files changed
+56 -35

No files matched your search

+3 -2
View File
@@ -30,10 +30,11 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ
## Running and looking at the app
- `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`.
- **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.
- 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.
- 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`).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
+3 -2
View File
@@ -30,10 +30,11 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ
## Running and looking at the app
- `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`.
- **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.
- 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.
- 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`).
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and time-to-parity estimates. Update it whenever a milestone task lands.
+3 -1
View File
@@ -62,6 +62,8 @@ cargo run --release -p drawcraft # desktop app
cargo run --release -p drawcraft -- examples/dusk-poster.drawcraft
cargo run --release -p drawcraft -- --control 7979 # + JSON control channel
cargo run --release -p drawcraft-cli -- mcp # MCP server (stdio)
cargo run --release -p drawcraft-cli -- run --in examples/ribbons.drawcraft --export out.pdf # headless batch
cargo run --release -p drawcraft-cli -- bench examples/neon-drive.drawcraft # render timing
cargo xtask bundle # dist/DrawCraft.app (macOS)
cd apps/drawcraft-web && trunk build --release # web build → dist/web
cargo xtask ci # fmt, clippy, tests, layering, wasm
@@ -73,7 +75,7 @@ Register with Claude Code: `claude mcp add drawcraft -- /path/to/drawcraft-cli m
DrawCraft is under active development. See **[ROADMAP.md](ROADMAP.md)** for what ships today, milestones, and honest time-to-parity estimates.
Workspace: `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`, `apps/{drawcraft, drawcraft-cli, drawcraft-web}`. The egui frontend is a separate crate, so the UI can be swapped without touching the engine. Agent guide: [`CLAUDE.md`](CLAUDE.md).
Workspace: `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, format, tools, engine, ui-egui, mcp, testkit}`, `apps/{drawcraft, drawcraft-cli, drawcraft-web}`. The egui frontend is a separate crate, so the UI can be swapped without touching the engine. Agent and contributor rules (clean-room, the asset policy, quality gates): [`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
## Crafting Apps
+10 -9
View File
@@ -4,7 +4,7 @@ DrawCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe
This file tracks **how far we are and what's left**. Estimates are wall-clock hours of continuous agent work (Claude Opus 5.5 lead plus 4–6 parallel agents). They were made by counting the remaining work, not measured, and are updated as work lands.
_Last updated: 2026-09-30._
_Last updated: 2026-10-01._
## Where we are
@@ -13,7 +13,7 @@ _Last updated: 2026-09-30._
| Infrastructure (engine, command registry, history, render, formats, MCP, web, packaging, tests) | **~90%** |
| Look & feel vs Illustrator 2026 default workspace (measured) | **~75–80%** |
| Feature surface vs full Illustrator | **~45–50%** |
| Estimated time to **checklist parity** (every menu item, tool, panel and dialog functional) | **~170–260 h** |
| Estimated time to **checklist parity** (every menu item, tool, panel and dialog functional) | **~210–340 h** (sum of the milestone rows below) |
| Estimated time to **1:1 feel** (edge cases, modifier nuances, typographic/colour fidelity) | **~500+ h** |
### Shipped so far
@@ -40,6 +40,7 @@ _Last updated: 2026-09-30._
- **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
- **Formats:**
@@ -55,21 +56,21 @@ _Last updated: 2026-09-30._
| M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 3–5 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Shaper, Pen modifier nuances) | 5–10 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks, freeform gradients pending | 10–15 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; system clipboard SVG/PNG interop, EPS/DXF pending | 8–12 |
| M5 | Performance | 🟡 background render + caches + MT done; dirty-region rendering, GPU backend spike | 10–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard SVG/PNG interop, EPS/DXF pending | 8–12 |
| M5 | Performance | 🟡 background render + caches + MT done; `drawcraft-cli bench`; raster-effect docs fall back to single-threaded (neon-drive demo: 214 ms/frame) — MT filters, dirty-region rendering, GPU backend spike pending | 10–20 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 in progress | 20–30 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends + Envelopes (warp/mesh/top object) done; Puppet Warp, Liquify, Perspective Grid pending | 20–30 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done; 3D pending | 25–40 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 |
| M11 | Artboards & views (artboard panel/tool done; print tiling, multiple windows, presentation polish) | 🟡 | 10–15 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork done; CMYK/ICC, separations, Graphs pending | 25–40 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview) done; Graphs pending | 12–20 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | ⬜ | 20–30 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 40–60 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 | 30–50 |
| | **Total to checklist parity** | | **~170–260** |
| | **Total to checklist parity** (sum of the rows above) | | **~210–340** |
## Out of scope (by design or by law)
- **Native `.ai` private data:** it's undocumented. We read the PDF-compatible part, so Illustrator-only live objects arrive as appearance.
+2 -2
View File
@@ -2,7 +2,7 @@
//!
//! ```text
//! 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 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]
//! ```
@@ -24,7 +24,7 @@ USAGE:
drawcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
Headless batch: open FILE (.drawcraft/.svg) or start a new document, run commands in
order, export (.svg, .png, .drawcraft by extension). Prints one JSON result per step.
order, export (.svg, .png, .pdf, .jpg, .webp, .drawcraft by extension). Prints one JSON result per step.
drawcraft-cli commands
Print the command catalogue as JSON.
+7 -2
View File
@@ -254,7 +254,7 @@ impl Headless {
Ok(json!({"path": path}))
}
/// `app.export {format?, path, scale?, artboard?}`: svg | png | drawcraft (format defaults to the extension).
/// `app.export {format?, path, scale?, artboard?}`: svg | png | pdf | jpg | webp | drawcraft (format defaults to the extension).
pub fn export(&mut self, p: &Value) -> Result<Value, String> {
let path = s(p, "path").ok_or("missing `path`")?;
let fmt = s(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| ext_of(path));
@@ -272,7 +272,12 @@ impl Headless {
self.renderer.render_region(&doc, r, scale, false).to_png()
}
"drawcraft" => drawcraft_format::save(&doc, true),
other => return Err(format!("unknown export format `{other}` (svg, png, drawcraft)")),
// pdf, jpg, webp…: the engine's exporter (same bytes as the app).
other => {
let params = json!({"path": path, "format": other, "scale": p.get("scale"), "artboard": p.get("artboard")});
let r = self.session.execute("document.export", &params).map_err(|e| e.to_string())?;
return Ok(json!({"path": path, "format": fmt, "bytes": r["bytes"]}));
}
};
std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?;
Ok(json!({"path": path, "format": fmt, "bytes": bytes.len()}))
+3 -3
View File
@@ -263,7 +263,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Export the active document: svg (artboard viewBox), png (rendered artboard) or drawcraft (native).",
obj(
json!({
"format": {"type": "string", "enum": ["svg", "png", "drawcraft"], "description": "Default: from the path's extension"},
"format": {"type": "string", "enum": ["svg", "png", "pdf", "jpg", "webp", "drawcraft"], "description": "Default: from the path's extension"},
"path": string("Destination file"),
"scale": num("PNG pixels per point (default 1)"),
}),
@@ -543,8 +543,8 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
.and_then(Value::as_str)
.map(str::to_ascii_lowercase)
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default());
if !["svg", "png", "drawcraft"].contains(&fmt.as_str()) {
return Err(format!("unknown export format `{fmt}` (svg, png, drawcraft)"));
if !["svg", "png", "pdf", "jpg", "jpeg", "webp", "drawcraft"].contains(&fmt.as_str()) {
return Err(format!("unknown export format `{fmt}` (svg, png, pdf, jpg, webp, drawcraft)"));
}
let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0);
// The desktop app exports native files through Save (which also sets the document path).
+8 -9
View File
@@ -167,7 +167,7 @@ pub fn save(app: &mut DrawcraftApp, path: Option<String>, save_as: bool) -> Resu
Ok(path)
}
/// Export the document as SVG / PNG / JPEG.
/// Export the document as SVG / PDF / PNG / JPEG / WebP.
pub fn export(app: &mut DrawcraftApp, format: &str, path: Option<String>, scale: f64) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
let doc = st.doc.clone();
@@ -182,10 +182,14 @@ pub fn export(app: &mut DrawcraftApp, format: &str, path: Option<String>, scale:
let bytes = match format {
"svg" => drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(),
"pdf" => drawcraft_pdf::export(&doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?,
"png" | "jpg" | "jpeg" => {
"png" | "jpg" | "jpeg" | "webp" => {
let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?;
let img = app.canvas.renderer.render_region(&doc, r, scale, format != "png");
if format == "png" { img.to_png() } else { encode_jpeg(&img)? }
let img = app.canvas.renderer.render_region(&doc, r, scale, format == "jpg" || format == "jpeg");
match format {
"png" => img.to_png(),
"webp" => img.to_webp(),
_ => img.to_jpeg(90),
}
}
other => return Err(format!("unknown export format `{other}`")),
};
@@ -194,11 +198,6 @@ pub fn export(app: &mut DrawcraftApp, format: &str, path: Option<String>, scale:
Ok(path)
}
fn encode_jpeg(img: &drawcraft_render::Rendered) -> Result<Vec<u8>, String> {
let _ = img;
Err("JPEG export lands with M4.6".into())
}
/// File → Place… (embed an image or SVG into the active document).
pub fn place_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> {
let e = ext(name);
+16 -4
View File
@@ -108,7 +108,8 @@ pub struct DrawcraftApp {
/// Commands whose shortcuts the native menu handles (skip them in egui to avoid double firing).
pub native_shortcuts: std::collections::HashSet<String>,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
/// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame.
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>, f64)>,
queued_screenshots: Vec<(u64, f64, u32)>,
screenshot_token: u64,
/// Synthetic input events (from the control channel) injected one step per frame.
@@ -237,7 +238,7 @@ impl DrawcraftApp {
let token = self.screenshot_token;
let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0;
self.queued_screenshots.push((token, now_ms() + settle, 0));
self.pending_screenshots.push((token, path, reply));
self.pending_screenshots.push((token, path, reply, now_ms() + settle + 8000.0));
}
}
}
@@ -278,11 +279,22 @@ impl DrawcraftApp {
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) {
let (_, path, reply) = self.pending_screenshots.remove(i);
if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) {
let (_, path, reply, _) = self.pending_screenshots.remove(i);
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
}
}
let now = now_ms();
self.pending_screenshots.retain(|(_, _, reply, deadline)| {
if now < *deadline {
return true;
}
let _ = reply.send(serde_json::json!({
"ok": false,
"error": "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard"
}));
false
});
}
/// Show a transient status message.
+1 -1
View File
@@ -15,7 +15,7 @@
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?}` | |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
| `ui.screenshot` | `{path?}` | capture the window (PNG) |
| `ui.screenshot` | `{path?}` | capture the window (PNG). Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
| `ui.render` | `{path?, scale?}` | render the artboard headlessly (PNG) |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{format, path, scale?}` | |