MCP: screenshot and render_preview can draw the Image Trace view

After imageTrace.setView, screenshot and render_preview still drew the
tracing result, because ui.render renders with the export options and only
the canvas turns on trace views. Only the app window showed the view.

ui.render now takes traceViews. With traceViews: true, Image Trace objects
draw in the view set with imageTrace.setView, as on the canvas, both
headless and in the running app. screenshot and render_preview take
traceViews and pass it on; without it they draw the tracing result, as
exports do. The trace-and-style prompt names the argument.

A supersampled render drew Outline view's lines, type outlines and dotted
guides as little as a quarter of an output pixel wide, so they came out
pale gray. The renderer now scales those widths and the guides' dashes by
the supersampling factor in the larger frame, so the lines stay one output
pixel wide.

Docs: docs/mcp.md (render_preview, screenshot), docs/control-protocol.md
(ui.render).
This commit is contained in:
dotCooCoo
2026-10-26 09:09:45 +07:00
committed by bflatastic
parent 8c67ade824
commit eadf7674f1
10 changed files with 173 additions and 17 deletions
+3 -1
View File
@@ -263,7 +263,9 @@ impl Headless {
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
vectorcraft_render::raster_size(r, scale)?;
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
let opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
// `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does.
let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true);
let img = self.renderer.render_region_with(&doc, r, scale, &opts);
// A cancelled render stops drawing: the picture is not whole and is not written.
vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?;
+5 -4
View File
@@ -309,8 +309,8 @@ Method:
imageTrace.presets to see every preset with its numbers.
Lower paths and higher corners give the clean, logo-like result; high fidelity needs paths
near 90. imageTrace.setView {view: \"outlinesWithSourceImage\"} draws the traced outlines
over the image in the app's window to compare them (screenshot {window: true} captures it);
render_preview and screenshot without `window` draw the tracing result, as exports do.
over the image in the app's window to compare them; render_preview and screenshot draw it
with traceViews: true, and the tracing result otherwise, as exports do.
3. imageTrace.expand keeps only the traced paths and removes the source image.
imageTrace.makeAndExpand traces and expands in one command, in place of steps 2 and 3.
4. apply_effect {effect} on the traced shapes. Call apply_effect with no `effect` for the
@@ -557,7 +557,8 @@ mod tests {
}
/// trace-and-style selects the opened image before tracing it, expands the trace, recolors it
/// instead of painting it one color, and says that screenshots draw the tracing result.
/// instead of painting it one color, and says that screenshots draw the trace view only with
/// traceViews.
#[test]
fn trace_and_style_selects_expands_and_recolors() {
let v = get("trace-and-style", &json!({"path": "art.png"})).unwrap();
@@ -568,7 +569,7 @@ mod tests {
let at = |s: &str| text.find(s).unwrap_or(usize::MAX);
assert!(at("select.all") < at("imageTrace.make") && at("imageTrace.make") < at("imageTrace.expand"), "{text}");
assert!(text.contains("black and white"), "{text}");
assert!(!text.contains("screenshots show it"), "{text}");
assert!(!text.contains("screenshots show it") && text.contains("traceViews: true"), "{text}");
assert!(!text.contains("own pixel size") && text.contains("resolution it declares"), "{text}");
// No step of its own paints the trace with set_paint.
let steps: Vec<&str> = text.lines().filter(|l| is_item(l)).filter_map(|l| l.split_once(". ").map(|(_, rest)| rest)).collect();
+52
View File
@@ -350,6 +350,58 @@ fn trace_and_style_prompt_steps_run_in_order() {
assert!(fills.len() > 1 && fills.contains("#00aa00") && !fills.contains(&from), "{fills:?}");
}
/// `screenshot` and `render_preview` with `traceViews: true` draw an Image Trace object in the
/// view set with imageTrace.setView, as the app's window does; without it they draw the tracing
/// result, as exports do. The image is a dark disc (r = 30 px) on blue, traced with Ignore White.
#[test]
fn screenshot_and_render_preview_draw_the_trace_view_with_trace_views() {
let mut s = server();
let png = tmp("trace-views.png");
image::RgbImage::from_fn(100, 100, |x, y| {
let (dx, dy) = (f64::from(x) + 0.5 - 50.0, f64::from(y) + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { image::Rgb([0, 0, 0]) } else { image::Rgb([170, 210, 255]) }
})
.save(&png)
.unwrap();
let ok = |s: &mut Server, id: u64, name: &str, args: Value| {
let r = call(s, id, name, args);
assert_eq!(r["isError"], false, "{name}: {r}");
r
};
ok(&mut s, 1, "open_file", json!({"path": png.to_str().unwrap()}));
ok(&mut s, 2, "run_command", json!({"command": "select.all"}));
ok(&mut s, 3, "run_command", json!({"command": "imageTrace.make", "params": {"params": {"ignoreWhite": true}}}));
let pixels = |r: &Value| {
let data = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap()["data"].as_str().unwrap();
image::load_from_memory(&vectorcraft_format::base64_decode(data).unwrap()).unwrap().to_rgba8()
};
let dark = |p: &image::Rgba<u8>| p[0] < 60 && p[1] < 60 && p[2] < 60;
let white = |p: &image::Rgba<u8>| p[0] > 245 && p[1] > 245 && p[2] > 245;
let blue = |p: &image::Rgba<u8>| p[0] < 200 && p[2] > 245;
let (middle, beside) = ((50, 50), (10, 10));
let renders = [("screenshot", json!({})), ("render_preview", json!({"max_side": 100}))];
for (id, (name, args)) in (10..).zip(renders) {
let with = |mut a: Value| {
a["traceViews"] = json!(true);
a
};
ok(&mut s, id, "run_command", json!({"command": "imageTrace.setView", "params": {"view": "sourceImage"}}));
let (view, result) = (pixels(&ok(&mut s, id, name, with(args.clone()))), pixels(&ok(&mut s, id, name, args.clone())));
assert_eq!((view.width(), result.width()), (100, 100), "{name}");
assert!(blue(view.get_pixel(beside.0, beside.1)), "{name}: the source image beside the disc {:?}", view.get_pixel(beside.0, beside.1));
assert!(white(result.get_pixel(beside.0, beside.1)), "{name}: the tracing result {:?}", result.get_pixel(beside.0, beside.1));
assert!(dark(view.get_pixel(middle.0, middle.1)) && dark(result.get_pixel(middle.0, middle.1)), "{name}: the disc's middle");
// Outlines: a line at the disc's right side (x = 80) and no fill in its middle.
ok(&mut s, id, "run_command", json!({"command": "imageTrace.setView", "params": {"view": "outlines"}}));
let (view, result) = (pixels(&ok(&mut s, id, name, with(args.clone()))), pixels(&ok(&mut s, id, name, args.clone())));
let row: Vec<u8> = (75..=85).map(|x| view.get_pixel(x, 50)[0]).collect();
assert!(row.iter().any(|&r| r < 128), "{name}: the disc's outline {row:?}");
assert!(white(view.get_pixel(middle.0, middle.1)), "{name}: no fill in outlines {:?}", view.get_pixel(middle.0, middle.1));
assert!(dark(result.get_pixel(middle.0, middle.1)), "{name}: the tracing result's fill");
assert_eq!(call(&mut s, id, name, json!({"traceViews": "yes"}))["isError"], true, "{name}: a non-boolean traceViews");
}
}
#[test]
fn headless_path_gesture_undo() {
let mut s = server();
+22 -2
View File
@@ -154,7 +154,7 @@ pub fn tool_definitions() -> Vec<Value> {
"Render a preview",
"Render an artboard (default the first) as inline PNG without changing the document. max_side defaults to 1024, limited to 1..4096.",
obj(
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"}}),
json!({"max_side":{"type":"integer","minimum":1,"maximum":4096},"artboard":{"type":"integer","minimum":0,"description":"0-based artboard (default 0)"},"traceViews":{"type":"boolean","description":TRACE_VIEWS}}),
&[],
),
true,
@@ -344,6 +344,7 @@ pub fn tool_definitions() -> Vec<Value> {
"artboard": {"type": "integer", "minimum": 0, "description": "Artboard index (default 0)"},
"window": {"type": "boolean", "description": "Capture the app window (desktop app only)"},
"max_side": {"type": "integer", "minimum": 1, "maximum": 4096, "description": MAX_SIDE_WINDOW},
"traceViews": {"type": "boolean", "description": TRACE_VIEWS},
}),
&[],
),
@@ -850,6 +851,7 @@ fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
if max.is_some() && !window {
return Err("`max_side` applies to a window capture (`window: true`); size an artboard render with `scale`".into());
}
let trace_views = trace_views(a)?;
// The backend writes the file, where its automation roots allow, and sends the image back too
// (`data`): this process writes nothing.
let mut p = json!({"data": true});
@@ -865,6 +867,9 @@ fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
p[k] = v.clone();
}
}
if trace_views {
p["traceViews"] = json!(true);
}
b.call("ui.render", p)?
};
let png = match r.get("pngBase64").and_then(Value::as_str) {
@@ -946,6 +951,16 @@ const BRIEF: &str = "Leave out each command's params description (the bulk of th
/// The `max_side` argument of the window captures.
const MAX_SIDE_WINDOW: &str =
"Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)";
/// The `traceViews` argument of the artboard renders.
const TRACE_VIEWS: &str = "Draw Image Trace objects in the view set with imageTrace.setView (outlines, the source image…), as the app's window does; by default they draw their tracing result, as exports do";
/// `traceViews`: false when absent.
fn trace_views(a: &Args) -> Result<bool, String> {
match a.get("traceViews") {
None => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| "`traceViews` must be a boolean".into()),
}
}
/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the
/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params`
@@ -1340,6 +1355,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
None => 0,
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
};
let trace_views = trace_views(a)?;
let doc = b.call("document.inspect", json!({"depth":0}))?;
let board =
doc.get("artboards").and_then(Value::as_array).and_then(|v| v.get(usize::try_from(index).ok()?)).ok_or(format!("no artboard {index}"))?;
@@ -1354,7 +1370,11 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
if width.max(height) * scale > 4096.0 {
return Err("artboard is too large for a bounded preview".into());
}
let r = b.call("ui.render", json!({"scale":scale, "artboard": index}))?;
let mut p = json!({"scale":scale, "artboard": index});
if trace_views {
p["traceViews"] = json!(true);
}
let r = b.call("ui.render", p)?;
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
+12 -4
View File
@@ -582,6 +582,10 @@ pub struct Renderer {
/// The art the last frame drew with Overprint Preview or Separations Preview: the next frame
/// draws it again for the objects that have not changed ([`proof::prepare`]).
proof_art: Option<proof::Prepared>,
/// Width of [`Self::hairline`] in the pixels being drawn: 1, or the supersampling factor while
/// a supersampled render draws its larger frame, so outlines stay one output pixel wide. Type
/// outlines and dotted guides scale by it too.
hairline: f64,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
@@ -697,6 +701,7 @@ impl Renderer {
isolation: None,
isolation_settled: false,
proof_art: None,
hairline: 1.0,
}
}
@@ -713,7 +718,9 @@ impl Renderer {
let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE));
if let Some(k) = supersample_factor(w, h) {
let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() };
let hairline = std::mem::replace(&mut self.hairline, f64::from(k));
let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks);
self.hairline = hairline;
let pixels = downsample(&big.pixels, big.width as usize, big.height as usize, k as usize);
self.stats.micros = now().saturating_sub(start);
return Rendered { width: w, height: h, pixels };
@@ -1371,9 +1378,10 @@ impl Renderer {
// running far off screen (from a file) asked for millions of them. Past
// [`MAX_GUIDE_DASHES`] (by its control polygon, which is at least as long) it
// is drawn solid.
if dots && polygon_length(&screen) / 4.0 <= MAX_GUIDE_DASHES {
let h = self.hairline;
if dots && polygon_length(&screen) / (4.0 * h) <= MAX_GUIDE_DASHES {
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.5).with_dashes(0.0, [1.5, 2.5]));
ctx.set_stroke(kurbo::Stroke::new(1.5 * h).with_dashes(0.0, [1.5 * h, 2.5 * h]));
ctx.set_paint(f.ink.fixed([r, g, b, 255]));
ctx.stroke_path(&screen);
} else {
@@ -1570,7 +1578,7 @@ impl Renderer {
ctx.set_transform(Affine::IDENTITY);
let mut screen = bp.clone();
screen.apply_affine(f.view);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_stroke(kurbo::Stroke::new(self.hairline));
ctx.set_paint(f.ink.fixed(rgba));
ctx.stroke_path(&screen);
}
@@ -1931,7 +1939,7 @@ impl Renderer {
let mut p = g.all.clone();
p.apply_affine(xf);
ctx.set_transform(Affine::IDENTITY);
ctx.set_stroke(kurbo::Stroke::new(1.0));
ctx.set_stroke(kurbo::Stroke::new(self.hairline));
ctx.set_paint(peniko::Color::BLACK);
ctx.stroke_path(&p);
// Colour bitmap glyphs: their boxes, as images show in outline mode.
+40
View File
@@ -1001,6 +1001,46 @@ fn supersampled_art_leaves_no_seam_between_shapes_that_meet() {
assert_eq!(supersample_factor(40_000, 10), None);
}
/// A supersampled render draws Outline view's lines one output pixel wide, as an unsupersampled
/// one does, instead of a quarter of a pixel: the line down x = 50.5 draws darker than 64 of 255
/// either way. So do the outlines of type and a dotted guide along y = 90.5.
#[test]
fn supersampled_outlines_stay_one_pixel_wide() {
let _fonts = crate::tests_fontchange::fonts_steady();
let mut d = Document::new(100.0, 100.0);
let style = vectorcraft_doc::text::CharStyle { size: 30.0, ..Default::default() };
let text = vectorcraft_doc::text::TextObject::point(vectorcraft_geom::Point::new(60.0, 80.0), "H", style);
let t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(text)));
d.insert(Some(d.layers[0].id), 0, t).unwrap();
let n = rect_node(&mut d, Rect::new(20.0, 20.0, 50.5, 80.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.insert(Some(d.layers[0].id), 0, n).unwrap();
let mut guide = Node::path(d.alloc_id(), shapes::line(Point::new(5.0, 90.5), Point::new(95.0, 90.5)), Appearance::default());
if let NodeKind::Path { guide, .. } = &mut guide.kind {
*guide = true;
}
d.insert(Some(d.layers[0].id), 1, guide).unwrap();
let draw = |supersample: bool| {
let opts = RenderOptions {
background: Some([255, 255, 255, 255]),
outline: true,
guide: ([0, 0, 0], true),
precise: true,
supersample,
..Default::default()
};
Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts)
};
let darkest = |r: &Rendered| (48..=52).map(|x| r.pixel(x, 50)[0]).min().unwrap();
assert!(darkest(&draw(false)) < 64, "unsupersampled: {}", darkest(&draw(false)));
assert!(darkest(&draw(true)) < 64, "supersampled: {}", darkest(&draw(true)));
let type_darkest = |r: &Rendered| (56..=95).flat_map(|x| (45..=85).map(move |y| (x, y))).map(|(x, y)| r.pixel(x, y)[0]).min().unwrap();
assert!(type_darkest(&draw(false)) < 64, "unsupersampled type: {}", type_darkest(&draw(false)));
assert!(type_darkest(&draw(true)) < 64, "supersampled type: {}", type_darkest(&draw(true)));
let guide_darkest = |r: &Rendered| (10..=90).flat_map(|x| (89..=92).map(move |y| (x, y))).map(|(x, y)| r.pixel(x, y)[0]).min().unwrap();
assert!(guide_darkest(&draw(false)) < 64, "unsupersampled guide: {}", guide_darkest(&draw(false)));
assert!(guide_darkest(&draw(true)) < 64, "supersampled guide: {}", guide_darkest(&draw(true)));
}
/// #1613: a zero-length subpath (`m` then `L` to the same point, as `.ai` files write dots) is a
/// dot as wide as the stroke: a disc under a round cap, a square under a projecting one, nothing
/// under a butt cap (PDF 32000-1 8.5.3.2, SVG 2 "Path data" zero-length segments).
+7 -4
View File
@@ -19,9 +19,10 @@
//! dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`])
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG
//! comes back as `pngBase64` as well)
//! - `ui.render {path?, scale?, artboard?, data?}`: render an artboard headlessly (`artboard`: its
//! 0-based index, default the first; PNG with the pixels of a PNG export on white; without a
//! path, or with `data: true`, as `pngBase64`)
//! - `ui.render {path?, scale?, artboard?, data?, traceViews?}`: render an artboard headlessly
//! (`artboard`: its 0-based index, default the first; PNG with the pixels of a PNG export on
//! white; `traceViews: true` draws Image Trace objects in their View, as the canvas does; 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)
@@ -456,7 +457,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
return err(e);
}
// Rendered with the options of a PNG export on white, so its pixels are the exported file's.
let opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
// `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does.
let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png);
opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true);
let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts);
let png = match img.to_png() {
Ok(png) => png,
+28
View File
@@ -180,6 +180,34 @@ fn control_render_matches_a_png_export_on_white() {
let _ = std::fs::remove_dir_all(base);
}
/// `ui.render {traceViews: true}` draws an Image Trace object in its view, as the canvas does:
/// in Source Image view the image's blue shows beside the traced disc, where `ui.render` without
/// it draws the tracing result on white.
#[test]
fn control_render_draws_the_trace_view_with_trace_views() {
let (mut app, tx, base) = confined("traceviews");
let png = base.join("in/disc.png");
image::RgbImage::from_fn(100, 100, |x, y| {
let (dx, dy) = (f64::from(x) + 0.5 - 50.0, f64::from(y) + 0.5 - 50.0);
if dx * dx + dy * dy <= 900.0 { image::Rgb([0, 0, 0]) } else { image::Rgb([170, 210, 255]) }
})
.save(&png)
.unwrap();
let r = request(&mut app, &tx, "app.open", json!({"path": s(png)}));
assert_eq!(r["ok"], true, "{r}");
app.run("select.all", json!({})).unwrap();
app.run("imageTrace.make", json!({"params": {"ignoreWhite": true}})).unwrap();
app.run("imageTrace.setView", json!({"view": "sourceImage"})).unwrap();
let pixels = |v: &Value| image::load_from_memory(&vectorcraft_format::base64_decode(v.as_str().unwrap()).unwrap()).unwrap().to_rgba8();
let view = pixels(&request(&mut app, &tx, "ui.render", json!({"traceViews": true}))["result"]["pngBase64"]);
let result = pixels(&request(&mut app, &tx, "ui.render", json!({}))["result"]["pngBase64"]);
let (beside, middle) = (view.get_pixel(10, 10).0, view.get_pixel(50, 50).0);
assert!(beside[0] < 200 && beside[2] > 245, "the source image beside the disc: {beside:?}");
assert!(middle[..3].iter().all(|&c| c < 60), "the disc: {middle:?}");
assert_eq!(result.get_pixel(10, 10).0, [255; 4], "the tracing result on white");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn frames_with_injected_input_are_confined() {
let (mut app, tx, base) = confined("synthetic");
+2
View File
@@ -105,6 +105,8 @@ the two can still win that race.
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was |
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
| `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?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): 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 with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `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 |
+2 -2
View File
@@ -358,7 +358,7 @@ The older names below remain listed because existing workflows use them.
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array |
| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) |
| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). Artboards too large to render within the allocation bound return a tool error |
| `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode. `ui_inspect` is the same call as `inspect_ui`, and `ui_screenshot` is `screenshot` with `window: true`. `ui_screenshot` takes an optional `max_side` (1–4096), as `screenshot {window: true}` does: a capture whose longer side is larger is scaled down to `max_side` pixels on that side, and the reply's text gives the returned size as `width`/`height` and the capture's as `capturedWidth`/`capturedHeight`. Without `max_side` the full capture comes back |
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
@@ -420,7 +420,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `type_text` | `{text}` | Remote only. |
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
| `save_file` | `{path?, compress?, version?, preview?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), and `preview: true` embeds a PNG of the first artboard. `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version` or `preview` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version` or `preview`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version` or `preview`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |