From c35fa82373ac371b264296340b45fc55df27275d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 01:23:57 -0400 Subject: [PATCH] MSPACE/PSPACE: work in model space through layout viewports Double-click a viewport (or MS) to edit model space inside it; the screen view maps consistently so picks, snaps, grips and previews work in model units, and zoom/pan change the viewport's view (or the sheet when the viewport is locked). PS or a double-click outside returns to paper space. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 100 +++++++++++++++++++++++++++++++- crates/engine/src/cmd/view.rs | 1 + crates/engine/src/lib.rs | 78 ++++++++++++++++++++++--- crates/ui-egui/src/canvas.rs | 76 +++++++++++++++++++----- 4 files changed, 230 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 49d5fb8..0830496 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -67,9 +67,70 @@ pub fn specs() -> Vec { .menu(&["File", "Export to PDF..."]) .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, lineweights?}") .noundo(), + CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace) + .alias(&["ms"]) + .params("{handle?: viewport, at?: [x,y] paper point inside a viewport} (default: the last active or first viewport)") + .noundo(), + CommandSpec::new("pspace", "Paper Space", run_pspace).alias(&["ps"]).noundo(), ] } +// ---------- model space through viewports ---------- + +fn layout_viewports(s: &Session) -> Result> { + let sp = s.layout_space(); + if sp == Space::Model { + return Err(bad("mspace", "** Command not allowed in Model Tab **")); + } + let d = s.doc()?; + Ok(d.space(&sp) + .map(|st| { + st.iter() + .filter_map(|e| match &e.kind { + EntityKind::Viewport(v) if v.id != 1 && v.height > 1e-12 && v.view_height > 1e-12 => Some((e.handle, v.clone())), + _ => None, + }) + .collect() + }) + .unwrap_or_default()) +} + +fn run_mspace(s: &mut Session, p: &Value) -> Result { + let vps = layout_viewports(s)?; + let at = p.get("at").and_then(|v| Some(Vec2::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))); + let want = p.get("handle").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))); + let pick = if let Some(h) = want { + vps.iter().find(|(x, _)| *x == h).map(|(h, _)| *h).ok_or_else(|| bad("mspace", "`handle` is not a viewport in this layout"))? + } else if let Some(a) = at { + vps.iter() + .rev() + .find(|(_, v)| (a.x - v.center.x).abs() <= v.width / 2.0 && (a.y - v.center.y).abs() <= v.height / 2.0) + .map(|(h, _)| *h) + .ok_or_else(|| bad("mspace", "no viewport at that point"))? + } else { + let last = s.state()?.mspace; + match last.filter(|h| vps.iter().any(|(x, _)| x == h)).or_else(|| vps.first().map(|(h, _)| *h)) { + Some(h) => h, + None => return Err(bad("mspace", "There are no active model space viewports.")), + } + }; + let st = s.state_mut()?; + st.mspace = Some(pick); + st.selection.clear(); + s.echo("MSPACE"); + Ok(json!({ "viewport": pick.hex() })) +} + +fn run_pspace(s: &mut Session, _p: &Value) -> Result { + if s.layout_space() == Space::Model { + return Err(bad("pspace", "** Command not allowed in Model Tab **")); + } + let st = s.state_mut()?; + st.mspace = None; + st.selection.clear(); + Ok(json!({ "space": "paper" })) +} + // ---------- layouts ---------- /// Characters AutoCAD does not allow in layout names. @@ -149,6 +210,7 @@ fn run_delete(s: &mut Session, p: &Value) -> Result { st.views.retain(|(sp, _)| sp != &gone); if st.space == gone { st.space = Space::Model; + st.mspace = None; st.selection.clear(); } s.touch(); @@ -156,7 +218,7 @@ fn run_delete(s: &mut Session, p: &Value) -> Result { } fn run_rename(s: &mut Session, p: &Value) -> Result { - let from = match (str_param(p, "from"), s.space()) { + let from = match (str_param(p, "from"), s.layout_space()) { (Some(f), _) => f.to_string(), (None, Space::Paper(n)) => n, (None, Space::Model) => return Err(bad("layout.rename", "`from` is required in the Model tab")), @@ -207,7 +269,7 @@ fn run_list(s: &mut Session, _p: &Value) -> Result { let d = s.doc()?; let mut ls: Vec<&Layout> = d.layouts.iter().collect(); ls.sort_by_key(|l| l.tab_order); - let current = s.space(); + let current = s.layout_space(); Ok(Value::Array( ls.iter() .map(|l| { @@ -268,7 +330,7 @@ fn target_layout(s: &Session, cmd: &str, p: &Value) -> Result { if let Some(n) = str_param(p, "layout") { return Ok(Space::Paper(layout_name(cmd, s.doc()?, n)?)); } - match s.space() { + match s.layout_space() { Space::Model => Err(bad(cmd, "** Command not allowed in Model Tab ** (switch to a layout or pass `layout`)")), sp => Ok(sp), } @@ -884,4 +946,36 @@ mod tests { assert!(s.execute(c, &p).is_err(), "{c} {p}"); } } + + #[test] + fn mspace_edits_model_through_viewport() { + let mut s = session_with_model(); + let before = s.doc().unwrap().model.len(); + s.execute("layout.new", &json!({"name": "L"})).unwrap(); + s.execute("layout.set", &json!({"name": "L"})).unwrap(); + assert!(s.execute("mspace", &json!({"at": [-1000, -1000]})).is_err()); + let vps = viewports(&s, "L"); + let (h, vp) = vps.iter().find(|(_, v)| v.id != 1).cloned().unwrap(); + s.execute("mspace", &json!({"at": [vp.center.x, vp.center.y]})).unwrap(); + assert_eq!(s.space(), Space::Model); + assert_eq!(s.layout_space(), Space::Paper("L".into())); + // The screen view in MSPACE maps the viewport centre to its model view centre. + let st = s.state().unwrap(); + let (pv, v) = (st.paper_view(), st.view()); + let k = vp.view_height / vp.height; + assert!((v.height - pv.height * k).abs() < 1e-9); + // New geometry goes to model space. + s.cmdline("circle 10,10 3").unwrap(); + assert_eq!(s.doc().unwrap().model.len(), before + 1); + // Zooming changes the viewport's model view, not the sheet. + s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 }); + let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1; + assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9); + assert_eq!(s.state().unwrap().paper_view(), pv); + s.execute("pspace", &json!({})).unwrap(); + assert_eq!(s.space(), Space::Paper("L".into())); + s.execute("mspace", &json!({})).unwrap(); + s.execute("layout.set", &json!({"name": "Model"})).unwrap(); + assert!(s.state().unwrap().mspace.is_none()); + } } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 2e63044..7847f67 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -206,6 +206,7 @@ fn run_layout_set(s: &mut Session, p: &Value) -> Result { }; let st = s.state_mut()?; st.space = space.clone(); + st.mspace = None; st.selection.clear(); if let Space::Paper(n) = &space && !st.views.iter().any(|(sp, _)| sp == &space) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5ba4e2b..4975171 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -89,6 +89,8 @@ pub struct DocState { pub saved: Arc, pub revision: u64, pub space: Space, + /// MSPACE: the layout viewport whose model space is being edited (None = paper space). + pub mspace: Option, pub views: Vec<(Space, View)>, pub view_history: Vec, pub uid: u64, @@ -110,6 +112,7 @@ impl DocState { title: title.into(), revision: 1, space: Space::Model, + mspace: None, views: Vec::new(), view_history: Vec::new(), uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), @@ -118,9 +121,67 @@ impl DocState { pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(&self.doc, &self.saved) } - pub fn view(&self) -> View { + /// The space edits, picks and snaps act on: model space inside an active viewport. + pub fn edit_space(&self) -> Space { + if self.active_viewport().is_some() { Space::Model } else { self.space.clone() } + } + /// The active MSPACE viewport, if it still exists in the current layout. + pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> { + let h = self.mspace?; + let e = self.doc.space(&self.space)?.get(h)?; + match &e.kind { + cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())), + _ => None, + } + } + pub fn paper_view(&self) -> View { self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default() } + /// The screen view. Inside an MSPACE viewport this is the model-space view that maps the + /// screen consistently with the paper view, so picks, snaps and previews work in model units. + pub fn view(&self) -> View { + let pv = self.paper_view(); + match self.active_viewport() { + Some((_, vp)) => { + let k = vp.view_height / vp.height; + View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k } + } + None => pv, + } + } + /// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view. + fn store_view(&mut self, v: View) { + if let Some((h, vp)) = self.active_viewport() { + let pv = self.paper_view(); + let k = v.height / pv.height.max(1e-12); + if vp.locked { + // A locked viewport keeps its scale: pan/zoom the sheet instead. + let k0 = vp.view_height / vp.height; + let pc = vp.center.xy() + (v.center - vp.view_center) / k0; + self.put_paper_view(View { center: pc, height: v.height / k0 }); + } else { + let center = v.center - (pv.center - vp.center.xy()) * k; + let height = vp.height * k; + let doc = Arc::make_mut(&mut self.doc); + if let Some(store) = doc.space_mut(&self.space.clone()) { + store.modify(h, |e| { + if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind { + x.view_center = center; + x.view_height = height; + } + }); + } + } + return; + } + self.put_paper_view(v); + } + fn put_paper_view(&mut self, v: View) { + match self.views.iter_mut().find(|(s, _)| *s == self.space) { + Some((_, slot)) => *slot = v, + None => self.views.push((self.space.clone(), v)), + } + } pub fn set_view(&mut self, v: View) { if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) { return; @@ -132,20 +193,14 @@ impl DocState { self.view_history.remove(0); } } - match self.views.iter_mut().find(|(s, _)| *s == self.space) { - Some((_, slot)) => *slot = v, - None => self.views.push((self.space.clone(), v)), - } + self.store_view(v); } /// Set without recording view history (realtime pan/zoom frames). pub fn set_view_quiet(&mut self, v: View) { if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) { return; } - match self.views.iter_mut().find(|(s, _)| *s == self.space) { - Some((_, slot)) => *slot = v, - None => self.views.push((self.space.clone(), v)), - } + self.store_view(v); } } @@ -316,7 +371,12 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut Drawing> { Ok(Arc::make_mut(&mut self.state_mut()?.doc)) } + /// The space commands act on (model space while working inside a layout viewport). pub fn space(&self) -> Space { + self.state().map(|s| s.edit_space()).unwrap_or_default() + } + /// The tab being displayed: Model or a layout, regardless of MSPACE. + pub fn layout_space(&self) -> Space { self.state().map(|s| s.space.clone()).unwrap_or_default() } pub fn selection(&self) -> Vec { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7562595..67b0124 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -78,7 +78,7 @@ pub fn apply_hot_grip(app: &mut CadApp, to: Vec2) { #[derive(Default)] pub struct CanvasState { pub list: Option, - key: (u64, u64, i32, bool), + key: (u64, u64, i32, bool, usize), /// Set when the app runs on wgpu: entities are drawn by [`crate::gpu`]; otherwise on the CPU. pub gpu: Option, /// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]). @@ -114,7 +114,7 @@ fn ensure_list(app: &mut CadApp, px: f64) { let Ok(st) = app.session.state() else { return }; // Rebuild when the drawing changes or the zoom moves by more than 2x (tessellation band). let band = px.max(1e-300).log2().floor() as i32; - let key = (st.uid, st.revision, band, app.session.settings.lwdisplay); + let key = (st.uid, st.revision, band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize); if app.canvas.list.is_some() && app.canvas.key == key { return; } @@ -424,7 +424,7 @@ fn effective_point(app: &mut CadApp, raw: Vec2, xf: &Xf) -> Vec2 { } let ap = s.aperture / xf.scale; if let Ok(st) = app.session.state() - && let Some(hit) = snap::osnap(&st.doc, &st.space, raw, ap, s.osmode, base) + && let Some(hit) = snap::osnap(&st.doc, &st.edit_space(), raw, ap, s.osmode, base) { app.canvas.snap = Some(hit); return hit.point; @@ -464,7 +464,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { app.canvas.xf = Some(xf); // ---------- input ---------- - let (hover_pos, scroll, mods, middle_down, pressed_primary, pressed_secondary, dbl_middle) = ui.input(|i| { + let (hover_pos, scroll, mods, middle_down, pressed_primary, pressed_secondary, dbl_middle, dbl_primary) = ui.input(|i| { ( i.pointer.hover_pos(), i.smooth_scroll_delta.y, @@ -473,6 +473,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.button_double_clicked(egui::PointerButton::Middle), + i.pointer.button_double_clicked(egui::PointerButton::Primary), ) }); let inside = hover_pos.is_some_and(|p| rect.contains(p)) && resp.hovered(); @@ -516,6 +517,33 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let scale = f64::from(rect.height()) / view.height.max(1e-12); let xf = Xf { rect, center: view.center, scale }; app.canvas.xf = Some(xf); + // The paper (display) transform; equal to `xf` except inside an MSPACE viewport. + let (xf_paper, active_vp) = match app.session.state() { + Ok(st) => { + let pv = st.paper_view(); + let xp = Xf { rect, center: pv.center, scale: f64::from(rect.height()) / pv.height.max(1e-12) }; + (if st.mspace.is_some() { xp } else { xf }, st.active_viewport().map(|(_, v)| v)) + } + Err(_) => (xf, None), + }; + // Double-click a viewport to work inside it; double-click the sheet outside it to return. + if inside + && dbl_primary + && app.session.running.is_none() + && matches!(app.session.layout_space(), cadcraft_doc::Space::Paper(_)) + && let Some(hp) = hover_pos + { + let pp = xf_paper.to_world(hp); + let in_active = active_vp.as_ref().is_some_and(|v| (pp.x - v.center.x).abs() <= v.width / 2.0 && (pp.y - v.center.y).abs() <= v.height / 2.0); + if !in_active { + app.canvas.hot_grip = None; + if app.run("mspace", serde_json::json!({"at": [pp.x, pp.y]})).is_err() && active_vp.is_some() { + let _ = app.run("pspace", serde_json::json!({})); + } + app.canvas.list = None; + return; + } + } let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p)); if let Some(w) = raw_world { @@ -565,7 +593,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if hover_pos != app.canvas.hover_at && let (Some(w), Ok(st)) = (raw_world, app.session.state()) { - app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.space, w, app.session.settings.pickbox.max(1.0) * 1.5 / scale); + app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, app.session.settings.pickbox.max(1.0) * 1.5 / scale); app.canvas.hover_at = hover_pos; } } else { @@ -576,20 +604,20 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // ---------- paint ---------- painter.rect_filled(rect, 0.0, t.canvas); let t0 = crate::now_ms(); - ensure_list(app, 1.0 / scale); + ensure_list(app, 1.0 / xf_paper.scale); let sheet = app.canvas.list.as_ref().and_then(|l| l.sheet); let bg = match sheet { Some(sh) => { // Paper space: grey surround, the sheet with a shadow, the printable area dashed. painter.rect_filled(rect, 0.0, Color32::from_rgb(0x50, 0x57, 0x63)); - let a = xf.to_screen(Vec2::new(0.0, sh.size.y)); - let b = xf.to_screen(Vec2::new(sh.size.x, 0.0)); + let a = xf_paper.to_screen(Vec2::new(0.0, sh.size.y)); + let b = xf_paper.to_screen(Vec2::new(sh.size.x, 0.0)); let paper = Rect::from_two_pos(a, b); painter.rect_filled(paper.translate(vec2(5.0, 5.0)), 0.0, Color32::from_black_alpha(110)); painter.rect_filled(paper, 0.0, Color32::WHITE); let pa = Rect::from_two_pos( - xf.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)), - xf.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)), + xf_paper.to_screen(Vec2::new(sh.printable.min.x, sh.printable.max.y)), + xf_paper.to_screen(Vec2::new(sh.printable.max.x, sh.printable.min.y)), ); let pts = [pa.left_top(), pa.right_top(), pa.right_bottom(), pa.left_bottom(), pa.left_top()]; painter.extend(Shape::dashed_line(&pts, Stroke::new(1.0, Color32::from_gray(150)), 4.0, 4.0)); @@ -602,11 +630,33 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }; let sel = app.session.selection(); if app.canvas.gpu.is_some() { - draw_list_gpu(&mut app.canvas, &painter, &xf, bg, app.session.settings.lwdisplay); + draw_list_gpu(&mut app.canvas, &painter, &xf_paper, bg, app.session.settings.lwdisplay); } else if let Some(list) = &app.canvas.list { - draw_list(&painter, &xf, list, bg, app.session.settings.lwdisplay); + draw_list(&painter, &xf_paper, list, bg, app.session.settings.lwdisplay); } - if let Some(list) = &app.canvas.list { + if let Some(vp) = &active_vp { + // The active viewport: a heavy border, highlights in model units clipped to it. + let r = Rect::from_two_pos( + xf_paper.to_screen(Vec2::new(vp.center.x - vp.width / 2.0, vp.center.y + vp.height / 2.0)), + xf_paper.to_screen(Vec2::new(vp.center.x + vp.width / 2.0, vp.center.y - vp.height / 2.0)), + ); + painter.rect_stroke(r, 0.0, Stroke::new(3.0, Color32::from_rgb(0x2a, 0x2a, 0x2a)), egui::StrokeKind::Middle); + if let Ok(st) = app.session.state() { + let mut hs = sel.clone(); + hs.extend(app.canvas.hover.filter(|h| !sel.contains(h))); + let ents: Vec<_> = hs.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref()).collect(); + if !ents.is_empty() { + let list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance: 0.5 / scale, ..Default::default() }); + let clipped = painter.with_clip_rect(r.intersect(rect)); + if let Some(h) = app.canvas.hover + && !sel.contains(&h) + { + draw_highlight(&clipped, &xf, &list, &[h], t.hover, 2.0, false); + } + draw_highlight(&clipped, &xf, &list, &sel, t.selection, 1.5, true); + } + } + } else if let Some(list) = &app.canvas.list { if let Some(h) = app.canvas.hover && !sel.contains(&h) {