diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 1677d27f9..b4b231a22 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -45,7 +45,7 @@ pub(crate) use symbols::{ }; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_instance_reset_transform, symbol_list, symbol_new, symbol_options, - symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, + symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_sort, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -312,6 +312,15 @@ pub fn specs() -> Vec { has_doc, symbol_unused ), + cmd!( + "symbol.sortByName", + "Sort by Name", + ["Window", "Symbols"], + None, + "{} sort the document's symbols by name, ignoring case (symbols whose names differ only in case keep their order), as one undo step; the current symbol stays current; no undo step is added when they are already in order", + has_doc, + symbol_sort + ), cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances), cmd!( "symbol.spray", diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 4c5cd89ed..b789f600b 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -140,6 +140,27 @@ pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { Ok(json!({ "names": names })) } +/// `symbol.sortByName`: the symbols in name order, ignoring case; a stable sort, so names that +/// differ only in case keep their order. The current symbol is stored by name first, so a document +/// whose current symbol is its first one keeps that symbol current. Nothing is changed, and no undo +/// step is added, when the symbols are already in order. +pub(super) fn symbol_sort(s: &mut Session, _: &Value) -> Result { + let key = |sym: &Symbol| sym.name.to_lowercase(); + let d = &s.doc()?.doc; + if d.symbols.is_sorted_by_key(key) { + return ok(); + } + let current = current_symbol(d).or_else(|| symbol_names(d).into_iter().next()); + s.edit("Sort by Name", |d, _| { + if let Some(name) = current { + d.unknown.insert(CURRENT_SYMBOL.into(), json!(name)); + } + d.symbols.sort_by_cached_key(key); + Ok(()) + })?; + ok() +} + fn symbol_names(d: &Document) -> Vec { d.symbols.iter().map(|s| s.name.clone()).collect() } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 902bedcdd..500098277 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -579,6 +579,7 @@ pub(super) const ALLOWED: &[&str] = &[ "symbol.replace", "symbol.selectInstances", "symbol.setCurrent", + "symbol.sortByName", "symbol.spray", "symbol.unused", "symbol.update", diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index b86cf89ee..fd1ac7047 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1951,3 +1951,63 @@ fn symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several() { } assert_eq!(names(&s).len(), 6); } + +/// Symbols panel › Sort by Name: the document's symbols in name order, ignoring case, with names +/// that differ only in case kept in their order, as one undo step that keeps the current symbol +/// and every instance; sorting symbols already in order adds no undo step. +#[test] +fn symbol_sort_by_name_orders_the_symbols_in_one_undo_step() { + let mut s = session(); + let names = |s: &mut Session| -> Vec { + let list = s.execute("symbol.list", &json!({})).unwrap(); + list["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap_or_default().to_string()).collect() + }; + let current = |s: &mut Session| s.execute("symbol.list", &json!({})).unwrap()["current"].clone(); + let made = ["leaf", "Badge", "apple", "Leaf", "Zed"]; + for (i, name) in made.into_iter().enumerate() { + rect(&mut s, 20.0 * i as f64, 0.0, 10.0, 10.0); + s.execute("symbol.new", &json!({"name": name})).unwrap(); + } + // No current symbol stored: the first one is current. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).unknown.remove("currentSymbol"); + assert_eq!(current(&mut s), "leaf"); + let instances = |s: &Session| { + let mut v = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind { + v.push((n.id, symbol.clone())); + } + }); + v + }; + let placed = instances(&s); + assert_eq!(placed.len(), 5); + let before = s.doc().unwrap().history.undo.len(); + s.execute("symbol.sortByName", &json!({})).unwrap(); + assert_eq!(names(&mut s), ["apple", "Badge", "leaf", "Leaf", "Zed"]); + assert_eq!(current(&mut s), "leaf", "the current symbol stays current"); + assert_eq!(instances(&s), placed, "every instance keeps its symbol"); + assert_eq!(s.doc().unwrap().history.undo.len(), before + 1, "one undo step"); + // Already in order: nothing changes and no undo step is added. + let revision = s.doc().unwrap().revision; + s.execute("symbol.sortByName", &json!({})).unwrap(); + assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().revision), (before + 1, revision)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&mut s), made); + assert_eq!(current(&mut s), "leaf"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(names(&mut s), ["apple", "Badge", "leaf", "Leaf", "Zed"]); + // An explicitly current symbol stays current too. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("symbol.setCurrent", &json!({"name": "Zed"})).unwrap(); + s.execute("symbol.sortByName", &json!({})).unwrap(); + assert_eq!(current(&mut s), "Zed"); + // Run as a batch step it sorts as well, and with no symbols it adds no undo step. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("command.batch", &json!({"commands": [{"command": "symbol.sortByName", "params": {}}]})).unwrap(); + assert_eq!(names(&mut s), ["apple", "Badge", "leaf", "Leaf", "Zed"]); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let before = s.doc().unwrap().history.undo.len(); + s.execute("symbol.sortByName", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), before); +} diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index f42c47470..c9dd29efb 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -1,5 +1,5 @@ -//! Symbols panel: the document's symbols as rendered thumbnails or a list. Click selects a symbol -//! (it becomes the Symbol Sprayer's current symbol); double-click opens it in symbol editing mode. Symbol +//! Symbols panel: the document's symbols as rendered thumbnails or in a small or large list. Click +//! selects a symbol (it becomes the Symbol Sprayer's current symbol); double-click opens it in symbol editing mode. Symbol //! libraries open in library panels ([`SymbolLibraries`]). use std::cell::RefCell; @@ -223,7 +223,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some((doc, keys)) = app.session.active().map(|d| (d.doc.clone(), document_symbols(d))) else { return }; let names: Vec = doc.symbols.iter().map(|s| s.name.clone()).collect(); - let list: bool = pstate(ui.ctx(), "sym-list"); + let view: View = pstate(ui.ctx(), VIEW); let sel = current(app); let uid = app.session.active().map(|d| d.uid); let marked = marked(ui.ctx(), uid, &names); @@ -237,9 +237,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { super::empty_state(ui, "spray-can", tl!("No symbols in this document"), tl!("Select art and click New Symbol to make one.")); return ui.min_rect(); } - if list { + if let Some((h, side, text)) = view.row() { for n in &names { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); // Dragged onto the canvas, the symbol is placed there. widgets::drag_source(ui, &resp, || PanelDrag::Symbol(n.clone())); if on(n) { @@ -247,8 +247,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } - chip(ui, &doc, &keys, egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(22.0, 22.0)), n); - ui.painter().text(r.left_center() + vec2(32.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(12.5), t.text); + chip(ui, &doc, &keys, egui::Rect::from_center_size(r.left_center() + vec2(3.0 + side / 2.0, 0.0), vec2(side, side)), n); + ui.painter().text(r.left_center() + vec2(side + 10.0, 0.0), egui::Align2::LEFT_CENTER, n, egui::FontId::proportional(text), t.text); if resp.double_clicked() { clicked = Some((n.clone(), true)); } else if resp.clicked() { @@ -373,13 +373,18 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } set_pstate(ui.ctx(), MARKED, (uid, unused)); } - ui.separator(); - let list: bool = pstate(ui.ctx(), "sym-list"); - if menu_item(ui, tl!("Thumbnail View"), true, !list) { - set_pstate(ui.ctx(), "sym-list", false); + // The document's symbols in name order, as one undo step. + if menu_item(ui, tl!("Sort by Name"), names.len() > 1, false) + && let Err(e) = app.run("symbol.sortByName", json!({})) + { + app.status(e); } - if menu_item(ui, tl!("List View"), true, list) { - set_pstate(ui.ctx(), "sym-list", true); + ui.separator(); + let view: View = pstate(ui.ctx(), VIEW); + for (v, label) in View::ALL { + if menu_item(ui, tl!(label), true, view == v) { + set_pstate(ui.ctx(), VIEW, v); + } } ui.separator(); ui.menu_button(tl!("Open Symbol Library"), |ui| library_panel::library_menu::(app, ui)); @@ -392,6 +397,32 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// The panel state key of the symbols Select All Unused picked. const MARKED: &str = "sym-unused"; +/// The panel state key of the panel's [`View`]. +const VIEW: &str = "sym-view"; + +/// The panel's views: Thumbnail View (the default) and the Small and Large List Views, whose rows +/// show each symbol's thumbnail and name. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum View { + #[default] + Thumbnail, + SmallList, + LargeList, +} + +impl View { + const ALL: [(View, &'static str); 3] = + [(View::Thumbnail, "Thumbnail View"), (View::SmallList, "Small List View"), (View::LargeList, "Large List View")]; + /// Row height, thumbnail side and name size of the list views; None for the thumbnail grid. + fn row(self) -> Option<(f32, f32, f32)> { + match self { + View::Thumbnail => None, + View::SmallList => Some((18.0, 14.0, 12.0)), + View::LargeList => Some((26.0, 22.0, 12.5)), + } + } +} + /// The symbols Select All Unused picked in the active document (`uid`) that it still has (`names`); /// none once another document is active. fn marked(ctx: &egui::Context, uid: Option, names: &[String]) -> Vec { @@ -1022,7 +1053,7 @@ mod tests { vec![egui::Event::PointerMoved(at), button(at, true), button(at, false), button(at, true), button(at, false)] } - /// A double-click on a symbol in the panel, in Thumbnail View and in List View, opens it in + /// A double-click on a symbol in the panel, in Thumbnail View and in Large List View, opens it in /// symbol editing mode and places no instance; a single click only makes it current. #[test] fn double_clicking_a_symbol_in_the_panel_edits_it_in_either_view() { @@ -1041,10 +1072,10 @@ mod tests { assert_eq!(editing(&app).as_deref(), Some("Leaf")); assert_eq!(art(&app, "Leaf").0, 1, "no instance is placed"); app.run("symbol.exitEditing", json!({})).unwrap(); - // List View: the row named Dot. + // Large List View: the row named Dot. let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - set_pstate(&ctx, "sym-list", true); + set_pstate(&ctx, VIEW, View::LargeList); let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui)); out.textures_delta.clear(); @@ -1055,6 +1086,78 @@ mod tests { assert_eq!(art(&app, "Dot").0, 0, "no instance is placed"); } + /// The names the panel draws (none in Thumbnail View) and where, in one headless frame. + fn panel_labels(ctx: &egui::Context, app: &mut VectorcraftApp, names: &[&str]) -> Vec<(String, Pos2)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let labels = crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()); + labels.into_iter().filter(|(t, _)| names.contains(&t.trim())).map(|(t, r)| (t.trim().to_string(), r.center())).collect() + } + + /// The view items of the panel menu, each with whether it is checked. + fn view_items(ctx: &egui::Context, app: &mut VectorcraftApp) -> Vec<(String, bool)> { + let labels = menu_frame(ctx, app, vec![]); + let items = labels.into_iter().map(|(t, _)| (t.trim_start_matches('✓').trim().to_string(), t.starts_with('✓'))); + items.filter(|(t, _)| t.ends_with("View")).collect() + } + + /// The panel menu's Sort by Name puts the symbols in name order, ignoring case, as one undo + /// step, and adds none when they are already in order. The menu offers Thumbnail View (the + /// default), Small List View and Large List View, one checked at a time. Both list views draw a + /// row with a thumbnail and the name for each symbol, the small one in shorter rows, and in each + /// view a click makes a symbol current and a double-click edits it. + #[test] + fn sort_by_name_and_the_small_and_large_list_views() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + for (i, name) in ["bee", "Cat", "ant"].into_iter().enumerate() { + app.run("shape.rectangle", json!({"x": 10.0 + 40.0 * i as f64, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("symbol.new", json!({"name": name})).unwrap(); + } + let undo = app.session.active().unwrap().history.undo.len(); + click_menu(&ctx, &mut app, "Sort by Name"); + assert_eq!(symbols(&mut app), ["ant", "bee", "Cat"]); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step"); + let views = |checked: &str| ["Thumbnail View", "Small List View", "Large List View"].map(|v| (v.to_string(), v == checked)).to_vec(); + assert_eq!(view_items(&ctx, &mut app), views("Thumbnail View"), "Thumbnail View by default"); + assert!(panel_labels(&ctx, &mut app, &["ant", "bee", "Cat"]).is_empty(), "the thumbnails draw no names"); + // Each list view: a row per symbol in panel order, the small rows 18 points high and the + // large ones 26, with the list's 3-point spacing between them. + let mut gaps = vec![]; + for (view, gap) in [("Small List View", 21.0), ("Large List View", 29.0)] { + click_menu(&ctx, &mut app, view); + assert_eq!(view_items(&ctx, &mut app), views(view)); + let rows = panel_labels(&ctx, &mut app, &["ant", "bee", "Cat"]); + let order: Vec<&str> = rows.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(order, ["ant", "bee", "Cat"], "{view}"); + let step = rows[1].1.y - rows[0].1.y; + assert!((step - gap).abs() < 0.5, "{view}: rows {step} apart"); + gaps.push(step); + // A click on the last row makes Cat current, a double-click edits it. + let row = rows[2].1; + app.run("symbol.setCurrent", json!({"name": "ant"})).unwrap(); + frame(&ctx, &mut app, vec![egui::Event::PointerMoved(row), button(row, true), button(row, false)]); + assert_eq!((editing(&app), current(&mut app)), (None, Some("Cat".into())), "{view}"); + let ctx2 = egui::Context::default(); + crate::theme::install_fonts(&ctx2); + set_pstate(&ctx2, VIEW, pstate::(&ctx, VIEW)); + frame(&ctx2, &mut app, vec![]); + frame(&ctx2, &mut app, double_click(row)); + assert_eq!(editing(&app).as_deref(), Some("Cat"), "{view}"); + app.run("symbol.exitEditing", json!({})).unwrap(); + } + assert!(gaps[0] < gaps[1], "the small rows are shorter"); + click_menu(&ctx, &mut app, "Thumbnail View"); + assert_eq!(view_items(&ctx, &mut app), views("Thumbnail View")); + // Already in order, Sort by Name adds no undo step. + let undo = app.session.active().unwrap().history.undo.len(); + click_menu(&ctx, &mut app, "Sort by Name"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + } + /// The panel menu's Edit Symbol edits the selected instance's symbol, and with no instance /// selected the symbol current in the panel. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index eb689de91..5b8dd33b4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2432,6 +2432,12 @@ already walks Image Trace. Short agent entry points: Instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and the art of the document's brushes count, and so do the instances in the art of a symbol that is used; a symbol placed only inside an unused symbol is unused too. +- The Symbols panel menu's Sort by Name: `symbol.sortByName` (`{}`) puts the document's symbols in name order, + ignoring case, as one undo step; symbols whose names differ only in case keep their order. `symbol.list` and the + panel list the symbols in the new order, the current symbol stays current, and instances are + unchanged. No undo step is added when the symbols are already in order. The panel menu's Thumbnail View, Small List + View and Large List View change only how the panel shows the symbols: thumbnails, or rows with a thumbnail and the + name (smaller rows in Small List View). - 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 6c4be41e6..e5307638e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -821,6 +821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **9-slice guides on the canvas** ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/maintain-proportions-while-scaling-symbols.html)): while a symbol with Enable Guides for 9-Slice Scaling on is edited in symbol editing mode, the canvas draws its two horizontal and two vertical guides as dotted lines across its art, with any tool and with ruler guides hidden. With the Selection tool the pointer near a guide shows the Move cursor, and dragging the guide moves it (`symbol.options`'s `nineSliceGuides`) as one undo step; Escape during the drag puts it back. VectorCraft's own rules: a guide within 4 screen pixels takes the press before the art and the bounding box; it stays within the art's box (guides outside the art are not supported yet) and stops short of the opposite guide; the drag does not snap. Tests: `nineslice::tests`, `symbol_nine_slice::tests::the_edited_symbols_guides_cross_its_art_where_the_editing_layer_shows_it`, `a_moved_guide_stays_in_the_art_and_short_of_the_opposite_guide`, `canvas::tests::the_edited_symbols_nine_slice_guides_are_drawn_and_dragged_on_the_canvas`. - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. + - **Symbols › Sort by Name and the list views** ([the help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Sort by Name puts the symbols in alphabetical order (`symbol.sortByName`), and its Thumbnail View, Small List View and Large List View show them as thumbnails, in a compact list or in a list with larger thumbnails. VectorCraft's own rules: the sort ignores case and keeps names that differ only in case in their order; it changes the document's order, which `symbol.list` reports, as one undo step, keeps the current symbol and adds no undo step when the symbols are already in order; Thumbnail View is the default, Small List View has 18-point rows with 14-point thumbnails and Large List View 26-point rows with 22-point thumbnails, and a click or double-click on a symbol does the same in every view. Tests: `tests::brushsym::symbol_sort_by_name_orders_the_symbols_in_one_undo_step`, `panels::symbols::tests::sort_by_name_and_the_small_and_large_list_views`. - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged).