From f22caa6108a8895265f58b2fc334c968e42dcbbd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 1 Nov 2026 04:39:50 +0700 Subject: [PATCH] Fix #4938: Dynamic symbols keep per-instance appearance linked to the symbol --- crates/brush/src/colorize.rs | 5 +- crates/brush/src/tests.rs | 2 +- crates/cad/src/import/build.rs | 2 +- crates/cad/src/scene.rs | 2 +- crates/cad/src/tests.rs | 2 +- crates/doc/src/depth.rs | 6 +- crates/doc/src/hit/tests.rs | 2 +- crates/doc/src/lib.rs | 2 + crates/doc/src/mem.rs | 1 + crates/doc/src/node/extras.rs | 4 + crates/doc/src/node/wire.rs | 31 +-- crates/doc/src/objects_on_path.rs | 2 +- crates/doc/src/orient.rs | 2 +- crates/doc/src/placed_document.rs | 8 +- crates/doc/src/records.rs | 4 + crates/doc/src/symbol_overrides.rs | 190 ++++++++++++++++++ crates/doc/src/symbols.rs | 2 +- crates/doc/src/tests.rs | 2 +- crates/effects/src/adjust.rs | 4 +- crates/effects/src/bake.rs | 6 +- crates/effects/src/live.rs | 2 +- crates/effects/src/tests.rs | 8 +- crates/engine/src/cmd/brushlib.rs | 2 +- crates/engine/src/cmd/brushsym.rs | 43 +++- crates/engine/src/cmd/brushsym/dynamic.rs | 151 ++++++++++++++ crates/engine/src/cmd/brushsym/symbols.rs | 64 ++++-- crates/engine/src/cmd/effectcmd.rs | 6 +- crates/engine/src/cmd/symbollib.rs | 2 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/docsetup.rs | 2 +- crates/engine/src/tests/dynamic_symbols.rs | 165 +++++++++++++++ crates/engine/src/tests/envelope_distort.rs | 2 +- crates/engine/src/tests/pdfraster.rs | 4 +- crates/engine/src/tests/placed_document.rs | 10 +- crates/engine/src/tests/previewbounds.rs | 4 +- crates/engine/src/tests/symbolbounds.rs | 18 +- crates/engine/src/tests/typescale.rs | 2 +- crates/engine/tests/import_fuzz.rs | 4 +- crates/eps/src/legacy_ai/writer.rs | 2 +- crates/eps/src/scene.rs | 2 +- crates/eps/src/tests.rs | 4 +- crates/format/tests/it/native_v3.rs | 2 +- crates/format/tests/it/save_options.rs | 2 +- crates/metafile/src/scene.rs | 2 +- crates/metafile/src/tests.rs | 2 +- crates/pdf/src/export/nodes.rs | 2 +- crates/pdf/src/tests.rs | 4 +- crates/pdf/src/tests/print.rs | 4 +- crates/render/src/brush_fx.rs | 6 +- crates/render/src/encode/tests.rs | 2 +- crates/render/src/fx/tests.rs | 4 +- crates/render/src/live.rs | 4 +- crates/render/src/renderer/text.rs | 4 +- crates/render/src/tests.rs | 20 +- crates/render/src/tests/adjust.rs | 2 +- crates/render/src/tests/changed.rs | 2 +- crates/render/src/tests/cmykblend.rs | 2 +- crates/render/src/tests/fontchange.rs | 6 +- crates/render/src/tests/memory.rs | 66 +++--- crates/render/src/tests/objectfx.rs | 4 +- crates/render/src/tests/placed_document.rs | 6 +- crates/render/src/tests/rasterize.rs | 2 +- crates/svg/src/export/nodes.rs | 2 +- crates/svg/src/import/importer.rs | 2 +- crates/svg/tests/it/data_attributes.rs | 2 +- crates/svg/tests/it/encoding_profile_fonts.rs | 2 +- crates/svg/tests/it/import_live.rs | 2 +- crates/svg/tests/it/symbols_layers.rs | 8 +- crates/tools/src/xform/eyedropper.rs | 20 +- crates/ui-egui/src/dialogs/symbol_options.rs | 28 ++- crates/ui-egui/src/i18n/bg.tsv | 12 ++ crates/ui-egui/src/i18n/bs.tsv | 12 ++ crates/ui-egui/src/i18n/ca.tsv | 12 ++ crates/ui-egui/src/i18n/cs.tsv | 12 ++ crates/ui-egui/src/i18n/da.tsv | 12 ++ crates/ui-egui/src/i18n/de.tsv | 12 ++ crates/ui-egui/src/i18n/el.tsv | 12 ++ crates/ui-egui/src/i18n/es.tsv | 12 ++ crates/ui-egui/src/i18n/fi.tsv | 12 ++ crates/ui-egui/src/i18n/fr.tsv | 12 ++ crates/ui-egui/src/i18n/hu.tsv | 12 ++ crates/ui-egui/src/i18n/id.tsv | 12 ++ crates/ui-egui/src/i18n/it.tsv | 12 ++ crates/ui-egui/src/i18n/ja.tsv | 12 ++ crates/ui-egui/src/i18n/ka.tsv | 12 ++ crates/ui-egui/src/i18n/ko.tsv | 12 ++ crates/ui-egui/src/i18n/nl.tsv | 12 ++ crates/ui-egui/src/i18n/pl.tsv | 12 ++ crates/ui-egui/src/i18n/pt-br.tsv | 12 ++ crates/ui-egui/src/i18n/ru.tsv | 12 ++ crates/ui-egui/src/i18n/sv.tsv | 12 ++ crates/ui-egui/src/i18n/tr.tsv | 12 ++ crates/ui-egui/src/i18n/uk.tsv | 12 ++ crates/ui-egui/src/i18n/vi.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 12 ++ crates/ui-egui/src/panels/brushes/tests.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 4 + crates/ui-egui/src/panels/properties/tests.rs | 18 ++ crates/ui-egui/src/panels/symbols.rs | 36 +++- docs/file-format-parity.md | 5 +- docs/gaps.md | 19 +- docs/mcp.md | 24 ++- docs/target-app-parity.md | 13 +- docs/ui-parity.md | 6 +- 105 files changed, 1218 insertions(+), 212 deletions(-) create mode 100644 crates/doc/src/symbol_overrides.rs create mode 100644 crates/engine/src/cmd/brushsym/dynamic.rs create mode 100644 crates/engine/src/tests/dynamic_symbols.rs diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index 9ef049f86..179c0de51 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -138,9 +138,10 @@ pub fn stain(inst: &Node) -> Option<(Color, f32)> { Some((f.paint.color()?, f.opacity)) } -/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]). +/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic +/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]). pub fn instance_art(art: &Node, inst: &Node) -> Node { - let mut a = art.clone(); + let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst); if let Some((c, amount)) = stain(inst) { tint_node(&mut a, &c, amount); } diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 6362c8da8..a7320895c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1134,7 +1134,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { let mut brushed = rect_art(10.0, 4.0); brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] { - doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) }); + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art.clone()) }); doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)])); } let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index 1e43c45dc..6857c2a17 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -622,7 +622,7 @@ impl<'d, 'a> Build<'d, 'a> { let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str()); let symbol = unique(label, &mut self.next_suffix, &self.symbol_names); self.symbol_names.insert(symbol.clone()); - self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) }); + self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) }); symbol }); self.symbols.insert(key, made.clone()); diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 34e69d6ed..6ae29487a 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -545,7 +545,7 @@ impl<'a> Scene<'a> { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); self.nest += 1; self.node(&art, true); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index c08417b8d..ba0a13b76 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -986,7 +986,7 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() { // while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it. let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]); let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let o = DxfOptions { crop: true, ..opts(&d) }; assert!(!kinds(&export(&d, &o).unwrap()).is_empty()); } diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 8e4f80728..6f10da9b7 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -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")); diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 08503e8a7..e5ee6c4c8 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -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(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b6a9a60ef..333890c53 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index bf9259943..a1268505f 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -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::() + e.intertwine_tiers.iter().map(path_bytes).sum::(); } + b += e.symbol_overrides.capacity() * size_of::(); b += e.objects_on_path.as_deref().map_or(0, |o| { ALLOC + size_of::() + o.members.capacity() * size_of::() }); diff --git a/crates/doc/src/node/extras.rs b/crates/doc/src/node/extras.rs index 93145985b..239dce93c 100644 --- a/crates/doc/src/node/extras.rs +++ b/crates/doc/src/node/extras.rs @@ -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, } /// 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 diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index 3456b359e..ed81b1391 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -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() -> 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, D::Error> { - struct Tiers; - impl<'de> serde::de::Visitor<'de> for Tiers { - type Value = Vec; +/// 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, D::Error> { + struct Items(std::marker::PhantomData); + impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { + type Value = Vec; 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>(self, mut seq: A) -> Result, A::Error> { + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { let mut out = Vec::new(); - while let Some(area) = seq.next_element::()? { + while let Some(item) = seq.next_element::()? { 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>, #[serde(default)] intertwine: Option>, - #[serde(default, deserialize_with = "counted_tiers")] + #[serde(default, deserialize_with = "counted_list")] intertwine_tiers: Vec, #[serde(default)] live_paint_gaps: Option>, @@ -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, } impl From for Node { @@ -253,6 +257,7 @@ impl From for Node { colorize: w.colorize, compound_shape: w.compound_shape, shape_mode: w.shape_mode, + symbol_overrides: w.symbol_overrides, }; Node { id: w.id, diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index ca2ec7dd9..779e2eced 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -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)); diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index 43c67d86f..114a15290 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -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); diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 9b00da57e..310d7b784 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -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; diff --git a/crates/doc/src/records.rs b/crates/doc/src/records.rs index b13a1e955..434a85a8f 100644 --- a/crates/doc/src/records.rs +++ b/crates/doc/src/records.rs @@ -108,6 +108,10 @@ impl SavedSelection { pub struct Symbol { pub name: String, pub art: Arc, + /// 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. diff --git a/crates/doc/src/symbol_overrides.rs b/crates/doc/src/symbol_overrides.rs new file mode 100644 index 000000000..b4e3a726f --- /dev/null +++ b/crates/doc/src/symbol_overrides.rs @@ -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, + /// The object's (top) fill paint; type takes it as its characters' fill. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill: Option, + /// The object's (top) stroke paint; type takes it as its characters' stroke. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke: Option, + /// The object's opacity, 0..1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// The object's blending mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blend: Option, +} + +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, D::Error> { + struct Path; + impl<'de> serde::de::Visitor<'de> for Path { + type Value = Vec; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a list of child indices") + } + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(i) = seq.next_element::()? { + 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>> { + 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, 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 { + 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::(&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() }); + } +} diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 8f095566b..bb3cbf11f 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -410,7 +410,7 @@ mod tests { /// Symbol `name` holding `art` in a group. fn symbol(d: &mut Document, name: &str, art: Vec) { 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] diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index df94ba2c9..37e2d39bc 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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. diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index 93601680f..a656f11e8 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -473,8 +473,8 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { let art; let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { - let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()); - art = crate::reshape::as_art(Some(d), n, symbol.as_deref())?; + let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)); + art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?; &art } _ => n, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index e713e0ffa..ee8aba6c2 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -122,10 +122,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(bake_pieces(d, m)); } let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)) + } _ => None, }; - let m = reshape(Some(d), n, symbol.as_deref())?; + let m = reshape(Some(d), n, symbol.as_ref())?; return Some(bake_node(d, &m).unwrap_or(m)); } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index aad8d8edd..769eae6b5 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -24,7 +24,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option { match &n.kind { NodeKind::SymbolInstance { symbol, xf } => { let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?; - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); Some(Node::group(n.id, vec![Arc::new(art)])) } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 198da329e..cff94b0f4 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1191,10 +1191,10 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { let instance = |name: &str| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })); let mut d = Document::new(100.0, 100.0); // A 500 × 500 grid as a symbol's art, directly and inside a group; a symbol holding itself. - d.symbols.push(Symbol { name: "Grid".into(), art: repeat(grid(unit(), 500)) }); - d.symbols.push(Symbol { name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); - d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); - d.symbols.push(Symbol { name: "Small".into(), art: repeat(grid(unit(), 3)) }); + d.symbols.push(Symbol { dynamic: false, name: "Grid".into(), art: repeat(grid(unit(), 500)) }); + d.symbols.push(Symbol { dynamic: false, name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); + d.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); + d.symbols.push(Symbol { dynamic: false, name: "Small".into(), art: repeat(grid(unit(), 3)) }); // A 3 × 3 grid of an instance of each: each copy draws 250,000 instances, so one copy fits. for name in ["Grid", "Grouped"] { let outer = Node::new(NodeId(0), NodeKind::Repeat(grid(instance(name), 3))); diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 9bb04dac9..04498111c 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -335,7 +335,7 @@ fn scatter_shapes() -> BrushLibrary { a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold)))); a }); - doc.symbols.push(Symbol { name: "Sparkle".into(), art: Arc::new(sparkle) }); + doc.symbols.push(Symbol { dynamic: false, name: "Sparkle".into(), art: Arc::new(sparkle) }); let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Sparkle".into(), xf: Affine::scale(0.3) }); let random = |a: f64, b: f64| (a, b); let base = Scatter { colorization: Colorization::Tints, ..Default::default() }; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 2124667ce..4f335dc0c 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -25,6 +25,7 @@ use super::{ use crate::EngineError; mod brushes; +mod dynamic; mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; @@ -32,6 +33,8 @@ use brushes::{ brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; +use dynamic::{symbol_instance_reset, symbol_instance_set_appearance}; +use symbols::selected_instances; 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_edit, symbol_list, symbol_new, symbol_options, symbol_place, @@ -157,13 +160,21 @@ pub fn specs() -> Vec { brush_freehand ), // ----- symbols ----- - cmd!(query "symbol.list", "Symbols", [], None, "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic}], current}", has_doc, symbol_list), + cmd!( + query "symbol.list", + "Symbols", + [], + None, + "{} → {symbols: [{name, instances, size: [w,h], registration: 0..8, exportType: movieClip|graphic, nineSlice, type: static|dynamic}], current}", + has_doc, + symbol_list + ), cmd!( "symbol.new", "New Symbol…", [], None, - "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice}", + "{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice, type}", has_selection, symbol_new ), @@ -172,7 +183,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array} rename a symbol and/or set Symbol Options › Registration and Export Type and 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1; default name: current) → {name, registration, exportType, nineSlice}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), @@ -190,7 +201,7 @@ pub fn specs() -> Vec { "Break Link to Symbol", [], None, - "{ids?|id?} expand selected instances into plain art → {ids}", + "{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol) → {ids}", has_selection, symbol_break_link ), @@ -199,7 +210,7 @@ pub fn specs() -> Vec { "Edit Symbol", [], None, - "{id?, ids?: [id…] (instead of id)} break the link of the selected instance so its art can be edited; finish with symbol.update → {name, ids}", + "{id?, ids?: [id…] (instead of id)} break the link of the selected instance so the symbol's art can be edited (without the instance's own appearance changes to a dynamic symbol); finish with symbol.update → {name, ids}", has_selection, symbol_edit ), @@ -208,7 +219,7 @@ pub fn specs() -> Vec { "Redefine Symbol", [], None, - "{name, ids?|id?} replace the symbol's art with the selection (instances update; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}", + "{name, ids?|id?} replace the symbol's art with the selection (instances update, keeping their appearance changes to the objects at paths the new art still has; the selection becomes an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds) → {name, id}", has_selection, symbol_update ), @@ -227,10 +238,28 @@ pub fn specs() -> Vec { "Replace Symbol", [], None, - "{name, ids?|id?} swap the symbol of the selected instances → {ids}", + "{name, ids?|id?} swap the symbol of the selected instances (they keep their appearance changes to the objects at paths a dynamic new symbol's art has) → {ids}", has_selection, symbol_replace ), + cmd!( + "symbol.instance.setAppearance", + "Symbol Instance Appearance", + [], + None, + "{id?|ids?: [one id] (default: the one selected object), path: [i…] (the object in the symbol's art: child indices in paint order from the art's root down through its groups; [] the root), fill?: \"#rrggbb\"|{…paint params of paint.setFill: color, none, swatch, tint, gradient, keepModel}|null (null: the symbol's own again), stroke?: as fill, opacity?: 0..100|null, blend?: name|null} change the appearance of one object of a dynamic symbol instance's art, for that instance only: it stays linked, so editing the symbol reshapes it and keeps the change; refused for a static symbol or a path naming no object → {id, overrides: how many objects the instance changes}", + has_selection, + symbol_instance_set_appearance + ), + cmd!( + "symbol.instance.reset", + "Reset Symbol Instance", + [], + None, + "{ids?|id?} drop the selected dynamic symbol instances' own appearance changes, so they show the symbol's appearance again → {ids: the instances that had changes}", + has_selection, + symbol_instance_reset + ), cmd!( "symbol.setCurrent", "Current Symbol", diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs new file mode 100644 index 000000000..baa721c45 --- /dev/null +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -0,0 +1,151 @@ +//! Dynamic symbols (Symbol Options › Symbol Type): an instance of a dynamic symbol changes the +//! appearance of objects in the symbol's art and stays linked to the symbol +//! (`symbol.instance.setAppearance`, `symbol.instance.reset`). The changes live on the instance +//! ([`vectorcraft_doc::symbol_overrides`]); editing the symbol keeps those whose object is still +//! there ([`fit_overrides`]). + +use std::sync::Arc; + +use vectorcraft_color::BlendMode; +use vectorcraft_doc::symbol_overrides::{self, SymbolOverride}; + +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, ids_json, json, roots, selected_instances}; + +/// The `type` param, Symbol Options › Symbol Type: `Some(true)` for dynamic. +pub(super) fn type_param(p: &Value, cmd: &str) -> Result> { + let Some(v) = p.get("type").filter(|v| !v.is_null()) else { return Ok(None) }; + match v.as_str() { + Some("static") => Ok(Some(false)), + Some("dynamic") => Ok(Some(true)), + _ => Err(bad(cmd, format!("type must be static or dynamic, not {v}"))), + } +} + +/// The `type` a symbol reports. +pub(super) fn type_id(dynamic: bool) -> &'static str { + if dynamic { "dynamic" } else { "static" } +} + +/// Fit the appearance changes of instances `ids` to their symbols: an instance of a static (or +/// missing) symbol keeps none, an instance of a dynamic one those naming an object of its art. +pub(super) fn fit_overrides(d: &mut Document, ids: &[NodeId]) { + for id in ids { + let art = match d.node(*id) { + Some(n) if !n.extras().symbol_overrides.is_empty() => match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).filter(|s| s.dynamic).map(|s| s.art.clone()), + _ => continue, + }, + _ => continue, + }; + let Some(n) = d.node_mut(*id) else { continue }; + let mut e = n.extras_mut(); + match art { + Some(art) => symbol_overrides::retain_parts(&mut e.symbol_overrides, &art), + None => e.symbol_overrides.clear(), + } + } +} + +/// The instance `id` names (else the one selected object) and its symbol's art; the symbol must +/// be dynamic. +fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc)> { + let d = &s.doc()?.doc; + let not_one = || EngineError::Other("select a symbol instance".into()); + let [id] = roots(s, p)?[..] else { return Err(not_one()) }; + let Some(NodeKind::SymbolInstance { symbol, .. }) = d.node(id).map(|n| &n.kind) else { return Err(not_one()) }; + let sym = d.symbols.iter().find(|x| x.name == *symbol).ok_or_else(|| bad(cmd, format!("no symbol named `{n}`", n = symbol)))?; + if !sym.dynamic { + let name = &sym.name; + return Err(bad(cmd, format!("`{name}` is a static symbol: set its Symbol Type to Dynamic first"))); + } + Ok((id, sym.art.clone())) +} + +/// The `path` param: child indices from the art's root, naming an object of `art`. +fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { + let path: Option> = + p.get("path").and_then(Value::as_array).and_then(|a| a.iter().map(|i| i.as_u64().and_then(|i| u32::try_from(i).ok())).collect()); + let path = path.ok_or_else(|| bad(cmd, "path must be a list of child indices"))?; + if symbol_overrides::part(art, &path).is_none() { + let shown = json!(path); + return Err(bad(cmd, format!("no object at path {shown} in the symbol's art"))); + } + Ok(path) +} + +/// `fill` or `stroke` of `p` for override `o`: absent leaves it, null clears it, a colour string or +/// an object of `paint.setFill`'s paint params sets it. +fn paint_change(s: &Session, p: &Value, key: &str, cmd: &str) -> Result>> { + let Some(v) = p.get(key) else { return Ok(None) }; + if v.is_null() { + return Ok(Some(None)); + } + let v = if v.is_string() { json!({ "color": v }) } else { v.clone() }; + let paint = crate::cmd::paint::paint_from(s, &v)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; + Ok(Some(Some(paint))) +} + +/// The change `p` asks of the override `old` (for the object at `path`). +fn changed(s: &Session, p: &Value, old: SymbolOverride, cmd: &str) -> Result { + let mut o = old; + if let Some(fill) = paint_change(s, p, "fill", cmd)? { + o.fill = fill; + } + if let Some(stroke) = paint_change(s, p, "stroke", cmd)? { + o.stroke = stroke; + } + check_numbers(p, &["opacity"], cmd)?; + if let Some(v) = p.get("opacity") { + o.opacity = v.as_f64().map(crate::cmd::opacitymask::percent); + } + if let Some(v) = p.get("blend") { + o.blend = match v { + Value::Null => None, + b => Some(b.as_str().and_then(BlendMode::parse).ok_or_else(|| bad(cmd, format!("unknown blend mode {b}")))?), + }; + } + Ok(o) +} + +pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.instance.setAppearance"; + if !["fill", "stroke", "opacity", "blend"].iter().any(|k| p.get(k).is_some()) { + return Err(bad(C, "give fill, stroke, opacity and/or blend")); + } + let (id, art) = dynamic_instance(s, p, C)?; + let path = path_param(p, &art, C)?; + let d = &s.doc()?.doc; + let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned()); + let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?; + let count = s.edit("Symbol Instance Appearance", |d, _| { + let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; + let mut e = n.extras_mut(); + let list = &mut e.symbol_overrides; + list.retain(|o| o.path != path); + if !new.is_empty() { + list.push(new); + } + Ok(list.len()) + })?; + Ok(json!({ "id": id.0, "overrides": count })) +} + +pub(super) fn symbol_instance_reset(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let d = &s.doc()?.doc; + let changed: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.extras().symbol_overrides.is_empty())).collect(); + if !changed.is_empty() { + s.edit("Reset Symbol Instance", |d, _| { + for id in &changed { + if let Some(n) = d.node_mut(*id) { + n.extras_mut().symbol_overrides.clear(); + } + } + Ok(()) + })?; + } + Ok(json!({ "ids": ids_json(&changed) })) +} diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index d1ce429a0..c7fe43354 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -1,3 +1,4 @@ +use super::dynamic::{fit_overrides, type_id, type_param}; use super::{ Affine, AppearanceItem, Arc, CURRENT_SYMBOL, Color, Document, EngineError, FillLayer, HALF, Node, NodeId, NodeKind, Paint, Point, Result, SIZES_KEY, Session, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration, Value, Vec2, bad, brush, color_value, ids_json, json, @@ -128,6 +129,7 @@ pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id(), "nineSlice": nine_slice_json(d, &sym.name), + "type": type_id(sym.dynamic), }) }) .collect(); @@ -138,7 +140,7 @@ pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result { /// current symbol. pub(crate) fn store_symbol(d: &mut Document, art: &Node, name: &str, size: (f64, f64)) { let art = d.reid(art); - d.symbols.push(Symbol { name: name.into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); set_natural_size(d, name, Some(size)); d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); } @@ -179,10 +181,14 @@ pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { let reg = registration_param(p, C)?.unwrap_or(SymbolRegistration::CENTER); let export = export_type_param(p, C)?.unwrap_or_default(); let nine = nine_slice_param(p, C, None)?.flatten(); + let dynamic = type_param(p, C)?.unwrap_or(false); // Keep the art where it was: the geometric centre stays at `c`, whatever the registration is. let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg); let id = s.edit("New Symbol", |d, sel| { store_symbol(d, &art, &name, size); + if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) { + sym.dynamic = dynamic; + } d.set_symbol_registration(&name, reg); d.set_symbol_export_type(&name, export); d.set_symbol_nine_slice(&name, nine); @@ -190,9 +196,14 @@ pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result { sel.set([id]); Ok(id) })?; - Ok( - json!({ "name": name, "id": id.0, "registration": reg.0, "exportType": export.id(), "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value) }), - ) + Ok(json!({ + "name": name, + "id": id.0, + "registration": reg.0, + "exportType": export.id(), + "nineSlice": nine.map_or(Value::Null, SymbolNineSlice::to_value), + "type": type_id(dynamic), + })) } pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { @@ -201,9 +212,10 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { let export = export_type_param(p, C)?; let reg = registration_param(p, C)?; let nine = nine_slice_param(p, C, s.doc()?.doc.symbol_nine_slice(&name))?; + let dynamic = type_param(p, C)?; let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); - if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() { - return Err(bad(C, "pass newName, registration, exportType and/or nineSlice")); + if new_name.is_none() && export.is_none() && reg.is_none() && nine.is_none() && dynamic.is_none() { + return Err(bad(C, "pass newName, registration, exportType, nineSlice and/or type")); } if let Some(ref n) = new_name && n != &name @@ -241,6 +253,13 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { if let Some(g) = nine { d.set_symbol_nine_slice(&final_name, g); } + if let Some(dynamic) = dynamic + && let Some(sym) = d.symbols.iter_mut().find(|x| x.name == final_name) + { + // A static symbol's instances drop their own appearance changes. + sym.dynamic = dynamic; + fit_overrides(d, &instances_of(d, Some(&final_name))); + } Ok(()) })?; let d = &s.doc()?.doc; @@ -249,6 +268,7 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { "registration": d.symbol_registration(&final_name).0, "exportType": d.symbol_export_type(&final_name).id(), "nineSlice": nine_slice_json(d, &final_name), + "type": type_id(d.symbols.iter().any(|x| x.name == final_name && x.dynamic)), })) } @@ -279,11 +299,12 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0 })) } -/// The plain art of instance `inst` in document space (fresh ids). -fn expanded_instance(d: &mut Document, inst: &Node) -> Option { +/// 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`. +fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option { let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None }; let sym = d.symbols.iter().find(|s| &s.name == symbol)?; - let mut art = (*sym.art).clone(); + let mut art = if own { vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst) } else { (*sym.art).clone() }; if let Some(f) = inst.appearance.fill() && let Some(c) = f.paint.color() { @@ -302,11 +323,13 @@ fn expanded_instance(d: &mut Document, inst: &Node) -> Option { Some(art) } -fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { +/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the +/// art's ids. +fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result> { let mut out = vec![]; for id in ids { let Some(inst) = d.node(*id).cloned() else { continue }; - let Some(art) = expanded_instance(d, &inst) else { continue }; + let Some(art) = expanded_instance(d, &inst, own) else { continue }; let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; let aid = art.id; d.remove(*id)?; @@ -316,7 +339,7 @@ fn break_links(d: &mut Document, ids: &[NodeId]) -> Result> { Ok(out) } -fn selected_instances(s: &Session, p: &Value) -> Result> { +pub(super) fn selected_instances(s: &Session, p: &Value) -> Result> { let d = &s.doc()?.doc; let mut out = vec![]; for id in roots(s, p)? { @@ -337,7 +360,7 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("no symbol instances selected".into())); } let out = s.edit("Break Link to Symbol", |d, sel| { - let out = break_links(d, &ids)?; + let out = break_links(d, &ids, true)?; sel.set(out.iter().copied()); Ok(out) })?; @@ -351,8 +374,9 @@ pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result { Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(), _ => return Err(EngineError::Other("select a symbol instance".into())), }; + // The symbol's own art: one instance's appearance changes would become every instance's. let out = s.edit("Edit Symbol", |d, sel| { - let out = break_links(d, &[first])?; + let out = break_links(d, &[first], false)?; sel.set(out.iter().copied()); Ok(out) })?; @@ -377,11 +401,14 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result { } set_natural_size(d, &name, Some(size)); let fix = Affine::scale_non_uniform(size.0 / old.0, size.1 / old.1); - for i in instances_of(d, Some(&name)) { - if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(i).map(|n| &mut n.kind) { + let instances = instances_of(d, Some(&name)); + for i in &instances { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(*i).map(|n| &mut n.kind) { *xf *= fix; } } + // Each instance keeps its appearance changes to the objects the new art still has. + fit_overrides(d, &instances); let reg = d.symbol_registration(&name); let id = instance_in_place(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?; sel.set([id]); @@ -400,7 +427,7 @@ pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result { s.edit("Delete Symbol", |d, _| { let inst = instances_of(d, Some(&name)); if expand { - break_links(d, &inst)?; + break_links(d, &inst, true)?; } else { remove_each(d, &inst)?; } @@ -430,7 +457,7 @@ pub(super) fn symbol_duplicate(s: &mut Session, p: &Value) -> Result { let nine = d.symbol_nine_slice(&name); s.edit("Duplicate Symbol", |d, _| { let art = d.reid(&sym.art); - d.symbols.push(Symbol { name: new.clone(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: sym.dynamic, name: new.clone(), art: Arc::new(art) }); set_natural_size(d, &new, Some(size)); d.set_symbol_registration(&new, reg); d.set_symbol_export_type(&new, export); @@ -462,6 +489,7 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); } } + fit_overrides(d, &ids); Ok(()) })?; Ok(json!({ "ids": ids_json(&ids) })) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 25a5ff004..8010931a7 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -682,10 +682,12 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // Type, images, symbol instances, live objects (and paths whose Transform effect copies // them with a gradient or changes their fills and strokes): through their outlines. let symbol = match &n.kind { - NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + NodeKind::SymbolInstance { symbol, .. } => { + d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, &n)) + } _ => None, }; - effects::expand_outlined(Some(&*d), &v, symbol.as_deref()).map(|mut m| { + effects::expand_outlined(Some(&*d), &v, symbol.as_ref()).map(|mut m| { expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 3f0c80944..49e9f5a61 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -181,7 +181,7 @@ fn basic_shapes() -> SymbolLibrary { let id = doc.alloc_id(); let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.0)); let Ok((art, size, _)) = normalize(art) else { continue }; - doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art) }); + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); set_natural_size(&mut doc, name, Some(size)); } doc.title = "Basic Shapes".into(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c6a2a06be..c5baf054d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -41,6 +41,7 @@ mod dashalign; mod distort; mod docsetup; mod draw2; +mod dynamic_symbols; mod editcolors; mod effectedit; mod emptytype; diff --git a/crates/engine/src/tests/docsetup.rs b/crates/engine/src/tests/docsetup.rs index acd6cd58b..c1c70ac07 100644 --- a/crates/engine/src/tests/docsetup.rs +++ b/crates/engine/src/tests/docsetup.rs @@ -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() }); diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs new file mode 100644 index 000000000..a580106d3 --- /dev/null +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -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 { + 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); +} diff --git a/crates/engine/src/tests/envelope_distort.rs b/crates/engine/src/tests/envelope_distort.rs index 1ad528544..b42b1da02 100644 --- a/crates/engine/src/tests/envelope_distort.rs +++ b/crates/engine/src/tests/envelope_distort.rs @@ -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 }))?; diff --git a/crates/engine/src/tests/pdfraster.rs b/crates/engine/src/tests/pdfraster.rs index f6e54bd0c..1e0a2ecfd 100644 --- a/crates/engine/src/tests/pdfraster.rs +++ b/crates/engine/src/tests/pdfraster.rs @@ -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)) }); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index eb3164db8..2182769aa 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -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. diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index 998f7fd14..f1b31671e 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -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); diff --git a/crates/engine/src/tests/symbolbounds.rs b/crates/engine/src/tests/symbolbounds.rs index 5b1c3f2c5..5140d5630 100644 --- a/crates/engine/src/tests/symbolbounds.rs +++ b/crates/engine/src/tests/symbolbounds.rs @@ -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(); diff --git a/crates/engine/src/tests/typescale.rs b/crates/engine/src/tests/typescale.rs index ad2bd1eaa..bdfb27f2f 100644 --- a/crates/engine/src/tests/typescale.rs +++ b/crates/engine/src/tests/typescale.rs @@ -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}); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 10109e1ec..e7a89d97c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -4312,7 +4312,7 @@ fn brushed_document(kind: usize, art: usize) -> Document { Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0)) }; let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc)); - doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) }); + doc.symbols.push(Symbol { dynamic: false, name: "s0".into(), art: std::sync::Arc::new(in_symbol) }); doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)])); let art = match art { 0 => instance(&mut doc), @@ -4502,7 +4502,7 @@ fn symbols_document(count: usize, levels: usize, last: &str) -> Document { for _ in 0..levels { art = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(art)]); } - doc.symbols.push(Symbol { name: format!("s{i}"), art: std::sync::Arc::new(art) }); + doc.symbols.push(Symbol { dynamic: false, name: format!("s{i}"), art: std::sync::Arc::new(art) }); } let first = instance(&mut doc, "s0".into()); let layer = doc.layers[0].id; diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 89df7701b..b0ba5508f 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -278,7 +278,7 @@ impl<'a> Writer<'a> { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); art.visible = true; self.nest += 1; diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 6e6ba0584..f99aea923 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -313,7 +313,7 @@ impl<'a> Scene<'a> { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); self.nest += 1; self.node(&art, true); diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index c8271dda8..6d1331c2f 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -633,7 +633,7 @@ fn an_instance_whose_art_reaches_the_region_is_written() { // while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it. let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]); let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let page = page_of(&d, &opts(&d)); assert!(page.contains("1 0 0 rg"), "{page}"); } @@ -652,7 +652,7 @@ fn an_instance_whose_brush_art_reaches_the_region_is_written() { vectorcraft_brush::store(&mut d, &lib); let mut art = path(&mut d, shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 1.0); art.appearance.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); - d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(art) }); let page = page_of(&d, &opts(&d)); assert!(page.contains("1 0 0 rg"), "{page}"); } diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 33633de5c..5475f39fb 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -129,7 +129,7 @@ fn unused_images_are_pruned() { n.extras_mut().mask = Some(Box::new(OpacityMask::new(image_node(&mut d, "in mask"), false))); d.insert(Some(layer), 0, n).unwrap(); let art = image_node(&mut d, "in symbol"); - d.symbols.push(Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); // Image objects inside foreign data (a brush library kept as JSON) keep theirs too. d.unknown.insert("brushes".into(), json!([{"art": {"type": "image", "key": "in brush"}}])); let back = load(&save(&d, true)).unwrap(); diff --git a/crates/format/tests/it/save_options.rs b/crates/format/tests/it/save_options.rs index 5cafcb114..c9a82839c 100644 --- a/crates/format/tests/it/save_options.rs +++ b/crates/format/tests/it/save_options.rs @@ -200,7 +200,7 @@ fn art_outside_the_layers_counts_too() { let mut art = paths_doc(1, anchors_for(1, 2 * PRETTY_MAX_BYTES)); let path = Arc::clone(&art.layers[0]); art.layers.clear(); - d.symbols.push(Symbol { name: "Leaves".into(), art: path }); + d.symbols.push(Symbol { dynamic: false, name: "Leaves".into(), art: path }); assert!(!SaveOptions::for_doc(&d).pretty); let bytes = save_file(&d); assert!(!is_pretty(&bytes)); diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 587577f41..fff90f697 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -217,7 +217,7 @@ impl<'a> Scene<'a> { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); self.nest += 1; self.node(&art, true); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 25bb51277..23bcfdc67 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -393,7 +393,7 @@ fn an_instance_whose_art_reaches_the_artboard_is_written() { let inst = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) }); let mut d = doc_with(100.0, 100.0, vec![inst]); let art = path(shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), red(), Paint::None, 0.0); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let k = kinds(&emf_records(&emf(&d).bytes)); assert!(k.contains(&39), "a brush fills the art: {k:?}"); } diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 9740362ec..c7090e8e2 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -217,7 +217,7 @@ impl Exporter<'_> { let total = sym.map_or(0, |sym| self.symbol_nodes.saturating_add(sym.art.count() as u64)); if let Some(sym) = sym.filter(|_| total <= vectorcraft_doc::MAX_SYMBOL_NODES) { self.symbol_nodes = total; - let mut art = (*sym.art).clone(); + let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n); art.transform(*xf, false); self.node(s, &art, page, true); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 0b7e3acea..8ac576a23 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -194,7 +194,7 @@ fn an_instance_whose_art_reaches_the_page_is_written() { // the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it. let mut d = doc(200.0, 100.0); let red = rect_node(Rect::new(100.0, 50.0, 140.0, 90.0), Color::rgb(1.0, 0.0, 0.0)); - let far = vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(red) }; + let far = vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(red) }; d.symbols.push(far.clone()); let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) }); add(&mut d, instance.clone()); @@ -228,7 +228,7 @@ fn an_instance_whose_brush_art_reaches_the_page_is_written() { 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(NodeId(0), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke); - d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(line) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(line) }); add(&mut d, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-5.0, 0.0)) })); let out = roundtrip(&d); let l = leaves(&out); diff --git a/crates/pdf/src/tests/print.rs b/crates/pdf/src/tests/print.rs index cd1232a4a..019beddd7 100644 --- a/crates/pdf/src/tests/print.rs +++ b/crates/pdf/src/tests/print.rs @@ -265,7 +265,7 @@ fn a_symbol_instance_prints_by_its_art() { let mut d = doc(100.0, 100.0, &[]); let red = Appearance::basic(rgb(1.0, 0.0, 0.0), Paint::None, 0.0); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 140.0, 140.0)), red); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let (layer, id) = (d.default_layer().unwrap(), d.alloc_id()); let xf = vectorcraft_geom::Affine::translate((-50.0, -50.0)); d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf })).unwrap(); @@ -293,7 +293,7 @@ fn a_symbol_instances_brush_art_keeps_its_artboard_printed() { let mut stroke = Appearance::basic(Paint::None, rgb(1.0, 0.0, 0.0), 1.0); stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); let art = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke); - d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: Arc::new(art) }); let (layer, id) = (d.default_layer().unwrap(), d.alloc_id()); let xf = vectorcraft_geom::Affine::translate((-5.0, 0.0)); d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Nib".into(), xf })).unwrap(); diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index c19bfb6e6..52f045cbb 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -370,7 +370,7 @@ mod tests { shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: std::sync::Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: std::sync::Arc::new(art) }); let id = d.alloc_id(); let mut inst = Node::new(id, vectorcraft_doc::NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 50.0)) }); inst.appearance.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); @@ -394,7 +394,7 @@ mod tests { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); ap.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); let art = Node::path(vectorcraft_doc::NodeId(0), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), ap); - d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: std::sync::Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: std::sync::Arc::new(art) }); let id = d.alloc_id(); let inst = Node::new(id, vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-6.0, 0.0)) }); let l = d.layers[0].id; @@ -424,7 +424,7 @@ mod tests { let moved = Affine::translate((-8.0, 0.0)); let source = if instance { let art = line(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "Nib".into(), art: std::sync::Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Nib".into(), art: std::sync::Arc::new(art) }); Node::new(d.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Nib".into(), xf: moved }) } else { let mut n = line(&mut d); diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index 03f2fcc1c..0935ba356 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -236,7 +236,7 @@ fn art_bounds_take_in_the_effects_in_a_symbols_art() { let mut art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), black); let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }; art.appearance.effects.push(shadow); - d.symbols.push(vectorcraft_doc::Symbol { name: "Box".into(), art: std::sync::Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Box".into(), art: std::sync::Arc::new(art) }); let (id, l) = (d.alloc_id(), d.layers[0].id); let xf = vectorcraft_geom::Affine::translate((20.0, 20.0)); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf })).unwrap(); diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index e7a46af24..274c763b0 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -479,7 +479,7 @@ fn thirty_stacked_glows_draw_quickly_cached_or_not() { shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 40.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); - instanced.symbols.push(vectorcraft_doc::Symbol { name: "Square".into(), art: std::sync::Arc::new(art) }); + instanced.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Square".into(), art: std::sync::Arc::new(art) }); let mut instance = Node::new(instanced.alloc_id(), vectorcraft_doc::NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((30.0, 30.0)) }); instance.appearance.effects = glows.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect(); @@ -754,7 +754,7 @@ fn a_moved_group_draws_the_shadow_of_the_instance_art_in_it_while_interacting() let mut base = Document::new(100.0, 100.0); let orange = Appearance::basic(Paint::solid(Color::rgb(0.9, 0.4, 0.1)), Paint::None, 0.0); let art = Node::path(base.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 130.0, 126.0)), orange); - base.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: std::sync::Arc::new(art) }); + base.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: std::sync::Arc::new(art) }); // The instance draws the art from (20, 20) to (50, 46), and the 20 × 20 square around the // origin from (-90, -90) to (-70, -70). let xf = Affine::translate((-80.0, -80.0)); diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 8d3d7d14c..e6315d645 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -771,7 +771,7 @@ mod tests { true => Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("S{}", k + 1), xf: Affine::IDENTITY }), false => square(&mut d), }; - 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 first = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S0".into(), xf: Affine::IDENTITY }); let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; @@ -805,7 +805,7 @@ mod tests { }; let (x, y) = (square(&mut d, 0.0), square(&mut d, 20.0)); for (name, art) in [("A", &x), ("B", &y), ("C", &x)] { - d.symbols.push(Symbol { name: name.into(), art: art.clone() }); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: art.clone() }); } let content = ["A", "B", "C"].map(|s| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: s.into(), xf: Affine::IDENTITY }))); let kind = EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; diff --git a/crates/render/src/renderer/text.rs b/crates/render/src/renderer/text.rs index 675247efd..c53cbb714 100644 --- a/crates/render/src/renderer/text.rs +++ b/crates/render/src/renderer/text.rs @@ -56,7 +56,9 @@ impl Renderer { /// placed again when the fonts change. pub(super) fn placed_art(&mut self, art: &Arc, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc, bool) { let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); - if !kept_kind || fx::has_object_fx(art) { + // An instance with appearance changes of its own isn't kept: the key doesn't tell them apart. + let overridden = inst.is_some_and(|i| !i.extras().symbol_overrides.is_empty()); + if !kept_kind || overridden || fx::has_object_fx(art) { return (place(art, xf, scale_strokes, inst), false); } let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index e7782b813..81a91d8ea 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -220,7 +220,7 @@ fn hidden_and_culled() { fn far_instance(art: Rect) -> Document { let mut d = Document::new(100.0, 100.0); let art = rect_node(&mut d, art, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) }); let (id, l) = (d.alloc_id(), d.layers[0].id); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })).unwrap(); d @@ -250,7 +250,7 @@ fn an_instance_is_culled_by_the_effects_in_its_art() { let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }; art.appearance.effects.push(shadow); - d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); let (id, l) = (d.alloc_id(), d.layers[0].id); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) })).unwrap(); let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); @@ -264,7 +264,7 @@ fn a_repeat_of_an_instance_is_culled_by_the_effects_in_its_art() { let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }; art.appearance.effects.push(shadow); - d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) }); let grid = vectorcraft_doc::TileType::Grid; let kind = @@ -287,11 +287,11 @@ fn past_the_measured_placements_an_instance_is_culled_by_an_estimate_of_its_art_ let mut art = rect_node(&mut d, Rect::new(-1.0, -1.0, 1.0, 1.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }; art.appearance.effects.push(shadow); - d.symbols.push(vectorcraft_doc::Symbol { name: "Spot".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Spot".into(), art: Arc::new(art) }); for name in ["Dot", "Late"] { let spot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Spot".into(), xf: Affine::IDENTITY }); let group = Node::group(d.alloc_id(), vec![Arc::new(spot)]); - d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(group) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(group) }); } let l = d.layers[0].id; for i in 0..4100 { @@ -317,7 +317,7 @@ fn nested_instances_are_culled_in_bounded_time(effect: Option { let art = Node::group(d.alloc_id(), vec![Arc::new(object)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Word".into(), art: Arc::new(art) }); object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 75.0)) }); object.appearance.effects.push(moved()); } @@ -243,7 +243,7 @@ fn enveloped_type_redraws_when_its_font_becomes_available() { let typed = word(&mut d, Point::new(10.0, 60.0)); let art = word(&mut d, Point::new(-140.0, 20.0)); let art = Node::group(d.alloc_id(), vec![Arc::new(art)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Word".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Word".into(), art: Arc::new(art) }); let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Word".into(), xf: Affine::translate((150.0, 120.0)) }); let l = d.layers[0].id; for (i, content) in [typed, shown].into_iter().enumerate() { diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index f1cf1da5a..f95c61799 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -389,7 +389,7 @@ fn type_with_an_inline_graphic_draws_its_symbols_new_size() { for i in 0..4 { let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let style = CharStyle { size: 24.0, ..Default::default() }; let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone()); t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone())); @@ -428,7 +428,7 @@ fn symbol_art(d: &mut Document) -> Node { fn symbol_doc() -> Document { let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let l = d.layers[0].id; let xfs = [ Affine::translate((30.0, 30.0)), @@ -579,9 +579,9 @@ fn art_placed_again_in_every_frame_keeps_one_outline_per_stroke() { if let Some(c) = art.children_mut() { c.push(Arc::new(image)); } - d.symbols.push(vectorcraft_doc::Symbol { name: "I".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "I".into(), art: Arc::new(art) }); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let l = d.layers[0].id; let instance = |d: &mut Document, symbol: &str, x: f64, y: f64| { let id = d.alloc_id(); @@ -707,7 +707,7 @@ fn strokes_of_brush_art_draw_as_a_new_renderer_does() { // Three instances show symbol `B`, a group of a wave. A fourth wave is a path of the document, // and a Mirror repeat draws a fifth wave and its reflection. let art = Node::group(d.alloc_id(), vec![Arc::new(wave(&mut d, (0.0, 0.0)))]); - d.symbols.push(vectorcraft_doc::Symbol { name: "B".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "B".into(), art: Arc::new(art) }); let l = d.layers[0].id; for at in [(50.0, 30.0), (150.0, 30.0), (50.0, 70.0)] { let id = d.alloc_id(); @@ -755,10 +755,10 @@ fn brushed_copies_doc() -> Document { use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; let mut d = Document::new(200.0, 100.0); let line = brushed_line(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "Line".into(), art: Arc::new(line) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Line".into(), art: Arc::new(line) }); let line = brushed_line(&mut d); let held = Node::group(d.alloc_id(), vec![Arc::new(line)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Held".into(), art: Arc::new(held) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Held".into(), art: Arc::new(held) }); let l = d.layers[0].id; for (symbol, at) in [("Line", (20.0, 15.0)), ("Line", (60.0, 15.0)), ("Line", (100.0, 15.0)), ("Held", (20.0, 45.0)), ("Held", (60.0, 45.0))] { let id = d.alloc_id(); @@ -856,7 +856,7 @@ fn brush_art_draws_edits_of_the_symbols_and_patterns_it_shows() { let doc = |dot_brushed: bool, fill_brushed: bool| { let mut d = Document::new(200.0, 100.0); let dot = art(&mut d, Paint::solid(Color::rgb(0.0, 0.0, 1.0)), dot_brushed); - d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(dot) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); let fill = art(&mut d, Paint::solid(Color::rgb(0.0, 0.6, 0.0)), fill_brushed); let mut def = PatternDef::new("Fill", vec![Arc::new(fill)]); def.tile = Rect::new(-6.0, -6.0, 6.0, 6.0); @@ -917,7 +917,7 @@ fn brush_art_draws_renamed_symbols_and_patterns_it_shows() { [("Dot", Paint::solid(Color::rgb(0.0, 0.0, 1.0)), false), ("Other", Paint::solid(Color::rgb(1.0, 0.0, 0.0)), true)] { let a = art(&mut d, paint, brushed); - d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(a) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(a) }); } for (name, paint, brushed) in [("Fill", Paint::solid(Color::rgb(0.0, 0.6, 0.0)), false), ("Spare", Paint::solid(Color::rgb(1.0, 0.0, 0.0)), true)] @@ -978,7 +978,7 @@ fn live_objects_in_brush_art_keep_their_evaluations_between_frames() { }; let held = bent(&mut d, (-60.0, 0.0), (60.0, 0.0)); let held = Node::group(d.alloc_id(), vec![Arc::new(held)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Wave".into(), art: Arc::new(held) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Wave".into(), art: Arc::new(held) }); let path = bent(&mut d, (20.0, 25.0), (180.0, 25.0)); let l = d.layers[0].id; d.insert(Some(l), usize::MAX, path).unwrap(); @@ -1006,7 +1006,7 @@ fn editing_an_instance_or_a_symbol_places_only_its_art_again() { let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), Paint::None, 0.0); let square = Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), green); let t = Node::group(d.alloc_id(), vec![Arc::new(square)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "T".into(), art: Arc::new(t) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "T".into(), art: Arc::new(t) }); let (l, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "T".into(), xf: Affine::translate((30.0, 80.0)) })).unwrap(); let opts = RenderOptions::default(); @@ -1056,7 +1056,7 @@ fn instances_of_art_outside_the_square_follow_their_symbols_art() { // the one from x 200 lies from (40, 40) to (60, 60), in it. The 20 × 20 square around the // origin lies from (-170, 30) to (-150, 50), left of the view. let (off, on) = (square_at(&mut d, 100.0), square_at(&mut d, 200.0)); - d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: off.clone() }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: off.clone() }); let (l, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-160.0, 40.0)) })).unwrap(); let opts = RenderOptions::default(); @@ -1098,7 +1098,7 @@ fn draws_as_new(d: &mut Document, before: &RenderOptions, after: &RenderOptions, /// `d` with symbol `Holder` (a group of `members`) shown by one instance at (100, 50). fn holder_doc(mut d: Document, members: Vec) -> Document { let holder = Node::group(d.alloc_id(), members.into_iter().map(Arc::new).collect()); - d.symbols.push(vectorcraft_doc::Symbol { name: "Holder".into(), art: Arc::new(holder) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Holder".into(), art: Arc::new(holder) }); let (l, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Holder".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap(); d @@ -1113,7 +1113,7 @@ fn art_showing_another_symbol_draws_its_edits() { use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let style = CharStyle { size: 24.0, ..Default::default() }; let mut t = TextObject::point(vectorcraft_geom::Point::new(-60.0, 10.0), "A", style.clone()); t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone())); @@ -1234,7 +1234,7 @@ fn live_objects_in_symbol_art_keep_their_evaluations_between_frames() { for (name, art, at) in [("Bent", bent, (40.0, 25.0)), ("Envelope", envelope, (100.0, 25.0)), ("Blend", blend, (160.0, 25.0)), ("Grid", grid, (20.0, 60.0))] { - d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); let id = d.alloc_id(); d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate(at) })).unwrap(); } @@ -1298,7 +1298,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { for bricks in [false, true] { let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let xf = Affine::translate((10.0, 10.0)) * Affine::scale(0.5); let dot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); let mut dots = PatternDef::new("Dots", vec![Arc::new(dot)]); @@ -1307,7 +1307,7 @@ fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { let band = filled(&mut d, rectangle(Rect::new(-80.0, -30.0, 80.0, 30.0)), pattern_paint("Dots"), vec![]); let envelope = arc_envelope(&mut d, vec![band], true); let art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(envelope)]) } else { envelope }; - d.symbols.push(vectorcraft_doc::Symbol { name: "Bent".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); let (l, id) = (d.layers[0].id, d.alloc_id()); d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap(); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; @@ -1401,7 +1401,7 @@ fn live_objects_draw_symbol_and_pattern_art_edits() { let with_s = || { let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); d }; let red_bar = |d: &mut Document| { @@ -1415,7 +1415,7 @@ fn live_objects_draw_symbol_and_pattern_art_edits() { let mut d = with_s(); let shown = instance(&mut d, "S", Affine::IDENTITY); let a = Node::group(d.alloc_id(), vec![Arc::new(shown)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "A".into(), art: Arc::new(a) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "A".into(), art: Arc::new(a) }); let a = instance(&mut d, "A", Affine::translate((100.0, 50.0)) * Affine::scale(2.0)); let envelope = arc_envelope(&mut d, vec![a], false); edit_draws_as_new(with_art(d, vec![envelope]), red_bar); @@ -1430,7 +1430,7 @@ fn live_objects_draw_symbol_and_pattern_art_edits() { let mut d = Document::new(200.0, 100.0); d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0))); let band = filled(&mut d, rectangle(Rect::new(-80.0, -30.0, 80.0, 30.0)), pattern_paint("Checks"), vec![]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Banded".into(), art: Arc::new(band) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Banded".into(), art: Arc::new(band) }); let banded = instance(&mut d, "Banded", Affine::translate((100.0, 50.0))); let envelope = arc_envelope(&mut d, vec![banded], true); edit_draws_as_new(with_art(d, vec![envelope]), |d| { @@ -1457,7 +1457,7 @@ fn symbol_in_live_and_effect_art() -> Document { use vectorcraft_geom::shapes::rectangle; let mut d = Document::new(500.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let bent = |d: &mut Document, x: f64| { let xf = Affine::translate((x, 50.0)) * Affine::scale(2.0); let s = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); @@ -1470,7 +1470,7 @@ fn symbol_in_live_and_effect_art() -> Document { group.appearance.effects.push(drop_shadow()); let copy = symbol_art(&mut d); let art = arc_envelope(&mut d, vec![copy], false); - d.symbols.push(vectorcraft_doc::Symbol { name: "Bent".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bent".into(), art: Arc::new(art) }); let xf = Affine::translate((250.0, 50.0)) * Affine::scale(2.0); let symbol = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Bent".into(), xf }); let mask = bent(&mut d, 350.0); @@ -1603,7 +1603,7 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() { let mut d = Document::new(200.0, 100.0); let art = square(&mut d, -20.0, -20.0, vec![]); let art = Node::group(d.alloc_id(), vec![Arc::new(art)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shaped".into(), art: Arc::new(art) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((100.0, 50.0)) }); plugin_change_draws_as_new(with_art(d, vec![instance]), ID, small.clone(), big); // Left of the view, the square is culled until the plug-in draws it in the view. @@ -1616,7 +1616,7 @@ fn objects_with_plugin_effects_follow_the_plugins_installed() { // effects in the art paint past the art, which the plug-in's first shape does not. let mut d = Document::new(200.0, 100.0); let art = square(&mut d, -20.0, -20.0, vec![]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shaped".into(), art: Arc::new(art) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((-30.0, 50.0)) }); plugin_change_draws_as_new(with_art(d, vec![instance]), ID, Some(triangle(-15.0, -15.0, 10.0)), Some(triangle(0.0, 0.0, 150.0))); } @@ -1676,7 +1676,7 @@ fn effect_art_follows_the_plugins_installed() { plugin_change_draws_as_new(group(vec![feather]), ID, small.clone(), big.clone()); let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((100.0, 50.0)) }); instance.appearance.effects.push(plugin()); plugin_change_draws_as_new(with_art(d, vec![instance]), ID, small.clone(), big.clone()); @@ -1711,7 +1711,7 @@ fn live_objects_follow_the_plugins_installed() { // Symbol `Shaped` is a square with the plug-in effect. let mut d = Document::new(200.0, 100.0); let art = black(&mut d, Rect::new(-20.0, -20.0, 20.0, 20.0)); - d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shaped".into(), art: Arc::new(art) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((100.0, 50.0)) }); let envelope = arc_envelope(&mut d, vec![instance], true); plugin_change_draws_as_new(with_art(d, vec![envelope]), ID, small.clone(), big.clone()); @@ -1753,7 +1753,7 @@ fn pattern_and_symbol_art_with_plugin_effects_follow_the_plugins_installed() { // the instance's box, from (80, 30) to (120, 70). let mut d = Document::new(200.0, 100.0); let art = filled(&mut d, rectangle(Rect::new(-20.0, -20.0, 20.0, 20.0)), Paint::solid(Color::BLACK), vec![plugin()]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Shaped".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shaped".into(), art: Arc::new(art) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: Affine::translate((100.0, 50.0)) }); let mut group = Node::group(d.alloc_id(), vec![Arc::new(instance)]); group.appearance.effects.push(drop_shadow()); @@ -1839,7 +1839,7 @@ fn pattern_tiles_draw_edits_to_what_their_art_shows() { let doc = |pattern: &str, effects: Vec| { let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((10.0, 10.0)) }); d.patterns.push(tile("Stamps", shown)); d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0))); @@ -1890,10 +1890,10 @@ fn rasters_draw_symbol_brush_and_image_edits() { // A group of an instance of symbol `A`, whose art shows symbol `S`, and a green square. let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY }); let a = Node::group(d.alloc_id(), vec![Arc::new(shown)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "A".into(), art: Arc::new(a) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "A".into(), art: Arc::new(a) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "A".into(), xf: Affine::translate((50.0, 50.0)) }); let green = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(0.0, 0.8, 0.0)), Paint::None, 0.0); let square = Node::path(d.alloc_id(), rectangle(Rect::new(120.0, 30.0, 160.0, 70.0)), green); @@ -2067,7 +2067,7 @@ fn proof_doc() -> Document { let spot = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 150.0, 50.0)), spot, vec![blur]); let yellow = filled(&mut d, rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)), vec![]); let art = Node::group(d.alloc_id(), vec![Arc::new(yellow)]); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((175.0, 75.0)) }); with_art(d, vec![cyan, circle, white, spot, instance]) } @@ -2228,7 +2228,7 @@ fn symbol_instances_draw_as_groups_of_their_placed_art() { let circle = vectorcraft_geom::shapes::ellipse(Rect::new(-6.0, -6.0, 6.0, 6.0)); let ap = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.8, 0.0)), Paint::solid(Color::BLACK), 1.0); let circle = Node::path(d.alloc_id(), circle, ap); - d.symbols.push(vectorcraft_doc::Symbol { name: "U".into(), art: Arc::new(circle) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "U".into(), art: Arc::new(circle) }); let id = d.alloc_id(); d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "U".into(), xf: Affine::translate((175.0, 25.0)) })).unwrap(); // `groups` holds the same art in place of the instances: groups, and the circle as a path. @@ -2272,7 +2272,7 @@ fn inline_graphics_keep_their_placed_art_between_frames() { use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; let mut d = Document::new(200.0, 100.0); let art = symbol_art(&mut d); - d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); let style = CharStyle { size: 24.0, ..Default::default() }; let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "A", style.clone()); t.runs.push(TextRun::inline(InlineArt::new("S"), style.clone())); diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index f5c963c0d..85f7505dd 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -300,7 +300,7 @@ fn symbol_doc(effects: Vec) -> Document { shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), ); - d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Square".into(), art: Arc::new(art) }); let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) }); n.appearance.effects = effects; doc_with(d, n) @@ -469,7 +469,7 @@ fn a_transform_effect_on_a_group_symbol_or_envelope_moves_and_scales_the_gradien t.id = NodeId(500); // The type in a group, as the art of a symbol, and in an envelope that leaves it as it is. let mut symbols = Document::new(100.0, 100.0); - symbols.symbols.push(Symbol { name: "Type".into(), art: Arc::new(t.clone()) }); + symbols.symbols.push(Symbol { dynamic: false, name: "Type".into(), art: Arc::new(t.clone()) }); let holders = [ doc_with(Document::new(100.0, 100.0), Node::group(NodeId(0), vec![Arc::new(t.clone())])), doc_with(symbols, Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Type".into(), xf: Affine::IDENTITY })), diff --git a/crates/render/src/tests/placed_document.rs b/crates/render/src/tests/placed_document.rs index 60e08a6a5..8ce2953cd 100644 --- a/crates/render/src/tests/placed_document.rs +++ b/crates/render/src/tests/placed_document.rs @@ -281,7 +281,11 @@ fn a_ready_bitmap_draws_again_where_its_file_shows() { let brush = vectorcraft_brush::Brush { name: "Cards".into(), kind: vectorcraft_brush::BrushKind::Scatter(scatter) }; vectorcraft_brush::store(&mut brushed, &[brush]); assert_eq!(placed_area(&brushed, &only_a), Changed::Everything); - d.symbols.push(vectorcraft_doc::Symbol { name: "Card".into(), art: Arc::new(placed_node(65, "area-a", 40.0, 20.0, Affine::IDENTITY)) }); + d.symbols.push(vectorcraft_doc::Symbol { + dynamic: false, + name: "Card".into(), + art: Arc::new(placed_node(65, "area-a", 40.0, 20.0, Affine::IDENTITY)), + }); assert_eq!(placed_area(&d, &only_a), Changed::Everything); } diff --git a/crates/render/src/tests/rasterize.rs b/crates/render/src/tests/rasterize.rs index 798d2f6d5..3f27ddfeb 100644 --- a/crates/render/src/tests/rasterize.rs +++ b/crates/render/src/tests/rasterize.rs @@ -310,7 +310,7 @@ fn rasterize_and_shadows_paint_in_stack_order() { Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), ); art.appearance.effects.push(Effect { id: shadow.0.into(), params: shadow.1.clone(), visible: true }); - d.symbols.push(vectorcraft_doc::Symbol { name: "Shadowed".into(), art: Arc::new(art) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Shadowed".into(), art: Arc::new(art) }); let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Shadowed".into(), xf: Affine::translate((20.0, 20.0)) }); instance.appearance.effects.push(Effect { id: "rasterize".into(), params: json!({}), visible: true }); let l = d.layers[0].id; diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index e88ad8509..358cbd38b 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -653,7 +653,7 @@ impl Writer<'_> { /// by a transform under which the def would not look as the canvas paints it ([`Reuse`]). fn symbol_use(&mut self, sym: &vectorcraft_doc::Symbol, inst: &Node, xf: Affine) -> Option { // SVG Tiny has no ``. - if self.tiny() || vectorcraft_brush::stain(inst).is_some() { + if self.tiny() || vectorcraft_brush::stain(inst).is_some() || !inst.extras().symbol_overrides.is_empty() { return None; } if !self.symbols.contains_key(&sym.name) { diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index c2220f9d8..0e0b8127e 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -186,7 +186,7 @@ impl Importer { let name = unique_name(self.name_of(&sym), |n| self.doc.symbols.iter().any(|s| s.name == n)); let reuse = Reuse::of(&self.doc, &art); let made = SymbolMade { name: name.clone(), art: without_ids(&art), reuse }; - self.doc.symbols.push(vectorcraft_doc::Symbol { name: name.clone(), art: Arc::new(art) }); + self.doc.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.clone(), art: Arc::new(art) }); self.symbols.insert(sym, made); if !whole || !reuse.allows(xf) { return None; diff --git a/crates/svg/tests/it/data_attributes.rs b/crates/svg/tests/it/data_attributes.rs index 981662c7c..752b8f1c3 100644 --- a/crates/svg/tests/it/data_attributes.rs +++ b/crates/svg/tests/it/data_attributes.rs @@ -230,7 +230,7 @@ fn symbol_document(name: &str) -> Document { let mut doc = import(&svg(r#""#)).unwrap(); let id = named(&doc, "instance").id; let art = doc.node(id).unwrap().clone(); - doc.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: std::sync::Arc::new(art) }); + doc.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art) }); let instance = doc.node_mut(id).unwrap(); instance.appearance = vectorcraft_doc::Appearance::default().into(); instance.kind = vectorcraft_doc::NodeKind::SymbolInstance { symbol: name.into(), xf: vectorcraft_geom::Affine::IDENTITY }; diff --git a/crates/svg/tests/it/encoding_profile_fonts.rs b/crates/svg/tests/it/encoding_profile_fonts.rs index 0cbb61b48..f0ea9804d 100644 --- a/crates/svg/tests/it/encoding_profile_fonts.rs +++ b/crates/svg/tests/it/encoding_profile_fonts.rs @@ -140,7 +140,7 @@ fn tiny_has_no_filters_masks_symbols_or_style_sheets() { Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), shapes::ellipse(Rect::new(80.0, 10.0, 130.0, 60.0)), fill(255, 255, 255)), true))); add(&mut d, b); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-5.0, -5.0, 5.0, 5.0)), fill(0, 128, 0)); - d.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(art) }); add(&mut d, Node::new(vectorcraft_doc::NodeId(0), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((200.0, 30.0)) })); let mut c = Node::path( vectorcraft_doc::NodeId(0), diff --git a/crates/svg/tests/it/import_live.rs b/crates/svg/tests/it/import_live.rs index 0bd80c5d4..395986a66 100644 --- a/crates/svg/tests/it/import_live.rs +++ b/crates/svg/tests/it/import_live.rs @@ -376,7 +376,7 @@ fn symbols_and_effects_export_import_export_is_stable() { shapes::ellipse(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0), ); - d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(dot) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); let l = d.layers[0].id; for x in [10.0, 60.0] { let id = d.alloc_id(); diff --git a/crates/svg/tests/it/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs index bc3645a7f..1e581ed37 100644 --- a/crates/svg/tests/it/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -28,7 +28,7 @@ fn symbol_doc(stroke: f64, xfs: &[Affine]) -> Document { let mut d = Document::new(200.0, 120.0); let ap = Appearance::basic(Paint::solid(Color::rgb8(30, 90, 220)), Paint::solid(Color::BLACK), stroke); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), ap); - d.symbols.push(Symbol { name: "My Mark".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "My Mark".into(), art: Arc::new(art) }); for xf in xfs { instance(&mut d, "My Mark", *xf); } @@ -108,7 +108,7 @@ fn pattern_paints_in_symbols_stay_on_the_page() { d.patterns.push(def); let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0)); - d.symbols.push(Symbol { name: "Tiles".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Tiles".into(), art: Arc::new(art) }); instance(&mut d, "Tiles", Affine::translate((10.0, 10.0))); instance(&mut d, "Tiles", Affine::translate((105.0, 37.0))); let svg = export(&d, &ExportOptions::default()); @@ -143,7 +143,7 @@ fn masks_in_symbols_move_with_the_art_when_linked() { let mut mask = vectorcraft_doc::OpacityMask::new(shape, true); mask.linked = linked; art.extras_mut().mask = Some(Box::new(mask)); - d.symbols.push(Symbol { name: "Half".into(), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: "Half".into(), art: Arc::new(art) }); instance(&mut d, "Half", Affine::translate((10.0, 10.0))); instance(&mut d, "Half", Affine::translate((15.0, 50.0))); let svg = export(&d, &ExportOptions::default()); @@ -160,7 +160,7 @@ fn symbols_inside_themselves_end() { let inner = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Loop".into(), xf: Affine::translate((5.0, 0.0)) }); let mut g = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(inner)], clip: false }); g.name = Some("g".into()); - d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(g) }); + d.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(g) }); instance(&mut d, "Loop", Affine::translate((20.0, 20.0))); let svg = export(&d, &ExportOptions::default()); assert!(import(&svg).is_ok(), "{svg}"); diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 5620be4d5..46b66599b 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -675,6 +675,7 @@ mod tests { assert_eq!(sampled_hex(&a), "#0000ff"); // A symbol of a green square, placed at (300, 300) and doubled. d.symbols.push(vectorcraft_doc::Symbol { + dynamic: false, name: "Green".into(), art: filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::rgb(0.0, 1.0, 0.0))), }); @@ -693,8 +694,9 @@ mod tests { use vectorcraft_geom::{Affine, Rect}; let (mut d, _) = doc_with_halves(); let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); - d.symbols.push(vectorcraft_doc::Symbol { name: "Red".into(), art: filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), red) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Red".into(), art: filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), red) }); d.symbols.push(vectorcraft_doc::Symbol { + dynamic: false, name: "Tiled".into(), art: filled_rect(Rect::new(0.0, 0.0, 20.0, 10.0), vectorcraft_doc::pattern::pattern_paint("Halves")), }); @@ -730,7 +732,7 @@ mod tests { let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); let pair = Node::group(NodeId(0), vec![filled_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red()), filled_rect(Rect::new(20.0, 0.0, 30.0, 10.0), red())]); - d.symbols.push(vectorcraft_doc::Symbol { name: "Pair".into(), art: Arc::new(pair) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Pair".into(), art: Arc::new(pair) }); let (l, inst) = (d.layers[0].id, d.alloc_id()); let mut n = Node::new(inst, NodeKind::SymbolInstance { symbol: "Pair".into(), xf: Affine::translate((100.0, 140.0)) }); n.appearance.set_fill(Paint::solid(Color::rgb(0.0, 0.0, 1.0))); @@ -744,7 +746,7 @@ mod tests { // A 200 pt tile holding an instance of a 180 pt green bar, which reaches far past the // square an instance is measured by without its symbol. let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0)); - d.symbols.push(vectorcraft_doc::Symbol { name: "Bar".into(), art: filled_rect(Rect::new(0.0, 0.0, 180.0, 10.0), green) }); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Bar".into(), art: filled_rect(Rect::new(0.0, 0.0, 180.0, 10.0), green) }); let bar = Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY })); let mut bars = vectorcraft_doc::PatternDef::new("Bars", vec![bar]); bars.tile = Rect::new(0.0, 0.0, 200.0, 10.0); @@ -779,15 +781,19 @@ mod tests { let instance = |name: String| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name, xf: Affine::IDENTITY })); for i in 0..12 { let art = Node::group(NodeId(0), (0..4).map(|_| instance(format!("Fan{}", i + 1))).collect()); - d.symbols.push(Symbol { name: format!("Fan{i}"), art: Arc::new(art) }); + d.symbols.push(Symbol { dynamic: false, name: format!("Fan{i}"), art: Arc::new(art) }); } - d.symbols.push(Symbol { name: "Fan12".into(), art: filled_rect(Rect::new(300.0, 300.0, 400.0, 400.0), pattern_paint("Nest8")) }); + d.symbols.push(Symbol { + dynamic: false, + name: "Fan12".into(), + art: filled_rect(Rect::new(300.0, 300.0, 400.0, 400.0), pattern_paint("Nest8")), + }); // "Deep0" to "Deep99": one instance of the next; "Deep100" a green square. for i in 0..100 { - d.symbols.push(Symbol { name: format!("Deep{i}"), art: instance(format!("Deep{}", i + 1)) }); + d.symbols.push(Symbol { dynamic: false, name: format!("Deep{i}"), art: instance(format!("Deep{}", i + 1)) }); } let green = Paint::solid(Color::rgb(0.0, 1.0, 0.0)); - d.symbols.push(Symbol { name: "Deep100".into(), art: filled_rect(Rect::new(300.0, 0.0, 400.0, 100.0), green) }); + d.symbols.push(Symbol { dynamic: false, name: "Deep100".into(), art: filled_rect(Rect::new(300.0, 0.0, 400.0, 100.0), green) }); let l = d.layers[0].id; for name in ["Fan0", "Deep0"] { let inst = d.alloc_id(); diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index f6b09c311..a8814a9e0 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -1,9 +1,11 @@ -//! Symbol Options: name, Export Type (Graphic or Movie Clip) and Registration for a new symbol +//! Symbol Options: name, Export Type (Graphic or Movie Clip), Symbol Type (Dynamic or Static) and +//! Registration for a new symbol //! from the selection (`symbol.new`), or for the current symbol in the Symbols panel //! (`symbol.options`). //! //! Fields: `name`, `nineSlice` (bool) with `sliceLeft` / `sliceTop` / `sliceRight` / `sliceBottom` -//! (percent of the symbol's size), `exportType` (`movieClip` | `graphic`), `registration` (0..8, the 9-point grid; +//! (percent of the symbol's size), `exportType` (`movieClip` | `graphic`), `type` (`dynamic` | +//! `static`; a new symbol is dynamic), `registration` (0..8, the 9-point grid; //! 4 = centre), `__edit` (true when editing an existing symbol). Help: //! . @@ -38,7 +40,7 @@ fn slice_params(d: &Dialog) -> Value { if d.bool("nineSlice") { json!({ "nineSlice": true, "nineSliceGuides": [l, t, r, b] }) } else { json!({ "nineSlice": false }) } } -/// Export Type choices: (value, label). Illustrator's default is Movie Clip. +/// Export Type choices: (value, label). The reference app's default is Movie Clip. const EXPORT_TYPES: [(&str, &str); 2] = [("movieClip", "Movie Clip"), ("graphic", "Graphic")]; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Symbol Options").into(), body, confirm, min_width: 320.0, ..DialogSpec::FORM }; @@ -50,7 +52,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { return Err("nothing selected".into()); } let name = st.doc.unique_symbol_name("New Symbol"); - let mut fields = json!({ "name": name, "exportType": "movieClip", "registration": 4, "__edit": false }); + let mut fields = json!({ "name": name, "exportType": "movieClip", "type": "dynamic", "registration": 4, "__edit": false }); if let Some(o) = fields.as_object_mut() { o.extend(slice_fields(None)); } @@ -76,9 +78,11 @@ pub fn open_edit(app: &mut VectorcraftApp, name: Option<&str>) -> Result bool { d.fields.insert("exportType".into(), json!(value)); } ui.end_row(); + label(ui, tl!("Symbol Type:")); + // A dynamic symbol's instances may change the appearance of parts of its art and stay linked. + ui.horizontal(|ui| { + let cur = d.str("type"); + for (value, text) in [("dynamic", tl!("Dynamic Symbol")), ("static", tl!("Static Symbol"))] { + if widgets::radio(ui, text, cur == value, true) { + d.fields.insert("type".into(), json!(value)); + } + } + }); + ui.end_row(); label(ui, tl!("Registration:")); let cur = d.fields.get("registration").and_then(Value::as_u64).unwrap_or(4) as usize; if let Some(i) = widgets::reference_point(ui, cur) { @@ -134,18 +149,19 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { return Err("name is empty".into()); } let export = d.str("exportType"); + let kind = d.str("type"); let registration = d.fields.get("registration").and_then(Value::as_u64).unwrap_or(4); let edit = d.fields.get("__edit").and_then(Value::as_bool).unwrap_or(false); let r = if edit { let from = d.fields.get("__from").and_then(Value::as_str).unwrap_or(name); - let mut params = json!({ "name": from, "exportType": export, "registration": registration }); + let mut params = json!({ "name": from, "exportType": export, "type": kind, "registration": registration }); merge(&mut params, slice_params(d)); if from != name { params["newName"] = json!(name); } app.run("symbol.options", params) } else { - let mut params = json!({ "name": name, "exportType": export, "registration": registration }); + let mut params = json!({ "name": name, "exportType": export, "type": kind, "registration": registration }); merge(&mut params, slice_params(d)); app.run("symbol.new", params) }; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bf02bfbf4..39e9235fa 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5971,3 +5971,15 @@ breakText Glyphs Глифи Odia Ория Urdu Урду VectorCraft {release} and later (version {n}) VectorCraft {release} и по-нови (версия {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Тип на символа: + Dynamic Symbol Динамичен символ + Static Symbol Статичен символ + Symbol Instance Appearance Външен вид на екземпляра на символ + Reset Symbol Instance Нулиране на екземпляра на символ +@msg type must be static or dynamic, not {v} type трябва да е static или dynamic, а не {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` е статичен символ: първо задайте типа на символа на „Динамичен“ +@msg path must be a list of child indices path трябва да е списък с индекси на дъщерни обекти +@msg no object at path {shown} in the symbol's art в графиката на символа няма обект на пътя {shown} +@msg give fill, stroke, opacity and/or blend задайте fill, stroke, opacity и/или blend +@msg pass newName, registration, exportType, nineSlice and/or type задайте newName, registration, exportType, nineSlice и/или type diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a22b6b985..ba39e61c9 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6004,3 +6004,15 @@ shape Open Otvoren Odia Odija Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} i novije (verzija {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Vrsta simbola: + Dynamic Symbol Dinamički simbol + Static Symbol Statički simbol + Symbol Instance Appearance Izgled instance simbola + Reset Symbol Instance Vrati instancu simbola na početno +@msg type must be static or dynamic, not {v} type mora biti static ili dynamic, a ne {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statički simbol: prvo postavite vrstu simbola na Dinamički +@msg path must be a list of child indices path mora biti lista indeksa podređenih objekata +@msg no object at path {shown} in the symbol's art nema objekta na putanji {shown} u grafici simbola +@msg give fill, stroke, opacity and/or blend navedite fill, stroke, opacity i/ili blend +@msg pass newName, registration, exportType, nineSlice and/or type navedite newName, registration, exportType, nineSlice i/ili type diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1086b60b8..dad043956 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6077,3 +6077,15 @@ breakText Glyphs Glifs Odia Oriya Urdu Urdú VectorCraft {release} and later (version {n}) VectorCraft {release} i posteriors (versió {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Tipus de símbol: + Dynamic Symbol Símbol dinàmic + Static Symbol Símbol estàtic + Symbol Instance Appearance Aparença de la instància de símbol + Reset Symbol Instance Restableix la instància de símbol +@msg type must be static or dynamic, not {v} type ha de ser static o dynamic, no {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` és un símbol estàtic: primer establiu el tipus de símbol a Dinàmic +@msg path must be a list of child indices path ha de ser una llista d’índexs de fills +@msg no object at path {shown} in the symbol's art no hi ha cap objecte al camí {shown} de l’art del símbol +@msg give fill, stroke, opacity and/or blend indiqueu fill, stroke, opacity i/o blend +@msg pass newName, registration, exportType, nineSlice and/or type indiqueu newName, registration, exportType, nineSlice i/o type diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 52a61db5c..a660cdb21 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6087,3 +6087,15 @@ breakText Glyphs Glyfy Odia Urijština Urdu Urdština VectorCraft {release} and later (version {n}) VectorCraft {release} a novější (verze {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Typ symbolu: + Dynamic Symbol Dynamický symbol + Static Symbol Statický symbol + Symbol Instance Appearance Vzhled instance symbolu + Reset Symbol Instance Obnovit instanci symbolu +@msg type must be static or dynamic, not {v} type musí být static nebo dynamic, ne {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` je statický symbol: nejprve nastavte jeho typ symbolu na Dynamický +@msg path must be a list of child indices path musí být seznam indexů potomků +@msg no object at path {shown} in the symbol's art v kresbě symbolu není na cestě {shown} žádný objekt +@msg give fill, stroke, opacity and/or blend zadejte fill, stroke, opacity a/nebo blend +@msg pass newName, registration, exportType, nineSlice and/or type zadejte newName, registration, exportType, nineSlice a/nebo type diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2b79b23dc..96bcb3327 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5985,3 +5985,15 @@ breakText Glyphs Glyffer Odia Oriya Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} og nyere (version {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Symboltype: + Dynamic Symbol Dynamisk symbol + Static Symbol Statisk symbol + Symbol Instance Appearance Symbolforekomstens udseende + Reset Symbol Instance Nulstil symbolforekomst +@msg type must be static or dynamic, not {v} type skal være static eller dynamic, ikke {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` er et statisk symbol: indstil først symboltypen til Dynamisk +@msg path must be a list of child indices path skal være en liste over underordnede indekser +@msg no object at path {shown} in the symbol's art intet objekt ved stien {shown} i symbolets grafik +@msg give fill, stroke, opacity and/or blend angiv fill, stroke, opacity og/eller blend +@msg pass newName, registration, exportType, nineSlice and/or type angiv newName, registration, exportType, nineSlice og/eller type diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 328205b3a..65a846eb7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5672,3 +5672,15 @@ breakText Glyphs Glyphen Odia Oriya Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} und neuer (Version {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Symboltyp: + Dynamic Symbol Dynamisches Symbol + Static Symbol Statisches Symbol + Symbol Instance Appearance Aussehen der Symbolinstanz + Reset Symbol Instance Symbolinstanz zurücksetzen +@msg type must be static or dynamic, not {v} type muss static oder dynamic sein, nicht {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` ist ein statisches Symbol: Stellen Sie zuerst den Symboltyp auf „Dynamisch“ ein +@msg path must be a list of child indices path muss eine Liste von Kindindizes sein +@msg no object at path {shown} in the symbol's art kein Objekt am Pfad {shown} in der Grafik des Symbols +@msg give fill, stroke, opacity and/or blend geben Sie fill, stroke, opacity und/oder blend an +@msg pass newName, registration, exportType, nineSlice and/or type geben Sie newName, registration, exportType, nineSlice und/oder type an diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bd0a4834d..6426946db 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6039,3 +6039,15 @@ breakText Glyphs Γλύφοι Odia Όντια Urdu Ούρντου VectorCraft {release} and later (version {n}) VectorCraft {release} και νεότερες (έκδοση {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Τύπος συμβόλου: + Dynamic Symbol Δυναμικό σύμβολο + Static Symbol Στατικό σύμβολο + Symbol Instance Appearance Εμφάνιση στιγμιότυπου συμβόλου + Reset Symbol Instance Επαναφορά στιγμιότυπου συμβόλου +@msg type must be static or dynamic, not {v} το type πρέπει να είναι static ή dynamic, όχι {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first το `{name}` είναι στατικό σύμβολο: ορίστε πρώτα τον τύπο συμβόλου σε Δυναμικό +@msg path must be a list of child indices το path πρέπει να είναι λίστα δεικτών θυγατρικών αντικειμένων +@msg no object at path {shown} in the symbol's art δεν υπάρχει αντικείμενο στη διαδρομή {shown} στο γραφικό του συμβόλου +@msg give fill, stroke, opacity and/or blend δώστε fill, stroke, opacity ή/και blend +@msg pass newName, registration, exportType, nineSlice and/or type δώστε newName, registration, exportType, nineSlice ή/και type diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aa29fafd2..104ed8668 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6029,3 +6029,15 @@ breakText Glyphs Glifos Odia Oriya Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} y posteriores (versión {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Tipo de símbolo: + Dynamic Symbol Símbolo dinámico + Static Symbol Símbolo estático + Symbol Instance Appearance Apariencia de la instancia de símbolo + Reset Symbol Instance Restablecer instancia de símbolo +@msg type must be static or dynamic, not {v} type debe ser static o dynamic, no {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` es un símbolo estático: primero establezca su tipo de símbolo en Dinámico +@msg path must be a list of child indices path debe ser una lista de índices de hijos +@msg no object at path {shown} in the symbol's art no hay ningún objeto en la ruta {shown} del arte del símbolo +@msg give fill, stroke, opacity and/or blend indique fill, stroke, opacity y/o blend +@msg pass newName, registration, exportType, nineSlice and/or type indique newName, registration, exportType, nineSlice y/o type diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b6a15979c..1c8f92623 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6117,3 +6117,15 @@ breakText Glyphs Merkit Odia Orija Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} ja uudemmat (versio {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Symbolin tyyppi: + Dynamic Symbol Dynaaminen symboli + Static Symbol Staattinen symboli + Symbol Instance Appearance Symboli-ilmentymän ulkoasu + Reset Symbol Instance Palauta symboli-ilmentymä +@msg type must be static or dynamic, not {v} type-arvon on oltava static tai dynamic, ei {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` on staattinen symboli: aseta ensin sen symbolin tyypiksi Dynaaminen +@msg path must be a list of child indices path-arvon on oltava luettelo alikohteiden indeksejä +@msg no object at path {shown} in the symbol's art symbolin kuvituksessa ei ole objektia polussa {shown} +@msg give fill, stroke, opacity and/or blend anna fill, stroke, opacity ja/tai blend +@msg pass newName, registration, exportType, nineSlice and/or type anna newName, registration, exportType, nineSlice ja/tai type diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f16a04927..6f0fe8aca 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6084,3 +6084,15 @@ breakText Glyphs Glyphes Odia Odia Urdu Ourdou VectorCraft {release} and later (version {n}) VectorCraft {release} et ultérieures (version {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Type de symbole : + Dynamic Symbol Symbole dynamique + Static Symbol Symbole statique + Symbol Instance Appearance Aspect de l'instance de symbole + Reset Symbol Instance Réinitialiser l'instance de symbole +@msg type must be static or dynamic, not {v} type doit être static ou dynamic, et non {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` est un symbole statique : définissez d'abord son type de symbole sur Dynamique +@msg path must be a list of child indices path doit être une liste d'indices d'enfants +@msg no object at path {shown} in the symbol's art aucun objet au chemin {shown} dans l'illustration du symbole +@msg give fill, stroke, opacity and/or blend indiquez fill, stroke, opacity et/ou blend +@msg pass newName, registration, exportType, nineSlice and/or type indiquez newName, registration, exportType, nineSlice et/ou type diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 37e40c442..10f83807e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6073,3 +6073,15 @@ breakText Glyphs Glifák Odia Odia Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} és újabb (verzió: {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Szimbólum típusa: + Dynamic Symbol Dinamikus szimbólum + Static Symbol Statikus szimbólum + Symbol Instance Appearance Szimbólumpéldány megjelenése + Reset Symbol Instance Szimbólumpéldány visszaállítása +@msg type must be static or dynamic, not {v} a type értéke static vagy dynamic lehet, nem {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statikus szimbólum: előbb állítsa a szimbólum típusát Dinamikusra +@msg path must be a list of child indices a path gyermekindexek listája kell legyen +@msg no object at path {shown} in the symbol's art a szimbólum grafikájában nincs objektum a(z) {shown} útvonalon +@msg give fill, stroke, opacity and/or blend adja meg a fill, stroke, opacity és/vagy blend értékét +@msg pass newName, registration, exportType, nineSlice and/or type adja meg a newName, registration, exportType, nineSlice és/vagy type értékét diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1b46b5d86..6cdd3a8cf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5985,3 +5985,15 @@ breakText Glyphs Glif Odia Oriya Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} dan yang lebih baru (versi {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Jenis simbol: + Dynamic Symbol Simbol dinamis + Static Symbol Simbol statis + Symbol Instance Appearance Tampilan instans simbol + Reset Symbol Instance Atur ulang instans simbol +@msg type must be static or dynamic, not {v} type harus static atau dynamic, bukan {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` adalah simbol statis: setel dulu jenis simbolnya ke Dinamis +@msg path must be a list of child indices path harus berupa daftar indeks anak +@msg no object at path {shown} in the symbol's art tidak ada objek di jalur {shown} dalam karya seni simbol +@msg give fill, stroke, opacity and/or blend berikan fill, stroke, opacity dan/atau blend +@msg pass newName, registration, exportType, nineSlice and/or type berikan newName, registration, exportType, nineSlice dan/atau type diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 701468a83..51ae7f5ef 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5706,3 +5706,15 @@ breakText Glyphs Glifi Odia Odia Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} e successive (versione {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Tipo di simbolo: + Dynamic Symbol Simbolo dinamico + Static Symbol Simbolo statico + Symbol Instance Appearance Aspetto dell'istanza di simbolo + Reset Symbol Instance Reimposta istanza di simbolo +@msg type must be static or dynamic, not {v} type deve essere static o dynamic, non {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` è un simbolo statico: imposta prima il tipo di simbolo su Dinamico +@msg path must be a list of child indices path deve essere un elenco di indici figlio +@msg no object at path {shown} in the symbol's art nessun oggetto al percorso {shown} nella grafica del simbolo +@msg give fill, stroke, opacity and/or blend specifica fill, stroke, opacity e/o blend +@msg pass newName, registration, exportType, nineSlice and/or type specifica newName, registration, exportType, nineSlice e/o type diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7057e12d7..850e48182 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5773,3 +5773,15 @@ breakText Glyphs 字形 Odia オディア語 Urdu ウルドゥー語 VectorCraft {release} and later (version {n}) VectorCraft {release} 以降 (バージョン {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: シンボルの種類: + Dynamic Symbol ダイナミックシンボル + Static Symbol スタティックシンボル + Symbol Instance Appearance シンボルインスタンスのアピアランス + Reset Symbol Instance シンボルインスタンスをリセット +@msg type must be static or dynamic, not {v} type は static または dynamic にしてください({v} は無効です) +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` はスタティックシンボルです。先にシンボルの種類をダイナミックに設定してください +@msg path must be a list of child indices path は子のインデックスのリストにしてください +@msg no object at path {shown} in the symbol's art シンボルのアートのパス {shown} にオブジェクトがありません +@msg give fill, stroke, opacity and/or blend fill、stroke、opacity、blend のいずれかを指定してください +@msg pass newName, registration, exportType, nineSlice and/or type newName、registration、exportType、nineSlice、type のいずれかを指定してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8af3007e1..6242e6e05 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6076,3 +6076,15 @@ breakText Glyphs გლიფები Odia ორია Urdu ურდუ VectorCraft {release} and later (version {n}) VectorCraft {release} და უფრო ახალი (ვერსია {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: სიმბოლოს ტიპი: + Dynamic Symbol დინამიკური სიმბოლო + Static Symbol სტატიკური სიმბოლო + Symbol Instance Appearance სიმბოლოს ეგზემპლარის იერსახე + Reset Symbol Instance სიმბოლოს ეგზემპლარის ჩამოყრა +@msg type must be static or dynamic, not {v} type უნდა იყოს static ან dynamic და არა {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` სტატიკური სიმბოლოა: ჯერ სიმბოლოს ტიპად დააყენეთ „დინამიკური“ +@msg path must be a list of child indices path უნდა იყოს შვილობილი ინდექსების სია +@msg no object at path {shown} in the symbol's art სიმბოლოს გრაფიკაში გზაზე {shown} ობიექტი არ არის +@msg give fill, stroke, opacity and/or blend მიუთითეთ fill, stroke, opacity და/ან blend +@msg pass newName, registration, exportType, nineSlice and/or type მიუთითეთ newName, registration, exportType, nineSlice და/ან type diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6156029db..b9d96eed0 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5669,3 +5669,15 @@ breakText Glyphs 글리프 Odia 오리야어 Urdu 우르두어 VectorCraft {release} and later (version {n}) VectorCraft {release} 이상 (버전 {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: 심볼 유형: + Dynamic Symbol 동적 심볼 + Static Symbol 정적 심볼 + Symbol Instance Appearance 심볼 인스턴스 모양새 + Reset Symbol Instance 심볼 인스턴스 재설정 +@msg type must be static or dynamic, not {v} type은 static 또는 dynamic이어야 합니다({v}는 안 됨) +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}`은(는) 정적 심볼입니다. 먼저 심볼 유형을 동적으로 설정하세요 +@msg path must be a list of child indices path는 자식 인덱스 목록이어야 합니다 +@msg no object at path {shown} in the symbol's art 심볼 아트의 경로 {shown}에 개체가 없습니다 +@msg give fill, stroke, opacity and/or blend fill, stroke, opacity 및/또는 blend를 지정하세요 +@msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice 및/또는 type을 지정하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ea641dab7..6a227c394 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6029,3 +6029,15 @@ breakText Glyphs Glyphs Odia Odia Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} en nieuwer (versie {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Symbooltype: + Dynamic Symbol Dynamisch symbool + Static Symbol Statisch symbool + Symbol Instance Appearance Uiterlijk van symboolinstantie + Reset Symbol Instance Symboolinstantie opnieuw instellen +@msg type must be static or dynamic, not {v} type moet static of dynamic zijn, niet {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` is een statisch symbool: stel eerst het symbooltype in op Dynamisch +@msg path must be a list of child indices path moet een lijst met onderliggende indexen zijn +@msg no object at path {shown} in the symbol's art geen object op pad {shown} in de illustratie van het symbool +@msg give fill, stroke, opacity and/or blend geef fill, stroke, opacity en/of blend op +@msg pass newName, registration, exportType, nineSlice and/or type geef newName, registration, exportType, nineSlice en/of type op diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a1f22e4d7..9443b2488 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5942,3 +5942,15 @@ breakText Glyphs Glify Odia Orija Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} i nowsze (wersja {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Typ symbolu: + Dynamic Symbol Symbol dynamiczny + Static Symbol Symbol statyczny + Symbol Instance Appearance Wygląd wystąpienia symbolu + Reset Symbol Instance Resetuj wystąpienie symbolu +@msg type must be static or dynamic, not {v} type musi mieć wartość static lub dynamic, a nie {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` jest symbolem statycznym: najpierw ustaw jego typ symbolu na Dynamiczny +@msg path must be a list of child indices path musi być listą indeksów elementów podrzędnych +@msg no object at path {shown} in the symbol's art brak obiektu w ścieżce {shown} w grafice symbolu +@msg give fill, stroke, opacity and/or blend podaj fill, stroke, opacity i/lub blend +@msg pass newName, registration, exportType, nineSlice and/or type podaj newName, registration, exportType, nineSlice i/lub type diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4b1d5f83f..e61e7a1a4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6006,3 +6006,15 @@ breakText Glyphs Glifos Odia Oriá Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} e posteriores (versão {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Tipo de símbolo: + Dynamic Symbol Símbolo dinâmico + Static Symbol Símbolo estático + Symbol Instance Appearance Aparência da instância de símbolo + Reset Symbol Instance Redefinir instância de símbolo +@msg type must be static or dynamic, not {v} type deve ser static ou dynamic, não {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` é um símbolo estático: defina primeiro o tipo de símbolo como Dinâmico +@msg path must be a list of child indices path deve ser uma lista de índices de filhos +@msg no object at path {shown} in the symbol's art nenhum objeto no caminho {shown} na arte do símbolo +@msg give fill, stroke, opacity and/or blend informe fill, stroke, opacity e/ou blend +@msg pass newName, registration, exportType, nineSlice and/or type informe newName, registration, exportType, nineSlice e/ou type diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3f05c0190..eb5449f0f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6064,3 +6064,15 @@ breakText Glyphs Глифы Odia Ория Urdu Урду VectorCraft {release} and later (version {n}) VectorCraft {release} и новее (версия {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Тип символа: + Dynamic Symbol Динамический символ + Static Symbol Статический символ + Symbol Instance Appearance Оформление экземпляра символа + Reset Symbol Instance Сбросить экземпляр символа +@msg type must be static or dynamic, not {v} type должен быть static или dynamic, а не {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статический символ: сначала задайте для него тип символа «Динамический» +@msg path must be a list of child indices path должен быть списком индексов дочерних объектов +@msg no object at path {shown} in the symbol's art в графике символа нет объекта по пути {shown} +@msg give fill, stroke, opacity and/or blend укажите fill, stroke, opacity и/или blend +@msg pass newName, registration, exportType, nineSlice and/or type укажите newName, registration, exportType, nineSlice и/или type diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index adabea465..a5cd18975 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6139,3 +6139,15 @@ breakText Glyphs Glyfer Odia Oriya Urdu Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} och senare (version {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Symboltyp: + Dynamic Symbol Dynamisk symbol + Static Symbol Statisk symbol + Symbol Instance Appearance Symbolinstansens utseende + Reset Symbol Instance Återställ symbolinstans +@msg type must be static or dynamic, not {v} type måste vara static eller dynamic, inte {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` är en statisk symbol: ställ först in symboltypen på Dynamisk +@msg path must be a list of child indices path måste vara en lista med underordnade index +@msg no object at path {shown} in the symbol's art inget objekt vid sökvägen {shown} i symbolens bild +@msg give fill, stroke, opacity and/or blend ange fill, stroke, opacity och/eller blend +@msg pass newName, registration, exportType, nineSlice and/or type ange newName, registration, exportType, nineSlice och/eller type diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 88017e191..36d2676d3 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6044,3 +6044,15 @@ breakText Glyphs Glifler Odia Oriya dili Urdu Urduca VectorCraft {release} and later (version {n}) VectorCraft {release} ve sonrası (sürüm {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Sembol Türü: + Dynamic Symbol Dinamik Sembol + Static Symbol Statik Sembol + Symbol Instance Appearance Sembol Örneği Görünümü + Reset Symbol Instance Sembol Örneğini Sıfırla +@msg type must be static or dynamic, not {v} type static veya dynamic olmalıdır, {v} değil +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` statik bir semboldür: önce Sembol Türünü Dinamik olarak ayarlayın +@msg path must be a list of child indices path alt öğe dizinlerinden oluşan bir liste olmalıdır +@msg no object at path {shown} in the symbol's art sembolün çiziminde {shown} yolunda nesne yok +@msg give fill, stroke, opacity and/or blend fill, stroke, opacity ve/veya blend verin +@msg pass newName, registration, exportType, nineSlice and/or type newName, registration, exportType, nineSlice ve/veya type verin diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c0533166d..2ae65e276 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5686,3 +5686,15 @@ breakText Glyphs Гліфи Odia Одія Urdu Урду VectorCraft {release} and later (version {n}) VectorCraft {release} і новіші (версія {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Тип символу: + Dynamic Symbol Динамічний символ + Static Symbol Статичний символ + Symbol Instance Appearance Оформлення екземпляра символу + Reset Symbol Instance Скинути екземпляр символу +@msg type must be static or dynamic, not {v} type має бути static або dynamic, а не {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` — статичний символ: спершу встановіть для нього тип символу «Динамічний» +@msg path must be a list of child indices path має бути списком індексів дочірніх об’єктів +@msg no object at path {shown} in the symbol's art у графіці символу немає об’єкта за шляхом {shown} +@msg give fill, stroke, opacity and/or blend вкажіть fill, stroke, opacity та/або blend +@msg pass newName, registration, exportType, nineSlice and/or type вкажіть newName, registration, exportType, nineSlice та/або type diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3ade68399..3ba54b124 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5689,3 +5689,15 @@ breakText Glyphs Glyph Odia Tiếng Odia Urdu Tiếng Urdu VectorCraft {release} and later (version {n}) VectorCraft {release} trở lên (phiên bản {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: Kiểu ký hiệu: + Dynamic Symbol Ký hiệu động + Static Symbol Ký hiệu tĩnh + Symbol Instance Appearance Diện mạo của đối tượng ký hiệu + Reset Symbol Instance Đặt lại đối tượng ký hiệu +@msg type must be static or dynamic, not {v} type phải là static hoặc dynamic, không phải {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` là ký hiệu tĩnh: trước tiên hãy đặt Kiểu ký hiệu thành Động +@msg path must be a list of child indices path phải là danh sách chỉ số con +@msg no object at path {shown} in the symbol's art không có đối tượng tại đường dẫn {shown} trong hình của ký hiệu +@msg give fill, stroke, opacity and/or blend hãy cung cấp fill, stroke, opacity và/hoặc blend +@msg pass newName, registration, exportType, nineSlice and/or type hãy cung cấp newName, registration, exportType, nineSlice và/hoặc type diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b3f76cc2b..7608eec56 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5929,3 +5929,15 @@ breakText Glyphs 字形 Odia 奥里亚语 Urdu 乌尔都语 VectorCraft {release} and later (version {n}) VectorCraft {release} 及更高版本 (版本 {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: 符号类型: + Dynamic Symbol 动态符号 + Static Symbol 静态符号 + Symbol Instance Appearance 符号实例外观 + Reset Symbol Instance 重置符号实例 +@msg type must be static or dynamic, not {v} type 必须是 static 或 dynamic,而不是 {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是静态符号:请先将其符号类型设为动态 +@msg path must be a list of child indices path 必须是子项索引列表 +@msg no object at path {shown} in the symbol's art 符号图稿中路径 {shown} 处没有对象 +@msg give fill, stroke, opacity and/or blend 请提供 fill、stroke、opacity 和/或 blend +@msg pass newName, registration, exportType, nineSlice and/or type 请提供 newName、registration、exportType、nineSlice 和/或 type diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index fd750354a..1482f4572 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5726,3 +5726,15 @@ breakText Glyphs 字符 Odia 歐利亞文 Urdu 烏都文 VectorCraft {release} and later (version {n}) VectorCraft {release} 及更新版本 (版本 {n}) +# Symbol Options › Symbol Type (dynamic symbols) + Symbol Type: 符號類型: + Dynamic Symbol 動態符號 + Static Symbol 靜態符號 + Symbol Instance Appearance 符號實體外觀 + Reset Symbol Instance 重設符號實體 +@msg type must be static or dynamic, not {v} type 必須是 static 或 dynamic,而不是 {v} +@msg `{name}` is a static symbol: set its Symbol Type to Dynamic first `{name}` 是靜態符號:請先將其符號類型設為動態 +@msg path must be a list of child indices path 必須是子項索引清單 +@msg no object at path {shown} in the symbol's art 符號圖稿中路徑 {shown} 處沒有物件 +@msg give fill, stroke, opacity and/or blend 請提供 fill、stroke、opacity 和/或 blend +@msg pass newName, registration, exportType, nineSlice and/or type 請提供 newName、registration、exportType、nineSlice 和/或 type diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index 0aef4fb63..e4c5b9d76 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -731,7 +731,7 @@ fn previews_follow_the_swatches_images_and_pattern_options_their_art_uses() { } vectorcraft_brush::store(d, &brushes); for (name, art) in [("Ink Symbol", &inked), ("Picture Symbol", &pictured), ("Stripes Symbol", &striped), ("Fade Symbol", &faded)] { - d.symbols.push(Symbol { name: name.into(), art: Arc::new(art.clone()) }); + d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art.clone()) }); } Ok(()) }) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c0ab7f5e9..f6f6724e5 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -136,6 +136,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if is_live { actions.push((tl!("Expand Shape"), "object.expandShape")); } + // A dynamic symbol instance with appearance changes of its own: Reset shows the symbol's again. + if super::symbols::changed_instances_selected(app) { + actions.push((tl!("Reset"), "symbol.instance.reset")); + } if multi_color(app, ui.ctx()) { actions.push((tl!("Recolor"), "ui.recolorDialog")); } diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 49acb3e8a..c4059b8d5 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1666,3 +1666,21 @@ fn offset_path_and_simplify_are_not_quick_actions_for_type_alone() { let texts = tall(&mut app, &ctx, vec![]); assert!(shows(&texts, "Offset Path") && shows(&texts, "Simplify"), "type and a path: {texts:?}"); } + +/// A dynamic symbol instance with appearance changes of its own has Reset in Quick Actions, which +/// drops them (#4938); without changes there is no Reset. +#[test] +fn reset_is_a_quick_action_for_a_changed_dynamic_instance() { + let mut app = new_app(); + let (a, b) = (rect(&mut app, 20.0, 20.0, 40.0, 40.0), rect(&mut app, 80.0, 20.0, 40.0, 40.0)); + let inst = app.run("symbol.new", json!({"ids": [a, b], "type": "dynamic"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [inst]})).unwrap(); + let ctx = egui::Context::default(); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Reset"), "nothing to reset"); + app.run("symbol.instance.setAppearance", json!({"path": [0], "fill": "#ff0000"})).unwrap(); + let texts = tall(&mut app, &ctx, vec![]); + press(&mut app, &ctx, text_at(&texts, "Reset").center()); + let id = vectorcraft_doc::NodeId(inst.as_u64().unwrap()); + assert!(app.session.doc().unwrap().doc.node(id).unwrap().extras().symbol_overrides.is_empty()); + assert!(!shows(&tall(&mut app, &ctx, vec![]), "Reset")); +} diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index bc77f8e4c..b5b305895 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -137,18 +137,29 @@ fn current(app: &mut VectorcraftApp) -> Option { app.run("symbol.list", json!({})).ok().and_then(|v| v["current"].as_str().map(str::to_string)) } -fn has_instances_selected(app: &VectorcraftApp) -> bool { +/// Does the selection hold (in it or in its groups) a symbol instance that `wanted` takes? +fn instance_selected(app: &VectorcraftApp, wanted: impl Fn(&Node) -> bool) -> bool { app.session.active().is_some_and(|st| { st.selection.objects.iter().any(|id| { let mut any = false; if let Some(n) = st.doc.node(*id) { - n.walk(&mut |c| any |= matches!(c.kind, NodeKind::SymbolInstance { .. })); + n.walk(&mut |c| any |= matches!(c.kind, NodeKind::SymbolInstance { .. }) && wanted(c)); } any }) }) } +fn has_instances_selected(app: &VectorcraftApp) -> bool { + instance_selected(app, |_| true) +} + +/// Does the selection hold a symbol instance with appearance changes of its own (a dynamic +/// symbol's), which Reset drops? +pub(crate) fn changed_instances_selected(app: &VectorcraftApp) -> bool { + instance_selected(app, |c| !c.extras().symbol_overrides.is_empty()) +} + /// The symbols of the selected objects when every one is a symbol instance; `None` otherwise. fn selected_instance_symbols(app: &VectorcraftApp) -> Option> { let st = app.session.active()?; @@ -242,7 +253,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let zone = ui.interact(list_rect, ui.scope_id().with("symbols-drop"), Sense::hover()); if let Some(ids) = widgets::art_drop(ui, &zone) - && let Err(e) = app.run("symbol.new", json!({ "ids": ids })) + && let Err(e) = app.run("symbol.new", json!({ "ids": ids, "type": "dynamic" })) { app.status(e); } @@ -269,9 +280,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0)); if widgets::icon_button_enabled(ui, "dc-new-item", tl!("New Symbol"), false, has_sel, 24.0).clicked() { - // Alt skips Symbol Options: the symbol is made under the default name. - let cmd = if ui.input(|i| i.modifiers.alt) { "symbol.new" } else { "ui.symbolOptions" }; - app.run(cmd, json!({})).ok(); + // Alt skips Symbol Options: the symbol is made under the default name, dynamic as the + // dialog's default. + let (cmd, p) = if ui.input(|i| i.modifiers.alt) { ("symbol.new", json!({"type": "dynamic"})) } else { ("ui.symbolOptions", json!({})) }; + app.run(cmd, p).ok(); } // Alt deletes an unused symbol without asking; a symbol with instances still asks. if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Symbol"), false, sel.is_some(), 24.0).clicked() @@ -303,12 +315,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { { crate::dialogs::symbol_delete::ask(app, n, false); } - let items: [(&str, bool, &str, Value); 6] = [ + let items: [(&str, bool, &str, Value); 7] = [ (tl!("Symbol Options…"), sel.is_some(), "ui.symbolOptions", json!({"edit": true, "name": sel})), (tl!("Edit Symbol"), inst, "symbol.edit", json!({})), (tl!("Place Symbol Instance"), sel.is_some(), "symbol.place", name.clone()), (tl!("Replace Symbol"), inst && sel.is_some(), "symbol.replace", name.clone()), (tl!("Break Link to Symbol"), inst, "symbol.breakLink", json!({})), + (tl!("Reset Symbol Instance"), changed_instances_selected(app), "symbol.instance.reset", json!({})), (tl!("Select All Instances"), sel.is_some(), "symbol.selectInstances", name), ]; for (label, enabled, cmd, params) in items { @@ -600,10 +613,12 @@ mod tests { assert!(symbols(&mut app).is_empty(), "no symbol before OK"); assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "New Symbol"); assert_eq!(app.ui.dialog.as_ref().unwrap().str("exportType"), "movieClip"); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("type"), "dynamic", "Symbol Type: a new symbol is dynamic"); crate::dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); let list = app.run("symbol.list", json!({})).unwrap(); assert_eq!(list["symbols"][0]["exportType"], "movieClip"); + assert_eq!(list["symbols"][0]["type"], "dynamic"); // The next one is offered a name no symbol has, and OK keeps the name typed and Export Type. app.run("shape.rectangle", json!({"x": 60, "y": 10, "width": 30, "height": 20})).unwrap(); app.run("ui.symbolOptions", json!({})).unwrap(); @@ -614,13 +629,18 @@ mod tests { assert_eq!(symbols(&mut app), ["New Symbol", "Badge"]); let list = app.run("symbol.list", json!({})).unwrap(); assert_eq!(list["symbols"][1]["exportType"], "graphic"); - // Symbol Options on the current symbol renames and keeps Export Type. + // Symbol Options on the current symbol renames, keeps Export Type and sets Symbol Type. app.run("ui.symbolOptions", json!({"edit": true, "name": "Badge"})).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().str("exportType"), "graphic"); app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mark")); + app.ui.dialog.as_mut().unwrap().fields.insert("type".into(), json!("static")); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(symbols(&mut app), ["New Symbol", "Mark"]); assert_eq!(app.run("symbol.list", json!({})).unwrap()["symbols"][1]["exportType"], "graphic"); + assert_eq!(app.run("symbol.list", json!({})).unwrap()["symbols"][1]["type"], "static"); + app.run("ui.symbolOptions", json!({"edit": true, "name": "Mark"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("type"), "static"); + app.ui.dialog = None; // Enable Guides for 9-Slice Scaling stores the guides (percent in the dialog) and reopens as set. assert!(app.run("symbol.list", json!({})).unwrap()["symbols"][1]["nineSlice"].is_null()); app.run("ui.symbolOptions", json!({"edit": true, "name": "Mark"})).unwrap(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fe9f55415..1d84d3862 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (exports draw artboard background colors, #4937) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (native format: dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -90,7 +90,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | |---|---|---|---|---| -| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778) | +| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) | | Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) | | Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus | | PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) | @@ -131,6 +131,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Native format: a symbol's Symbol Type (`dynamic`) and a dynamic symbol instance's appearance changes to objects of its art (`symbol_overrides`) round-trip; both are left out when unset, so other documents save unchanged (#4938). `.ai` reading and writing of dynamic symbols remains. | | 2026-11-01 | minor | Exports draw each artboard's background color behind its art (#4937): PNG, JPEG, GIF, TIFF, WebP, BMP, Targa and PSD (a layer of its own; on a transparent background too), PDF, SVG and EPS pages, a `.ai` file's PDF pages and PostScript and PDF print; `includeArtboardFill: false` leaves it out. Files with editing data (Preserve Editing, `.ai`, EPS, native) keep it as the artboard's color and reopen without extra art. Not read from the reference app's own `.ai` data, nor written to it. DXF, EMF and WMF leave it out | | 2026-10-31 | minor | PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`; scores unchanged (estimated) | | 2026-10-31 | minor | DXF export (2000–2018): the LAYOUT objects wrote the paper image origin as a point at group codes 148 and 158, but PLOTSETTINGS defines 148 and 149 and no 158, so strict readers could reject the file (Autodesk Fusion reported "DXF File failed to insert", #3085). They now write 148/149, the shade plot mode, resolution and DPI (76–78, 2004 and later) and flag 1024 on the Model layout; the plot style ACDBPLACEHOLDER no longer carries a subclass marker. Test `r2000_and_later_files_have_the_structure_cad_apps_require` (required sections and table entries, handles and references, LAYOUT group codes); exports pass ezdxf's recover and audit; not tested in Fusion itself; scores unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index d8fa3849b..5c04e8704 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (G13: artboard background colors in exports and print, #4937; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (G5: dynamic symbols, #4938; estimate 15–26 → 14–24 h) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -57,7 +57,7 @@ stage ([ROADMAP.md](../ROADMAP.md)). | 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 1–2 | no | [effects-parity.md](effects-parity.md) | | 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) | | 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) | -| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 16–27 | no | [target-app-parity.md](target-app-parity.md) | +| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 15–25 | no | [target-app-parity.md](target-app-parity.md) | | 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) | | 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) | | 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) | @@ -521,7 +521,13 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall any place and size, narrowed by Appearance, Size and the artboards to search, take an edit of any one of them in proportion, as one undo step ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)); on a group (#4939), the groups like it (the same objects at the same places, moved or scaled) take an edit - made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)). + made to an object inside it at the object in the same place ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)); + dynamic symbols (#4938): Symbol Options › Symbol Type (Dynamic Symbol / Static Symbol; `type` on + `symbol.new` / `symbol.options` / `symbol.list`), an instance's own fill, stroke, opacity and blend for an + object of a dynamic symbol's art (`symbol.instance.setAppearance`, by child-index path), drawn and exported + with the instance; redefining the symbol keeps each change whose object remains, Break Link bakes them, + Reset (`symbol.instance.reset`, the Properties panel and the Symbols panel menu) drops them, and making the + symbol static drops them; stored in the native format. Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art); symbol libraries ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol Libraries Menu open each library in a library panel of its own (one original library generated in code, @@ -541,10 +547,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file (`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`). - **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original, - generated in code, never Adobe's); dynamic symbols; Global Edit of groups holding type or + generated in code, never Adobe's); dynamic symbols' on-canvas editing (Direct Selection of an object inside a + dynamic instance, with the Fill/Stroke controls and Appearance panel then setting its change) and their `.ai` + read and write; Global Edit of groups holding type or images, rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them. -- **Estimate:** 15–26 h. +- **Estimate:** 14–24 h. ## G3 3D and Materials @@ -679,6 +687,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | G5: dynamic symbols (#4938): Symbol Options › Symbol Type, per-instance fill, stroke, opacity and blend for objects of a dynamic symbol's art that stay linked (redefining keeps them, Break Link bakes them, Reset and Static drop them; `symbol.instance.setAppearance`, `symbol.instance.reset`; `crates/engine/src/tests/dynamic_symbols.rs`); on-canvas Direct Selection of the parts and `.ai` round trip remain. Estimate 15–26 → 14–24 h. | | 2026-11-01 | minor | G13: Artboard background colors in output (#4937): raster exports (a transparent background too), PDF, SVG and EPS pages, `.ai` files' PDF pages, Export for Screens and print (and its preview) draw each artboard's color behind its art, inside the artboard and in the color's own model; Export for Screens has Include Artboard Fill beside Include Bleed (on by default, remembered; `includeArtboardFill`, also on `document.export` and `file.print`); editing data keeps the color as the artboard's, and Export for Screens' document rows keep it too; estimate unchanged. | | 2026-11-01 | minor | G5: Select › Start Global Edit on a group takes the groups like it (the same objects at the same places, moved or scaled; Appearance, Size and the artboard options apply) and gives an edit made to an object inside one to the object at its place in each of the others, in proportion, as one undo step (#4939); rotated copies and Shift-click exclusion remain. Estimate unchanged. | | 2026-10-31 | minor | G4: PDF import finds an installed font whose PDF name holds hyphens (`Noto-Sans-Bold` is Noto Sans, Bold: the longest part before a hyphen that names an installed family) and a composite font by its CIDFont's name rather than its base name, which ends in the CMap's (`Name-Identity-H`, [ISO 32000-1 §9.7.6.1](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf)); a missing font is split only before a style part (`Helvetica-BoldOblique`), so it is reported as `MS PGothic`, not `MS`. Estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index c0eafc23c..9a85428f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1553,14 +1553,30 @@ Guides) can't be picked. An artboard guide is picked, drawn and snapped to acros These families are in the live catalog (`command_list` / `vectorcraft://commands`); the prompt `trace-and-style` already walks Image Trace. Short agent entry points: -- Symbols: `symbol.list` (each row includes `registration` 0..8 and `exportType`: `movieClip`|`graphic`), - `symbol.new {name?, registration?, exportType?, nineSlice?, nineSliceGuides?, ids?}`, - `symbol.options {name?, newName?, registration?, exportType?, nineSlice?, nineSliceGuides?}` (`nineSlice` bool turns +- Symbols: `symbol.list` (each row includes `registration` 0..8, `exportType`: `movieClip`|`graphic` and `type`: + `static`|`dynamic`), + `symbol.new {name?, registration?, exportType?, nineSlice?, nineSliceGuides?, type?, ids?}`, + `symbol.options {name?, newName?, registration?, exportType?, nineSlice?, nineSliceGuides?, type?}` (`nineSlice` bool turns 9-slice guides on or off; `nineSliceGuides` is `[left, top, right, bottom]`, fractions 0..1 of the symbol size; rows - of `symbol.list` carry `nineSlice`: the guides or `null`), + of `symbol.list` carry `nineSlice`: the guides or `null`; `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.edit`, `symbol.duplicate`, `symbol.selectInstances {name}`. `symbol.new` and `symbol.duplicate {name, newName?}` trim the white space around the name they are given, as `symbol.options` trims `newName`; a blank one gives "New Symbol" or " copy". +- Dynamic symbols: an instance of a symbol whose `type` is `dynamic` changes the appearance of objects in the symbol's + art for itself only and stays linked. `symbol.instance.setAppearance {id?, path, fill?, stroke?, opacity?, blend?}` + names the object by `path`, child indices in paint order from the art's root down through its groups (`[]` is the + root; `[1, 0]` is the first object of the art's second member when that member is a group). `fill` and `stroke` take + a colour string or the paint params of `paint.setFill` (`color`, `none`, `swatch`, `tint`, `gradient`), `opacity` is + 0..100 and `blend` a blend mode name; `null` gives one back to the symbol. It is refused for a static symbol and for + a path that names no object. `symbol.instance.reset {ids?}` drops the selected instances' changes (the Properties + panel's Reset). Redefining the symbol (`symbol.update`) reshapes every instance and keeps each change whose path the + new art still has; `symbol.breakLink` bakes the changes into the expanded art; `symbol.edit` expands the symbol's own + art; making the symbol static drops them. + +```json +{"name":"run_command","arguments":{"command":"symbol.instance.setAppearance","params":{"id":42,"path":[1],"fill":"#d0021b","opacity":80}}} +``` - The Brushes panel menu's Select All Unused: `brush.unused` (a query) lists the brushes no stroke paints with, in library order, and the panel selects them. Strokes in the art (opacity masks included), symbols, graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and graphic styles count, and so do the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 442dba38a..726ed9483 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (artboard background colors in exports and print, #4937; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (dynamic symbols, #4938; score unchanged, hours 5–9 → 4–8) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -129,9 +129,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) are done | 5–9 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guide settings (stored, not drawn) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters, a Transform effect in a fill's or stroke's own effects ignoring Transform Patterns and Scale Strokes & Effects | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | @@ -140,7 +140,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Type core | 9 | 80% | Auto-leading style resets are implemented; remaining: hyphenation and justification options, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; colour emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; scores and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | -| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, dynamic symbols, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) are done | 5–9 | estimated | +| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration and Export Type (Graphic / Movie Clip) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and Arrange | 9–14 | estimated; document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | @@ -1415,10 +1415,15 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Document Tools › New Symbols from Layers makes one symbol of each top-level layer's art (the layers given, else those highlighted in the Layers panel when two or more are, else every layer), named after the layer (a number added when a symbol has that name), with New Symbol's own steps: the art normalized to its natural size, registration in the centre, threaded text keeping the text it shows, and sublayers held as groups. Convert Layers to Symbols does the same and puts an instance of each symbol in place of its layer's art, at the same place and size, selecting the instances; locked layers are skipped then. Empty layers are skipped and reported, all in one undo step (none when nothing is made). Tests: `cmd::addons::document::tests` in `vectorcraft-engine` (a symbol per layer, unique names, empty and locked layers reported, replacement in place, the highlighted layers as the default, bad layer ids refused, one undo step), `new_symbols_from_layers_and_convert_layers_to_symbols` in `vectorcraft-ui-egui` and `command_sweep`. Commands: `docs/mcp.md` › Document Tools add-on. +## Dynamic symbols (2026-11-01) + +Symbols area (#4938), from the public symbol documentation ([create and place symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html), [edit symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html)). Symbol Options has Symbol Type (Dynamic Symbol / Static Symbol; the dialog's default is Dynamic, `symbol.new`'s `type` defaults to static so scripts behave as before). An instance of a dynamic symbol keeps its own fill, stroke, opacity and blend for any object of the symbol's art, named by a child-index path (`symbol.instance.setAppearance`), and stays an instance: the canvas, SVG, PDF, EPS, legacy `.ai`, EMF/WMF and DXF writers, effects and Expand draw the art with those changes. Redefining the symbol reshapes every instance and keeps each change whose object the new art still has; Break Link bakes the changes into the art; Edit Symbol expands the symbol's own art; Reset (`symbol.instance.reset`: the Properties panel's Quick Actions and the Symbols panel menu) and making the symbol static drop them. The type is stored on the symbol and the changes on the instance in the native format, both left out when unset, so documents without them save byte for byte as before. Remaining: Direct Selection of an object inside a dynamic instance on the canvas (with the Fill/Stroke controls then setting its change), and reading and writing the type and changes in `.ai` files. Evidence: `crates/engine/src/tests/dynamic_symbols.rs`, `crates/doc/src/symbol_overrides.rs` tests. Score unchanged at 87% (estimated); hours 5–9 → 4–8. + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Symbols: dynamic symbols (#4938): Symbol Options › Symbol Type, per-instance appearance for objects of a dynamic symbol's art that stays linked through redefinition, Break Link baking, Reset and Static dropping it; evidence added. Score unchanged at 87% (estimated), hours 5–9 → 4–8. | | 2026-11-01 | minor | Artboard background colors in output (#4937): raster exports (a transparent background too), PDF, SVG and EPS pages, `.ai` files' PDF pages, Export for Screens and print (and its preview) draw each artboard's color behind its art, inside the artboard and in the color's own model; Export for Screens has Include Artboard Fill beside Include Bleed (on by default, remembered; `includeArtboardFill`, also on `document.export` and `file.print`); editing data keeps the color as the artboard's, and Export for Screens' document rows keep it too; evidence updated; scores unchanged. | | 2026-11-01 | minor | Selection: Select › Start Global Edit on a group takes the groups like it (the same objects at the same places, moved or scaled; Appearance, Size and the artboard options apply) and gives an edit made to an object inside one to the object at its place in each of the others, in proportion, as one undo step (#4939); Global Edit of rotated copies and Shift-click exclusion remain. Score unchanged at 85% (estimated), hours unchanged. | | 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): New Symbols from Layers (#4927); evidence added; scores unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e9b977ff7..7a68606d7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Export for Screens: Include Artboard Fill, #4937) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (dynamic symbols, #4938) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -304,7 +304,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | | Symbol libraries: Window › Symbol Libraries (Basic Shapes, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; only one built-in library (original, generated in code) | | Brush libraries: Window › Brush Libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select for Add to Brushes, a brush dragged onto a path is added and applied to it and one dragged onto the Brushes panel is added; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; four built-in libraries (original, generated in code) | -| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (guides stored, not drawn on the canvas); Dynamic/Static symbol type still missing | +| Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (guides stored, not drawn on the canvas); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | +| Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; on-canvas Direct Selection of a part inside an instance (with the Fill/Stroke controls then setting its change) remains | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | | Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) | | Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains | @@ -385,6 +386,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Dynamic symbols (#4938): Symbol Options › Symbol Type; per-instance appearance for objects of a dynamic symbol's art; Reset in the Properties panel and the Symbols panel menu; on-canvas Direct Selection of the parts remains | | 2026-11-01 | minor | Export for Screens has Include Artboard Fill beside Include Bleed (on by default, remembered; `includeArtboardFill`): the files draw each artboard's background color behind its art, as exports and print now do, and the Print dialog's preview shows it (#4937); scores and estimates unchanged | | 2026-11-01 | minor | Select › Start Global Edit on a group takes the groups like it (the same objects at the same places, moved or scaled; Appearance, Size and the artboard options apply) and gives an edit made to an object inside one to the object at its place in each of the others, in proportion, as one undo step (#4939). Scores and estimates unchanged | | 2026-11-01 | minor | Document Tools add-on (a VectorCraft addition): New Symbols from Layers and Convert Layers to Symbols (#4927); scores and estimates unchanged |