Fix #4938: Dynamic symbols keep per-instance appearance linked to the symbol

This commit is contained in:
bflatastic
2026-11-01 04:53:38 +07:00
parent 3f1928efc8
commit f22caa6108
105 changed files with 1218 additions and 212 deletions
+1 -1
View File
@@ -256,7 +256,7 @@ fn a_file_with_script_proportions_out_of_range_opens_with_them_in_range() {
}
let mut art = d.node(id).unwrap().clone();
art.id = NodeId(9000);
d.symbols.push(vectorcraft_doc::Symbol { name: "Formula".into(), art: Arc::new(art) });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Formula".into(), art: Arc::new(art) });
if let Some(g) = d.node_mut(graph).and_then(|n| n.extras_if_any()).and_then(|e| e.graph.as_deref_mut()) {
let position = CharPosition::Subscript(ScriptMetrics { size: 0.0, position: 1000.0 });
g.label_styles.legend = Some(vectorcraft_doc::CharStyle { position, small_caps: Some(0.0), ..vectorcraft_doc::CharStyle::default() });
+165
View File
@@ -0,0 +1,165 @@
//! Dynamic symbols (#4938): an instance changes the appearance of objects in its symbol's art and
//! stays linked; editing the symbol keeps those changes, Reset and Break Link handle them.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Rect;
use crate::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A blue square at `x` (40 pt, no stroke).
fn square(s: &mut Session, x: f64) -> NodeId {
let r = id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 40, "height": 40})).unwrap());
s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0000ff"})).unwrap();
s.execute("paint.setStroke", &json!({"ids": [r.0], "none": true})).unwrap();
r
}
/// Symbol "Pair" of two blue squares (x 10..50 and 60..100) of `kind`, made in place: its instance.
fn pair(s: &mut Session, kind: &str) -> NodeId {
let (a, b) = (square(s, 10.0), square(s, 60.0));
id(&s.execute("symbol.new", &json!({"name": "Pair", "ids": [a.0, b.0], "type": kind})).unwrap())
}
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().doc.node(id).unwrap().clone()
}
fn overrides(s: &Session, id: NodeId) -> usize {
node(s, id).extras().symbol_overrides.len()
}
fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] {
vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, true).pixel(x, y)
}
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
#[test]
fn a_dynamic_instance_paints_one_object_its_own_way_and_stays_linked() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
assert_eq!(s.execute("symbol.list", &json!({})).unwrap()["symbols"][0]["type"], "dynamic");
let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap());
let r = s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap();
assert_eq!(r["overrides"], 1);
assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }), "still an instance");
assert_eq!(pixel(&s, 80, 30), RED, "the changed object");
assert_eq!(pixel(&s, 30, 30), BLUE, "its sibling keeps the symbol's look");
assert_eq!(overrides(&s, other), 0, "other instances keep the symbol's look");
// A later change to the same object merges with the first; null takes one back.
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": 50})).unwrap();
assert_eq!(node(&s, inst).extras().symbol_overrides[0].opacity, Some(0.5));
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "opacity": null})).unwrap();
let o = node(&s, inst).extras().symbol_overrides[0].clone();
assert!(o.opacity.is_none() && o.fill.is_some());
// SVG export draws it too.
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
assert!(svg.to_ascii_lowercase().contains("#ff0000") || svg.contains("rgb(255,0,0)"), "{svg}");
}
#[test]
fn static_symbols_and_bad_targets_are_refused() {
let mut s = session();
let inst = pair(&mut s, "static");
let set = |s: &mut Session, p: Value| s.execute("symbol.instance.setAppearance", &p);
assert!(set(&mut s, json!({"id": inst.0, "path": [0], "fill": "#ff0000"})).is_err(), "a static symbol");
s.execute("symbol.options", &json!({"name": "Pair", "type": "dynamic"})).unwrap();
for bad in [
json!({"id": inst.0, "path": [2], "fill": "#ff0000"}),
json!({"id": inst.0, "path": [0, 0], "fill": "#ff0000"}),
json!({"id": inst.0, "path": [-1], "fill": "#ff0000"}),
json!({"id": inst.0, "path": "0", "fill": "#ff0000"}),
json!({"id": inst.0, "fill": "#ff0000"}),
json!({"id": inst.0, "path": [0]}),
json!({"id": inst.0, "path": [0], "blend": "sparkle"}),
json!({"id": inst.0, "path": [0], "opacity": "half"}),
json!({"id": inst.0, "path": [0], "fill": {}}),
] {
assert!(set(&mut s, bad.clone()).is_err(), "{bad}");
}
let plain = square(&mut s, 120.0);
assert!(set(&mut s, json!({"id": plain.0, "path": [], "fill": "#ff0000"})).is_err(), "not an instance");
assert!(s.execute("symbol.options", &json!({"name": "Pair", "type": "sometimes"})).is_err());
assert_eq!(overrides(&s, inst), 0);
}
#[test]
fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap();
// New geometry with the same structure: two squares, now 10..40 and 50..80 tall.
let (a, b) = (square(&mut s, 10.0), square(&mut s, 120.0));
s.execute("symbol.update", &json!({"name": "Pair", "ids": [a.0, b.0]})).unwrap();
assert_eq!(overrides(&s, inst), 1, "the change follows the edited symbol");
// Art of one object (no children) leaves path [1] behind.
let c = square(&mut s, 10.0);
s.execute("symbol.update", &json!({"name": "Pair", "ids": [c.0]})).unwrap();
assert_eq!(overrides(&s, inst), 0);
}
#[test]
fn reset_and_static_drop_the_changes_and_undo_brings_them_back() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [0], "fill": "#ff0000", "blend": "multiply"})).unwrap();
let r = s.execute("symbol.instance.reset", &json!({"ids": [inst.0]})).unwrap();
assert_eq!(r["ids"], json!([inst.0]));
assert_eq!(overrides(&s, inst), 0);
assert_eq!(pixel(&s, 30, 30), BLUE);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(overrides(&s, inst), 1);
// Nothing to reset: no undo step.
let other = id(&s.execute("symbol.place", &json!({"name": "Pair"})).unwrap());
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("symbol.instance.reset", &json!({"ids": [other.0]})).unwrap()["ids"], json!([]));
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
// Static again: the instances show the symbol's appearance.
s.execute("symbol.options", &json!({"name": "Pair", "type": "static"})).unwrap();
assert_eq!(overrides(&s, inst), 0);
}
#[test]
fn break_link_bakes_the_changes_and_edit_symbol_shows_the_symbols_own_art() {
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap();
let other = id(&s.execute("symbol.place", &json!({"name": "Pair", "x": 55, "y": 80})).unwrap());
s.execute("symbol.instance.setAppearance", &json!({"id": other.0, "path": [1], "fill": "#ff0000"})).unwrap();
let fills = |s: &Session, art: NodeId| -> Vec<String> {
node(s, art).children().unwrap().iter().map(|c| c.appearance.fill_paint().color().unwrap().to_hex()).collect()
};
let art = NodeId(s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap()["ids"][0].as_u64().unwrap());
assert_eq!(fills(&s, art), ["#0000ff", "#ff0000"]);
let art = NodeId(s.execute("symbol.edit", &json!({"ids": [other.0]})).unwrap()["ids"][0].as_u64().unwrap());
assert_eq!(fills(&s, art), ["#0000ff", "#0000ff"], "the symbol's art, without this instance's changes");
}
#[test]
fn dynamic_symbols_and_their_changes_round_trip_and_old_documents_save_as_before() {
let mut s = session();
pair(&mut s, "static");
let json = serde_json::to_string(&s.doc().unwrap().doc).unwrap();
assert!(!json.contains("\"dynamic\"") && !json.contains("symbol_overrides"), "nothing new is written for static symbols");
let mut s = session();
let inst = pair(&mut s, "dynamic");
s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000", "opacity": 25})).unwrap();
let doc = &s.doc().unwrap().doc;
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
assert!(back.symbols[0].dynamic);
assert_eq!(back.node(inst).unwrap().extras().symbol_overrides, doc.node(inst).unwrap().extras().symbol_overrides);
assert_eq!(back.symbols, doc.symbols);
}
+1 -1
View File
@@ -274,7 +274,7 @@ fn symbols_bend_with_the_envelope() {
shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)),
vectorcraft_doc::Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
);
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) });
let id = d.alloc_id();
let xf = Affine::translate((200.0, 150.0)) * Affine::scale_non_uniform(10.0, 5.0);
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf }))?;
+2 -2
View File
@@ -119,9 +119,9 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() {
// A symbol instance with a shadow, and a symbol whose art blurs.
let mut d = Document::new(200.0, 200.0);
let art = rect(&mut d, Rect::new(0.0, 0.0, 50.0, 50.0), Color::rgb(0.9, 0.6, 0.0));
d.symbols.push(Symbol { name: "Tile".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Tile".into(), art: Arc::new(art) });
let blurred = with(rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), Color::rgb(0.2, 0.2, 0.8)), blur());
d.symbols.push(Symbol { name: "Soft".into(), art: Arc::new(blurred) });
d.symbols.push(Symbol { dynamic: false, name: "Soft".into(), art: Arc::new(blurred) });
let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Tile".into(), xf: Affine::translate((30.0, 30.0)) });
add(&mut d, with(inst, shadow()));
let soft = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Soft".into(), xf: Affine::translate((120.0, 120.0)) });
+6 -4
View File
@@ -32,7 +32,7 @@ fn star_source(path: &str, title: &str) {
d.title = title.into();
let layer = d.layers[0].id;
let art = Node::path(vectorcraft_doc::NodeId(900), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), look(BLUE));
d.symbols.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(art) });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Star".into(), art: std::sync::Arc::new(art) });
let id = d.alloc_id();
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Star".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
write(path, &vectorcraft_format::save_file(&d));
@@ -45,9 +45,11 @@ fn look(c: [u8; 3]) -> Appearance {
/// The active document gets a red symbol named `Star` of its own.
fn parent_star(s: &mut Session) {
let star = Node::path(vectorcraft_doc::NodeId(901), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look(RED));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc)
.symbols
.push(vectorcraft_doc::Symbol { name: "Star".into(), art: std::sync::Arc::new(star) });
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols.push(vectorcraft_doc::Symbol {
dynamic: false,
name: "Star".into(),
art: std::sync::Arc::new(star),
});
}
/// Place `path` (linked: the default), centred on the artboard → its id.
+2 -2
View File
@@ -380,13 +380,13 @@ fn an_object_has_the_art_of_at_most_4096_symbol_instances_drawn() {
let mut filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
filled.effects.push(Effect { id: "path.offsetPath".into(), params: json!({"offset": 1}), visible: true });
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), filled);
d.symbols.push(Symbol { name: "S0".into(), art: Arc::new(square) });
d.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(square) });
for k in 1..=12 {
let mut instance =
|dx: f64| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf: Affine::translate((dx, 0.0)) }));
let pair = vec![instance(0.0), instance(20.0 * f64::from(1 << (k - 1)))];
let art = Node::group(d.alloc_id(), pair);
d.symbols.push(Symbol { name: format!("S{k}"), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: format!("S{k}"), art: Arc::new(art) });
}
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S12".into(), xf: Affine::IDENTITY });
let (layer, id) = (d.layers[0].id, instance.id);
+9 -9
View File
@@ -115,7 +115,7 @@ fn set_bounds_with_use_preview_bounds_keeps_the_strokes_in_the_symbol_at_their_w
let mut d = Document::new(200.0, 120.0);
let stroked = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 2.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)), stroked);
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) });
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((50.0, 50.0)) });
*instance.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
let (layer, id) = (d.layers[0].id, instance.id);
@@ -136,7 +136,7 @@ fn set_bounds_with_use_preview_bounds_keeps_the_strokes_in_the_symbol_at_their_w
let mut round = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
round.stroke_mut().unwrap().join = vectorcraft_doc::LineJoin::Round;
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)), round);
d.symbols.push(Symbol { name: "Ring".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Ring".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Ring".into(), xf: Affine::translate((50.0, 50.0)) });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
@@ -164,7 +164,7 @@ fn set_bounds_sizes_an_instance_and_a_path_whose_strokes_differ() {
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(at, at, at + 10.0, at + 10.0)), stroked)
};
let art = square(&mut d, 0.0, 10.0);
d.symbols.push(Symbol { name: "Thick".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Thick".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Thick".into(), xf: Affine::translate((50.0, 50.0)) });
let path = square(&mut d, 50.0, 2.0);
let (layer, ids) = (d.layers[0].id, [instance.id, path.id]);
@@ -202,7 +202,7 @@ fn set_bounds_measures_the_objects_in_an_instances_art_one_by_one() {
*art.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
*instance.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
}
d.symbols.push(Symbol { name: "Mixed".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Mixed".into(), art: Arc::new(art) });
let mut roots = vec![if grouped { Node::group(d.alloc_id(), vec![Arc::new(instance)]) } else { instance }];
if plain {
let filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
@@ -271,7 +271,7 @@ fn set_bounds_measures_an_instance_as_one_object_past_4096_objects_in_its_art()
art.push(Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)), square)));
}
let art = Node::group(d.alloc_id(), art);
d.symbols.push(Symbol { name: "Many".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Many".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Many".into(), xf: Affine::translate((0.0, 100.0)) });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
@@ -293,7 +293,7 @@ fn set_bounds_measures_a_turned_instance_by_the_box_around_its_turned_art_box()
let mut d = Document::new(400.0, 400.0);
let filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let circle = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(-10.0, -10.0, 10.0, 10.0)), filled);
d.symbols.push(Symbol { name: "Disc".into(), art: Arc::new(circle) });
d.symbols.push(Symbol { dynamic: false, name: "Disc".into(), art: Arc::new(circle) });
let xf = Affine::translate((100.0, 100.0)) * Affine::rotate(30f64.to_radians());
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Disc".into(), xf });
let (layer, id) = (d.layers[0].id, instance.id);
@@ -327,7 +327,7 @@ fn far_on_artboard_2() -> (Session, NodeId) {
let mut d = Document::new(800.0, 600.0);
let rect = shapes::rectangle(Rect::new(1100.0, 50.0, 1140.0, 70.0));
let art = Node::path(d.alloc_id(), rect, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
d.symbols.push(Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let mut s = Session::new();
@@ -437,7 +437,7 @@ fn an_artboards_svg_keeps_an_instance_whose_brush_art_is_on_it() {
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
d.symbols.push(Symbol { name: "Nib".into(), art: Arc::new(line) });
d.symbols.push(Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(line) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-5.0, 0.0)) })).unwrap();
let mut s = Session::new();
@@ -462,7 +462,7 @@ fn an_artboards_svg_keeps_an_instance_whose_shadow_is_on_it() {
} else {
instance.appearance.effects.push(shadow);
}
d.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
+1 -1
View File
@@ -247,7 +247,7 @@ fn a_file_with_type_sizes_out_of_range_opens_with_them_in_range() {
(h.size, h.h_scale, h.v_scale, h.baseline_shift, h.leading) = (f64::MAX, f64::MAX, f64::MAX, f64::MAX, Some(f64::MAX));
(k.size, k.h_scale, k.v_scale, k.baseline_shift, k.leading) = (0.001, 0.5, 0.5, 5000.0, Some(0.0));
let art = d.node(id).unwrap().clone();
d.symbols.push(vectorcraft_doc::Symbol { name: "Lines".into(), art: Arc::new(art) });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Lines".into(), art: Arc::new(art) });
let attrs = json!({"size": 0, "h_scale": 1e9, "v_scale": -1, "baseline_shift": -1e9, "leading": -1});
d.char_styles.push(vectorcraft_doc::TextStyleDef { name: "Broken".into(), attrs: attrs.as_object().unwrap().clone() });
let attrs = json!({"size": 40, "leading": null});