diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 49c7892a1..9caf4551a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -59,7 +59,7 @@ pub fn specs() -> Vec { spec!( "node.create", "Create layer", - "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}", + "{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]; x/y move their own origin, so the layer's left/top is x/y plus their box's unscaled offset), closed? (vector), props? {node.set keys}, select? (default true)}", true, create ), @@ -106,7 +106,7 @@ pub fn specs() -> Vec { spec!( "vector.create", "Vector", - "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y? (move the path's own origin: the layer's left/top is x/y plus the box's unscaled offset), width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(s, p, "vector") ), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 2cf139915..1cb141b58 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -187,15 +187,15 @@ fn tools() -> Vec { obj( json!({ "type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"}, - "x": num("Left, relative to the parent frame (default 0)"), - "y": num("Top, relative to the parent frame (default 0)"), + "x": num("Left, relative to the parent frame (default 0). For a vector with `path` or `points`, x/y move their own origin instead: data whose box starts at (30, 0) makes a layer whose left is x + 30. Start the path at 0,0 to place its left edge at x, or set x afterwards with set_properties (which moves the left edge)"), + "y": num("Top, relative to the parent frame (default 0). For a vector with `path` or `points`, their own top is added, as for x"), "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"), "height": num("Height (default 100; ignored for lines/arrows and auto-height text)"), "parent": id_schema("Parent frame/group/component id (default: the current page)"), "index": int("Z-index among the parent's children (default: on top)"), "name": string("Layer name"), "text": string("Characters, for type text"), - "path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\")"), + "path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\"); its coordinates are kept relative to x/y, so a path that doesn't start at 0,0 is offset by its own box"), "points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "For type vector, instead of path: the points [[x, y], ...]"}, "closed": boolean("For type vector: close the path"), "select": boolean("Select the new layer (default true)"), diff --git a/crates/mcp/tests/it/protocol.rs b/crates/mcp/tests/it/protocol.rs index 2794cc0e2..cadadbad0 100644 --- a/crates/mcp/tests/it/protocol.rs +++ b/crates/mcp/tests/it/protocol.rs @@ -325,3 +325,28 @@ fn set_properties_passes_on_what_it_skipped() { assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none()); assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout")); } + +#[test] +fn create_node_x_y_offset_a_vector_path_by_its_own_box_as_documented() { + // An agent placed `M95 0 L225 0 ...` (box starting at x 30) with x 830 expecting its left at + // 830: x/y move the path's own origin, so the layer lands at 860. The schema has to say so. + let mut c = Client::new(); + let lid = "M95 0 L225 0 L290 130 L232 470 L88 470 L30 130 Z"; + let offset = c.create(json!({"type": "vector", "path": lid, "x": 830, "y": 445, "width": 260, "height": 470})); + let n = c.node(&offset); + assert_eq!((n["x"].as_f64(), n["y"].as_f64(), n["width"].as_f64()), (Some(860.0), Some(445.0), Some(260.0)), "{n}"); + // The same shape drawn from 0,0 lands at x/y. + let at_origin = c.create(json!({"type": "vector", "path": "M65 0 L195 0 L260 130 L202 470 L58 470 L0 130 Z", "x": 830, "y": 445})); + assert_eq!(c.node(&at_origin)["x"].as_f64(), Some(830.0)); + // Points are placed the same way. + let points = c.create(json!({"type": "vector", "points": [[30, 0], [290, 130], [88, 470]], "closed": true, "x": 830, "y": 445})); + assert_eq!(c.node(&points)["x"].as_f64(), Some(860.0)); + // set_properties x moves the left edge. + c.ok("set_properties", json!({"ids": [offset], "props": {"x": 830}})); + assert_eq!(c.node(&offset)["x"].as_f64(), Some(830.0)); + let r = c.rpc("tools/list", json!({})); + let tool = r["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "create_node").unwrap().clone(); + let x_doc = tool["inputSchema"]["properties"]["x"]["description"].as_str().unwrap(); + assert!(x_doc.contains("path") && x_doc.contains("x + 30"), "{x_doc}"); + assert!(tool["inputSchema"]["properties"]["path"]["description"].as_str().unwrap().contains("offset by its own box")); +}