diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index e6ed1dcaa..9f827330b 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -39,6 +39,8 @@ mod tests_conventions; #[cfg(test)] mod tests_distortkeys; #[cfg(test)] +mod tests_drawopts; +#[cfg(test)] mod tests_exportas; #[cfg(test)] mod tests_fileio; diff --git a/crates/mcp/src/tests_drawopts.rs b/crates/mcp/src/tests_drawopts.rs new file mode 100644 index 000000000..666ff1632 --- /dev/null +++ b/crates/mcp/src/tests_drawopts.rs @@ -0,0 +1,140 @@ +//! `draw_path` / `draw_shape` style the object they draw in the same call: `name`, `opacity`, +//! `dash`, `cap`, `join` and `effects` (#2112). + +use serde_json::{Value, json}; +use vectorcraft_doc::{LineCap, LineJoin, Node, NodeId}; + +use crate::{Backend, Headless, Server, call_tool}; + +fn headless() -> Headless { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 400, "height": 400}})).unwrap(); + h +} + +fn draw(h: &mut Headless, tool: &str, args: Value) -> (NodeId, Value) { + let r = call_tool(h, tool, &args); + assert!(!r.is_error, "{tool} {args}: {r:?}"); + let v: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap(); + (NodeId(v["id"].as_u64().unwrap()), v) +} + +fn node(h: &Headless, id: NodeId) -> Node { + h.session.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn objects(h: &Headless) -> usize { + h.session.doc().unwrap().doc.node_count() +} + +#[test] +fn draw_path_takes_name_opacity_dash_cap_join_and_effects() { + let mut h = headless(); + let args = json!({ + "d": "M230 30 L230 300", "fill": "none", "stroke": "#c19a4b", "strokeWidth": 1.2, + "dash": [4, 3], "cap": "round", "join": "bevel", + "opacity": 60, "name": "Zip seam", + "effects": [{"effect": "blur.gaussian", "params": {"radius": 6}}], + }); + let (id, reply) = draw(&mut h, "draw_path", args); + let n = node(&h, id); + assert_eq!(n.name.as_deref(), Some("Zip seam")); + assert!((n.opacity - 0.6).abs() < 1e-6, "{}", n.opacity); + let st = n.appearance.stroke().unwrap(); + assert!((st.width - 1.2).abs() < 1e-9); + assert_eq!(st.dash.as_ref().unwrap().pattern, vec![4.0, 3.0]); + assert_eq!(st.cap, LineCap::Round); + assert_eq!(st.join, LineJoin::Bevel); + assert_eq!(n.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), ["blur.gaussian"]); + assert_eq!(n.appearance.effects[0].params["radius"], 6.0); + // The reply says what was applied. + assert_eq!(reply["name"], "Zip seam"); + assert_eq!(reply["opacity"].as_f64(), Some(60.0)); + assert_eq!(reply["paint"]["dash"], json!([4, 3])); + assert_eq!(reply["effects"].as_array().unwrap().len(), 1); +} + +#[test] +fn draw_shape_takes_the_same_options() { + let mut h = headless(); + let args = json!({ + "shape": "ellipse", "x": 10, "y": 10, "width": 80, "height": 40, "fill": "#000000", "stroke": "none", + "opacity": 35, "name": "Shadow", "effects": [{"effect": "blur.gaussian"}], + }); + let (id, _) = draw(&mut h, "draw_shape", args); + let n = node(&h, id); + assert_eq!(n.name.as_deref(), Some("Shadow")); + assert!((n.opacity - 0.35).abs() < 1e-6); + assert_eq!(n.appearance.effects.len(), 1); + // A dash alone gives a dashed stroke at the inherited weight. + let (id, _) = draw(&mut h, "draw_shape", json!({"shape": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0, "dash": [2, 2], "cap": "butt"})); + let n = node(&h, id); + assert_eq!(n.appearance.stroke().unwrap().dash.as_ref().unwrap().pattern, vec![2.0, 2.0]); + assert_eq!(n.appearance.stroke().unwrap().cap, LineCap::Butt); +} + +#[test] +fn without_the_options_nothing_changes() { + let mut h = headless(); + let (id, reply) = draw(&mut h, "draw_path", json!({"d": "M0 0 L100 0 L50 80 Z", "fill": "#550505", "stroke": "none"})); + let n = node(&h, id); + assert_eq!(n.name, None); + assert_eq!(n.opacity, 1.0); + assert!(n.appearance.effects.is_empty()); + for k in ["name", "opacity", "effects"] { + assert!(reply.get(k).is_none(), "{k}: {reply}"); + } +} + +/// A bad option is refused before anything is drawn: no half-styled object is left behind. +#[test] +fn bad_options_draw_nothing() { + let mut h = headless(); + let before = objects(&h); + for bad in [ + json!({"opacity": "half"}), + json!({"opacity": 150}), + json!({"opacity": -1}), + json!({"name": 3}), + json!({"dash": "4 3"}), + json!({"dash": [4, "x"]}), + json!({"cap": "pointy"}), + json!({"join": 1}), + json!({"effects": {"effect": "blur.gaussian"}}), + json!({"effects": [{"params": {}}]}), + json!({"effects": [{"effect": "blur.gaussian", "extra": 1}]}), + json!({"effects": [{"effect": "no.suchEffect"}]}), + json!({"effects": [{"effect": "blur.gaussian", "params": 6}]}), + ] { + for (tool, base) in + [("draw_path", json!({"d": "M0 0 L10 10"})), ("draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10}))] + { + let mut args = base.clone(); + for (k, v) in bad.as_object().unwrap() { + args[k] = v.clone(); + } + let r = call_tool(&mut h, tool, &args); + assert!(r.is_error, "{tool} {args} must fail"); + assert_eq!(objects(&h), before, "{tool} {args} drew something"); + } + } +} + +/// The new arguments are in the tools' schemas, so `tools/call` accepts them (#713 refuses +/// arguments a schema doesn't list). +#[test] +fn schemas_list_the_options() { + let mut s = Server::new(Box::new(Headless::with_document())); + for (tool, args) in [ + ("draw_path", json!({"d": "M0 0 L10 10", "dash": [1, 1], "cap": "round", "join": "round", "opacity": 50, "name": "a", "effects": []})), + ( + "draw_shape", + json!({"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "dash": null, "cap": "square", "join": "miter", "opacity": 50, "name": "b", "effects": []}), + ), + ] { + let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string(); + let v: Value = serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap(); + assert!(v.get("error").is_none(), "{tool}: {v}"); + assert_eq!(v["result"]["isError"], false, "{tool}: {v}"); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 33db9e4a4..5947034c5 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -42,6 +42,26 @@ fn paint_schema(what: &str) -> Value { "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}}, {"type": "object"}] }) } +/// The options `draw_path` and `draw_shape` apply to the object they draw (#2112). +fn with_draw_options(mut props: Value) -> Value { + let more = json!({ + "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`)"}, + "name": string("Name in the Layers panel"), + "effects": { + "type": "array", + "description": "Live effects applied in order, as apply_effect: [{effect: id (effect.list), params?: {…}}]", + "items": obj(json!({"effect": string("Effect id, e.g. \"blur.gaussian\""), "params": {"type": "object"}}), &["effect"]), + }, + }); + if let (Some(p), Value::Object(more)) = (props.as_object_mut(), more) { + p.extend(more); + } + props +} + fn mods_schema() -> Value { json!({ "type": "object", @@ -203,16 +223,16 @@ pub fn tool_definitions() -> Vec { tool( "draw_path", "Draw path", - "Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.", + "Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint, stroke dash/cap/join, opacity, name and live effects. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke). For a filled path without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.", obj( - json!({ + with_draw_options(json!({ "points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}}, "d": string("SVG path data (alternative to points), e.g. \"M0 0 L100 0 L50 80 Z\""), "closed": {"type": "boolean", "description": "Close the path (points only)"}, "fill": paint_schema("Fill"), "stroke": paint_schema("Stroke"), "strokeWidth": num("Stroke weight in pt"), - }), + })), &[], ), false, @@ -220,9 +240,9 @@ pub fn tool_definitions() -> Vec { tool( "draw_shape", "Draw shape", - "Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Returns the new object id.", + "Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Omitted paint fields inherit the current new-art appearance (initially white fill and 1 pt black stroke); lines start with no fill and a visible stroke. For a filled shape without an outline, pass an explicit fill and stroke:\"none\". Stroke dash/cap/join, opacity, name and live effects apply to the new shape as in draw_path. Returns the new object id.", obj( - json!({ + with_draw_options(json!({ "shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]}, "x": num("Left (rectangle/ellipse)"), "y": num("Top (rectangle/ellipse)"), @@ -243,7 +263,7 @@ pub fn tool_definitions() -> Vec { "fill": paint_schema("Fill"), "stroke": paint_schema("Stroke"), "strokeWidth": num("Stroke weight in pt"), - }), + })), &["shape"], ), false, @@ -513,19 +533,109 @@ fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option) -> Result Result<(), String> { + match a.get("dash") { + None | Some(Value::Null) => {} + Some(Value::Array(d)) if d.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)) => {} + Some(_) => return Err("`dash` must be a list of lengths (numbers) or null".into()), + } + let one_of = |k: &str, names: &[&str]| match a.get(k) { + None => Ok(()), + Some(v) if v.as_str().is_some_and(|s| names.contains(&s)) => Ok(()), + Some(_) => Err(format!("`{k}` must be one of {}", names.join(", "))), + }; + one_of("cap", &["butt", "round", "square", "projecting"])?; + one_of("join", &["miter", "round", "bevel"])?; + if let Some(o) = a.get("opacity") + && !o.as_f64().is_some_and(|o| (0.0..=100.0).contains(&o)) + { + return Err("`opacity` must be a number from 0 to 100 (%)".into()); + } + if a.get("name").is_some_and(|n| !n.is_string()) { + return Err("`name` must be a string".into()); + } + let Some(effects) = a.get("effects") else { return Ok(()) }; + let effects = effects.as_array().ok_or("`effects` must be a list of {effect, params?}")?; + if effects.is_empty() { + return Ok(()); + } + let catalog = exec(b, "effect.list", json!({}))?; + let known: Vec<&str> = catalog.get("catalog").and_then(Value::as_array).into_iter().flatten().filter_map(|e| e.get("id")?.as_str()).collect(); + for (i, e) in effects.iter().enumerate() { + let e = e.as_object().ok_or_else(|| format!("effect {i} must be {{effect, params?}}"))?; + if let Some(k) = e.keys().find(|k| *k != "effect" && *k != "params") { + return Err(format!("effect {i}: unknown key `{k}` (expected effect, params)")); + } + let id = e.get("effect").and_then(Value::as_str).ok_or_else(|| format!("effect {i}: missing string `effect`"))?; + if !known.contains(&id) { + return Err(format!("effect {i}: unknown effect `{id}` (see apply_effect without arguments)")); + } + if e.get("params").is_some_and(|p| !p.is_object()) { + return Err(format!("effect {i}: `params` must be an object")); + } + } + Ok(()) +} + +/// Applies the checked options of [`check_draw_options`] to the new object `ids`, adding what was +/// applied to `made`. Paint and stroke options are [`apply_paint`]'s. +fn apply_draw_options(b: &mut dyn Backend, a: &Args, ids: Option, made: &mut Value) -> Result<(), String> { + let mut props = json!({}); + for k in ["name", "opacity"] { + if let Some(v) = a.get(k) { + props[k] = v.clone(); + made[k] = v.clone(); + } + } + if let Some(ids) = &ids { + props["ids"] = ids.clone(); + } + if a.contains_key("name") || a.contains_key("opacity") { + exec(b, "object.setProps", props)?; + } + let effects = a.get("effects").and_then(Value::as_array).filter(|e| !e.is_empty()); + if let Some(effects) = effects { + let mut applied = vec![]; + for e in effects { + // The whole object (item: null), as the Effect menu applies it. + let mut p = json!({"effect": e.get("effect"), "params": e.get("params").cloned().unwrap_or(json!({})), "item": null}); + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + applied.push(exec(b, "effect.apply", p)?); + } + made["effects"] = Value::Array(applied); + } + Ok(()) +} + fn created_id(r: &Value) -> Option { r.get("id").filter(|v| v.is_u64()).map(|id| json!([id])) } fn draw_path(b: &mut dyn Backend, a: &Args) -> Result { + check_draw_options(b, a)?; let mut p = json!({}); if let Some(d) = a.get("d").and_then(Value::as_str) { p["d"] = json!(d); @@ -548,7 +658,9 @@ fn draw_path(b: &mut dyn Backend, a: &Args) -> Result { } let r = exec(b, "path.create", p)?; let paint = apply_paint(b, a, created_id(&r))?; - Ok(json!({"id": r.get("id"), "paint": paint})) + let mut made = json!({"id": r.get("id"), "paint": paint}); + apply_draw_options(b, a, created_id(&r), &mut made)?; + Ok(made) } fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result { @@ -561,13 +673,16 @@ fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result { "line" => "shape.line", other => return Err(format!("unknown shape `{other}` (rectangle, ellipse, polygon, star, line)")), }; + check_draw_options(b, a)?; let mut p = a.clone(); - for k in ["shape", "fill", "stroke", "strokeWidth"] { + for k in ["shape", "fill", "stroke", "strokeWidth", "dash", "cap", "join", "opacity", "name", "effects"] { p.remove(k); } let r = exec(b, cmd, Value::Object(p))?; let paint = apply_paint(b, a, created_id(&r))?; - Ok(json!({"id": r.get("id"), "shape": shape, "paint": paint})) + let mut made = json!({"id": r.get("id"), "shape": shape, "paint": paint}); + apply_draw_options(b, a, created_id(&r), &mut made)?; + Ok(made) } /// Select `ids` when given (tools that act on the selection). diff --git a/docs/mcp.md b/docs/mcp.md index f97517d58..7d24a2497 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -278,6 +278,13 @@ explicit `ids`. Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). +For `draw_shape` and `draw_path`, omitted paint fields inherit the current [new-art appearance](#new-art). Both +also style the new object in the same call: `dash` (lengths, or null for solid), `cap` +(butt|round|square) and `join` (miter|round|bevel) go to `stroke.set` with `strokeWidth`; `opacity` (0–100 %) +and `name` to `object.setProps`; `effects: [{effect, params?}]` are applied in order as `apply_effect` +does, to the whole object. They are checked before anything is drawn (effect ids against `effect.list`), +so a bad one draws nothing. The reply adds `name`, `opacity` and `effects` (each `effect.apply` result) +when given; `paint` lists the stroke options applied. A gradient given to several objects fits each one's own bounds; `paint.setFill` / `paint.setStroke` with `span: "selection"` place a linear or radial gradient once on the union of the targets' bounds instead, so one ramp runs across them. @@ -298,8 +305,8 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | | `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. | -| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. | -| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | +| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. The options are below. | +| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?, dash?, cap?, join?, opacity?, name?, effects?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool). | | `type_text` | `{text}` | Remote only. |