diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f202111fa..f7035cc86 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -7,7 +7,7 @@ use serde_json::json; use vectorcraft_doc::NodeKind; use crate::panels::stroke as stroke_panel; -use crate::state::{ZOOM_STOPS, zoom_label}; +use crate::state::{ROTATE_VIEW_ANGLES, ZOOM_STOPS, zoom_label}; use crate::theme::{self, Tokens}; use crate::widgets; use crate::{VectorcraftApp, icons, menus, titlebar}; @@ -558,11 +558,10 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui( ui, |ui| { - for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] { - if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked() - && let Some(v) = app.view_mut() - { - v.rotation = a; + for (label, a) in ROTATE_VIEW_ANGLES { + if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() { + // It fails only without a document, when there is no view to turn. + app.run("view.rotate", json!({"angle": a})).ok(); } } }, diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2d25b08a9..8e8f3969f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2357,6 +2357,7 @@ Reset Panel Bedienfeld zurücksetzen Reset Preferences Voreinstellungen zurücksetzen Reset Profiles Profile zurücksetzen + Rotate View Ansicht drehen Reset Rotate View Ansichtsdrehung zurücksetzen Reset the rows to the artwork's colors Zeilen auf die Farben der Grafik zurücksetzen Reset to Default Auf Standard zurücksetzen diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index dfa1b127d..773b2c9ee 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2408,6 +2408,7 @@ Reset Panel Reimposta pannello Reset Preferences Reimposta preferenze Reset Profiles Reimposta profili + Rotate View Ruota vista Reset Rotate View Reimposta rotazione vista Reset the rows to the artwork's colors Reimposta le righe sui colori della grafica Reset to Default Reimposta valori predefiniti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 70030631b..608ca5ab3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -483,6 +483,7 @@ Fit Artboard in Window アートボードを全体表示 Fit All in Window すべてのアートボードを全体表示 Actual Size 100% 表示 + Rotate View ビューを回転 Reset Rotate View ビューを回転の初期化 Hide Edges エッジを隠す Hide Artboards アートボードを隠す diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 005642690..98ebee7e4 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -479,6 +479,7 @@ Fit Artboard in Window 창에 아트보드 맞추기 Fit All in Window 창에 모두 맞추기 Actual Size 실제 크기 + Rotate View 보기 회전 Reset Rotate View 회전 보기 재설정 Hide Edges 가장자리 숨기기 Hide Artboards 아트보드 숨기기 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 38817fd39..f7c59945d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -444,6 +444,7 @@ Fit Artboard in Window Dopasuj obszar kompozycji do okna Fit All in Window Dopasuj wszystko do okna Actual Size Rzeczywisty rozmiar + Rotate View Obróć widok Reset Rotate View Resetuj obrót widoku Hide Edges Ukryj krawędzie Show Edges Pokaż krawędzie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c5b008a4d..d98968075 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1986,6 +1986,7 @@ Reset Panel Redefinir Painel Reset Preferences Redefinir Preferências Reset Profiles Redefinir Perfis + Rotate View Girar Visualização Reset Rotate View Redefinir Visualização Girada Reset the rows to the artwork's colors Redefinir as linhas para as cores da arte Reset to Default Redefinir para o Padrão diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f116b3222..ca113bb98 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2404,6 +2404,7 @@ Reset Panel Сбросить панель Reset Preferences Сбросить настройки Reset Profiles Сбросить профили + Rotate View Повернуть вид Reset Rotate View Сбросить поворот вида Reset the rows to the artwork's colors Сбросить строки к цветам рисунка Reset to Default Сбросить к значениям по умолчанию diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b866de9f6..72a9ca3ce 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2400,6 +2400,7 @@ Reset Panel Скинути панель Reset Preferences Скинути налаштування Reset Profiles Скинути профілі + Rotate View Повернути вид Reset Rotate View Скинути поворот виду Reset the rows to the artwork's colors Скинути рядки до кольорів малюнка Reset to Default Скинути до значень за замовчуванням diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e1e65add7..3093a360c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2656,6 +2656,7 @@ Reset Panel Đặt lại bảng Reset Preferences Đặt lại tùy chọn Reset Profiles Đặt lại hồ sơ màu + Rotate View Xoay chế độ xem Reset Rotate View Đặt lại xoay chế độ xem Reset Ruler Origin Đặt lại gốc thước Reset the rows to the artwork's colors Đặt lại các hàng về màu của tác phẩm diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bd8252726..7dd826cdc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2386,6 +2386,7 @@ Reset Panel 重設面板 Reset Preferences 重設偏好設定 Reset Profiles 重設描述檔 + Rotate View 旋轉檢視 Reset Rotate View 重設旋轉檢視 Reset the rows to the artwork's colors 將各列重設為圖稿的顏色 Reset to Default 重設為預設值 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04cfa8a3a..6c4f40830 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -123,6 +123,8 @@ mod tests_recovery; #[cfg(test)] mod tests_removeanchors; #[cfg(test)] +mod tests_rotateview; +#[cfg(test)] mod tests_ruler_origin; #[cfg(test)] mod tests_save; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 06732c572..cc9e5b8c1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -252,6 +252,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("view.snapToPoint", "Snap to Point", "Cmd+Alt+'", "{}"), ("view.presentation", "Presentation Mode", "Shift+F", "{}"), ("view.screenMode", "Screen Mode", "F", "{mode?: 0..2} (no param cycles)"), + ( + "view.rotate", + "Rotate View", + "", + "{angle: degrees, clockwise on screen as the status bar shows it} turn the view to that angle about the center of the canvas; returns {angle}, normalized to (-180, 180]", + ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), ("window.toolbar", "Tools", "", "{}"), @@ -1192,6 +1198,21 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { + let key = "angle"; + let Some(angle) = p.get(key).and_then(Value::as_f64).filter(|a| a.is_finite()) else { + return Some(Err(format!("`{key}` must be a number"))); + }; + match app.view_mut() { + Some(v) => { + v.rotation = vectorcraft_geom::normalize_deg(angle); + Ok(json!({ "angle": v.rotation })) + } + None => Err("no document".into()), + } + } "view.rotateReset" => { if let Some(v) = app.view_mut() { v.rotation = 0.0; @@ -1583,6 +1604,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "view.snapToGrid" => v.snap_to_grid, "view.snapToPoint" => v.snap_to_point, "view.rulers" => v.rulers, + // A View › Rotate View item is checked when the active view is turned to its angle; without + // a document no item is checked. + "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, @@ -2033,7 +2057,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { | "view.zoomOut" | "view.fitArtboard" | "view.fitAll" - | "view.actualSize" => app.session.active().is_some(), + | "view.actualSize" + | "view.rotate" => app.session.active().is_some(), id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(), "file.clearRecent" => !app.ui.recent_files.is_empty(), id if id.starts_with("type.recentFont") => { @@ -2610,6 +2635,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Fit All in Window", "view.fitAll"), c("Actual Size", "view.actualSize"), Sep, + sub("Rotate View", crate::state::ROTATE_VIEW_ANGLES.iter().map(|(l, a)| cp(l, "view.rotate", json!({ "angle": a }))).collect()), c("Reset Rotate View", "view.rotateReset"), Sep, c("Hide Edges", "view.edges"), @@ -3247,6 +3273,17 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with + // the view's angle filled in. + if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) { + match app.view_mut().map(|v| v.rotation) { + Some(angle) => { + app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle}))); + } + None => app.status("no document"), + } + return; + } // Save Selection…: a name dialog, starting from the first free "Selection N". if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); @@ -3681,7 +3718,8 @@ pub const DYNAMIC_LABELS: &[&str] = &[ /// Every English string the menus, the command palette, the panel registry, the toolbar and the /// Preferences dialog can show: what a complete language catalog has to cover. Names that are user -/// data (fonts, recent files, saved views, libraries) and the point sizes are left out. +/// data (fonts, recent files, saved views, libraries), the point sizes and the view angles are +/// left out. pub fn menu_strings() -> std::collections::BTreeSet { use std::collections::BTreeSet; fn user_data(id: &str) -> bool { @@ -3692,9 +3730,14 @@ pub fn menu_strings() -> std::collections::BTreeSet { match it { Item::Cmd(l, id, _) => { let size = l.strip_suffix(" pt").is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())); + // View › Rotate View lists angles: an optional minus sign, digits and a degree sign. + let angle = l + .strip_suffix('°') + .map(|n| n.strip_prefix('-').unwrap_or(n)) + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())); // VectorCraft › Language lists each language by its own name. let language_name = *id == "app.language" && crate::i18n::Lang::all().any(|lang| lang.name() == *l); - if !user_data(id) && !size && !language_name && *l != "—" { + if !user_data(id) && !size && !angle && !language_name && *l != "—" { out.insert(l.to_string()); } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 12015c988..52916faeb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -45,6 +45,23 @@ impl View { } } +/// The status bar's angle menu and View › Rotate View list these preset view angles, each as +/// (label, degrees clockwise on screen). +pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [ + ("0°", 0.0), + ("15°", 15.0), + ("30°", 30.0), + ("45°", 45.0), + ("60°", 60.0), + ("90°", 90.0), + ("180°", 180.0), + ("-15°", -15.0), + ("-30°", -30.0), + ("-45°", -45.0), + ("-60°", -60.0), + ("-90°", -90.0), +]; + /// Illustrator's preset zoom stops, in percent. pub const ZOOM_STOPS: [f64; 26] = [ 3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, diff --git a/crates/ui-egui/src/tests_rotateview.rs b/crates/ui-egui/src/tests_rotateview.rs new file mode 100644 index 000000000..301e9364d --- /dev/null +++ b/crates/ui-egui/src/tests_rotateview.rs @@ -0,0 +1,121 @@ +//! View › Rotate View and `view.rotate`: the view turns to one of the status bar's preset angles +//! from the menu, or to any angle from the command, and the menu checks the angle the view is turned to. + +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, menus}; + +/// These are the status bar's angles and their labels, in the status bar's order. +const ANGLES: [(&str, f64); 12] = [ + ("0°", 0.0), + ("15°", 15.0), + ("30°", 30.0), + ("45°", 45.0), + ("60°", 60.0), + ("90°", 90.0), + ("180°", 180.0), + ("-15°", -15.0), + ("-30°", -30.0), + ("-45°", -45.0), + ("-60°", -60.0), + ("-90°", -90.0), +]; + +fn with_document() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app +} + +/// Returns the labels of the View › Rotate View items that are checked. +fn checked(app: &VectorcraftApp) -> Vec<&'static str> { + ANGLES.iter().filter(|(_, a)| menus::checked(app, "view.rotate", &json!({ "angle": a })) == Some(true)).map(|(l, _)| *l).collect() +} + +#[test] +fn view_rotate_turns_the_view_to_any_angle() { + let mut app = with_document(); + let center = app.view().unwrap().center; + assert_eq!(app.run("view.rotate", json!({"angle": 30})).unwrap(), json!({"angle": 30.0})); + let v = app.view().unwrap(); + assert_eq!((v.rotation, v.center), (30.0, center), "it turns about the center of the canvas"); + // Angles are normalized to (-180, 180], as a Rotate View drag normalizes them. + for (angle, want) in [(390.0, 30.0), (-190.0, 170.0), (-180.0, 180.0), (12.5, 12.5)] { + assert_eq!(app.run("view.rotate", json!({ "angle": angle })).unwrap(), json!({ "angle": want }), "{angle}"); + assert_eq!(app.view().unwrap().rotation, want, "{angle}"); + } + for p in [json!({"angle": "x"}), json!({}), json!({"angle": null})] { + assert_eq!(app.run("view.rotate", p.clone()).unwrap_err(), "`angle` must be a number", "{p}"); + } + assert_eq!(app.view().unwrap().rotation, 12.5, "an error leaves the view as it was"); + let mut empty = VectorcraftApp::new(Session::new(), crate::Services::default()); + assert_eq!(empty.run("view.rotate", json!({"angle": 30})).unwrap_err(), "no document"); +} + +#[test] +fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() { + let mut app = with_document(); + let entries = menus::menu_entries(&app); + let at = entries.iter().position(|e| e.path == ["View", "Rotate View"]).expect("View › Rotate View"); + let (items, after) = entries[at..].split_at(ANGLES.len()); + let shown: Vec<_> = items.iter().map(|e| (e.path.join(" › "), e.label.as_str(), e.command.as_deref(), e.params.clone(), e.enabled)).collect(); + let want: Vec<_> = ANGLES.iter().map(|(l, a)| ("View › Rotate View".to_string(), *l, Some("view.rotate"), json!({ "angle": a }), true)).collect(); + assert_eq!(shown, want); + assert_eq!((after[0].path.join(" › "), after[0].label.as_str()), ("View".to_string(), "Reset Rotate View"), "the submenu comes right before it"); + + app.run("view.rotate", json!({"angle": 30})).unwrap(); + assert_eq!(checked(&app), ["30°"]); + app.run("view.rotate", json!({"angle": 17})).unwrap(); + assert!(checked(&app).is_empty()); + // Every item stays a check item, unchecked, while the view is at another angle. + assert!(ANGLES.iter().all(|(_, a)| menus::checked(&app, "view.rotate", &json!({ "angle": a })) == Some(false))); + // A click on 180° runs its command. + let item = &items[6]; + let (id, p) = menus::click_target(&item.label, item.command.as_deref().unwrap(), &item.params); + menus::invoke(&mut app, &id, p); + assert_eq!(app.view().unwrap().rotation, 180.0); + assert_eq!(checked(&app), ["180°"]); +} + +/// Rotate View with no angle, as the command palette runs it, opens a dialog with the view's angle +/// filled in, and OK turns the view to the angle entered. +#[test] +fn rotate_view_without_an_angle_asks_for_one() { + assert!(crate::palette::items().iter().any(|(label, id, _)| (label.as_str(), id.as_str()) == ("Rotate View", "view.rotate"))); + let mut app = with_document(); + app.run("view.rotate", json!({"angle": 30})).unwrap(); + menus::invoke(&mut app, "view.rotate", json!({})); + let mut d = app.ui.dialog.clone().expect("Rotate View dialog"); + assert_eq!((d.kind.as_str(), d.str("__command"), d.str("__label")), ("command", "view.rotate".into(), "Rotate View".into())); + assert_eq!(d.fields.get("angle"), Some(&json!(30.0))); + d.fields.insert("angle".into(), json!(-45)); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(app.view().unwrap().rotation, -45.0); + // Without a document no dialog opens, and the status bar shows "no document". + let mut empty = VectorcraftApp::new(Session::new(), crate::Services::default()); + menus::invoke(&mut empty, "view.rotate", json!({})); + assert!(empty.ui.dialog.is_none()); + assert_eq!(empty.ui.status, "no document"); +} + +#[test] +fn rotate_view_needs_a_document() { + let empty = VectorcraftApp::new(Session::new(), crate::Services::default()); + assert!(!menus::enabled(&empty, "view.rotate")); + assert_eq!(menus::checked(&empty, "view.rotate", &json!({"angle": 0})), Some(false), "a check item without a document"); + assert!(menus::enabled(&with_document(), "view.rotate")); +} + +/// The strings the catalogs cover include the submenu's name and leave out the angle labels, as they +/// leave out Type › Size's point sizes. +#[test] +fn only_the_submenu_name_needs_translating() { + let strings = menus::menu_strings(); + assert!(strings.contains("Rotate View")); + for (label, _) in ANGLES { + assert!(!strings.contains(label), "{label}"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 7dfd42d1e..754a2d18b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1862,6 +1862,10 @@ draws, and View › Snap to Grid snaps to its subdivisions. Changing either sets every document they read except a native file and the native document that an SVG, PDF, `.ai` or EPS file carries (`restored: true`), which keep the grid they were saved with. +`view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle +turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the +angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0. + The Smart Guides preferences (Preferences › Smart Guides) apply to `pointer_gesture` with Smart Guides on (the default view) and to the mouse; they change what the tools show and how far a target pulls, and only Construction Guides change where a snapped point lands: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 41c8bbb39..558c7e141 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -492,6 +492,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then. Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. + View › Rotate View turns the view to the status bar's preset angles (`view.rotate {angle}`, which takes any angle). When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then. - **Geometry and effects:** - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635).