From 7d0d475aee9835853c03ac57f02ed8daa5ef82ea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 25 Oct 2026 15:58:29 -0700 Subject: [PATCH] View: Viewports > Named Viewports... lists, restores and deletes saved viewport configurations -VPORTS saved, restored and deleted named model space viewport configurations, and MVIEW Restore placed one in a layout, but View > Viewports > Named Viewports... had no command. The new command vports.named runs from the menu item. Typed or from the menu it opens the Named Viewports dialog: the saved configurations, a preview of the selected one's tiles, and in the Model tab the name of the configuration whose tiles are where the Model tab's are, else *Active. Restore shows the configuration in the Model tab, and in a layout it starts MVIEW Restore with the name, which asks for the corners. Delete deletes the saved configuration. The JSON form takes {name?, delete?, p1?, p2?, layout?}, and without a name it returns the configurations and the current one. Catalog rows for the new menu label are added in all 15 languages. In docs/gaps.md, U11 is narrowed, and F15, U11 and F21 no longer list MVIEW Restore (#633) or PURGE of multiline styles, which are on main. docs/parity-checklist.md is regenerated with cargo xtask parity. --- crates/engine/src/cmd/view/vports.rs | 93 ++++++++++++++- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/named_viewports.rs | 101 ++++++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/pt_br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh.tsv | 1 + crates/ui-egui/src/managers.rs | 7 +- crates/ui-egui/src/managers/named_vports.rs | 112 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + .../tests/it/named_viewports_dialog.rs | 74 ++++++++++++ docs/gaps.md | 9 +- docs/parity-checklist.md | 5 +- 24 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/it/named_viewports.rs create mode 100644 crates/ui-egui/src/managers/named_vports.rs create mode 100644 crates/ui-egui/tests/it/named_viewports_dialog.rs diff --git a/crates/engine/src/cmd/view/vports.rs b/crates/engine/src/cmd/view/vports.rs index 5b95f720a..f040133fc 100644 --- a/crates/engine/src/cmd/view/vports.rs +++ b/crates/engine/src/cmd/view/vports.rs @@ -6,11 +6,12 @@ //! tiles is an undo step. The session keeps each tile's view ([`crate::ModelTiles`]); the current //! tile shows the model space view itself, so ZOOM, PAN and every pick work in it unchanged. The //! UI draws the tiles from [`Session::model_tiles`] and hands clicks in other tiles to -//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. +//! [`Session::click_tile`]. In a layout -VPORTS is MVIEW. View > Viewports > Named Viewports... +//! restores or deletes a saved configuration. use std::sync::Arc; -use cadcraft_doc::{Drawing, Space, VportConfig, VportTile}; +use cadcraft_doc::{Drawing, EntityKind, Space, VportConfig, VportTile}; use cadcraft_geom::Vec2; use serde_json::{Value, json}; @@ -37,6 +38,12 @@ pub fn specs() -> Vec { ) .interactive(vports_factory), CommandSpec::new("vports.activate", "Set Current Viewport", run_activate).params("{index: tile number from 1}").noundo(), + CommandSpec::new("vports.named", "Named Viewports...", run_named) + .menu(&["View", "Viewports", "Named Viewports..."]) + .params( + "{name?: a saved viewport configuration, delete?: bool, p1?, p2?, layout?} (no name: → {current, configurations: [{name, viewports}]}; the Model tab shows the configuration's tiles, a layout gets one viewport per tile over p1-p2 or the printable area; typed or from the menu: the Named Viewports dialog)", + ) + .interactive(named_factory), ] } @@ -388,7 +395,59 @@ fn run_activate(s: &mut Session, p: &Value) -> Result { summary(s) } +/// View > Viewports > Named Viewports...: without a name, the saved configurations and the one +/// the Model tab shows; with one, restore it (its tiles in the Model tab, MVIEW Restore in a +/// layout) or, with `delete`, delete it. +fn run_named(s: &mut Session, p: &Value) -> Result { + let delete = match p.get("delete") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad("vports.named", "`delete` must be true or false")), + }; + let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) else { + let configs: Vec = + s.doc()?.vports.iter().filter(|c| !c.is_active()).map(|c| json!({ "name": c.name, "viewports": c.tiles.len() })).collect(); + return Ok(json!({ "current": s.viewport_configuration(), "configurations": configs })); + }; + let saved = s + .doc()? + .vports + .iter() + .find(|c| !c.is_active() && c.name.eq_ignore_ascii_case(name)) + .map(|c| c.name.clone()) + .ok_or_else(|| EngineError::Other(format!("Could not find viewport configuration \"{}\".", name.chars().take(64).collect::())))?; + if delete { + s.doc_mut()?.vports.retain(|c| c.is_active() || c.name != saved); + return Ok(json!({ "deleted": saved, "message": format!("Viewport configuration \"{saved}\" deleted.") })); + } + if s.layout_space() != Space::Model || p.get("layout").is_some() { + let mview = find_command("mview").ok_or_else(|| EngineError::UnknownCommand("mview".into()))?; + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("delete"); + o.insert("option".into(), json!("restore")); + o.insert("name".into(), json!(saved)); + } + return (mview.run)(s, &p); + } + run_vports(s, &json!({ "option": "restore", "name": saved })) +} + +/// Whether two configurations have their tiles in the same places (in any order). +fn same_places(a: &[VportTile], b: &[VportTile]) -> bool { + let near = |p: Vec2, q: Vec2| (p.x - q.x).abs() < EPS && (p.y - q.y).abs() < EPS; + a.len() == b.len() && a.iter().all(|t| b.iter().any(|u| near(t.min, u.min) && near(t.max, u.max))) +} + impl Session { + /// The saved viewport configuration whose tiles are where the Model tab's tiles are now (the + /// first such one in the drawing), if any. + pub fn viewport_configuration(&self) -> Option { + let st = self.state().ok()?; + let (tiles, _) = live(st, self.viewport_px); + st.doc.vports.iter().filter(|c| !c.is_active()).find(|c| same_places(&c.tiles, &tiles)).map(|c| c.name.clone()) + } + /// The model space tiles to draw (empty: one view, or not in the Model tab). pub fn model_tiles(&self) -> Vec { let Ok(st) = self.state() else { return Vec::new() }; @@ -598,3 +657,33 @@ impl VportsM { } } } + +/// Named Viewports... without the dialog: -VPORTS Restore in the Model tab, MVIEW Restore in a +/// layout. +fn named_factory(s: &Session) -> Result> { + Ok(Box::new(Restoring(vports_factory(s)?))) +} + +/// -VPORTS or MVIEW started at its Restore option. +struct Restoring(Box); + +impl Interactive for Restoring { + fn name(&self) -> &'static str { + self.0.name() + } + fn begin(&mut self, s: &mut Session) -> Result { + match self.0.begin(s)? { + Step::Continue => self.0.input(s, Input::Keyword("Restore".into())), + other => Ok(other), + } + } + fn prompt(&self, s: &Session) -> Prompt { + self.0.prompt(s) + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + self.0.input(s, i) + } + fn preview(&self, s: &Session, cursor: Vec2) -> Vec { + self.0.preview(s, cursor) + } +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8babc2c49..de49c8b12 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -75,6 +75,7 @@ mod modify_curves; mod mtext_columns; mod mtext_options; mod multiline_attributes; +mod named_viewports; mod named_views; mod non_finite_edits; mod offset_closed_spline; diff --git a/crates/engine/tests/it/named_viewports.rs b/crates/engine/tests/it/named_viewports.rs new file mode 100644 index 000000000..03ebe9ea4 --- /dev/null +++ b/crates/engine/tests/it/named_viewports.rs @@ -0,0 +1,101 @@ +//! View > Viewports > Named Viewports...: the saved model space viewport configurations, the one +//! the Model tab shows, and restoring or deleting one (its tiles in the Model tab, layout +//! viewports in a layout). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Space}; +use serde_json::json; + +/// A drawing with "Two" (two tiles side by side) and "Quad" (four tiles) saved, showing one view. +fn session() -> Session { + let mut s = Session::new(); + s.viewport_px = (1200.0, 800.0); + s.execute("line", &json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + s.execute("-vports", &json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Two" })).unwrap(); + s.execute("-vports", &json!({ "option": "4", "applyTo": "display" })).unwrap(); + s.execute("-vports", &json!({ "option": "save", "name": "Quad" })).unwrap(); + s.execute("-vports", &json!({ "option": "single" })).unwrap(); + s +} + +fn rects(s: &Session) -> Vec<[f64; 4]> { + let mut r: Vec<[f64; 4]> = s.model_tiles().iter().map(|t| [t.min.x, t.min.y, t.max.x, t.max.y]).collect(); + r.sort_by(|a, b| a.partial_cmp(b).unwrap()); + r +} + +fn layout_viewports(s: &Session) -> usize { + let d = s.doc().unwrap(); + d.space(&Space::Paper("Layout1".into())).unwrap().iter().filter(|e| matches!(e.kind, EntityKind::Viewport(_))).count() +} + +#[test] +fn named_viewports_lists_restores_and_deletes_in_the_model_tab() { + let mut s = session(); + let list = s.execute("vports.named", &json!({})).unwrap(); + assert_eq!(list["configurations"], json!([{ "name": "Two", "viewports": 2 }, { "name": "Quad", "viewports": 4 }])); + assert_eq!(list["current"], json!(null), "one view matches no saved configuration"); + assert_eq!(s.viewport_configuration(), None); + + s.execute("vports.named", &json!({ "name": "two" })).unwrap(); + assert_eq!(rects(&s), vec![[0.0, 0.0, 0.5, 1.0], [0.5, 0.0, 1.0, 1.0]]); + assert_eq!(s.viewport_configuration().as_deref(), Some("Two")); + assert_eq!(s.execute("vports.named", &json!({})).unwrap()["current"], json!("Two")); + s.undo().unwrap(); + assert!(rects(&s).is_empty(), "restoring is one undo step"); + + let r = s.execute("vports.named", &json!({ "name": "QUAD", "delete": true })).unwrap(); + assert_eq!(r["deleted"], json!("Quad")); + let names: Vec = s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).map(|c| c.name.clone()).collect(); + assert_eq!(names, vec!["Two".to_string()]); + s.undo().unwrap(); + assert!(s.doc().unwrap().vports.iter().any(|c| c.name == "Quad"), "deleting is one undo step"); +} + +#[test] +fn named_viewports_refuses_unknown_names_and_a_delete_that_is_not_a_boolean() { + let mut s = session(); + for bad in [ + json!({ "name": "nope" }), + json!({ "name": "*Active" }), + json!({ "name": "Two", "delete": "yes" }), + json!({ "name": "nope", "delete": true }), + ] { + assert!(s.execute("vports.named", &bad).is_err(), "{bad}"); + } + // Only the exact name is deleted, never a wildcard match. + assert!(s.execute("vports.named", &json!({ "name": "*", "delete": true })).is_err()); + assert_eq!(s.doc().unwrap().vports.iter().filter(|c| !c.is_active()).count(), 2); +} + +#[test] +fn named_viewports_places_a_configuration_in_a_layout() { + let mut s = session(); + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let before = layout_viewports(&s); + let r = s.execute("vports.named", &json!({ "name": "Quad", "p1": [10, 10], "p2": [210, 110] })).unwrap(); + assert_eq!(r["configuration"], json!("Quad")); + assert_eq!(layout_viewports(&s), before + 4); + assert!(s.model_tiles().is_empty(), "the Model tab's tiles do not change"); +} + +#[test] +fn typed_named_viewports_asks_for_the_configuration() { + let mut s = session(); + s.start("vports.named").unwrap(); + assert!(s.prompt_text().contains("Enter name of viewport configuration to restore"), "{}", s.prompt_text()); + s.cmdline("Two").unwrap(); + assert!(s.running.is_none()); + assert_eq!(rects(&s).len(), 2); + + s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap(); + let before = layout_viewports(&s); + s.start("vports.named").unwrap(); + assert!(s.prompt_text().contains("Enter viewport configuration name"), "{}", s.prompt_text()); + s.cmdline("Quad").unwrap(); + assert!(s.prompt_text().contains("Specify first corner"), "{}", s.prompt_text()); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert_eq!(layout_viewports(&s), before + 4, "Enter fits the configuration to the printable area"); +} diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 9ada44442..be95aea50 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -587,6 +587,7 @@ Name الاسم Name (A–Z) الاسم (أ–ي) Named مسمى + Named Viewports... أطر العرض المسماة... Named Views... طرق عرض مسماة... Nearest الأقرب New جديد diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6df6a4168..79365bf7b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -537,6 +537,7 @@ N S Name Název Name (A–Z) Název (A–Z) + Named Viewports... Pojmenované výřezy... Named Views... Pojmenované pohledy... Nearest Nejbližší New Nový diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 860b23136..d839650ae 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -457,6 +457,7 @@ N N Name Name Name (A–Z) Name (A–Z) + Named Viewports... Benannte Ansichtsfenster... Nearest Nächstgelegener New Neu New (metric) Neu (metrisch) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fe09face6..160e7928e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -640,6 +640,7 @@ Name Όνομα Name (A–Z) Όνομα (Α–Ω) Named Με όνομα + Named Viewports... Αποθηκευμένα παράθυρα προβολής... Named Views... Αποθηκευμένες όψεις... Nearest Πλησιέστερο (Nearest) New Νέο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index afa3c90a5..93be54ef7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -567,6 +567,7 @@ N N Name Nombre Name (A–Z) Nombre (A–Z) + Named Viewports... Ventanas gráficas con nombre... Named Views... Vistas con nombre... Nearest Cercano New Nuevo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 30639fe83..fb52f53ed 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -452,6 +452,7 @@ N N Name Nom Name (A–Z) Nom (A–Z) + Named Viewports... Fenêtres nommées... Nearest Plus proche New Nouveau New (metric) Nouveau (métrique) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 67769707e..4f6fcc72b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -650,6 +650,7 @@ Name Név Name (A–Z) Név (A–Z) Named Elnevezett + Named Viewports... Elnevezett nézetablakok... Named Views... Elnevezett nézetek... Nearest Legközelebbi New Új diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 27786b2e5..da3d60056 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -549,6 +549,7 @@ menu Modify Edita N N Name Nome Name (A–Z) Nome (A–Z) + Named Viewports... Finestre con nome... Named Views... Viste con nome... Nearest Vicino New Nuovo diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7c5c9016c..0932af696 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -539,6 +539,7 @@ N 북 Name 이름 Name (A–Z) 이름(A–Z) + Named Viewports... 명명된 뷰포트... Named Views... 명명된 뷰... Nearest 근처점 New 새로 만들기 diff --git a/crates/ui-egui/src/i18n/pt_br.tsv b/crates/ui-egui/src/i18n/pt_br.tsv index 04fafff2f..fa0f890ce 100644 --- a/crates/ui-egui/src/i18n/pt_br.tsv +++ b/crates/ui-egui/src/i18n/pt_br.tsv @@ -565,6 +565,7 @@ N N Name Nome Name (A–Z) Nome (A–Z) + Named Viewports... Viewports nomeados... Named Views... Vistas nomeadas... Nearest Mais próximo New Novo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a5feb4589..640e7f4bc 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -452,6 +452,7 @@ N С Name Название Name (A–Z) Название (А–Я) + Named Viewports... Именованные видовые экраны... Nearest Ближайшая точка New Создать New (metric) Создать (метрический) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2896520d3..6098e5685 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -569,6 +569,7 @@ N N Name Namn Name (A–Z) Namn (A–Ö) + Named Viewports... Namngivna vyfönster... Named Views... Namngivna vyer... Nearest Närmaste New Ny diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index cdf4ec36f..906013878 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -651,6 +651,7 @@ Name Ad Name (A–Z) Ad (A–Z) Named Adlandırılmış + Named Viewports... Adlandırılmış Görünüm Pencereleri... Named Views... Adlandırılmış Görünümler... Nearest En Yakın New Yeni diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f75d7c529..9b0c829f1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -553,6 +553,7 @@ N Пн Name Назва Name (A–Z) Назва (А–Я) + Named Viewports... Іменовані видові екрани... Named Views... Іменовані види... Nearest Найближча точка New Створити diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 80183d064..1a9e9235c 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -1121,6 +1121,7 @@ Open Recent 打开最近的图形 Sheet Books to PDF... 图纸册转 PDF... Drawing Recovery Manager... 图形恢复管理器... + Named Viewports... 命名视口... Named Views... 命名视图... Polygonal Viewport 多边形视口 From Template... 来自模板... diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 712c02a61..ebc5e4cf0 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -1,5 +1,6 @@ //! Style and settings dialogs opened from the menus: Linetype Manager, Point Style, Text Style, -//! Table Style, Multileader Style, Page Setup Manager, Plot and Constraint Settings. +//! Table Style, Multileader Style, Page Setup Manager, Plot, Constraint Settings and Named +//! Viewports. //! //! Typed or chosen from a menu (no parameters) these commands open their dialog; JSON calls run //! the command. The dialogs read the drawing and change it only through the same commands @@ -16,6 +17,8 @@ use crate::theme::Tokens; /// The Block Attribute Manager (BATTMAN). mod battman; +/// Named Viewports (View > Viewports > Named Viewports...). +mod named_vports; /// Open the dialog for a typed or menu-invoked command. `None` if `id` has no dialog here or the /// call carries parameters. @@ -32,6 +35,7 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option "mleaderstyle", "pagesetup" => "pagesetup", "constraintsettings" | "csettings" => "csettings", + "vports.named" => "namedvports", "plot" | "print" => { PLOT_GEN.fetch_add(1, Ordering::Relaxed); "plot" @@ -53,6 +57,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool "mleaderstyle" => mleader_style(app, ctx, open), "pagesetup" => page_setup(app, ctx, open), "csettings" => constraint_settings(app, ctx, open), + "namedvports" => named_vports::dialog(app, ctx, open), "plot" => plot(app, ctx, open), _ => { *open = false; diff --git a/crates/ui-egui/src/managers/named_vports.rs b/crates/ui-egui/src/managers/named_vports.rs new file mode 100644 index 000000000..6bffa5c4e --- /dev/null +++ b/crates/ui-egui/src/managers/named_vports.rs @@ -0,0 +1,112 @@ +//! Named Viewports (View > Viewports > Named Viewports...): the drawing's saved model space +//! viewport configurations, with the selected one's tiles drawn as a preview. Restore runs +//! `vports.named` in the Model tab; in a layout it starts MVIEW Restore with the name, so the +//! viewports' corners are picked next. Delete runs `vports.named` with `delete`. + +use cadcraft_doc::{Space, VportConfig, VportTile}; +use cadcraft_engine::Input; +use egui::{RichText, Sense, Stroke, vec2}; +use serde_json::{Value, json}; + +use super::{set_temp, temp}; +use crate::CadApp; +use crate::theme::Tokens; + +/// Draws the tiles of a configuration in a 180 x 120 box; tile coordinates (0, 0) are the bottom left. +fn preview(ui: &mut egui::Ui, tiles: &[VportTile], t: &Tokens) { + let (rect, _) = ui.allocate_exact_size(vec2(180.0, 120.0), Sense::hover()); + let p = ui.painter_at(rect); + p.rect_filled(rect, 0.0, t.panel); + let at = |x: f64, y: f64| egui::pos2(rect.min.x + x.clamp(0.0, 1.0) as f32 * rect.width(), rect.max.y - y.clamp(0.0, 1.0) as f32 * rect.height()); + for tile in tiles.iter().filter(|t| t.min.is_finite() && t.max.is_finite()) { + let r = egui::Rect::from_two_pos(at(tile.min.x, tile.min.y), at(tile.max.x, tile.max.y)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Inside); + } + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); +} + +/// Show the dialog; returns the command to run. +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option<(&'static str, Value)> { + let t = Tokens::get(); + let Ok(d) = app.session.doc() else { + egui::Window::new("Named Viewports").open(open).collapsible(false).resizable(false).show(ctx, |ui| { + ui.label("Open a drawing first."); + }); + return None; + }; + let configs: Vec<(String, Vec)> = d.vports.iter().filter(|c| !c.is_active()).map(|c| (c.name.clone(), c.tiles.clone())).collect(); + let model = app.session.layout_space() == Space::Model; + let current = if model { app.session.viewport_configuration() } else { None }; + let mut sel = temp::(ctx, "namedvp_sel") + .filter(|s| configs.iter().any(|c| c.0 == *s)) + .or_else(|| current.clone()) + .or_else(|| configs.first().map(|c| c.0.clone())); + let mut action: Option<(&'static str, Value)> = None; + let mut restore = false; + let mut close = false; + egui::Window::new("Named Viewports").open(open).default_width(400.0).resizable(false).show(ctx, |ui| { + if model { + ui.label(format!("Current name: {}", current.as_deref().unwrap_or(VportConfig::ACTIVE))); + } else { + ui.label(RichText::new("Saved model space configurations to place in this layout").color(t.text_dim)); + } + ui.separator(); + if configs.is_empty() { + ui.label("This drawing has no named viewport configurations."); + } + ui.horizontal_top(|ui| { + egui::ScrollArea::vertical().id_salt("namedvp_list").max_height(120.0).min_scrolled_width(180.0).show(ui, |ui| { + ui.set_min_width(180.0); + for (name, _) in &configs { + let r = ui.selectable_label(sel.as_deref() == Some(name.as_str()), name); + if r.clicked() { + sel = Some(name.clone()); + } + if r.double_clicked() { + sel = Some(name.clone()); + restore = true; + } + } + }); + let tiles = sel.as_ref().and_then(|s| configs.iter().find(|c| c.0 == *s)).map(|c| c.1.as_slice()).unwrap_or_default(); + preview(ui, tiles, &t); + }); + ui.separator(); + ui.horizontal(|ui| { + let hint = if model { "Show this configuration's viewports" } else { "Place this configuration's viewports in the layout" }; + if ui.add_enabled(sel.is_some(), egui::Button::new("Restore")).on_hover_text(hint).clicked() { + restore = true; + } + if ui.add_enabled(sel.is_some(), egui::Button::new("Delete")).on_hover_text("Delete the saved configuration").clicked() + && let Some(n) = &sel + { + action = Some(("vports.named", json!({ "name": n, "delete": true }))); + } + if ui.button("Close").clicked() { + close = true; + } + }); + }); + if let Some(n) = sel.clone() { + set_temp(ctx, "namedvp_sel", n); + } + if close { + *open = false; + } + let Some(name) = sel.filter(|_| restore) else { return action }; + *open = false; + if model { + return Some(("vports.named", json!({ "name": name }))); + } + // The viewports' corners are picked in the layout, as for MVIEW Restore. + app.start("mview"); + if app.session.running.as_ref().is_some_and(|r| r.id == "mview") { + for i in [Input::Keyword("Restore".into()), Input::Text(name)] { + if let Err(e) = app.session.input(i) { + app.session.echo(e.to_string()); + break; + } + } + } + None +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 5faf3135b..5dd2cbb27 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -17,6 +17,7 @@ mod lisp_files; mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; +mod named_viewports_dialog; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/named_viewports_dialog.rs b/crates/ui-egui/tests/it/named_viewports_dialog.rs new file mode 100644 index 000000000..19b0a6e67 --- /dev/null +++ b/crates/ui-egui/tests/it/named_viewports_dialog.rs @@ -0,0 +1,74 @@ +//! View > Viewports > Named Viewports... opens a dialog that lists the saved viewport +//! configurations: Restore shows one in the Model tab or starts placing it in a layout, and Delete +//! deletes it. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, dialogs, menus}; +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +/// A drawing with "Two" (two tiles side by side) saved, showing one view, and the dialog open. +fn dialog() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.run("line", json!({ "points": [[0, 0], [100, 50]] })).unwrap(); + app.run("-vports", json!({ "option": "2", "arrangement": "vertical" })).unwrap(); + app.run("-vports", json!({ "option": "save", "name": "Two" })).unwrap(); + app.run("-vports", json!({ "option": "single" })).unwrap(); + menus::activate(&mut app, "vports.named"); + assert_eq!(app.ui.dialog.as_deref(), Some("namedvports")); + let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h +} + +#[test] +fn restore_and_delete_in_the_model_tab() { + let mut h = dialog(); + h.get_by_label("Current name: *Active"); + h.get_by_label("Two").click(); + h.run_steps(2); + h.get_by_label("Restore").click(); + h.run_steps(3); + let app = h.state_mut(); + assert_eq!(app.session.model_tiles().len(), 2); + assert_eq!(app.ui.dialog, None, "Restore closes the dialog"); + + menus::activate(app, "vports.named"); + h.run_steps(3); + h.get_by_label("Current name: Two"); + h.get_by_label("Delete").click(); + h.run_steps(3); + let app = h.state_mut(); + assert!(app.session.doc().unwrap().vports.iter().all(|c| c.name != "Two")); + assert_eq!(app.ui.dialog.as_deref(), Some("namedvports"), "Delete keeps the dialog open"); + h.get_by_label("This drawing has no named viewport configurations."); +} + +#[test] +fn restore_in_a_layout_asks_where_the_viewports_go() { + let mut h = dialog(); + let app = h.state_mut(); + app.ui.dialog = None; + app.run("layout.set", json!({ "name": "Layout1" })).unwrap(); + menus::activate(app, "vports.named"); + h.run_steps(3); + h.get_by_label("Two").click(); + h.run_steps(2); + h.get_by_label("Restore").click(); + h.run_steps(3); + let app = h.state_mut(); + assert!(app.session.prompt_text().contains("Specify first corner"), "{}", app.session.prompt_text()); + let before = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count(); + app.cmdline(""); + assert!(app.session.running.is_none()); + let after = app.session.doc().unwrap().layout("Layout1").unwrap().entities.iter().count(); + assert_eq!(after, before + 2, "Enter fits the two viewports to the printable area"); +} diff --git a/docs/gaps.md b/docs/gaps.md index a4a12cff8..9c0bf3630 100644 --- 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-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Named Viewports dialog: U11 narrowed; MVIEW Restore and PURGE of multiline styles had landed: F15 and F21 corrected) · **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 @@ -37,12 +37,12 @@ do them first. | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 | -| F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 | +| F15 | Viewports: a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453), MVIEW Restore in #633 | `cmd/layout/viewports.rs` | Low | 2–4 | | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 | | F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 | | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | | F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | -| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | +| F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data) | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 | | F22 | Inquiry leftovers (COUNT across spaces and nested blocks, the QuickCalc palette and COMPARE landed, #563; MASSPROP on regions, #510): COMPARE compares the tab shown, not every layout at once (COMPAREEXPORT, the comparison as a new drawing, landed); COMPAREEXPORT puts the two sets of objects in model-space groups, where AutoCAD puts them in blocks; QuickCalc keeps its history and variables for the session only | `file/compare.rs`, `palettes/quickcalc.rs` | Low | 3–5 | | F23 | No Sheet Set Manager, eTransmit, Share, Traces, Markup Import, Activity Insights | menu items uncovered | Low-medium (team workflows) | 40–70 | | F24 | No 3D: UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights, rendering (150 menu items) | [parity-checklist](parity-checklist.md) | Medium (3D users) | 300–450 | @@ -63,7 +63,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode | U6 | Selection: lasso; the CLass keyword; isolation saved with the drawing (OBJECTISOLATIONMODE 1) and the status-bar isolate button. Press-drag windows (#63), the W, C, F, WP and CP keywords (#226), BOX, AUto, SIngle, Multiple, Group, Add, Remove and Undo, SELECTSIMILAR, ISOLATEOBJECTS/HIDEOBJECTS/UNISOLATEOBJECTS (#428) and selection cycling (#1143) landed | Medium | 3–5 | | U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 | | U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 | -| U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 | +| U11 | VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463, MVIEW Restore in #633, and the Named Viewports dialog, which restores or deletes a saved configuration) | Low | 1–2 | | U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 | | U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Named Viewports (View ▸ Viewports ▸ Named Viewports..., `vports.named`) lists the saved viewport configurations with a preview and the current one's name, and restores or deletes one; in a layout Restore places it as MVIEW Restore does: U11 narrowed. F15 and U11 no longer list MVIEW Restore (#633), and F21 no longer says PURGE skips multiline styles | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_ vs .dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 9c2186c90..1e82b202c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,13 +4,13 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft command is registered under the same menu label. It measures breadth, not depth; see [target-app-parity.md](target-app-parity.md) for the weighted assessment. -**Menu breadth: 296 / 491 items (60%)** · 441 registered commands. +**Menu breadth: 297 / 491 items (60%)** · 442 registered commands. | Menu | Items | Covered | % | |---|---|---|---| | File | 22 | 13 | 59% | | Edit | 13 | 12 | 92% | -| View | 75 | 36 | 48% | +| View | 75 | 37 | 49% | | Insert | 11 | 5 | 45% | | Format | 27 | 26 | 96% | | Tools | 85 | 66 | 78% | @@ -37,7 +37,6 @@ Menu-breadth parity: a reference menu item counts as covered when a CADCraft com - View > Orbit > Free Orbit - View > Camera > Adjust Distance - View > Camera > Swivel -- View > Viewports > Named Viewports... - View > 3D Views > Viewpoint - View > 3D Views > Plan View > Current UCS - View > 3D Views > Plan View > World UCS