From 5c224a0d0810c4786638d09da4901bfb708c4380 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 21:01:27 -0800 Subject: [PATCH] Symbols: Break Link keeps the instance's opacity mask Break Link to Symbol dropped the instance's opacity mask. The art now keeps it, or a group around the art holds it when the symbol's art has a mask of its own. The objects in the instance's mask art count against Break Link's limit of 1,000,000 objects. --- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/symbols.rs | 16 +++++-- crates/engine/src/tests/brushsym.rs | 53 +++++++++++++++++++++++ docs/mcp.md | 2 +- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index adfb221ef..f34ae619d 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -201,7 +201,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 → {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 → {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 9a3606311..534b51506 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -341,7 +341,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { /// The plain art of instance `inst` in document space (fresh ids), with the instance's own /// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice -/// guides when it has them. +/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it +/// when the art has a mask of its own. fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; @@ -361,13 +362,21 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { art.opacity *= inst.opacity; art.blend = inst.blend; } + if let Some(mask) = &inst.extras().mask { + if art.extras().mask.is_some() { + let id = d.alloc_id(); + art = Node::group(id, vec![Arc::new(art)]); + } + art.extras_mut().mask = Some(mask.clone()); + } Some(art) } /// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its /// symbol's object count with the art of its opacity masks, or /// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus -/// the group that opacity or a blending mode adds. 0 for any other object. +/// the group that opacity, a blending mode or the instance's opacity mask adds, plus the art of +/// the instance's opacity mask. 0 for any other object. fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 }; let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 }; @@ -378,7 +387,8 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 { None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64, None => sym.art.count_with_masks() as u64, }; - most.saturating_add(1) + let mask = inst.extras().mask.as_ref().map_or(0, |m| m.art.count_with_masks() as u64); + most.saturating_add(1).saturating_add(mask) } /// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index f14ba2b6a..c1f33ac97 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1242,6 +1242,59 @@ fn break_link_then_update_redefines_all_instances() { assert!((bounds(&s, other).center().x - 300.0).abs() < 1e-6); } +/// A linked opacity mask of a 10 × 10 square at (100, 50). +fn square_mask(s: &mut Session) -> Box { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let art = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 110.0, 60.0)), Default::default()); + Box::new(vectorcraft_doc::OpacityMask::new(art, true)) +} + +/// Break Link to Symbol keeps the instance's opacity mask on the art. +#[test] +fn break_link_keeps_the_instances_opacity_mask() { + let mut s = session(); + let (inst, _, _) = make_symbol(&mut s); + let m = square_mask(&mut s); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(inst).unwrap().extras_mut().mask = Some(m.clone()); + let r = s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap(); + let art = node(&s, NodeId(r["ids"][0].as_u64().unwrap())); + assert!(matches!(art.kind, NodeKind::Path { .. }), "{}", art.kind_label()); + assert_eq!(art.extras().mask, Some(m), "the art keeps the mask"); +} + +/// When the symbol's art has an opacity mask of its own, Break Link puts the instance's mask on a +/// group around the art, and the art keeps its own. +#[test] +fn break_link_puts_the_instances_mask_on_a_group_around_masked_art() { + let mut s = session(); + let (inst, name, _) = make_symbol(&mut s); + let (own, m) = (square_mask(&mut s), square_mask(&mut s)); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let sym = doc.symbols.iter_mut().find(|s| s.name == name).unwrap(); + std::sync::Arc::make_mut(&mut sym.art).extras_mut().mask = Some(own); + doc.node_mut(inst).unwrap().extras_mut().mask = Some(m.clone()); + let r = s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap(); + let art = node(&s, NodeId(r["ids"][0].as_u64().unwrap())); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group around the art: {}", art.kind_label()) }; + assert_eq!(art.extras().mask, Some(m), "the group holds the instance's mask"); + assert!(children.len() == 1 && children[0].extras().mask.is_some(), "the art keeps its own mask"); +} + +/// The art of the instance's opacity mask counts against Break Link's object limit: an instance +/// whose mask holds more than a million objects stays an instance. +#[test] +fn break_link_counts_the_instances_mask_art() { + let mut s = session(); + let (inst, _, _) = make_symbol(&mut s); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let square = + std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 110.0, 60.0)), Default::default())); + let art = Node::group(doc.alloc_id(), vec![square; 1_000_000]); + doc.node_mut(inst).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true))); + assert!(s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).is_err(), "too many objects"); + assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }), "it stays an instance"); +} + #[test] fn delete_duplicate_and_replace_symbols() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 4036c1c1b..131acd9be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1603,7 +1603,7 @@ 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?}`, `symbol.replace`, + `symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask), `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