From 294d13d857cc28cd1f55a0a7baf847440bdfcbfe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 00:37:35 -0700 Subject: [PATCH] Symbols: an instance in an opacity mask follows an edit of its dynamic symbol The appearance changes of the instances on the layers now follow the edited objects through the same pass as the instances in other symbols, graph designs, pattern tiles and brushes. That pass also reaches instances used as opacity-mask art, which keep their changes on the same objects when an edit reorders them and drop the change to an object the edit deletes. --- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/symbols.rs | 9 +---- crates/engine/src/cmd/symboledit.rs | 16 +++----- crates/engine/src/tests/symboledit.rs | 45 +++++++++++++++++++++++ 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 7386f7718..6f7661afc 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -35,7 +35,7 @@ use brushes::{ }; use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; use symbols::selected_instances; -pub(crate) use symbols::{fit_instances_of, instance_in_place, instances_of, natural_size, normalize, place_xf, set_natural_size, store_symbol}; +pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol}; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 9bfbb902a..8eb21dd64 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -95,14 +95,7 @@ pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { Ok((art, (w, h), c)) } -/// After the art of symbol `name` changed: each of its instances keeps its appearance changes to -/// the objects the art still has. -pub(crate) fn fit_instances_of(d: &mut Document, name: &str) { - let ids = instances_of(d, Some(name)); - fit_overrides(d, &ids); -} - -pub(crate) fn instances_of(d: &Document, name: Option<&str>) -> Vec { +fn instances_of(d: &Document, name: Option<&str>) -> Vec { let mut v = vec![]; d.walk(|n| { if let NodeKind::SymbolInstance { symbol, .. } = &n.kind diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 4cb58788b..0c365919b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -419,17 +419,12 @@ fn each_changed_instance(n: &mut Node, name: &str, f: &mut dyn FnMut(&mut Vec], bool), new: (&[Arc], bool)) { - for id in super::brushsym::instances_of(d, Some(name)) { - let Some(n) = d.node_mut(id) else { continue }; - if n.extras().symbol_overrides.is_empty() { - continue; - } - follow_paths(&mut n.extras_mut().symbol_overrides, old, new); - } let art = d.symbols.iter().find(|s| s.name == name).filter(|s| s.dynamic).map(|s| s.art.clone()); let mut follow = |o: &mut Vec| { follow_paths(o, old, new); @@ -439,7 +434,7 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc], bool), new: } }; let symbols = d.symbols.iter_mut().filter(|s| s.name != name).map(|s| &mut s.art); - let trees = symbols.chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let trees = d.layers.iter_mut().chain(symbols).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); for root in trees.chain(patterns.flat_map(|p| &mut p.art)) { if holds_changed_instance(root, name) { @@ -522,7 +517,6 @@ pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> { }; d.symbols[index].art = Arc::new(new_art); follow_objects(d, &se.symbol, (&old, old_group), (&art, group)); - super::brushsym::fit_instances_of(d, &se.symbol); if let Some(e) = d.symbol_edit.as_mut() { e.changed = true; e.group = group; diff --git a/crates/engine/src/tests/symboledit.rs b/crates/engine/src/tests/symboledit.rs index 9a3ce144c..8cd11aaa2 100644 --- a/crates/engine/src/tests/symboledit.rs +++ b/crates/engine/src/tests/symboledit.rs @@ -581,6 +581,51 @@ fn instances_in_pattern_tiles_graph_designs_and_brushes_follow_an_edit_of_their_ assert!(stored.as_array().is_some_and(|a| a.iter().any(|e| e["name"] == "Pair Band" && e["note"] == "kept")), "{stored}"); } +/// An instance of a dynamic symbol that is the opacity mask of an object on a layer keeps its +/// appearance changes on the same objects when the edit reorders them, and drops the change to an +/// object the edit deleted. +#[test] +fn an_instance_in_an_opacity_mask_follows_an_edit_of_its_dynamic_symbol() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 300})); + let a = id(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40}))); + let b = id(&run(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40}))); + let inst = id(&run(&mut s, "symbol.new", json!({"name": "Pair", "ids": [a.0, b.0], "type": "dynamic"}))); + run(&mut s, "symbol.instance.setAppearance", json!({"id": inst.0, "path": [0], "fill": "#00ff00"})); + run(&mut s, "symbol.instance.setAppearance", json!({"id": inst.0, "path": [1], "fill": "#ff0000"})); + let masked = id(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 60}))); + run(&mut s, "select.set", json!({"ids": [inst.0]})); + run(&mut s, "object.arrange.bringToFront", json!({})); + run(&mut s, "transparency.makeOpacityMask", json!({"ids": [masked.0, inst.0]})); + // The changes of the instance of "Pair" in the masked object's mask, by path and color. + let held = |s: &Session| -> Vec<(Vec, String)> { + let d = &s.doc().unwrap().doc; + let mask = d.node(masked).and_then(|n| n.extras().mask.clone()).expect("the opacity mask"); + let mut out = vec![]; + vectorcraft_doc::swatches::walk_with_masks(&mask.art, &mut |n| { + if matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Pair") { + out.extend(n.extras().symbol_overrides.iter().map(|o| (o.path.clone(), o.fill.as_ref().and_then(|p| p.color()).unwrap().to_hex()))); + } + }); + out + }; + assert_eq!(held(&s), vec![(vec![0], "#00ff00".to_string()), (vec![1], "#ff0000".to_string())]); + let other = id(&run(&mut s, "symbol.place", json!({"name": "Pair", "x": 200, "y": 200}))); + run(&mut s, "symbol.edit", json!({"id": other.0})); + let art = layer_art(&s); + assert_eq!(art.len(), 2); + // Sending the second object to the back: each change follows its object. + run(&mut s, "select.set", json!({"ids": [art[1].0]})); + run(&mut s, "object.arrange.sendToBack", json!({})); + assert_eq!(held(&s), vec![(vec![1], "#00ff00".to_string()), (vec![0], "#ff0000".to_string())]); + // Deleting the first object: the change to it goes. + run(&mut s, "select.set", json!({"ids": [art[0].0]})); + run(&mut s, "edit.clear", json!({})); + assert_eq!(held(&s), vec![(vec![0], "#ff0000".to_string())]); + run(&mut s, "symbol.exitEditing", json!({})); + assert_eq!(held(&s), vec![(vec![0], "#ff0000".to_string())]); +} + #[test] fn undo_back_into_an_earlier_edit_leaves_it_to_its_own_isolation() { let (mut s, first, second) = two_instances();