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.
This commit is contained in:
dotCooCoo
2026-11-04 12:44:46 +07:00
committed by bflatastic
parent bf58f605fe
commit 5c224a0d08
4 changed files with 68 additions and 5 deletions
+1 -1
View File
@@ -201,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+13 -3
View File
@@ -341,7 +341,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<Node> {
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<Node> {
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
+53
View File
@@ -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<vectorcraft_doc::OpacityMask> {
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();
+1 -1
View File
@@ -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