diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4f362fd6..1f403a76 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { ), 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?} → layer tree, artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → 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)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.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, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", 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))), @@ -151,20 +151,22 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result { fn doc_node(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; - let opts = slice_opts(p, "document.node")?; + let mut opts = slice_opts(p, "document.node")?; let summary = bool_or(p, "summary", false); if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { // The full object JSON is never truncated: a slice without `summary` would // otherwise silently return the whole subtree. return Err(bad("document.node", "`depth` and `childLimit` slice the summary: pass `summary: true`")); } - let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let doc = &s.doc()?.doc; + let n = doc.node(id).ok_or(EngineError::NoNode(id))?; + opts.compound_member = doc.parent_of(id).and_then(|p| doc.node(p)).is_some_and(|p| matches!(p.kind, vectorcraft_doc::NodeKind::Compound { .. })); if summary { Ok(inspect::node_summary_opts(n, opts)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } } /// `depth` / `childLimit` for a summary read: absent (or null) means no limit. fn slice_opts(p: &Value, cmd: &str) -> Result { - Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")? }) + Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")?, compound_member: false }) } /// An optional count: absent (or null) is `None`; a present value that is not a diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 11fca08a..c9c3164f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -21,6 +21,9 @@ pub struct SummaryOpts { /// Children shown per node (`None`: all, top of the stack first). A level that shows /// fewer children than it has reports `childCount`, so truncation is never silent. pub child_limit: Option, + /// The node is a member of a compound path, which paints it: it has no paint of its own + /// to report. + pub compound_member: bool, } /// Compact tree summary of a node (for `document.inspect`): the whole subtree. @@ -62,13 +65,15 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value { v["objectStroke"] = json!(n.appearance.stroke_paint().label()); v["objectStrokeWidth"] = json!(n.appearance.stroke_width()); } + } else if opts.compound_member { + v["compoundMember"] = json!(true); } else if !n.is_container() || matches!(n.kind, NodeKind::Compound { .. }) { // A compound path is a container, but its fill and stroke live on it, not on its members. v["fill"] = json!(n.appearance.fill_paint().label()); v["stroke"] = json!(n.appearance.stroke_paint().label()); v["strokeWidth"] = json!(n.appearance.stroke_width()); } - if let Some(st) = node_stroke(n) { + if let Some(st) = node_stroke(n).filter(|_| !opts.compound_member) { v["strokeOptions"] = stroke_options(&st); } match &n.kind { @@ -104,7 +109,11 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value { let total = ch.len(); let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize); if opts.depth.is_none_or(|d| d > 0) { - let next = SummaryOpts { depth: opts.depth.map(|d| d.saturating_sub(1)), child_limit: opts.child_limit }; + let next = SummaryOpts { + depth: opts.depth.map(|d| d.saturating_sub(1)), + child_limit: opts.child_limit, + compound_member: matches!(n.kind, NodeKind::Compound { .. }), + }; v["children"] = Value::Array(ch.iter().rev().take(take_n).map(|c| node_summary_opts(c, next)).collect()); } if take_n < total || v.get("children").is_none() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ef397c57..56a0a009 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1517,9 +1517,6 @@ fn inspect_lists_everything() { assert!(s.commands().len() > 100); } -/// `document.node {summary: true}` answers the `document.inspect` shape for one node: -/// the same fields the full-tree summary carries, without serializing the whole tree. -#[test] /// A compound path reports the paint that lives on it, and any node reports a blend other than Normal. #[test] fn inspect_reports_compound_paint_and_blend() { @@ -1542,6 +1539,9 @@ fn inspect_reports_compound_paint_and_blend() { assert!(plain.get("blend").is_none(), "Normal is omitted: {plain}"); } +/// `document.node {summary: true}` answers the `document.inspect` shape for one node: +/// the same fields the full-tree summary carries, without serializing the whole tree. +#[test] fn document_node_summary_matches_inspect() { let mut s = session(); let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0; @@ -1554,6 +1554,47 @@ fn document_node_summary_matches_inspect() { assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err()); } +/// #1389: the paint of a compound path is on the compound, which reports it; its members report +/// that they are members instead of a paint they don't have. Every node reports a blend mode +/// that isn't Normal, as it does an opacity below 100%. +#[test] +fn inspect_reports_a_compound_paths_paint_and_blend_modes() { + let mut s = session(); + let plain = rect(&mut s, 300.0, 0.0, 50.0, 50.0).0; + let (a, b) = (rect(&mut s, 0.0, 0.0, 200.0, 200.0), rect(&mut s, 50.0, 50.0, 100.0, 100.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.compoundPath.make", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + s.execute("object.setProps", &json!({"opacity": 80, "blend": "Color Burn"})).unwrap(); + let v = s.execute("document.inspect", &json!({})).unwrap(); + let nodes = v["layers"][0]["children"].as_array().unwrap(); + let compound = nodes.iter().find(|n| n["kind"] == "Compound Path").unwrap(); + assert_eq!((&compound["fill"], &compound["stroke"], &compound["strokeWidth"]), (&json!("#ff0000"), &json!("#0000ff"), &json!(3.0)), "{compound}"); + assert_eq!(compound["blend"], "Color Burn", "{compound}"); + // The label is what `object.setProps` takes. + s.execute("object.setProps", &json!({"blend": compound["blend"]})).unwrap(); + let members = compound["children"].as_array().unwrap(); + assert_eq!(members.len(), 2); + for m in members { + assert_eq!(m["compoundMember"], true, "{m}"); + assert!(["fill", "stroke", "strokeWidth", "strokeOptions", "blend"].iter().all(|k| m.get(k).is_none()), "{m}"); + // One member read on its own says the same. + assert_eq!(&s.execute("document.node", &json!({"id": m["id"], "summary": true})).unwrap(), m); + } + assert_eq!(s.execute("document.node", &json!({"id": compound["id"], "summary": true})).unwrap(), *compound); + // A plain object: no blend while it is Normal, nothing about compound paths. + let plain = nodes.iter().find(|n| n["id"] == plain).unwrap(); + assert!(plain.get("blend").is_none() && plain.get("compoundMember").is_none() && plain.get("fill").is_some(), "{plain}"); + // A group has a blend mode too, and still no paint. + s.execute("select.set", &json!({"ids": [plain["id"]]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.setProps", &json!({"ids": [g], "blend": "Multiply"})).unwrap(); + let group = s.execute("document.node", &json!({"id": g, "summary": true})).unwrap(); + assert!(group["blend"] == "Multiply" && group.get("fill").is_none(), "{group}"); +} + /// `depth` and `childLimit` slice a summary read; a level that shows fewer children /// than it has reports `childCount`, and invalid values are rejected, not reinterpreted. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 77c429ee..078dd52d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -57,7 +57,7 @@ the two can still win that race. |---|---|---| | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | -| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does | +| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does. Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead | | `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected | diff --git a/docs/mcp.md b/docs/mcp.md index e39c31f8..c6aec821 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -162,6 +162,11 @@ that shows fewer children than it has reports their total as `childCount`, so tr nothing truncated the reply is the plain summary. Both must be non-negative integers and need `summary: true` (the full object JSON is never truncated); anything else is an error. +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"`). + 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 document.find {name?, kind?, text?, limit?}` locates nodes across the whole tree, and `document.node` reads the