From 2faadd28f4b06f4b2a10a92c6fceb3c2a9893fab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 10 Nov 2026 20:09:28 -0800 Subject: [PATCH] Symbols: Break Link keeps a hidden or locked instance's art hidden or locked Break Link to Symbol on a selected group, and Delete Symbol with its instances expanded, turned a hidden or locked instance into visible, unlocked art. The art now keeps the instance's hidden and locked state, as Object > Expand does, and Break Link leaves hidden or locked art out of the selection. --- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/symbols.rs | 13 ++++--- crates/engine/src/tests/brushsym.rs | 47 +++++++++++++++++++++++ docs/mcp.md | 3 +- 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 13b6ab881..85c1412f0 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -208,7 +208,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}", has_selection, symbol_break_link ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 60353921e..ee4487ba7 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -390,9 +390,9 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { most.saturating_add(1).saturating_add(mask) } -/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the -/// art's ids. Fails before any edit when the instances together would make more than -/// [`MAX_EXPAND_NODES`] objects. +/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). A hidden or +/// locked instance's art stays hidden or locked. Returns the art's ids. Fails before any edit when +/// the instances together would make more than [`MAX_EXPAND_NODES`] objects. fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own))); if total > MAX_EXPAND_NODES as u64 { @@ -401,8 +401,10 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result Result { } let out = s.edit("Break Link to Symbol", |d, sel| { let out = break_links(d, &ids, true)?; - sel.set(out.iter().copied()); + // Hidden or locked art is left out of the selection. + sel.set(out.iter().copied().filter(|id| d.node(*id).is_some_and(|n| n.visible && !n.locked))); Ok(out) })?; Ok(json!({ "ids": ids_json(&out) })) diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index 638b7a1f4..34f2cc4b0 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1321,6 +1321,53 @@ fn break_link_counts_the_instances_mask_art() { assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }), "it stays an instance"); } +/// A hidden, a locked and a plain instance of one symbol in a group, the group selected. +fn group_with_hidden_and_locked_instances(s: &mut Session) -> (NodeId, String) { + let (shown, name, _) = make_symbol(s); + let hidden = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); + let locked = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 100})).unwrap()["id"].as_u64().unwrap()); + select(s, &[shown, hidden, locked]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.node_mut(hidden).unwrap().visible = false; + doc.node_mut(locked).unwrap().locked = true; + select(s, &[group]); + (group, name) +} + +/// The visibility and lock state of the children of `group`, in paint order. +fn shown_and_locked(s: &Session, group: NodeId) -> Vec<(bool, bool)> { + let g = node(s, group); + let NodeKind::Group { children, .. } = &g.kind else { panic!("a group: {}", g.kind_label()) }; + assert!(children.iter().all(|c| !matches!(c.kind, NodeKind::SymbolInstance { .. })), "every instance is expanded"); + children.iter().map(|c| (c.visible, c.locked)).collect() +} + +/// Break Link to Symbol on a selected group keeps a hidden instance's art hidden and a locked +/// instance's art locked, and selects only the art that can be selected. +#[test] +fn break_link_keeps_hidden_and_locked_instances_hidden_and_locked() { + let mut s = session(); + let (group, _) = group_with_hidden_and_locked_instances(&mut s); + let r = s.execute("symbol.breakLink", &json!({})).unwrap(); + assert_eq!(r["ids"].as_array().unwrap().len(), 3, "{r}"); + assert_eq!(shown_and_locked(&s, group), vec![(true, false), (false, false), (true, true)]); + let d = &s.doc().unwrap().doc; + let selected: Vec = s.doc().unwrap().selection.objects.to_vec(); + assert_eq!(selected.len(), 1, "{selected:?}"); + assert!(selected.iter().all(|id| d.node(*id).is_some_and(|n| n.visible && !n.locked)), "{selected:?}"); +} + +/// Delete Symbol, expanding the instances, keeps a hidden instance's art hidden and a locked +/// instance's art locked. +#[test] +fn delete_symbol_keeps_hidden_and_locked_instances_hidden_and_locked() { + let mut s = session(); + let (group, name) = group_with_hidden_and_locked_instances(&mut s); + s.execute("symbol.delete", &json!({"name": name})).unwrap(); + assert_eq!(shown_and_locked(&s, group), vec![(true, false), (false, false), (true, true)]); +} + #[test] fn delete_duplicate_and_replace_symbols() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 14a5bfb6c..749ddb266 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2014,7 +2014,8 @@ already walks Image Trace. Short agent entry points: a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog), - `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask), `symbol.replace`, + `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask; a hidden or locked instance's + art stays hidden or locked and is not selected), `symbol.replace`, `symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard `artboard`, the active one the app passes, or in a document without artboards on `viewCenter`, the view's center the