mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
MCP: doc_inspect reports opacityPct in the percent scale the drawing tools take
draw_path, draw_shape and object.setProps take opacity in percent (0-100),
but the document.inspect / document.node summary behind doc_inspect,
inspect_document and vectorcraft://object/{id} reported the stored 0-1
fraction under the same name. An agent that read 0.55 and wrote it back
got a nearly invisible object.
The summary now adds opacityPct (0-100, rounded to 1/100 of a percent)
beside the unchanged opacity, and the tool, resource and command
descriptions say which scale each field uses. Round-trip test in the mcp
crate: draw at 55 %, read 55 back from doc_inspect, inspect_document and
the object resource, write it back unchanged.
This commit is contained in:
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
|
||||
@@ -45,6 +45,10 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) ->
|
||||
});
|
||||
if n.opacity < 1.0 {
|
||||
v["opacity"] = json!(n.opacity);
|
||||
// The same value in the percent scale `object.setProps`, `transparency.set` and the MCP
|
||||
// drawing tools take, so a reader can pass it back as it is. Rounded to 1/100 of a
|
||||
// percent so a 55 written as an f32 reads back as 55, not 55.000001.
|
||||
v["opacityPct"] = json!((f64::from(n.opacity) * 10_000.0).round() / 100.0);
|
||||
}
|
||||
if n.blend != BlendMode::Normal {
|
||||
v["blend"] = json!(n.blend.label());
|
||||
|
||||
@@ -47,7 +47,7 @@ pub static TEMPLATES: &[Template] = &[
|
||||
uri: "vectorcraft://object/{id}",
|
||||
name: "object",
|
||||
title: "One layer or object",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true}). Large containers can be sliced with run_command document.node {id, summary, depth, childLimit}: truncated levels report childCount",
|
||||
description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true}; `opacity` is a 0–1 fraction, `opacityPct` the 0–100 % the drawing tools take). Large containers can be sliced with run_command document.node {id, summary, depth, childLimit}: truncated levels report childCount",
|
||||
mime: "application/json",
|
||||
live: Live::Objects,
|
||||
},
|
||||
|
||||
@@ -180,3 +180,37 @@ fn a_bad_paint_draws_nothing() {
|
||||
assert_eq!(history.last().unwrap().label, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// The opacity `draw_path` takes in percent reads back in the same scale: `doc_inspect` (and
|
||||
/// the `vectorcraft://object/{id}` resource, the same summary) report `opacityPct` beside the
|
||||
/// stored 0–1 `opacity`, so an agent that reuses what it read does not turn 55 % into 0.55 %.
|
||||
#[test]
|
||||
fn inspect_reports_opacity_in_the_percent_scale_the_drawing_tools_take() {
|
||||
let mut h = headless();
|
||||
let (id, reply) = draw(&mut h, "draw_path", json!({"d": "M10 10 L90 90", "opacity": 55}));
|
||||
assert_eq!(reply["opacity"], json!(55));
|
||||
let summary = |h: &mut Headless, id: NodeId| -> Value {
|
||||
let r = call_tool(h, "doc_inspect", &json!({"ids": [id.0]}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
let v: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap();
|
||||
v["nodes"].as_array().unwrap().iter().find(|n| n["id"] == id.0).cloned().expect("the node")
|
||||
};
|
||||
let n = summary(&mut h, id);
|
||||
assert_eq!(n["opacityPct"], json!(55.0), "{n}");
|
||||
assert!((n["opacity"].as_f64().unwrap() - 0.55).abs() < 1e-6, "{n}");
|
||||
// The whole tree and the object resource say the same.
|
||||
let r = call_tool(&mut h, "inspect_document", &json!({}));
|
||||
let tree: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap();
|
||||
let in_tree = tree["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id.0).expect("the path in its layer");
|
||||
assert_eq!(in_tree["opacityPct"], json!(55.0), "{in_tree}");
|
||||
let res = crate::resources::read(&mut h, &format!("vectorcraft://object/{}", id.0)).unwrap();
|
||||
assert_eq!((res["opacityPct"].clone(), res["opacity"].as_f64().map(|o| (o * 100.0).round())), (json!(55.0), Some(55.0)), "{res}");
|
||||
// Writing `opacityPct` back, as the descriptions say, keeps the object as it was.
|
||||
let r = call_tool(&mut h, "run_command", &json!({"command": "object.setProps", "params": {"id": id.0, "opacity": n["opacityPct"]}}));
|
||||
assert!(!r.is_error, "{r:?}");
|
||||
assert!((node(&h, id).opacity - 0.55).abs() < 1e-6);
|
||||
// A fully opaque object reports neither field, so the two never disagree.
|
||||
let (full, _) = draw(&mut h, "draw_path", json!({"d": "M0 0 L5 5"}));
|
||||
let n = summary(&mut h, full);
|
||||
assert!(n.get("opacity").is_none() && n.get("opacityPct").is_none(), "{n}");
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ fn with_draw_options(mut props: Value) -> Value {
|
||||
"dash": {"description": "Dash and gap lengths in pt ([d, g, …]), or null for a solid stroke (stroke.set `dash`)", "anyOf": [{"type": "array", "items": {"type": "number"}}, {"type": "null"}]},
|
||||
"cap": {"type": "string", "enum": ["butt", "round", "square", "projecting"], "description": "Stroke cap"},
|
||||
"join": {"type": "string", "enum": ["miter", "round", "bevel"], "description": "Stroke corner join"},
|
||||
"opacity": {"type": "number", "minimum": 0, "maximum": 100, "description": "Opacity in % (object.setProps `opacity`)"},
|
||||
"opacity": {"type": "number", "minimum": 0, "maximum": 100, "description": "Opacity in % (object.setProps `opacity`); doc_inspect reads it back as `opacityPct` (its `opacity` is a 0–1 fraction)"},
|
||||
"name": string("Name in the Layers panel"),
|
||||
"effects": {
|
||||
"type": "array",
|
||||
@@ -145,7 +145,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"doc_inspect",
|
||||
"Inspect document",
|
||||
"Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.",
|
||||
"Summary of the active document, including artboards, layer tree, selection and history. A node below full opacity reports `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools and object.setProps take: pass `opacityPct` back, never `opacity`. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.",
|
||||
obj(inspect_props(), &[]),
|
||||
true,
|
||||
),
|
||||
@@ -196,7 +196,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"inspect_document",
|
||||
"Inspect document",
|
||||
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node. doc_inspect returns the same summary.",
|
||||
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke; `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools take), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node. doc_inspect returns the same summary.",
|
||||
obj(inspect_props(), &[]),
|
||||
true,
|
||||
),
|
||||
|
||||
+4
-2
@@ -233,8 +233,10 @@ nothing truncated the reply is the plain summary. Both must be non-negative inte
|
||||
|
||||
In the summary an object reports its paint as `fill`, `stroke` (labels) and `strokeWidth`. So does a compound path,
|
||||
whose paint is its own: its member paths report `compoundMember: true` instead of a paint they don't have. Every
|
||||
node (layers and groups too) reports `opacity` when it is below 1 and `blend` when it isn't Normal, as the label
|
||||
`object.setProps {blend}` takes (`"Multiply"`, `"Color Burn"`).
|
||||
node (layers and groups too) reports its opacity when it is below 1, in both scales: `opacity` is the 0–1 fraction
|
||||
the document stores and `opacityPct` the 0–100 % that `object.setProps`, `transparency.set`, `draw_path` and
|
||||
`draw_shape` take (so an agent passes `opacityPct` back, never `opacity`); and `blend` when it isn't Normal, as
|
||||
the label `object.setProps {blend}` takes (`"Multiply"`, `"Color Burn"`).
|
||||
|
||||
On a large document, drill instead of dumping: `inspect_document {depth: 0}` (or `run_command document.inspect
|
||||
{depth?, childLimit?}`, same rules) is the skeleton (artboards with top layers and counts), `run_command
|
||||
|
||||
Reference in New Issue
Block a user