diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index a1f03b6a3..7386f7718 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -247,7 +247,7 @@ pub fn specs() -> Vec { "Replace Symbol", [], None, - "{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}", + "{name, ids?|id?} swap the symbol of the selected instances (an instance given a different symbol drops its appearance changes; one given its own symbol keeps them) → {ids}", has_selection, symbol_replace ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 0b69b7f8a..9bfbb902a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -590,14 +590,19 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { s.edit("Replace Symbol", |d, _| { let new = natural_size(d, &name); for id in &ids { - let old = match d.node(*id).map(|n| &n.kind) { - Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol), + let (old, other) = match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { symbol, .. }) => (natural_size(d, symbol), *symbol != name), _ => continue, }; - if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) { + let Some(n) = d.node_mut(*id) else { continue }; + if let NodeKind::SymbolInstance { symbol, xf } = &mut n.kind { *symbol = name.clone(); *xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1); } + // An override's path names an object of the old symbol's art. + if other && !n.extras().symbol_overrides.is_empty() { + n.extras_mut().symbol_overrides.clear(); + } } fit_overrides(d, &ids); Ok(()) diff --git a/crates/engine/src/tests/dynamic_symbols.rs b/crates/engine/src/tests/dynamic_symbols.rs index aff7690c1..1c46d5302 100644 --- a/crates/engine/src/tests/dynamic_symbols.rs +++ b/crates/engine/src/tests/dynamic_symbols.rs @@ -111,6 +111,28 @@ fn redefining_the_symbol_keeps_the_changes_whose_objects_remain() { assert_eq!(overrides(&s, inst), 0); } +#[test] +fn replace_symbol_drops_the_changes_made_for_the_old_symbol() { + let mut s = session(); + // "Other": two blue squares in the same places, the instance taken off the canvas. + let (a, b) = (square(&mut s, 10.0), square(&mut s, 60.0)); + let first = id(&s.execute("symbol.new", &json!({"name": "Other", "ids": [a.0, b.0], "type": "dynamic"})).unwrap()); + s.execute("edit.clear", &json!({"ids": [first.0]})).unwrap(); + let inst = pair(&mut s, "dynamic"); + s.execute("symbol.instance.setAppearance", &json!({"id": inst.0, "path": [1], "fill": "#ff0000"})).unwrap(); + // The same symbol: nothing changes. + s.execute("symbol.replace", &json!({"name": "Pair", "ids": [inst.0]})).unwrap(); + assert_eq!(overrides(&s, inst), 1); + assert_eq!(pixel(&s, 80, 30), RED); + s.execute("symbol.replace", &json!({"name": "Other", "ids": [inst.0]})).unwrap(); + assert_eq!(overrides(&s, inst), 0, "the change named an object of Pair's art"); + assert_eq!(pixel(&s, 80, 30), BLUE, "Other's art draws as the symbol has it"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(&node(&s, inst).kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Pair")); + assert_eq!(overrides(&s, inst), 1); + assert_eq!(pixel(&s, 80, 30), RED); +} + #[test] fn reset_and_static_drop_the_changes_and_undo_brings_them_back() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index f1e55b9fd..c059e6762 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1586,7 +1586,8 @@ already walks Image Trace. Short agent entry points: 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` edits the symbol's own - art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them. + art, and each edit there keeps the changes whose path the art still has; making the symbol static drops them, and + so does `symbol.replace` with a different symbol (a replace with the instance's own symbol keeps them). ```json {"name":"run_command","arguments":{"command":"symbol.instance.setAppearance","params":{"id":42,"path":[1],"fill":"#d0021b","opacity":80}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 40e1d1267..edd3ca93d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1454,7 +1454,7 @@ A VectorCraft addition, not a feature of the reference app, so no area score mov ## 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. +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, and Replace Symbol drops them when it gives the instance a different symbol. 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. ## Document Tools add-on: Export All Open Documents (2026-11-01)