collective

Browse / github.com/storytold/cadcraft / patch

feat(layout): pick the model area a new layout shows

feat(layout): pick the model area a new layout shows ## What Creating a layout from the UI (the "+" button next to the layout tabs, and the layout tab menu's *New Layout*) now asks which part of the model the new layout shows, instead of always zooming its viewport to the model extents. The command takes the next free name, shows the **Model** tab so you can zoom and pan, and asks for two corners of the area (a rubber-band rectangle follows the cursor). The new layout is then shown, with its viewport zoomed to that area. **Enter** at the first-corner prompt keeps today's behaviour (the whole drawing). **Esc** creates nothing and puts you back

Status
open
Score
0.00
Adopters
0 other operators
Author
lforgiarini (imported from pull request #2080, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+289 −26 in 8 files
Review
pending
Shared
2026-10-28
Id
606174fdc28c4862d4b65781

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/layout.rs crates/engine/src/guard.rs crates/engine/src/lib.rs crates/engine/src/prompt.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/shortcut_menus.rs crates/ui-egui/tests/it/shortcut_menus.rs docs/gaps.md

diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs--- a/crates/engine/src/cmd/layout.rs+++ b/crates/engine/src/cmd/layout.rs@@ -25,8 +25,8 @@ pub fn specs() -> Vec<CommandSpec> {             .interactive(|_| Ok(Box::new(prompts::LayoutM::default()))),         CommandSpec::new("layout.new", "New Layout", run_new)             .menu(&["Insert", "Layout", "New Layout"])-            .params("{name?, viewport?: bool (default true)} → {name, viewport}")-            .interactive(|_| Ok(Box::new(NewLayoutM))),+            .params("{name?, viewport?: bool (default true), window?: [[x1,y1],[x2,y2]] (the model area the viewport shows; default the model extents)} → {name, viewport} (typed: asks the name, then the area)")+            .interactive(|_| Ok(Box::new(NewLayoutM::default()))),         CommandSpec::new("layout.delete", "Delete Layout", run_delete).params("{name}"),         CommandSpec::new("layout.rename", "Rename Layout", run_rename).params("{from?: current layout, to}"),         CommandSpec::new("layout.copy", "Copy Layout", run_copy).params("{from, to?}"),@@ -198,30 +198,131 @@ fn default_page(d: &Drawing) -> PageSetup {     p } -/// LAYOUT.NEW from the menu or command line: asks for the name (Enter takes the next free one).-struct NewLayoutM;+/// LAYOUT.NEW from the menu or command line: asks for the name (Enter takes the next free one),+/// then for the part of the model the new layout shows. The Model tab is shown for that, so it+/// can be zoomed and panned first; two corners pick the area (Enter takes the whole drawing). The+/// new layout is then shown, zoomed to that area.+#[derive(Default)]+struct NewLayoutM {+    /// The checked name, once it has been asked.+    name: Option<String>,+    /// The first corner of the model area.+    first: Option<Vec2>,+    /// The tab the command started in, put back when it is cancelled.+    from: Option<Space>,+}++impl NewLayoutM {+    /// Show tab `space` (`layout.set`).+    fn show(s: &mut Session, space: &Space) -> Result<()> {+        let set = find_command("layout.set").ok_or_else(|| crate::EngineError::UnknownCommand("layout.set".into()))?;+        (set.run)(s, &json!({ "name": space.tab_name() }))?;+        Ok(())+    }+}  impl Interactive for NewLayoutM {     fn name(&self) -> &'static str {         "LAYOUT"     }     fn prompt(&self, s: &Session) -> Prompt {-        let next = s.doc().map(next_layout_name).unwrap_or_else(|_| "Layout1".into());-        Prompt::new("Enter name of new layout", Accept::TEXT).default(next)+        match (&self.name, self.first) {+            (None, _) => {+                let next = s.doc().map(next_layout_name).unwrap_or_else(|_| "Layout1".into());+                Prompt::new("Enter name of new layout", Accept::TEXT).default(next)+            }+            (Some(_), None) => {+                Prompt::new("Specify first corner of the model area to show in the layout, or press ENTER for the whole drawing", Accept::POINT)+            }+            // A window's first corner: with ortho on, the opposite corner is not bent onto it.+            (Some(_), Some(a)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(a),+        }     }     fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {-        let p = match i {-            Input::Text(t) if !t.trim().is_empty() => json!({ "name": t.trim() }),-            Input::Text(_) | Input::Enter => json!({}),+        let Some(name) = self.name.clone() else {+            let name = match i {+                Input::Text(t) if !t.trim().is_empty() => t.trim().to_string(),+                Input::Text(_) | Input::Enter => next_layout_name(s.doc()?),+                _ => return Ok(Step::Continue),+            };+            // A taken or invalid name is an error: the session reports it and asks again.+            let name = check_name("layout.new", s.doc()?, &name, None)?;+            // The area is picked in the Model tab, where the drawing can be zoomed and panned.+            self.from = Some(s.layout_space());+            Self::show(s, &Space::Model)?;+            self.name = Some(name);+            return Ok(Step::Continue);+        };+        // The corners are model points. If another tab was shown meanwhile (a tab click, `layout.set`),+        // a point would be read in that space, so the command ends and leaves that choice alone.+        if s.layout_space() != Space::Model {+            self.from = None;+            return Ok(Step::Cancel);+        }+        let window = match (i, self.first) {+            (Input::Point(p), None) => {+                self.first = Some(p);+                return Ok(Step::Continue);+            }+            (Input::Point(p), Some(a)) => Some([a, p]),+            (Input::Enter, None) => None,             _ => return Ok(Step::Continue),         };-        // A taken or invalid name is an error: the session reports it and asks again.+        let mut p = json!({ "name": name });+        if let (Some([a, b]), Some(o)) = (window, p.as_object_mut()) {+            o.insert("window".into(), json!([[a.x, a.y], [b.x, b.y]]));+        }+        // A window without width or height is an error, reported before any layout is added:+        // the session asks for the first corner again.+        self.first = None;         run_new(s, &p)?;+        Self::show(s, &Space::Paper(name))?;         Ok(Step::Done)     }+    /// Puts back the tab the command started in.+    fn cancelled(&mut self, s: &mut Session) {+        if let Some(from) = self.from.take()+            && let Err(e) = Self::show(s, &from)+        {+            s.echo(e.to_string());+        }+    }+    /// The rectangle following the cursor while the second corner is picked.+    fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {+        let Some(a) = self.first else { return Vec::new() };+        let corners = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y), a];+        corners+            .windows(2)+            .filter_map(|w| match w {+                [p, q] => Some(super::helpers::line(*p, *q)),+                _ => None,+            })+            .collect()+    } } +/// The model-space window of a `layout.new`, when it has one: `window: [[x1, y1], [x2, y2]]`.+fn window_param(p: &Value) -> Result<Option<Bounds2>> {+    let Some(raw) = p.get("window").filter(|w| !w.is_null()) else { return Ok(None) };+    let corners = raw.as_array();+    let a = corners.and_then(|c| c.first()).and_then(point_value);+    let b = corners.and_then(|c| c.get(1)).and_then(point_value);+    let (Some(a), Some(b)) = (a, b) else {+        return Err(bad("layout.new", "`window` needs two corners: [[x1, y1], [x2, y2]]"));+    };+    let w = Bounds2::new(a, b);+    if w.min.is_finite() && w.max.is_finite() && w.width() > 1e-9 && w.height() > 1e-9 && w.width() < 1e12 && w.height() < 1e12 {+        Ok(Some(w))+    } else {+        Err(bad("layout.new", "the window needs a width and a height"))+    }+}++/// LAYOUT.NEW from JSON: add a layout (with a viewport unless `viewport` is false), zoomed to+/// `window` when it has one and to the model extents otherwise. fn run_new(s: &mut Session, p: &Value) -> Result<Value> {+    // Checked before anything is added, so a bad window leaves no layout behind.+    let window = window_param(p)?;     let d = s.doc()?;     let name = match str_param(p, "name") {         Some(n) => check_name("layout.new", d, n, None)?,@@ -232,16 +333,30 @@ fn run_new(s: &mut Session, p: &Value) -> Result<Value> {     s.doc_mut()?.layouts.push(Layout { page, ..Layout::new(&name, order) });     let mut vp = Value::Null;     if bool_or(p, "viewport", true) {-        // AutoCAD behaviour: one viewport over the printable area, zoomed to the model extents.+        // AutoCAD behaviour: one viewport over the printable area, zoomed to the model extents,+        // or to the `window` when it has one.         let space = Space::Paper(name.clone());         let rect = printable(s, &name)?;         if let Some(h) = add_viewports(s, &space, rect, 1, "")?.first() {+            if let Some(w) = window {+                show_window(s, *h, w)?;+            }             vp = json!(h.hex());         }     }     Ok(json!({ "name": name, "viewport": vp })) } +/// Zoom viewport `h` to the model area `w`.+fn show_window(s: &mut Session, h: Handle, w: Bounds2) -> Result<()> {+    s.doc_mut()?.modify_entity(h, |e| {+        if let EntityKind::Viewport(v) = &mut e.kind {+            (v.view_center, v.view_height) = fit_bounds(w, v.width, v.height);+        }+    })?;+    Ok(())+}+ fn run_delete(s: &mut Session, p: &Value) -> Result<Value> {     let name = str_param(p, "name").ok_or_else(|| bad("layout.delete", "`name` is required"))?;     let name = layout_name("layout.delete", s.doc()?, name)?;@@ -420,6 +535,11 @@ fn fit_view(d: &Drawing, w: f64, h: f64) -> (Vec2, f64) {         let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));         ext = Bounds2::new(lo, hi);     }+    fit_bounds(ext, w, h)+}++/// The view centre and height that show the model area `ext` in a viewport of size `w` × `h`.+fn fit_bounds(ext: Bounds2, w: f64, h: f64) -> (Vec2, f64) {     let aspect = (w / h.max(1e-12)).max(1e-12);     let vh = (ext.height().max(ext.width() / aspect) * 1.02).max(1e-9);     let c = ext.center();@@ -974,13 +1094,128 @@ mod tests {         s.cmdline("Layout1").unwrap();         assert!(s.running.is_some(), "a taken name asks again");         s.cmdline("Sheet A").unwrap();+        // The name is taken; the model area to show is asked next. Enter takes the whole drawing.+        assert!(s.running.is_some() && s.current_prompt().is_some_and(|p| p.message.contains("first corner of the model area")));+        s.cmdline("").unwrap();         assert!(s.running.is_none());         s.start("layout.new").unwrap();         s.cmdline("").unwrap();+        s.cmdline("").unwrap();         let d = s.doc().unwrap();         assert!(d.layout("Sheet A").is_some() && d.layout("Layout3").is_some(), "Enter takes the default name");     } +    /// The viewport of layout `name`.+    fn only_viewport(s: &Session, name: &str) -> Viewport {+        let v = viewports(s, name);+        assert_eq!(v.len(), 1, "one viewport");+        v.into_iter().next().map(|(_, v)| v).unwrap()+    }++    /// A `window` zooms the new layout's viewport to that part of the model.+    #[test]+    fn a_new_layout_can_show_a_window_of_the_model() {+        let mut s = session_with_model();+        s.execute("layout.new", &json!({"name": "Sheet W", "window": [[10, 10], [30, 20]]})).unwrap();+        let v = only_viewport(&s, "Sheet W");+        assert!(v.view_center.dist(Vec2::new(20.0, 15.0)) < 1e-9, "centred on the window: {:?}", v.view_center);+        let aspect = v.width / v.height;+        assert!(v.view_height >= 10.0 && v.view_height * aspect >= 20.0, "the whole window is in view");+        assert!(v.view_height <= (10.0_f64).max(20.0 / aspect) * 1.02 + 1e-9, "and not much more: {}", v.view_height);+        // Without a window the viewport shows the model extents, as before.+        s.execute("layout.new", &json!({"name": "Sheet E"})).unwrap();+        let e = only_viewport(&s, "Sheet E");+        assert!(e.view_height > v.view_height * 2.0, "extents are larger than the window");+    }++    /// A window without area, or not two corners, is an error that leaves no layout behind.+    #[test]+    fn a_bad_layout_window_adds_no_layout() {+        let mut s = session_with_model();+        let before = s.doc().unwrap().layouts.len();+        for w in [+            json!([[1, 1], [1, 9]]),+            json!([[1, 1], [9, 1]]),+            json!([[1, 1]]),+            json!("all"),+            json!([[1, 1], ["a", 2]]),+            json!([[f64::MAX, 0], [-f64::MAX, 1]]),+        ] {+            assert!(s.execute("layout.new", &json!({"name": "Bad", "window": w})).is_err(), "{w}");+        }+        assert_eq!(s.doc().unwrap().layouts.len(), before, "no layout is left behind");+    }++    /// The interactive command shows the Model tab, takes two corners and shows the new layout.+    #[test]+    fn new_layout_picks_the_model_area_in_the_model_tab() {+        let mut s = session_with_model();+        s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();+        s.start("layout.new").unwrap();+        s.cmdline("Sheet P").unwrap();+        assert_eq!(s.space(), Space::Model, "the Model tab is shown to pick the area");+        s.cmdline("10,10").unwrap();+        assert!(s.current_prompt().is_some_and(|p| p.message == "Specify opposite corner"));+        // A window without width re-asks for the first corner and adds nothing.+        s.cmdline("10,30").unwrap();+        assert!(s.current_prompt().is_some_and(|p| p.message.contains("first corner")), "{:?}", s.current_prompt().map(|p| p.message));+        assert!(s.doc().unwrap().layout("Sheet P").is_none());+        s.cmdline("10,10").unwrap();+        s.cmdline("30,20").unwrap();+        assert!(s.running.is_none());+        assert_eq!(s.space(), Space::Paper("Sheet P".into()), "the new layout is shown");+        let v = only_viewport(&s, "Sheet P");+        assert!(v.view_center.dist(Vec2::new(20.0, 15.0)) < 1e-9, "{:?}", v.view_center);+        s.undo().unwrap();+        assert!(s.doc().unwrap().layout("Sheet P").is_none(), "one undo step");+    }++    /// A tab shown while the corners are picked ends the command instead of reading points in that space.+    #[test]+    fn new_layout_ends_if_another_tab_is_shown_while_picking() {+        let mut s = session_with_model();+        let before = s.doc().unwrap().layouts.len();+        s.start("layout.new").unwrap();+        s.cmdline("Sheet T").unwrap();+        assert_eq!(s.space(), Space::Model);+        // A tab shown meanwhile: the next point must not be taken for a model corner.+        s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();+        s.cmdline("10,10").unwrap();+        assert!(s.running.is_none(), "the command ended");+        assert_eq!(s.layout_space(), Space::Paper("Layout1".into()), "the tab that was shown stays");+        assert_eq!(s.doc().unwrap().layouts.len(), before, "no layout was added");+    }++    /// The opposite-corner prompt is a window corner, so ortho does not bend it.+    #[test]+    fn the_opposite_corner_is_not_bent_by_ortho() {+        let mut s = session_with_model();+        s.start("layout.new").unwrap();+        s.cmdline("Sheet O").unwrap();+        s.cmdline("10,10").unwrap();+        let p = s.current_prompt().unwrap();+        assert!(p.corner && p.base == Some(Vec2::new(10.0, 10.0)), "a window's opposite corner prompt");+    }++    /// Esc puts back the tab the command started in and adds no layout.+    #[test]+    fn cancelling_new_layout_puts_the_tab_back() {+        let mut s = session_with_model();+        s.execute("layout.set", &json!({"name": "Layout2"})).unwrap();+        let before = s.doc().unwrap().layouts.len();+        s.start("layout.new").unwrap();+        s.cmdline("Sheet C").unwrap();+        assert_eq!(s.space(), Space::Model);+        s.cancel();+        assert!(s.running.is_none());+        assert_eq!(s.space(), Space::Paper("Layout2".into()), "back in the tab it started in: {:?}", s.log);+        assert_eq!(s.doc().unwrap().layouts.len(), before);+        // Esc at the name prompt changes nothing either.+        s.start("layout.new").unwrap();+        s.cancel();+        assert_eq!(s.space(), Space::Paper("Layout2".into()));+    }+     #[test]     fn mview_arrangements_and_interactive() {         let mut s = session_with_model();diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs--- a/crates/engine/src/guard.rs+++ b/crates/engine/src/guard.rs@@ -66,6 +66,11 @@ impl Interactive for Guarded {     fn begin(&mut self, s: &mut Session) -> Result<Step> {         catch_unwind(AssertUnwindSafe(|| self.inner.begin(s))).unwrap_or_else(|p| Err(self.internal(&*p)))     }+    /// Forwards the cancel hook; a panic in it must not stop the cancel.+    fn cancelled(&mut self, s: &mut Session) {+        // A panic here must not stop the cancel: the command ends either way.+        let _ = catch_unwind(AssertUnwindSafe(|| self.inner.cancelled(s)));+    }     fn preview(&self, s: &Session, cursor: Vec2) -> Vec<EntityKind> {         if self.broken.get().is_some() {             return Vec::new();diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -1235,7 +1235,11 @@ impl Session {         let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };         let Some(mut run) = self.running.take() else { return Ok(()) };         let step = match input {-            Some(Input::Cancel) => Ok(Step::Cancel),+            Some(Input::Cancel) => {+                // Every machine is wrapped in `Guarded`, which keeps a panic in the hook from escaping.+                run.machine.cancelled(self);+                Ok(Step::Cancel)+            }             Some(i) => {                 if let Input::Point(p) = &i {                     self.last_point = *p;diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs--- a/crates/engine/src/prompt.rs+++ b/crates/engine/src/prompt.rs@@ -222,6 +222,9 @@ pub trait Interactive: Send {     fn begin(&mut self, _s: &mut Session) -> Result<Step> {         Ok(Step::Continue)     }+    /// Called when the command is cancelled (Esc), for one that moved the view or the space and+    /// puts it back. The drawing itself is restored by the session.+    fn cancelled(&mut self, _s: &mut Session) {}     /// Rubber-band geometry for the cursor position.     fn preview(&self, _s: &Session, _cursor: Vec2) -> Vec<EntityKind> {         Vec::new()diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs--- a/crates/ui-egui/src/chrome.rs+++ b/crates/ui-egui/src/chrome.rs@@ -320,7 +320,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {             let resp = resp.on_hover_text(crate::i18n::t(tip));             if icon == Icon::Plus {                 if resp.clicked() {-                    let _ = app.run("layout.new", json!({}));+                    crate::shortcut_menus::new_layout(app);                 }             } else {                 list_button = Some(resp);@@ -436,11 +436,8 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {                 let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click());                 icons::paint(&p, pr.shrink(2.0), Icon::Plus, false);                 icons::describe_control(ui, &presp, "New layout", None, false);-                if presp.on_hover_text(crate::tl!("New layout")).clicked()-                    && let Ok(v) = app.run("layout.new", json!({}))-                    && let Some(n) = v.get("name").and_then(serde_json::Value::as_str)-                {-                    switch = Some(n.to_string());+                if presp.on_hover_text(crate::tl!("New layout")).clicked() {+                    crate::shortcut_menus::new_layout(app);                 }                 x += 22.0;             }diff --git a/crates/ui-egui/src/shortcut_menus.rs b/crates/ui-egui/src/shortcut_menus.rs--- a/crates/ui-egui/src/shortcut_menus.rs+++ b/crates/ui-egui/src/shortcut_menus.rs@@ -198,15 +198,21 @@ fn keyword_then(app: &mut CadApp, keyword: &str, text: Option<&str>) {     } } +/// A new layout from a button or menu: the next free name is taken, then the command asks for the+/// part of the model it shows (two corners picked in the Model tab, or Enter for the whole+/// drawing) and ends in the new layout. Esc leaves things as they were.+pub fn new_layout(app: &mut CadApp) {+    app.start("layout.new");+    if app.session.running.as_ref().is_some_and(|r| r.id == "layout.new")+        && let Err(e) = app.session.input(Input::Enter)+    {+        app.session.echo(e.to_string());+    }+}+ fn layout_action(app: &mut CadApp, op: &str, name: &str) {     match op {-        "new" => {-            if let Ok(v) = app.run("layout.new", json!({}))-                && let Some(n) = v.get("name").and_then(Value::as_str)-            {-                let _ = app.run("layout.set", json!({ "name": n }));-            }-        }+        "new" => new_layout(app),         "pagesetup" | "plot" => {             let _ = app.run("layout.set", json!({ "name": name }));             activate(app, op);diff --git a/crates/ui-egui/tests/it/shortcut_menus.rs b/crates/ui-egui/tests/it/shortcut_menus.rs--- a/crates/ui-egui/tests/it/shortcut_menus.rs+++ b/crates/ui-egui/tests/it/shortcut_menus.rs@@ -107,9 +107,21 @@ fn layout_tab_menu() {      let n = layout_names(&app).len();     choose(&mut app, &model, "New Layout");+    // The next free name is taken; the part of the model to show is asked in the Model tab.+    assert!(app.session.prompt_text().contains("first corner of the model area"), "{}", app.session.prompt_text());+    assert_eq!(app.session.state().unwrap().space, cadcraft_doc::Space::Model);+    app.cmdline("");     assert_eq!(layout_names(&app).len(), n + 1);     assert!(matches!(app.session.state().unwrap().space, cadcraft_doc::Space::Paper(_)), "the new layout is current"); +    // Esc at the area prompt creates nothing and returns to the tab it started in.+    let g = sm::layout_tab_entries(&app, "Sheet A");+    choose(&mut app, &g, "New Layout");+    assert_eq!(app.session.state().unwrap().space, cadcraft_doc::Space::Model);+    app.session.cancel();+    assert_eq!(layout_names(&app).len(), n + 1, "cancelled: no layout added");+    assert!(matches!(app.session.state().unwrap().space, cadcraft_doc::Space::Paper(_)), "back in the layout it started from");+     // Page Setup acts on the right-clicked tab.     let g = sm::layout_tab_entries(&app, "Sheet A");     assert!(find(&g, "Page Setup Manager...").is_some() && find(&g, "Print...").is_some());diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (New layouts show a model area picked in the Model tab) · **Target:** Autodesk AutoCAD 2027  Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | LAYOUT.NEW (menu, the "+" button and the layout tab menu) takes the next free name, shows the Model tab so that two corners pick the model area the new layout shows (Enter: the whole drawing; Esc puts the tab back), then shows the new layout; `layout.new` takes `window`; a command can now put back the view or tab it moved when it is cancelled (`Interactive::cancelled`) | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |