diff --git a/ROADMAP.md b/ROADMAP.md index fca8ef63..b4ee24dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bdf2fd94..6ca73099 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,8 +16,8 @@ //! - `ui.dialog.set {field, value}` / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) -//! - `ui.render {path?, scale?, data?}`: render the active artboard headlessly (PNG; without a path, -//! or with `data: true`, as `pngBase64`) +//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its +//! 0-based index, default the first; PNG; without a path, or with `data: true`, as `pngBase64`) //! - `app.open {path}` (any readable format) / `app.save {path?, svg?: {…SVG options}}` / `app.quit` //! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each //! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) @@ -321,7 +321,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); - let Some(r) = doc.artboards.first().map(|a| a.rect) else { return err("no artboard") }; + // The artboard asked for (0-based, as headless), else the first. + let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok()); + let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); @@ -390,3 +392,42 @@ pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: None => json!({"ok": false, "error": "no writer configured"}), } } + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + use vectorcraft_engine::Session; + + use super::ControlRequest; + use crate::{Services, VectorcraftApp}; + + /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked + /// for, at the scale asked for; the first without one. + #[test] + fn render_takes_the_artboard_asked_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx); + let mut request = |method: &str, params: Value| { + let (req, reply) = ControlRequest::new(method, params); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + reply.try_recv().unwrap() + }; + let new = json!({"width": 64, "height": 64, "units": "Points", "artboards": 2}); + request("engine.execute", json!({"command": "file.new", "params": new})); + let board = json!({"index": 1, "x": 100, "y": 0, "width": 96, "height": 48}); + let r = request("engine.execute", json!({"command": "artboard.setProps", "params": board})); + assert_eq!(r["ok"], true, "{r}"); + for (params, size) in [ + (json!({}), [64, 64]), + (json!({"artboard": 0}), [64, 64]), + (json!({"artboard": 1}), [96, 48]), + (json!({"artboard": 1, "scale": 0.5}), [48, 24]), + ] { + let r = request("ui.render", params.clone()); + let got = [&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64); + assert_eq!(got, size.map(Some), "{params}: {r}"); + } + assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 549048af..8ad6a967 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ the two can still win that race. | `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?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. 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?, data?}` | render the artboard headlessly (PNG): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | +| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |