From 5c678ed52cbd6ce1920a6fba0d93b86b900a0bbb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 1 Nov 2026 00:14:58 -0700 Subject: [PATCH] Symbols: instances in pattern tiles, graph designs and brushes follow an edit of their dynamic symbol --- crates/engine/src/cmd/swatch.rs | 2 +- crates/engine/src/cmd/symboledit.rs | 49 +++++++++++++++----- crates/engine/src/tests/symboledit.rs | 65 ++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 2de2d0581..80e8e2a12 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -601,7 +601,7 @@ pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize { /// keeps the fields of `raw` that `base` leaves out, and drops the fields that `base` holds and /// `new` leaves out. An array takes the length of `new`, each element merged with the element at /// the same index in `raw`. -fn merge_stored(raw: Value, base: &Value, new: Value) -> Value { +pub(super) fn merge_stored(raw: Value, base: &Value, new: Value) -> Value { match (raw, new) { (Value::Object(mut raw), Value::Object(new)) => { let mut out = serde_json::Map::new(); diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index ead753827..4cb58788b 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -420,7 +420,8 @@ 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 }; @@ -430,17 +431,45 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc], bool), new: 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()); - for sym in d.symbols.iter_mut().filter(|s| s.name != name) { - if !holds_changed_instance(&sym.art, name) { - continue; + let mut follow = |o: &mut Vec| { + follow_paths(o, old, new); + match &art { + Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art), + None => o.clear(), } - each_changed_instance(Arc::make_mut(&mut sym.art), name, &mut |o| { - follow_paths(o, old, new); - match &art { - Some(art) => vectorcraft_doc::symbol_overrides::retain_parts(o, art), - None => o.clear(), + }; + 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 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) { + each_changed_instance(Arc::make_mut(root), name, &mut follow); + } + } + // The default brushes hold no instances, so only a stored library can change. A stored entry + // whose art holds no changed instance, or that does not parse, is stored again as it was read; + // a changed one is merged into its entry, which keeps the fields the brush definition does not read. + let Some(stored) = d.unknown.get(vectorcraft_brush::DOC_KEY).and_then(Value::as_array).cloned() else { return }; + let mut changed = false; + let lib: Vec = stored + .into_iter() + .map(|raw| { + let Ok(mut b) = vectorcraft_brush::Brush::from_json(raw.clone()) else { return raw }; + if !b.art().into_iter().any(|n| holds_changed_instance(n, name)) { + return raw; } - }); + let base = serde_json::to_value(&b).unwrap_or_default(); + for art in b.art_mut() { + if holds_changed_instance(art, name) { + each_changed_instance(art, name, &mut follow); + } + } + changed = true; + super::swatch::merge_stored(raw, &base, serde_json::to_value(&b).unwrap_or_default()) + }) + .collect(); + if changed { + d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib)); } } diff --git a/crates/engine/src/tests/symboledit.rs b/crates/engine/src/tests/symboledit.rs index 0027ce8a1..9a3ce144c 100644 --- a/crates/engine/src/tests/symboledit.rs +++ b/crates/engine/src/tests/symboledit.rs @@ -1,7 +1,7 @@ //! Symbol editing mode (`symbol.edit`, `symbol.exitEditing`). use serde_json::{Value, json}; -use vectorcraft_doc::NodeKind; +use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::Rect; use crate::*; @@ -518,6 +518,69 @@ fn an_instance_inside_another_symbols_art_follows_an_edit_of_its_dynamic_symbol( assert_eq!(nested(&s), vec![(vec![0], "#ff0000".to_string())]); } +/// Instances of the edited dynamic symbol in a pattern tile, a graph design and an art brush keep +/// their appearance changes on the same objects when the edit reorders them, and drop the change +/// to an object the edit deleted. +#[test] +fn instances_in_pattern_tiles_graph_designs_and_brushes_follow_an_edit_of_their_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"})); + run(&mut s, "object.pattern.make", json!({"ids": [inst.0], "name": "Tile", "edit": false})); + run(&mut s, "select.set", json!({"ids": [inst.0]})); + run(&mut s, "graph.design", json!({"new": "Pairs"})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Pair Band", "ids": [inst.0]})); + // A stored entry that does not parse, and a field the brush definition does not read, are kept. + let junk = json!({"name": "Unreadable", "kind": 7}); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") }; + entries.iter_mut().filter(|e| e["name"] == "Pair Band").for_each(|e| e["note"] = json!("kept")); + entries.push(junk.clone()); + // The changes of each instance of "Pair" in `n`, by path and color. + fn changes(n: &Node, out: &mut Vec<(Vec, String)>) { + n.walk(&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()))); + } + }); + } + // The changes in the tile, the graph design and the brush. + let held = |s: &Session| -> [Vec<(Vec, String)>; 3] { + let d = &s.doc().unwrap().doc; + let mut out: [Vec<(Vec, String)>; 3] = Default::default(); + d.patterns.iter().filter(|p| p.name == "Tile").flat_map(|p| &p.art).for_each(|n| changes(n, &mut out[0])); + d.graph_designs.iter().for_each(|g| changes(&g.art, &mut out[1])); + let brush = vectorcraft_brush::find(d, "Pair Band").expect("the brush"); + brush.art().into_iter().for_each(|n| changes(n, &mut out[2])); + out + }; + let start = vec![(vec![0], "#00ff00".to_string()), (vec![1], "#ff0000".to_string())]; + assert_eq!(held(&s), [start.clone(), start.clone(), start]); + 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!({})); + let swapped = vec![(vec![1], "#00ff00".to_string()), (vec![0], "#ff0000".to_string())]; + assert_eq!(held(&s), [swapped.clone(), swapped.clone(), swapped]); + // 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!({})); + let left = vec![(vec![0], "#ff0000".to_string())]; + assert_eq!(held(&s), [left.clone(), left.clone(), left.clone()]); + run(&mut s, "symbol.exitEditing", json!({})); + assert_eq!(held(&s), [left.clone(), left.clone(), left]); + let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY]; + assert!(stored.as_array().is_some_and(|a| a.contains(&junk)), "{stored}"); + assert!(stored.as_array().is_some_and(|a| a.iter().any(|e| e["name"] == "Pair Band" && e["note"] == "kept")), "{stored}"); +} + #[test] fn undo_back_into_an_earlier_edit_leaves_it_to_its_own_isolation() { let (mut s, first, second) = two_instances();