mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #1397 from vbasky/fix/1389-inspect-paint
Report compound-path paint and a non-normal blend in document summaries.
This commit is contained in:
5 files changed
+92
-8
No files matched your search
@@ -24,7 +24,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?} → 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<Value> {
|
||||
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<inspect::SummaryOpts> {
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Agent-friendly document summaries and view-models for panels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use crate::Session;
|
||||
@@ -20,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<u64>,
|
||||
/// 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.
|
||||
@@ -40,6 +44,9 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value {
|
||||
if n.opacity < 1.0 {
|
||||
v["opacity"] = json!(n.opacity);
|
||||
}
|
||||
if n.blend != BlendMode::Normal {
|
||||
v["blend"] = json!(n.blend.label());
|
||||
}
|
||||
// The angle of its rotated bounding box (counter-clockwise degrees).
|
||||
if n.bbox_angle != 0.0 {
|
||||
v["rotation"] = json!(n.bbox_angle);
|
||||
@@ -58,12 +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 !n.is_container() {
|
||||
} 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 {
|
||||
@@ -99,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() {
|
||||
|
||||
@@ -1517,6 +1517,28 @@ fn inspect_lists_everything() {
|
||||
assert!(s.commands().len() > 100);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
let b = rect(&mut s, 10.0, 10.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.setProps", &json!({"id": c, "blend": "Multiply"})).unwrap();
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let node = &v["layers"][0]["children"][0];
|
||||
assert_eq!(node["id"], c);
|
||||
assert_eq!(node["kind"], "Compound Path");
|
||||
assert!(node["fill"].as_str().is_some_and(|f| f != "None"), "compound paint: {node}");
|
||||
assert!(node.get("stroke").is_some() && node.get("strokeWidth").is_some(), "{node}");
|
||||
assert_eq!(node["blend"], "Multiply");
|
||||
let plain = rect(&mut s, 80.0, 0.0, 10.0, 10.0);
|
||||
let v = s.execute("document.inspect", &json!({})).unwrap();
|
||||
let plain = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == plain.0).unwrap();
|
||||
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]
|
||||
@@ -1532,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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user