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
+3 -3
View File
@@ -485,7 +485,7 @@ mod tests {
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
@@ -504,7 +504,7 @@ mod tests {
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
d.symbols.push(crate::Symbol { dynamic: false, name: format!("s{i}"), art });
}
d
}
@@ -573,7 +573,7 @@ mod tests {
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
+1 -1
View File
@@ -841,7 +841,7 @@ fn objects_at_matches_repeated_hits_on_random_documents() {
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
+2
View File
@@ -24,11 +24,13 @@ pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_nine_slice;
pub mod symbol_overrides;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
+1
View File
@@ -256,6 +256,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
if e.intertwine_tiers.capacity() > 0 {
b += ALLOC + e.intertwine_tiers.capacity() * size_of::<PathData>() + e.intertwine_tiers.iter().map(path_bytes).sum::<usize>();
}
b += e.symbol_overrides.capacity() * size_of::<crate::SymbolOverride>();
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
+4
View File
@@ -63,6 +63,9 @@ pub struct NodeExtras {
pub compound_shape: bool,
/// As a member of a compound shape, how this object combines with the members below it.
pub shape_mode: crate::ShapeMode,
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
/// art (see [`crate::symbol_overrides`]); empty for none.
pub symbol_overrides: Vec<crate::SymbolOverride>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -87,6 +90,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
colorize: None,
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+18 -13
View File
@@ -52,6 +52,7 @@ impl Serialize for Node {
e.colorize.is_none(),
is_default(&e.compound_shape),
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -92,6 +93,7 @@ impl Serialize for Node {
field!("colorize", &e.colorize);
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
st.end()
}
}
@@ -138,26 +140,26 @@ fn count_one<E: serde::de::Error>() -> Result<(), E> {
Ok(())
}
/// A member's Intertwine areas of tier 2 and up, each counting against [`with_node_budget`] as
/// an object does: a compressed file of repeated empty areas stays small while they take memory
/// once read.
fn counted_tiers<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<PathData>, D::Error> {
struct Tiers;
impl<'de> serde::de::Visitor<'de> for Tiers {
type Value = Vec<PathData>;
/// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol
/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed
/// file of repeated empty items stays small while they take memory once read.
fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Vec<T>, D::Error> {
struct Items<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items<T> {
type Value = Vec<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of areas")
f.write_str("a list")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<PathData>, A::Error> {
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, A::Error> {
let mut out = Vec::new();
while let Some(area) = seq.next_element::<PathData>()? {
while let Some(item) = seq.next_element::<T>()? {
count_one()?;
out.push(area);
out.push(item);
}
Ok(out)
}
}
d.deserialize_seq(Tiers)
d.deserialize_seq(Items(std::marker::PhantomData))
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
@@ -216,7 +218,7 @@ pub(super) struct NodeWire {
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default, deserialize_with = "counted_tiers")]
#[serde(default, deserialize_with = "counted_list")]
intertwine_tiers: Vec<PathData>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
@@ -228,6 +230,8 @@ pub(super) struct NodeWire {
compound_shape: bool,
#[serde(default)]
shape_mode: crate::ShapeMode,
#[serde(default, deserialize_with = "counted_list")]
symbol_overrides: Vec<crate::SymbolOverride>,
}
impl From<NodeWire> for Node {
@@ -253,6 +257,7 @@ impl From<NodeWire> for Node {
colorize: w.colorize,
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
};
Node {
id: w.id,
+1 -1
View File
@@ -337,7 +337,7 @@ mod tests {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
+1 -1
View File
@@ -200,7 +200,7 @@ mod tests {
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
+6 -2
View File
@@ -667,7 +667,7 @@ mod tests {
pixels: None,
})),
);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Star".into(), art: Arc::new(art) });
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))];
if bytes.starts_with(b"nest") {
@@ -702,7 +702,11 @@ mod tests {
fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() {
set_loader(stub_loader);
let mut d = Document::new(200.0, 200.0);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) });
d.symbols.push(Symbol {
dynamic: false,
name: "Star".into(),
art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })),
});
d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec()));
let p = placed("doc1-1");
let layer = d.layers[0].id;
+4
View File
@@ -108,6 +108,10 @@ impl SavedSelection {
pub struct Symbol {
pub name: String,
pub art: Arc<Node>,
/// Symbol Options › Symbol Type › Dynamic Symbol: each instance may change the appearance of
/// objects in the art and stay linked (see [`crate::symbol_overrides`]). Static by default.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub dynamic: bool,
}
/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects.
+190
View File
@@ -0,0 +1,190 @@
//! Dynamic symbols (Symbol Options › Symbol Type › Dynamic Symbol): an instance of a dynamic
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Paint};
use crate::{Node, NodeKind};
/// Deepest object an override may name: groups nested this deep in the symbol's art.
pub const MAX_PATH_DEPTH: usize = 64;
/// One instance's appearance changes to one object of its dynamic symbol's art.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SymbolOverride {
/// The object: child indices (paint order) from the art's root down through its groups; empty
/// for the root itself.
#[serde(deserialize_with = "short_path")]
pub path: Vec<u32>,
/// The object's (top) fill paint; type takes it as its characters' fill.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<Paint>,
/// The object's (top) stroke paint; type takes it as its characters' stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke: Option<Paint>,
/// The object's opacity, 0..1.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opacity: Option<f32>,
/// The object's blending mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blend: Option<BlendMode>,
}
impl SymbolOverride {
/// Does this override change nothing?
pub fn is_empty(&self) -> bool {
self.fill.is_none() && self.stroke.is_none() && self.opacity.is_none() && self.blend.is_none()
}
/// Put this override's changes on object `n`.
fn apply(&self, n: &mut Node) {
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if let Some(p) = &self.fill {
r.style.fill = p.clone();
}
if let Some(p) = &self.stroke {
r.style.stroke = p.clone();
}
}
} else {
if let Some(p) = &self.fill {
n.appearance.set_fill(p.clone());
}
if let Some(p) = &self.stroke {
n.appearance.set_stroke(p.clone());
}
}
if let Some(o) = self.opacity.filter(|o| o.is_finite()) {
n.opacity = o.clamp(0.0, 1.0);
}
if let Some(b) = self.blend {
n.blend = b;
}
}
}
/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file
/// of one endless path stays small once read.
fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
struct Path;
impl<'de> serde::de::Visitor<'de> for Path {
type Value = Vec<u32>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of child indices")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
let mut out = Vec::new();
while let Some(i) = seq.next_element::<u32>()? {
if out.len() == MAX_PATH_DEPTH {
return Err(serde::de::Error::custom(format!("a symbol override path is deeper than {MAX_PATH_DEPTH}")));
}
out.push(i);
}
Ok(out)
}
}
d.deserialize_seq(Path)
}
/// The children a path steps through: a group's (clip groups included) or a layer's.
fn groups_children(n: &Node) -> Option<&Vec<Arc<Node>>> {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }).then(|| n.children()).flatten()
}
/// The object `path` names in symbol art `art`, if there is one.
pub fn part<'a>(art: &'a Node, path: &[u32]) -> Option<&'a Node> {
if path.len() > MAX_PATH_DEPTH {
return None;
}
path.iter().try_fold(art, |n, &i| groups_children(n)?.get(i as usize).map(|c| &**c))
}
/// [`part`], to change (the nodes on the way are copied when shared).
fn part_mut<'a>(art: &'a mut Node, path: &[u32]) -> Option<&'a mut Node> {
let Some((&i, rest)) = path.split_first() else { return Some(art) };
if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
return None;
}
let c = art.children_mut()?.get_mut(i as usize)?;
part_mut(Arc::make_mut(c), rest)
}
/// Put `overrides` on symbol art `art`; an override naming no object of it is left out.
pub fn apply(art: &mut Node, overrides: &[SymbolOverride]) {
for o in overrides.iter().filter(|o| o.path.len() <= MAX_PATH_DEPTH) {
if let Some(n) = part_mut(art, &o.path) {
o.apply(n);
}
}
}
/// Symbol art `art` as instance `inst` shows it: a copy with the instance's overrides applied.
pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
let mut a = art.clone();
apply(&mut a, &inst.extras().symbol_overrides);
a
}
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
overrides.retain(|o| part(art, &o.path).is_some());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, NodeId};
use vectorcraft_color::Color;
use vectorcraft_geom::{Rect, shapes};
fn square(id: u64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))
}
fn art() -> Node {
let inner = Node::group(NodeId(3), vec![square(4), square(5)]);
Node::group(NodeId(1), vec![square(2), Arc::new(inner)])
}
#[test]
fn paths_name_objects_through_groups_only() {
let a = art();
assert_eq!(part(&a, &[]).map(|n| n.id), Some(NodeId(1)));
assert_eq!(part(&a, &[1, 0]).map(|n| n.id), Some(NodeId(4)));
assert!(part(&a, &[2]).is_none());
assert!(part(&a, &[0, 0]).is_none(), "a path has no children");
assert!(part(&a, &[0; MAX_PATH_DEPTH + 1]).is_none());
}
#[test]
fn overrides_paint_their_object_and_skip_missing_ones() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let o = vec![
SymbolOverride { path: vec![1, 1], fill: Some(red.clone()), opacity: Some(0.5), ..Default::default() },
SymbolOverride { path: vec![9], fill: Some(red.clone()), ..Default::default() },
];
let mut a = art();
apply(&mut a, &o);
let n = part(&a, &[1, 1]).unwrap();
assert_eq!(n.appearance.fill_paint(), red);
assert_eq!(n.opacity, 0.5);
assert_eq!(part(&a, &[1, 0]).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE), "its sibling keeps the symbol's look");
let mut kept = o;
retain_parts(&mut kept, &a);
assert_eq!(kept.len(), 1);
}
#[test]
fn a_path_deeper_than_the_cap_does_not_read() {
let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]);
assert!(serde_json::from_str::<SymbolOverride>(&deep).is_err());
let ok: SymbolOverride = serde_json::from_str("{\"path\":[1,0],\"opacity\":0.25}").unwrap();
assert_eq!(ok, SymbolOverride { path: vec![1, 0], opacity: Some(0.25), ..Default::default() });
}
}
+1 -1
View File
@@ -410,7 +410,7 @@ mod tests {
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) });
}
#[test]
+1 -1
View File
@@ -225,7 +225,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
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 (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.